From b99899f128f8a1c532b28602541a8e0507891f7a Mon Sep 17 00:00:00 2001 From: York Date: Tue, 14 Apr 2026 11:47:03 +0800 Subject: [PATCH] fix MPV visualizer on macOS and handle exclusive mode UX (#1930) --- electron-builder-alpha.yml | 1 + electron-builder-beta.yml | 1 + electron-builder.yml | 1 + src/i18n/locales/en.json | 3 +- src/main/index.ts | 22 ++++++++++++- .../hooks/use-visualizer-system-audio.ts | 5 +-- .../visualizer-system-audio-bridge.tsx | 31 +++++++++++++++++-- 7 files changed, 58 insertions(+), 6 deletions(-) diff --git a/electron-builder-alpha.yml b/electron-builder-alpha.yml index 2cc23db73..9d7a54881 100644 --- a/electron-builder-alpha.yml +++ b/electron-builder-alpha.yml @@ -47,6 +47,7 @@ mac: gatekeeperAssess: false notarize: false extendInfo: + NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin" NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin' dmg: diff --git a/electron-builder-beta.yml b/electron-builder-beta.yml index b4a9f5624..d88f7ca9b 100644 --- a/electron-builder-beta.yml +++ b/electron-builder-beta.yml @@ -47,6 +47,7 @@ mac: gatekeeperAssess: false notarize: false extendInfo: + NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin" NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin' dmg: diff --git a/electron-builder.yml b/electron-builder.yml index 8ae7c4544..55050f5fa 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -47,6 +47,7 @@ mac: gatekeeperAssess: false notarize: false extendInfo: + NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin' NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin' dmg: diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cefca8ecf..d23a4456c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -761,7 +761,7 @@ "artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page", "audioDevice_description": "select the audio device to use for playback", "audioDevice": "audio device", - "audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio", + "audioExclusiveMode_description": "enable exclusive output mode. in this mode, the system is usually locked out, and only mpv will be able to output audio. visualizer system audio capture will not work while this is enabled", "audioExclusiveMode": "audio exclusive mode", "audioPlayer_description": "select the audio player to use for playback", "audioPlayer": "audio player", @@ -1220,6 +1220,7 @@ "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.", + "systemAudioExclusiveModeNotSupported": "Visualizer is unavailable while audio exclusive mode is enabled. Disable Audio Exclusive Mode in MPV settings and try again.", "visualizerType": "Visualizer Type", "cyclePresets": "Cycle Presets", "cycleTime": "Cycle Time (seconds)", diff --git a/src/main/index.ts b/src/main/index.ts index c5253edc0..97a2bc4a3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, BrowserWindowConstructorOptions, + desktopCapturer, globalShortcut, ipcMain, Menu, @@ -733,7 +734,26 @@ async function createWindow(first = true): Promise { }); mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => { - callback({ audio: 'loopback' }); + if (!isMacOS()) { + callback({ audio: 'loopback' }); + return; + } + + desktopCapturer + .getSources({ thumbnailSize: { height: 0, width: 0 }, types: ['screen'] }) + .then((sources) => { + const source = sources[0]; + if (!source) { + callback({}); + return; + } + + callback({ audio: 'loopback', video: source }); + }) + .catch((err) => { + log.warn('desktopCapturer.getSources failed', err); + callback({}); + }); }); if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) { diff --git a/src/renderer/features/player/hooks/use-visualizer-system-audio.ts b/src/renderer/features/player/hooks/use-visualizer-system-audio.ts index b83960f5c..7ab536b93 100644 --- a/src/renderer/features/player/hooks/use-visualizer-system-audio.ts +++ b/src/renderer/features/player/hooks/use-visualizer-system-audio.ts @@ -19,6 +19,7 @@ export function useVisualizerSystemAudio(options: { onDeniedRef.current = onSystemAudioCaptureDenied; onSuccessRef.current = onSystemAudioCaptureSuccess; const playbackType = usePlaybackType(); + const isMacOS = Boolean(window.api?.utils?.isMacOS?.()); const { setWebAudio, webAudio } = useWebAudio(); const webAudioRef = useRef(webAudio); const streamRef = useRef(null); @@ -80,7 +81,7 @@ export function useVisualizerSystemAudio(options: { try { const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, - video: false, + video: isMacOS, // On macOS, getDisplayMedia requires video to be requested in order to capture system audio }); const audioTracks = stream.getAudioTracks(); @@ -124,7 +125,7 @@ export function useVisualizerSystemAudio(options: { } finally { connectInFlightRef.current = false; } - }, [disconnect, setWebAudio]); + }, [disconnect, isMacOS, setWebAudio]); const connectRef = useRef(connect); connectRef.current = connect; 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 0e257f79d..d37580689 100644 --- a/src/renderer/features/visualizer/components/visualizer-system-audio-bridge.tsx +++ b/src/renderer/features/visualizer/components/visualizer-system-audio-bridge.tsx @@ -1,16 +1,17 @@ import isElectron from 'is-electron'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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 { useMpvSettings, 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 { toast } from '/@/shared/components/toast/toast'; import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { PlayerType } from '/@/shared/types/types'; @@ -31,12 +32,21 @@ export function VisualizerSystemAudioBridgeHook() { function VisualizerSystemAudioBridge() { const { t } = useTranslation(); const playbackType = usePlaybackType(); + const { audioExclusiveMode } = useMpvSettings(); const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible(); const [promptState, setPromptState] = useState('loading'); const [sessionAllowCapture, setSessionAllowCapture] = useState(false); + const wasBlockedByExclusiveModeRef = useRef(false); const [isPromptOpen, { close: closePrompt, open: openPrompt, toggle: togglePrompt }] = useDisclosure(false); + const isExclusiveModeEnabled = audioExclusiveMode === 'yes'; + const isVisualizerBlockedByExclusiveMode = + isElectron() && + playbackType === PlayerType.LOCAL && + isVisualizerSurfaceVisible && + isExclusiveModeEnabled; + const persistConsent = useCallback((granted: boolean) => { if (!isElectron() || !window.api.localSettings) { return; @@ -67,6 +77,7 @@ function VisualizerSystemAudioBridge() { const eligibleForPrompt = isElectron() && playbackType === PlayerType.LOCAL && + !isExclusiveModeEnabled && isVisualizerSurfaceVisible && promptState !== 'loading' && !promptState.consent && @@ -80,9 +91,25 @@ function VisualizerSystemAudioBridge() { } }, [eligibleForPrompt, closePrompt, openPrompt]); + useEffect(() => { + if (isVisualizerBlockedByExclusiveMode && !wasBlockedByExclusiveModeRef.current) { + toast.error({ + message: t('visualizer.systemAudioExclusiveModeNotSupported', { + postProcess: 'sentenceCase', + }), + }); + setSessionAllowCapture(false); + closePrompt(); + closeLocalVisualizerSurfaces(); + } + + wasBlockedByExclusiveModeRef.current = isVisualizerBlockedByExclusiveMode; + }, [closePrompt, isVisualizerBlockedByExclusiveMode, t]); + const shouldAttemptConnection = isElectron() && playbackType === PlayerType.LOCAL && + !isExclusiveModeEnabled && isVisualizerSurfaceVisible && promptState !== 'loading' && (promptState.consent || sessionAllowCapture);