add in-app prompt for system audio connection

This commit is contained in:
jeffvli
2026-04-05 22:19:09 -07:00
parent c8e8f58cce
commit 3f300c40cc
8 changed files with 210 additions and 12 deletions
+4
View File
@@ -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",
+1
View File
@@ -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,
-2
View File
@@ -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';
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 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>
);
} }