From 8ae29407ec4820d83064bd0c821726906f993fe3 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 6 Feb 2026 21:38:05 -0800 Subject: [PATCH] support ytmusic controls on web/mpv players --- .../features/musicbrainz/api/youtube-api.ts | 2 + .../audio-player/engine/mpv-player-engine.tsx | 113 ++++++++++++------ .../audio-player/engine/web-player-engine.tsx | 107 ++++++++++++++--- .../audio-player/hooks/use-stream-url.tsx | 36 +++++- .../player/audio-player/mpv-player.tsx | 10 +- .../player/audio-player/web-player.tsx | 50 ++++---- 6 files changed, 243 insertions(+), 75 deletions(-) diff --git a/src/renderer/features/musicbrainz/api/youtube-api.ts b/src/renderer/features/musicbrainz/api/youtube-api.ts index 27412d35c..2caa47776 100644 --- a/src/renderer/features/musicbrainz/api/youtube-api.ts +++ b/src/renderer/features/musicbrainz/api/youtube-api.ts @@ -10,8 +10,10 @@ async function searchYoutube(query: string): Promise { return queryOptions({ + gcTime: 1000 * 60 * 1, queryFn: () => searchYoutube(args.query), queryKey: ['youtube', 'search', args.query], + staleTime: 1000 * 60 * 1, }); }, }; diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index 44c2c5a94..dbd7e800f 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -21,8 +21,10 @@ import { PlayerStatus } from '/@/shared/types/types'; export interface MpvPlayerEngineHandle extends AudioPlayer {} interface MpvPlayerEngineProps { + currentSongUrl: string | undefined; isMuted: boolean; isTransitioning: boolean; + nextSongUrl: string | undefined; onEnded: () => void; onProgress: (e: PlayerOnProgressProps) => void; playerRef: RefObject; @@ -39,8 +41,10 @@ const PROGRESS_UPDATE_INTERVAL = 250; export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { const { + currentSongUrl: currentSongUrlProp, isMuted, isTransitioning, + nextSongUrl: nextSongUrlProp, onEnded, onProgress, playerRef, @@ -56,6 +60,11 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { const isInitializedRef = useRef(false); const hasPopulatedQueueRef = useRef(false); const isMountedRef = useRef(true); + const currentSongUrlRef = useRef(currentSongUrlProp); + const nextSongUrlRef = useRef(nextSongUrlProp); + + currentSongUrlRef.current = currentSongUrlProp; + nextSongUrlRef.current = nextSongUrlProp; const { mpvAudioDeviceId, transcode } = usePlaybackSettings(); const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters); @@ -124,15 +133,17 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { if (!radioState.currentStreamUrl) { const playerData = usePlayerStore.getState().getPlayerData(); - const currentSongUrl = playerData.currentSong - ? getSongUrl(playerData.currentSong, transcode) - : undefined; - const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) - : undefined; + const currentResolved = + currentSongUrlProp ?? + (playerData.currentSong + ? getSongUrl(playerData.currentSong, transcode) + : undefined); + const nextResolved = + nextSongUrlProp ?? + (playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined); - if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { - mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true); + if (currentResolved && !hasPopulatedQueueRef.current && mpvPlayer) { + mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, true); hasPopulatedQueueRef.current = true; } } @@ -157,6 +168,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [mpvExtraParameters, mpvProperties, mpvAudioDeviceId, reloadTrigger]); + // Sync queue when current/next song URLs change (e.g. user selects song, or external URL resolves from useSongUrl) + useEffect(() => { + if (!mpvPlayer || !isInitializedRef.current) { + return; + } + + const radioState = useRadioStore.getState(); + if (radioState.currentStreamUrl) { + return; + } + + const playerData = usePlayerStore.getState().getPlayerData(); + const currentResolved = + currentSongUrlProp ?? + (playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined); + const nextResolved = + nextSongUrlProp ?? + (playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined); + + if (currentResolved) { + mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, false); + } + }, [currentSongUrlProp, nextSongUrlProp, currentSong?.id, currentSong?._uniqueId, transcode]); + // Update volume useEffect(() => { if (!mpvPlayer) { @@ -257,7 +292,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { const handleOnAutoNext = () => { mediaAutoNext(); - handleMpvAutoNext(transcode); + handleMpvAutoNext(transcode, nextSongUrlRef.current); }; mpvPlayerListener.rendererAutoNext(handleOnAutoNext); @@ -270,10 +305,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { usePlayerEvents( { onMediaNext: () => { - replaceMpvQueue(transcode); + replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current); }, onMediaPrev: () => { - replaceMpvQueue(transcode); + replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current); }, onNextSongInsertion: (song) => { const radioState = useRadioStore.getState(); @@ -282,11 +317,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { return; } - const nextSongUrl = song ? getSongUrl(song, transcode) : undefined; + const nextSongUrl = + nextSongUrlRef.current ?? (song ? getSongUrl(song, transcode) : undefined); mpvPlayer?.setQueueNext(nextSongUrl); }, onPlayerPlay: () => { - replaceMpvQueue(transcode); + replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current); }, onQueueCleared: () => {}, }, @@ -337,24 +373,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { MpvPlayerEngine.displayName = 'MpvPlayerEngine'; -function handleMpvAutoNext(transcode: { - bitrate?: number | undefined; - enabled: boolean; - format?: string | undefined; -}) { +function handleMpvAutoNext( + transcode: { + bitrate?: number | undefined; + enabled: boolean; + format?: string | undefined; + }, + nextUrlOverride?: string, +) { const playerData = usePlayerStore.getState().getPlayerData(); - const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) - : undefined; + const nextSongUrl = + nextUrlOverride ?? + (playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined); mpvPlayer?.autoNext(nextSongUrl); } -function replaceMpvQueue(transcode: { - bitrate?: number | undefined; - enabled: boolean; - format?: string | undefined; -}) { - // Don't override queue if radio is active +function replaceMpvQueue( + transcode: { + bitrate?: number | undefined; + enabled: boolean; + format?: string | undefined; + }, + currentUrlOverride?: string, + nextUrlOverride?: string, +) { const radioState = useRadioStore.getState(); if (radioState.currentStreamUrl) { @@ -362,11 +404,14 @@ function replaceMpvQueue(transcode: { } const playerData = usePlayerStore.getState().getPlayerData(); - const currentSongUrl = playerData.currentSong - ? getSongUrl(playerData.currentSong, transcode) - : undefined; - const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) - : undefined; - mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false); + const currentSongUrl = + currentUrlOverride ?? + (playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined); + const nextSongUrl = + nextUrlOverride ?? + (playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined); + + if (currentSongUrl) { + mpvPlayer?.setQueue(currentSongUrl, nextSongUrl ?? currentSongUrl, false); + } } diff --git a/src/renderer/features/player/audio-player/engine/web-player-engine.tsx b/src/renderer/features/player/audio-player/engine/web-player-engine.tsx index dec9ff203..a2030d947 100644 --- a/src/renderer/features/player/audio-player/engine/web-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/web-player-engine.tsx @@ -10,14 +10,17 @@ import { logMsg } from '/@/renderer/utils/logger-message'; import { PlayerStatus } from '/@/shared/types/types'; export interface WebPlayerEngineHandle extends AudioPlayer { - player1(): { - ref: null | ReactPlayer; - setVolume: (volume: number) => void; - }; - player2(): { - ref: null | ReactPlayer; - setVolume: (volume: number) => void; - }; + player1(): WebPlayerEnginePlayerHandle; + player2(): WebPlayerEnginePlayerHandle; +} + +export interface WebPlayerEnginePlayerHandle { + getCurrentTime: () => number; + getDuration: () => number; + pause: () => void; + play: () => void; + ref: null | ReactPlayer; + setVolume: (volume: number) => void; } interface WebPlayerEngineProps { @@ -39,6 +42,70 @@ interface WebPlayerEngineProps { volume: number; } +interface YouTubePlayer { + getCurrentTime?: () => number; + getDuration?: () => number; + pauseVideo?: () => void; + playVideo?: () => void; +} + +function getInternalCurrentTime(ref: null | ReactPlayer): number { + const internal = ref?.getInternalPlayer(); + if (!internal) return 0; + if (internal instanceof HTMLMediaElement) { + return (internal as HTMLMediaElement).currentTime ?? 0; + } + if (isYouTubePlayer(internal) && typeof internal.getCurrentTime === 'function') { + return internal.getCurrentTime() ?? 0; + } + return 0; +} + +function getInternalDuration(ref: null | ReactPlayer): number { + const internal = ref?.getInternalPlayer(); + if (!internal) return 0; + if (internal instanceof HTMLMediaElement) { + return (internal as HTMLMediaElement).duration ?? 0; + } + if (isYouTubePlayer(internal) && typeof internal.getDuration === 'function') { + return internal.getDuration() ?? 0; + } + return 0; +} + +function isYouTubePlayer(internal: unknown): internal is YouTubePlayer { + return ( + typeof internal === 'object' && + internal !== null && + 'playVideo' in internal && + typeof (internal as YouTubePlayer).playVideo === 'function' + ); +} + +function pauseInternalPlayer(ref: null | ReactPlayer): void { + const internal = ref?.getInternalPlayer(); + if (!internal) return; + if (internal instanceof HTMLMediaElement) { + (internal as HTMLMediaElement).pause(); + return; + } + if (isYouTubePlayer(internal)) { + internal.pauseVideo?.(); + } +} + +function playInternalPlayer(ref: null | ReactPlayer): void { + const internal = ref?.getInternalPlayer(); + if (!internal) return; + if (internal instanceof HTMLMediaElement) { + void (internal as HTMLMediaElement).play().catch(() => {}); + return; + } + if (isYouTubePlayer(internal)) { + internal.playVideo?.(); + } +} + // Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393 // This is used so that the player will always have an