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"
},
"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}}",
"systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.",
"visualizerType": "Visualizer Type",
+1
View File
@@ -40,6 +40,7 @@ export const store = new Store<any>({
playbackType: 'web',
should_prompt_accessibility: true,
shown_accessibility_warning: false,
visualizer_system_audio_consent_granted: false,
window_enable_tray: true,
window_exit_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 { 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 { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
@@ -80,7 +79,6 @@ const AppShell = memo(function AppShell() {
<WebAudioContext.Provider value={webAudioProvider}>
<PlayerProvider>
<AudioPlayers />
<VisualizerSystemAudioBridge />
<AppRouter />
</PlayerProvider>
</WebAudioContext.Provider>
@@ -25,6 +25,7 @@ import {
useIsRadioActive,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
import {
updateQueueFavorites,
updateQueueRatings,
@@ -135,6 +136,7 @@ export const AudioPlayers = () => {
<UpdateCurrentSongHook />
<RadioAudioInstanceHook />
<RadioMetadataHook />
<VisualizerSystemAudioBridgeHook />
<AutosaveHook />
<AudioPlayersContent
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 { 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 { setWebAudio, webAudio } = useWebAudio();
const webAudioRef = useRef(webAudio);
@@ -41,10 +51,10 @@ export function useVisualizerSystemAudio() {
}, [setWebAudio]);
useEffect(() => {
if (playbackType === PlayerType.WEB) {
if (playbackType === PlayerType.WEB || !shouldAttemptConnection) {
disconnect();
}
}, [playbackType, disconnect]);
}, [playbackType, shouldAttemptConnection, disconnect]);
const connect = useCallback(async () => {
if (!isElectron()) {
@@ -76,7 +86,7 @@ export function useVisualizerSystemAudio() {
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
stream.getTracks().forEach((t) => t.stop());
toast.error({ message: i18n.t('visualizer.systemAudioNoAudioTrack') });
onDeniedRef.current?.();
return;
}
@@ -99,9 +109,11 @@ export function useVisualizerSystemAudio() {
const next = { ...latest, visualizerInputs: [source] };
setWebAudio(next);
webAudioRef.current = next;
onSuccessRef.current?.();
} catch (e) {
const name = (e as DOMException)?.name;
if (name === 'NotAllowedError' || name === 'AbortError') {
onDeniedRef.current?.();
return;
}
toast.error({
@@ -118,7 +130,7 @@ export function useVisualizerSystemAudio() {
connectRef.current = connect;
useEffect(() => {
if (playbackType !== PlayerType.LOCAL || !isElectron()) {
if (playbackType !== PlayerType.LOCAL || !isElectron() || !shouldAttemptConnection) {
return;
}
@@ -134,5 +146,10 @@ export function useVisualizerSystemAudio() {
}
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() {
useVisualizerSystemAudio();
return null;
import { useIsLocalVisualizerSurfaceVisible } from '/@/renderer/features/player/hooks/use-is-local-visualizer-surface-visible';
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 <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>
);
}