mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add in-app prompt for system audio connection
This commit is contained in:
@@ -1214,6 +1214,10 @@
|
|||||||
"mainText": "drop a file here"
|
"mainText": "drop a file here"
|
||||||
},
|
},
|
||||||
"visualizer": {
|
"visualizer": {
|
||||||
|
"systemAudioConsentAllow": "Allow",
|
||||||
|
"systemAudioConsentBody": "The visualizer requires access to the system audio to work",
|
||||||
|
"systemAudioConsentDecline": "Deny",
|
||||||
|
"systemAudioConsentTitle": "Allow access to system audio?",
|
||||||
"systemAudioCaptureFailed": "Could not start capture: {{message}}",
|
"systemAudioCaptureFailed": "Could not start capture: {{message}}",
|
||||||
"systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.",
|
"systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.",
|
||||||
"visualizerType": "Visualizer Type",
|
"visualizerType": "Visualizer Type",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const store = new Store<any>({
|
|||||||
playbackType: 'web',
|
playbackType: 'web',
|
||||||
should_prompt_accessibility: true,
|
should_prompt_accessibility: true,
|
||||||
shown_accessibility_warning: false,
|
shown_accessibility_warning: false,
|
||||||
|
visualizer_system_audio_consent_granted: false,
|
||||||
window_enable_tray: true,
|
window_enable_tray: true,
|
||||||
window_exit_to_tray: false,
|
window_exit_to_tray: false,
|
||||||
window_minimize_to_tray: false,
|
window_minimize_to_tray: false,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'reac
|
|||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||||
import { VisualizerSystemAudioBridge } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
|
|
||||||
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
|
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
|
||||||
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
|
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
|
||||||
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
||||||
@@ -80,7 +79,6 @@ const AppShell = memo(function AppShell() {
|
|||||||
<WebAudioContext.Provider value={webAudioProvider}>
|
<WebAudioContext.Provider value={webAudioProvider}>
|
||||||
<PlayerProvider>
|
<PlayerProvider>
|
||||||
<AudioPlayers />
|
<AudioPlayers />
|
||||||
<VisualizerSystemAudioBridge />
|
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
</WebAudioContext.Provider>
|
</WebAudioContext.Provider>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
useIsRadioActive,
|
useIsRadioActive,
|
||||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||||
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
|
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
|
||||||
|
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
|
||||||
import {
|
import {
|
||||||
updateQueueFavorites,
|
updateQueueFavorites,
|
||||||
updateQueueRatings,
|
updateQueueRatings,
|
||||||
@@ -135,6 +136,7 @@ export const AudioPlayers = () => {
|
|||||||
<UpdateCurrentSongHook />
|
<UpdateCurrentSongHook />
|
||||||
<RadioAudioInstanceHook />
|
<RadioAudioInstanceHook />
|
||||||
<RadioMetadataHook />
|
<RadioMetadataHook />
|
||||||
|
<VisualizerSystemAudioBridgeHook />
|
||||||
<AutosaveHook />
|
<AutosaveHook />
|
||||||
<AudioPlayersContent
|
<AudioPlayersContent
|
||||||
audioContext={audioContext}
|
audioContext={audioContext}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
useFullScreenPlayerStore,
|
||||||
|
usePlaybackSettings,
|
||||||
|
useShowVisualizerInSidebar,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
|
||||||
|
export function useIsLocalVisualizerSurfaceVisible(): boolean {
|
||||||
|
const { webAudio: webAudioEnabled } = usePlaybackSettings();
|
||||||
|
const showVisualizerInSidebar = useShowVisualizerInSidebar();
|
||||||
|
const { activeTab, expanded, visualizerExpanded } = useFullScreenPlayerStore();
|
||||||
|
|
||||||
|
const sidebarVisualizer = showVisualizerInSidebar && webAudioEnabled;
|
||||||
|
const fullScreenPlayerVisualizerTab = expanded && activeTab === 'visualizer' && webAudioEnabled;
|
||||||
|
const fullScreenVisualizerOverlay = visualizerExpanded && webAudioEnabled;
|
||||||
|
|
||||||
|
return sidebarVisualizer || fullScreenPlayerVisualizerTab || fullScreenVisualizerOverlay;
|
||||||
|
}
|
||||||
@@ -7,7 +7,17 @@ import { usePlaybackType } from '/@/renderer/store/settings.store';
|
|||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
export function useVisualizerSystemAudio() {
|
export function useVisualizerSystemAudio(options: {
|
||||||
|
onSystemAudioCaptureDenied?: () => void;
|
||||||
|
onSystemAudioCaptureSuccess?: () => void;
|
||||||
|
shouldAttemptConnection: boolean;
|
||||||
|
}) {
|
||||||
|
const { onSystemAudioCaptureDenied, onSystemAudioCaptureSuccess, shouldAttemptConnection } =
|
||||||
|
options;
|
||||||
|
const onDeniedRef = useRef(onSystemAudioCaptureDenied);
|
||||||
|
const onSuccessRef = useRef(onSystemAudioCaptureSuccess);
|
||||||
|
onDeniedRef.current = onSystemAudioCaptureDenied;
|
||||||
|
onSuccessRef.current = onSystemAudioCaptureSuccess;
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
const { setWebAudio, webAudio } = useWebAudio();
|
const { setWebAudio, webAudio } = useWebAudio();
|
||||||
const webAudioRef = useRef(webAudio);
|
const webAudioRef = useRef(webAudio);
|
||||||
@@ -41,10 +51,10 @@ export function useVisualizerSystemAudio() {
|
|||||||
}, [setWebAudio]);
|
}, [setWebAudio]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playbackType === PlayerType.WEB) {
|
if (playbackType === PlayerType.WEB || !shouldAttemptConnection) {
|
||||||
disconnect();
|
disconnect();
|
||||||
}
|
}
|
||||||
}, [playbackType, disconnect]);
|
}, [playbackType, shouldAttemptConnection, disconnect]);
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
if (!isElectron()) {
|
if (!isElectron()) {
|
||||||
@@ -76,7 +86,7 @@ export function useVisualizerSystemAudio() {
|
|||||||
const audioTracks = stream.getAudioTracks();
|
const audioTracks = stream.getAudioTracks();
|
||||||
if (audioTracks.length === 0) {
|
if (audioTracks.length === 0) {
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
toast.error({ message: i18n.t('visualizer.systemAudioNoAudioTrack') });
|
onDeniedRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +109,11 @@ export function useVisualizerSystemAudio() {
|
|||||||
const next = { ...latest, visualizerInputs: [source] };
|
const next = { ...latest, visualizerInputs: [source] };
|
||||||
setWebAudio(next);
|
setWebAudio(next);
|
||||||
webAudioRef.current = next;
|
webAudioRef.current = next;
|
||||||
|
onSuccessRef.current?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const name = (e as DOMException)?.name;
|
const name = (e as DOMException)?.name;
|
||||||
if (name === 'NotAllowedError' || name === 'AbortError') {
|
if (name === 'NotAllowedError' || name === 'AbortError') {
|
||||||
|
onDeniedRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error({
|
toast.error({
|
||||||
@@ -118,7 +130,7 @@ export function useVisualizerSystemAudio() {
|
|||||||
connectRef.current = connect;
|
connectRef.current = connect;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playbackType !== PlayerType.LOCAL || !isElectron()) {
|
if (playbackType !== PlayerType.LOCAL || !isElectron() || !shouldAttemptConnection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,5 +146,10 @@ export function useVisualizerSystemAudio() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void connectRef.current();
|
void connectRef.current();
|
||||||
}, [playbackType, webAudio?.context, webAudio?.visualizerInputs?.length]);
|
}, [
|
||||||
|
playbackType,
|
||||||
|
shouldAttemptConnection,
|
||||||
|
webAudio?.context,
|
||||||
|
webAudio?.visualizerInputs?.length,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useFullScreenPlayerStore, useSettingsStore } from '/@/renderer/store';
|
||||||
|
|
||||||
|
export function closeLocalVisualizerSurfaces(): void {
|
||||||
|
const fullScreen = useFullScreenPlayerStore.getState();
|
||||||
|
fullScreen.actions.setStore({
|
||||||
|
...(fullScreen.expanded && fullScreen.activeTab === 'visualizer'
|
||||||
|
? { activeTab: 'queue' as const }
|
||||||
|
: {}),
|
||||||
|
visualizerExpanded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useSettingsStore.getState().actions.setSettings({
|
||||||
|
general: { showVisualizerInSidebar: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,150 @@
|
|||||||
import { useVisualizerSystemAudio } from '/@/renderer/features/player/hooks/use-visualizer-system-audio';
|
import isElectron from 'is-electron';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function VisualizerSystemAudioBridge() {
|
import { useIsLocalVisualizerSurfaceVisible } from '/@/renderer/features/player/hooks/use-is-local-visualizer-surface-visible';
|
||||||
useVisualizerSystemAudio();
|
import { useVisualizerSystemAudio } from '/@/renderer/features/player/hooks/use-visualizer-system-audio';
|
||||||
return null;
|
import { closeLocalVisualizerSurfaces } from '/@/renderer/features/player/utils/close-local-visualizer-surfaces';
|
||||||
|
import { usePlaybackType } from '/@/renderer/store';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { Modal } from '/@/shared/components/modal/modal';
|
||||||
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||||
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const CONSENT_GRANTED_KEY = 'visualizer_system_audio_consent_granted';
|
||||||
|
|
||||||
|
type PromptState = 'loading' | { consent: boolean };
|
||||||
|
|
||||||
|
export function VisualizerSystemAudioBridgeHook() {
|
||||||
|
const playbackType = usePlaybackType();
|
||||||
|
|
||||||
|
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VisualizerSystemAudioBridge />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisualizerSystemAudioBridge() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const playbackType = usePlaybackType();
|
||||||
|
const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible();
|
||||||
|
const [promptState, setPromptState] = useState<PromptState>('loading');
|
||||||
|
const [sessionAllowCapture, setSessionAllowCapture] = useState(false);
|
||||||
|
const [isPromptOpen, { close: closePrompt, open: openPrompt, toggle: togglePrompt }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const persistConsent = useCallback((granted: boolean) => {
|
||||||
|
if (!isElectron() || !window.api.localSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.api.localSettings.set(CONSENT_GRANTED_KEY, granted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron() || !window.api.localSettings) {
|
||||||
|
setPromptState({ consent: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const ls = window.api.localSettings!;
|
||||||
|
const consent = Boolean(await ls.get(CONSENT_GRANTED_KEY));
|
||||||
|
if (!cancelled) {
|
||||||
|
setPromptState({ consent });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const eligibleForPrompt =
|
||||||
|
isElectron() &&
|
||||||
|
playbackType === PlayerType.LOCAL &&
|
||||||
|
isVisualizerSurfaceVisible &&
|
||||||
|
promptState !== 'loading' &&
|
||||||
|
!promptState.consent &&
|
||||||
|
!sessionAllowCapture;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eligibleForPrompt) {
|
||||||
|
openPrompt();
|
||||||
|
} else {
|
||||||
|
closePrompt();
|
||||||
|
}
|
||||||
|
}, [eligibleForPrompt, closePrompt, openPrompt]);
|
||||||
|
|
||||||
|
const shouldAttemptConnection =
|
||||||
|
isElectron() &&
|
||||||
|
playbackType === PlayerType.LOCAL &&
|
||||||
|
isVisualizerSurfaceVisible &&
|
||||||
|
promptState !== 'loading' &&
|
||||||
|
(promptState.consent || sessionAllowCapture);
|
||||||
|
|
||||||
|
const handleCaptureSuccess = useCallback(() => {
|
||||||
|
persistConsent(true);
|
||||||
|
setPromptState({ consent: true });
|
||||||
|
setSessionAllowCapture(false);
|
||||||
|
}, [persistConsent]);
|
||||||
|
|
||||||
|
const handleCaptureDenied = useCallback(() => {
|
||||||
|
persistConsent(false);
|
||||||
|
setPromptState({ consent: false });
|
||||||
|
setSessionAllowCapture(false);
|
||||||
|
closeLocalVisualizerSurfaces();
|
||||||
|
}, [persistConsent]);
|
||||||
|
|
||||||
|
useVisualizerSystemAudio({
|
||||||
|
onSystemAudioCaptureDenied: handleCaptureDenied,
|
||||||
|
onSystemAudioCaptureSuccess: handleCaptureSuccess,
|
||||||
|
shouldAttemptConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAllow = useCallback(() => {
|
||||||
|
setSessionAllowCapture(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDecline = useCallback(() => {
|
||||||
|
persistConsent(false);
|
||||||
|
setPromptState({ consent: false });
|
||||||
|
setSessionAllowCapture(false);
|
||||||
|
closeLocalVisualizerSurfaces();
|
||||||
|
closePrompt();
|
||||||
|
}, [closePrompt, persistConsent]);
|
||||||
|
|
||||||
|
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
closeOnClickOutside={false}
|
||||||
|
closeOnEscape={false}
|
||||||
|
handlers={{ close: closePrompt, open: openPrompt, toggle: togglePrompt }}
|
||||||
|
opened={isPromptOpen}
|
||||||
|
size="md"
|
||||||
|
title={t('visualizer.systemAudioConsentTitle', { postProcess: 'sentenceCase' })}
|
||||||
|
withCloseButton={false}
|
||||||
|
>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Text size="sm">
|
||||||
|
{t('visualizer.systemAudioConsentBody', { postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button onClick={handleDecline} variant="default">
|
||||||
|
{t('visualizer.systemAudioConsentDecline', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAllow} variant="filled">
|
||||||
|
{t('visualizer.systemAudioConsentAllow', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user