support ytmusic controls on web/mpv players

This commit is contained in:
jeffvli
2026-02-06 21:38:05 -08:00
parent 8e603871b7
commit 8ae29407ec
6 changed files with 243 additions and 75 deletions
@@ -10,8 +10,10 @@ async function searchYoutube(query: string): Promise<Array<{ type: string; video
export const youtubeQueries = {
search: (args: { query: string }) => {
return queryOptions({
gcTime: 1000 * 60 * 1,
queryFn: () => searchYoutube(args.query),
queryKey: ['youtube', 'search', args.query],
staleTime: 1000 * 60 * 1,
});
},
};
@@ -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<MpvPlayerEngineHandle | null>;
@@ -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<boolean>(false);
const hasPopulatedQueueRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const currentSongUrlRef = useRef<string | undefined>(currentSongUrlProp);
const nextSongUrlRef = useRef<string | undefined>(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);
}
}
@@ -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 <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
@@ -108,25 +175,33 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
},
pause() {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
pauseInternalPlayer(player1Ref.current);
pauseInternalPlayer(player2Ref.current);
},
play() {
if (playerNum === 1) {
player1Ref.current?.getInternalPlayer()?.play();
playInternalPlayer(player1Ref.current);
} else {
player2Ref.current?.getInternalPlayer()?.play();
playInternalPlayer(player2Ref.current);
}
},
player1() {
player1(): WebPlayerEnginePlayerHandle {
return {
ref: player1Ref?.current,
getCurrentTime: () => getInternalCurrentTime(player1Ref.current),
getDuration: () => getInternalDuration(player1Ref.current),
pause: () => pauseInternalPlayer(player1Ref.current),
play: () => playInternalPlayer(player1Ref.current),
ref: player1Ref?.current ?? null,
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
};
},
player2() {
player2(): WebPlayerEnginePlayerHandle {
return {
ref: player2Ref?.current,
getCurrentTime: () => getInternalCurrentTime(player2Ref.current),
getDuration: () => getInternalDuration(player2Ref.current),
pause: () => pauseInternalPlayer(player2Ref.current),
play: () => playInternalPlayer(player2Ref.current),
ref: player2Ref?.current ?? null,
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
};
},
@@ -30,6 +30,7 @@ export function useSongUrl(
return prior.current[1];
}
const url = getYoutubeUrlFromSearchResults(youtubeSearch.data);
if (url) prior.current = [song._uniqueId, url];
return url;
}, [song, isExternal, current, youtubeSearch.data]);
@@ -81,12 +82,16 @@ function getYoutubeUrlFromSearchResults(
): string | undefined {
if (!results?.length) return undefined;
const first = results.find((r) => r.type === 'SONG' || r.type === 'VIDEO');
return first && 'videoId' in first && first.videoId
? `${YOUTUBE_WATCH_BASE}${first.videoId}`
: undefined;
}
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig): string => {
if (song._serverType === ServerType.EXTERNAL) {
return '';
}
return api.controller.getStreamUrl({
apiClientProps: { serverId: song._serverId },
query: {
@@ -97,3 +102,32 @@ export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
},
});
};
export async function getSongUrlAsync(
song: QueueSong | undefined,
transcode: TranscodingConfig,
): Promise<string | undefined> {
if (!song) {
return undefined;
}
if (song._serverType === ServerType.EXTERNAL) {
if (typeof window === 'undefined' || !window.api?.youtube) {
return undefined;
}
const searchQuery = `${song.artistName ?? ''} ${song.name ?? ''}`.trim();
if (!searchQuery) {
return undefined;
}
try {
const results = await window.api.youtube.search(searchQuery);
console.log('results', results);
return getYoutubeUrlFromSearchResults(results);
} catch {
return undefined;
}
}
const url = getSongUrl(song, transcode);
return url || undefined;
}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
usePlaybackSettings,
@@ -23,12 +24,15 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
export function MpvPlayer() {
const playerRef = useRef<MpvPlayerEngineHandle>(null);
const { currentSong, status } = usePlayerData();
const { currentSong, nextSong, status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { speed } = usePlayerProperties();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
const { audioFadeOnStatusChange } = usePlaybackSettings();
const { audioFadeOnStatusChange, transcode } = usePlaybackSettings();
const currentSongUrl = useSongUrl(currentSong, true, transcode);
const nextSongUrl = useSongUrl(nextSong, false, transcode);
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
const [isTransitioning, setIsTransitioning] = useState(false);
@@ -174,8 +178,10 @@ export function MpvPlayer() {
return (
<MpvPlayerEngine
currentSongUrl={currentSongUrl}
isMuted={isMuted}
isTransitioning={isTransitioning}
nextSongUrl={nextSongUrl}
onEnded={handleOnEnded}
onProgress={onProgress}
playerRef={playerRef}
@@ -106,7 +106,7 @@ export function WebPlayer() {
currentPlayer: playerRef.current.player1(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
duration: getDuration(playerRef.current.player1()),
hasNextSong: Boolean(player2),
isTransitioning,
nextPlayer: playerRef.current.player2(),
@@ -118,7 +118,7 @@ export function WebPlayer() {
case PlayerStyle.GAPLESS:
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
duration: getDuration(playerRef.current.player1()),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player2(),
@@ -144,7 +144,7 @@ export function WebPlayer() {
currentPlayer: playerRef.current.player2(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
duration: getDuration(playerRef.current.player2()),
hasNextSong: Boolean(player1),
isTransitioning,
nextPlayer: playerRef.current.player1(),
@@ -156,7 +156,7 @@ export function WebPlayer() {
case PlayerStyle.GAPLESS:
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
duration: getDuration(playerRef.current.player2()),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player1(),
@@ -175,7 +175,7 @@ export function WebPlayer() {
});
promise.then(() => {
playerRef.current?.player1()?.ref?.getInternalPlayer().pause();
playerRef.current?.player1()?.pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
@@ -188,7 +188,7 @@ export function WebPlayer() {
});
promise.then(() => {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
playerRef.current?.player2()?.pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
@@ -213,11 +213,11 @@ export function WebPlayer() {
if (num === 1) {
playerRef.current?.player1()?.setVolume(volume);
playerRef.current?.player2()?.setVolume(0);
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player2()?.pause();
} else {
playerRef.current?.player2()?.setVolume(volume);
playerRef.current?.player1()?.setVolume(0);
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player1()?.pause();
}
}
@@ -241,11 +241,11 @@ export function WebPlayer() {
if (num === 1) {
playerRef.current?.player1()?.setVolume(volume);
playerRef.current?.player2()?.setVolume(0);
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player2()?.pause();
} else {
playerRef.current?.player2()?.setVolume(volume);
playerRef.current?.player1()?.setVolume(0);
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player1()?.pause();
}
}
@@ -294,14 +294,12 @@ export function WebPlayer() {
const interval = setInterval(() => {
const activePlayer =
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
const internalPlayer =
activePlayer?.ref?.getInternalPlayer() as HTMLAudioElement | null;
if (!internalPlayer) {
if (!activePlayer) {
return;
}
const currentTime = internalPlayer.currentTime;
const currentTime = activePlayer.getCurrentTime();
if (
transitionType === PlayerStyle.CROSSFADE ||
@@ -468,6 +466,7 @@ function crossfadeHandler(args: {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: {
pause: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
@@ -477,6 +476,8 @@ function crossfadeHandler(args: {
hasNextSong: boolean;
isTransitioning: boolean | string;
nextPlayer: {
pause: () => void;
play: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
@@ -504,7 +505,7 @@ function crossfadeHandler(args: {
if (!hasNextSong) {
currentPlayer.setVolume(volume);
nextPlayer.setVolume(0);
nextPlayer.ref?.getInternalPlayer()?.pause();
nextPlayer.pause();
if (isTransitioning) {
setIsTransitioning(false);
@@ -516,7 +517,7 @@ function crossfadeHandler(args: {
if (!isTransitioning) {
if (duration > 0 && currentTime > duration - crossfadeDuration) {
nextPlayer.setVolume(0);
nextPlayer.ref?.getInternalPlayer().play();
nextPlayer.play();
return setIsTransitioning(player);
}
@@ -586,6 +587,7 @@ function gaplessHandler(args: {
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
play: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
@@ -604,10 +606,8 @@ function gaplessHandler(args: {
const durationPadding = getDurationPadding(isFlac);
if (currentTime + durationPadding >= duration) {
return nextPlayer.ref
?.getInternalPlayer()
?.play()
.catch(() => {});
nextPlayer.play();
return;
}
return null;
@@ -647,8 +647,14 @@ function getCrossfadeEasing(style: CrossfadeStyle): {
}
}
function getDuration(ref: null | ReactPlayer | undefined) {
return ref?.getInternalPlayer()?.duration || 0;
function getDuration(
player:
| undefined
| {
getDuration: () => number;
},
) {
return player?.getDuration?.() ?? 0;
}
function getDurationPadding(isFlac: boolean) {