mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-06 20:10: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"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user