From 3f300c40cc172ac976d802f20243abdc50a142ce Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 5 Apr 2026 22:19:09 -0700 Subject: [PATCH] add in-app prompt for system audio connection --- src/i18n/locales/en.json | 4 + src/main/features/core/settings/index.ts | 1 + src/renderer/app.tsx | 2 - .../player/components/audio-players.tsx | 2 + ...use-is-local-visualizer-surface-visible.ts | 17 ++ .../hooks/use-visualizer-system-audio.ts | 29 +++- .../utils/close-local-visualizer-surfaces.ts | 15 ++ .../visualizer-system-audio-bridge.tsx | 152 +++++++++++++++++- 8 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 src/renderer/features/player/hooks/use-is-local-visualizer-surface-visible.ts create mode 100644 src/renderer/features/player/utils/close-local-visualizer-surfaces.ts diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7c11f3812..cefca8ecf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/main/features/core/settings/index.ts b/src/main/features/core/settings/index.ts index b35ca5c6f..c08dcb128 100644 --- a/src/main/features/core/settings/index.ts +++ b/src/main/features/core/settings/index.ts @@ -40,6 +40,7 @@ export const store = new Store({ 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, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 8505e76b9..d32580dc0 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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() { - diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index a34785eb0..1a75bb114 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -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 = () => { + 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, + ]); } diff --git a/src/renderer/features/player/utils/close-local-visualizer-surfaces.ts b/src/renderer/features/player/utils/close-local-visualizer-surfaces.ts new file mode 100644 index 000000000..2c360c40f --- /dev/null +++ b/src/renderer/features/player/utils/close-local-visualizer-surfaces.ts @@ -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 }, + }); +} diff --git a/src/renderer/features/visualizer/components/visualizer-system-audio-bridge.tsx b/src/renderer/features/visualizer/components/visualizer-system-audio-bridge.tsx index f68c194fc..0e257f79d 100644 --- a/src/renderer/features/visualizer/components/visualizer-system-audio-bridge.tsx +++ b/src/renderer/features/visualizer/components/visualizer-system-audio-bridge.tsx @@ -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 ; +} + +function VisualizerSystemAudioBridge() { + const { t } = useTranslation(); + const playbackType = usePlaybackType(); + const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible(); + const [promptState, setPromptState] = useState('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 ( + + + + {t('visualizer.systemAudioConsentBody', { postProcess: 'sentenceCase' })} + + + + + + + + ); }