From 61e70342a4981666ed8a0d0fdb60f66a7f2441bc Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 11 Dec 2025 20:36:47 -0800 Subject: [PATCH] fix mpv player queue behavior to handle gapless playback --- src/main/features/core/player/index.ts | 11 +- src/renderer/events/events.ts | 18 ++ .../audio-player/engine/mpv-player-engine.tsx | 185 +++++++++++------- .../audio-player/hooks/use-player-events.ts | 36 +++- .../audio-player/hooks/use-stream-url.tsx | 12 ++ .../player/audio-player/mpv-player.tsx | 10 +- src/renderer/store/player.store.ts | 113 ++++++++++- 7 files changed, 283 insertions(+), 102 deletions(-) diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 9080037b0..fc1e75106 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -371,16 +371,6 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p // Replaces the queue in position 1 to the given data ipcMain.on('player-set-queue-next', async (_event, url?: string) => { try { - const size = await getMpvInstance()?.getPlaylistSize(); - - if (!size) { - return; - } - - if (size > 1) { - await getMpvInstance()?.playlistRemove(1); - } - if (url) { await getMpvInstance()?.load(url, 'append'); } @@ -394,6 +384,7 @@ ipcMain.on('player-auto-next', async (_event, url?: string) => { // Always keep the current song as position 0 in the mpv queue // This allows us to easily set update the next song in the queue without // disturbing the currently playing song + try { await getMpvInstance() ?.playlistRemove(0) diff --git a/src/renderer/events/events.ts b/src/renderer/events/events.ts index 1db436725..2c3219f09 100644 --- a/src/renderer/events/events.ts +++ b/src/renderer/events/events.ts @@ -3,6 +3,9 @@ import { LibraryItem } from '/@/shared/types/domain-types'; export type EventMap = { ITEM_LIST_REFRESH: ItemListRefreshEventPayload; ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload; + MEDIA_NEXT: MediaNextEventPayload; + MEDIA_PREV: MediaPrevEventPayload; + PLAYER_PLAY: PlayerPlayEventPayload; PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload; @@ -22,6 +25,21 @@ export type ItemListUpdateItemEventPayload = { key: string; }; +export type MediaNextEventPayload = { + currentIndex: number; + nextIndex: number; +}; + +export type MediaPrevEventPayload = { + currentIndex: number; + prevIndex: number; +}; + +export type PlayerPlayEventPayload = { + id: string; + index: number; +}; + export type PlaylistMoveEventPayload = { playlistId: string; sourceIds: string[]; 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 fae079af2..c4a90fc95 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 @@ -3,18 +3,23 @@ import type { RefObject } from 'react'; import isElectron from 'is-electron'; import { useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; +import { getSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url'; import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; -import { useSettingsStore } from '/@/renderer/store'; +import { + usePlaybackSettings, + usePlayerActions, + usePlayerStore, + useSettingsStore, +} from '/@/renderer/store'; import { PlayerStatus } from '/@/shared/types/types'; export interface MpvPlayerEngineHandle extends AudioPlayer {} interface MpvPlayerEngineProps { - currentSrc: string | undefined; isMuted: boolean; isTransitioning: boolean; - nextSrc: string | undefined; onEnded: () => void; onProgress: (e: PlayerOnProgressProps) => void; playerRef: RefObject; @@ -28,14 +33,11 @@ const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null; const ipc = isElectron() ? window.api.ipc : null; const PROGRESS_UPDATE_INTERVAL = 250; -const TRANSITION_PROGRESS_INTERVAL = 10; export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { const { - currentSrc, isMuted, isTransitioning, - nextSrc, onEnded, onProgress, playerRef, @@ -46,49 +48,74 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { const [internalVolume, setInternalVolume] = useState(volume / 100 || 0); const [duration] = useState(0); - const [previousCurrentSrc, setPreviousCurrentSrc] = useState(currentSrc); const progressIntervalRef = useRef(null); const isInitializedRef = useRef(false); const hasPopulatedQueueRef = useRef(false); const isMountedRef = useRef(true); - const currentSrcRef = useRef(currentSrc); - const nextSrcRef = useRef(nextSrc); + // const currentSrcRef = useRef(currentSrc); + // const nextSrcRef = useRef(nextSrc); + const { transcode } = usePlaybackSettings(); const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters); const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties); + // const [previousCurrentSrc, setPreviousCurrentSrc] = useState(currentSrc); + // const [previousNextSrc, setPreviousNextSrc] = useState(nextSrc); + // Start the mpv instance on startup useEffect(() => { isMountedRef.current = true; const initializeMpv = async () => { + // Always quit mpv first to ensure clean state, especially during HMR remounts const isRunning: boolean | undefined = await mpvPlayer?.isRunning(); + if (isRunning) { + mpvPlayer?.quit(); - if (!isRunning) { - const properties: Record = { - // speed: usePlayerStore.getState().speed, - ...getMpvProperties(mpvProperties), - }; - - await mpvPlayer?.initialize({ - extraParameters: mpvExtraParameters, - properties, - }); - - mpvPlayer?.volume(properties.volume); - isInitializedRef.current = true; - } else { - isInitializedRef.current = true; + let attempts = 0; + const maxAttempts = 20; + while (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const stillRunning = await mpvPlayer?.isRunning(); + if (!stillRunning) { + break; + } + attempts++; + } } + // Reset initialization state + isInitializedRef.current = false; + hasPopulatedQueueRef.current = false; + + // Initialize mpv with fresh state + const properties: Record = { + ...getMpvProperties(mpvProperties), + speed: speed, + }; + + await mpvPlayer?.initialize({ + extraParameters: mpvExtraParameters, + properties, + }); + + // Set volume from the current app volume + mpvPlayer?.volume(volume); + isInitializedRef.current = true; + // After initialization, populate the queue if currentSrc is available - const latestCurrentSrc = currentSrcRef.current; - const latestNextSrc = nextSrcRef.current; - if (latestCurrentSrc && !hasPopulatedQueueRef.current && mpvPlayer) { - mpvPlayer.setQueue(latestCurrentSrc, latestNextSrc, true); + const playerData = usePlayerStore.getState().getPlayerData(); + const currentSongUrl = playerData.currentSong + ? getSongUrl(playerData.currentSong, transcode) + : undefined; + const nextSongUrl = playerData.nextSong + ? getSongUrl(playerData.nextSong, transcode) + : undefined; + + if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { + mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true); hasPopulatedQueueRef.current = true; - setPreviousCurrentSrc(latestCurrentSrc); } }; @@ -96,16 +123,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { return () => { isMountedRef.current = false; + // Quit mpv on unmount mpvPlayer?.quit(); isInitializedRef.current = false; hasPopulatedQueueRef.current = false; }; - }, [mpvExtraParameters, mpvProperties]); - - useEffect(() => { - currentSrcRef.current = currentSrc; - nextSrcRef.current = nextSrc; - }, [currentSrc, nextSrc]); + }, [mpvExtraParameters, mpvProperties, speed, transcode, volume]); // Update volume useEffect(() => { @@ -142,35 +165,6 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { mpvPlayer.setProperties({ speed }); }, [speed]); - // Handle current song changes - update queue position 0 - // When currentSrc changes, we need to update the queue - useEffect(() => { - if (!mpvPlayer) { - return; - } - - // If currentSrc changed, update the queue - if (currentSrc !== previousCurrentSrc) { - if (currentSrc) { - // Set current song at position 0 and next song at position 1 - mpvPlayer.setQueue(currentSrc, nextSrc, playerStatus !== PlayerStatus.PLAYING); - setPreviousCurrentSrc(currentSrc); - } else { - // Only clear queue if we had a previous currentSrc (intentional clear) - if (previousCurrentSrc !== undefined) { - mpvPlayer.setQueue(undefined, undefined, true); - setPreviousCurrentSrc(undefined); - } - } - } else { - // If currentSrc hasn't changed but nextSrc has, update position 1 - // This happens when the next song changes but current song stays the same - if (currentSrc) { - mpvPlayer.setQueueNext(nextSrc); - } - } - }, [currentSrc, previousCurrentSrc, nextSrc, playerStatus]); - // Handle play/pause status useEffect(() => { if (!mpvPlayer) { @@ -208,13 +202,9 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { } }; - if (currentSrc) { - const interval = isTransitioning - ? TRANSITION_PROGRESS_INTERVAL - : PROGRESS_UPDATE_INTERVAL; - progressIntervalRef.current = setInterval(updateProgress, interval); - updateProgress(); - } + const interval = PROGRESS_UPDATE_INTERVAL; + progressIntervalRef.current = setInterval(updateProgress, interval); + updateProgress(); return () => { isMountedRef.current = false; @@ -223,23 +213,66 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { progressIntervalRef.current = null; } }; - }, [currentSrc, isTransitioning, duration, onProgress]); + }, [isTransitioning, duration, onProgress]); + + const { mediaAutoNext } = usePlayerActions(); useEffect(() => { if (!mpvPlayerListener) { return; } - const handleOnEnded = () => { - onEnded(); + const handleOnAutoNext = () => { + mediaAutoNext(); + const playerData = usePlayerStore.getState().getPlayerData(); + const nextSongUrl = playerData.nextSong + ? getSongUrl(playerData.nextSong, transcode) + : undefined; + mpvPlayer?.setQueueNext(nextSongUrl); }; - mpvPlayerListener.rendererAutoNext(handleOnEnded); + mpvPlayerListener.rendererAutoNext(handleOnAutoNext); return () => { ipc?.removeAllListeners('renderer-player-auto-next'); }; - }, [nextSrc, onEnded]); + }, [mediaAutoNext, onEnded, transcode]); + + usePlayerEvents( + { + onMediaNext: () => { + 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); + }, + onMediaPrev: () => { + 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); + }, + onPlayerPlay: () => { + 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); + }, + }, + [mpvPlayer, transcode], + ); useImperativeHandle(playerRef, () => ({ decreaseVolume(by: number) { diff --git a/src/renderer/features/player/audio-player/hooks/use-player-events.ts b/src/renderer/features/player/audio-player/hooks/use-player-events.ts index 8aebd6a9a..18bf520db 100644 --- a/src/renderer/features/player/audio-player/hooks/use-player-events.ts +++ b/src/renderer/features/player/audio-player/hooks/use-player-events.ts @@ -22,10 +22,13 @@ interface PlayerEvents { interface PlayerEventsCallbacks { onCurrentSongChange?: ( - properties: { index: number; remaining: number; song: QueueSong | undefined }, - prev: { index: number; remaining: number; song: QueueSong | undefined }, + properties: { index: number; song: QueueSong | undefined }, + prev: { index: number; song: QueueSong | undefined }, ) => void; + onMediaNext?: (properties: { currentIndex: number; nextIndex: number }) => void; + onMediaPrev?: (properties: { currentIndex: number; prevIndex: number }) => void; onPlayerMute?: (properties: { muted: boolean }, prev: { muted: boolean }) => void; + onPlayerPlay?: (properties: { id: string; index: number }) => void; onPlayerProgress?: (properties: { timestamp: number }, prev: { timestamp: number }) => void; onPlayerQueueChange?: (queue: QueueData, prev: QueueData) => void; onPlayerRepeat?: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void; @@ -129,23 +132,44 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents { unsubscribers.push(unsubscribe); } - if (callbacks.onUserRating) { - eventEmitter.on('USER_RATING', callbacks.onUserRating); + if (callbacks.onMediaNext) { + eventEmitter.on('MEDIA_NEXT', callbacks.onMediaNext); + } + + if (callbacks.onMediaPrev) { + eventEmitter.on('MEDIA_PREV', callbacks.onMediaPrev); + } + + if (callbacks.onPlayerPlay) { + eventEmitter.on('PLAYER_PLAY', callbacks.onPlayerPlay); } if (callbacks.onUserFavorite) { eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite); } + if (callbacks.onUserRating) { + eventEmitter.on('USER_RATING', callbacks.onUserRating); + } + return { cleanup: () => { unsubscribers.forEach((unsubscribe) => unsubscribe()); - if (callbacks.onUserRating) { - eventEmitter.off('USER_RATING', callbacks.onUserRating); + if (callbacks.onMediaNext) { + eventEmitter.off('MEDIA_NEXT', callbacks.onMediaNext); + } + if (callbacks.onMediaPrev) { + eventEmitter.off('MEDIA_PREV', callbacks.onMediaPrev); + } + if (callbacks.onPlayerPlay) { + eventEmitter.off('PLAYER_PLAY', callbacks.onPlayerPlay); } if (callbacks.onUserFavorite) { eventEmitter.off('USER_FAVORITE', callbacks.onUserFavorite); } + if (callbacks.onUserRating) { + eventEmitter.off('USER_RATING', callbacks.onUserRating); + } }, }; } diff --git a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx index 03544c7ec..c0462a95b 100644 --- a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx +++ b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx @@ -47,3 +47,15 @@ export function useSongUrl( transcode.enabled, ]); } + +export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => { + return api.controller.getStreamUrl({ + apiClientProps: { serverId: song._serverId }, + query: { + bitrate: transcode.bitrate, + format: transcode.format, + id: song.id, + transcode: transcode.enabled, + }, + }); +}; diff --git a/src/renderer/features/player/audio-player/mpv-player.tsx b/src/renderer/features/player/audio-player/mpv-player.tsx index 9a0d2bc1e..35cb973b2 100644 --- a/src/renderer/features/player/audio-player/mpv-player.tsx +++ b/src/renderer/features/player/audio-player/mpv-player.tsx @@ -4,7 +4,6 @@ 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 { usePlaybackSettings, usePlayerActions, @@ -22,12 +21,12 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; export function MpvPlayer() { const playerRef = useRef(null); - const { currentSong, nextSong, status } = usePlayerData(); + const { status } = usePlayerData(); const { mediaAutoNext, setTimestamp } = usePlayerActions(); const { speed } = usePlayerProperties(); const isMuted = usePlayerMuted(); const volume = usePlayerVolume(); - const { audioFadeOnStatusChange, transcode } = usePlaybackSettings(); + const { audioFadeOnStatusChange } = usePlaybackSettings(); const [localPlayerStatus, setLocalPlayerStatus] = useState(status); const [isTransitioning, setIsTransitioning] = useState(false); @@ -163,15 +162,10 @@ export function MpvPlayer() { return () => clearInterval(interval); }, [localPlayerStatus, setTimestamp]); - const currentUrl = useSongUrl(currentSong, true, transcode); - const nextUrl = useSongUrl(nextSong, false, transcode); - return ( void; decreaseVolume: (value: number) => void; getCurrentSong: () => QueueSong | undefined; + getPlayerData: () => PlayerData; getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue; getQueueOrder: () => { groups: { count: number; name: string }[]; @@ -1130,6 +1132,74 @@ export const usePlayerStoreBase = createWithEqualityFn()( return queue.items[index]; }, + getPlayerData: () => { + const state = get(); + const queue = state.getQueue(); + const index = state.player.index; + + // If shuffle is enabled and not in priority mode, map shuffled position to actual queue position for display + let queueIndex = index; + if (isShuffleEnabled(state)) { + queueIndex = mapShuffledToQueueIndex(index, state.queue.shuffled); + } + + const currentSong = queue.items[queueIndex]; + const repeat = state.player.repeat; + + // For previousSong calculation, we need to consider the shuffled order (only if not in priority mode) + let previousSong: QueueSong | undefined; + if (isShuffleEnabled(state)) { + // Calculate previous in shuffled order + const previousShuffledIndex = index - 1; + if (previousShuffledIndex >= 0) { + const previousQueueIndex = state.queue.shuffled[previousShuffledIndex]; + previousSong = queue.items[previousQueueIndex]; + } else if (repeat === PlayerRepeat.ALL) { + // Wrap to last in shuffled order + const lastShuffledIndex = state.queue.shuffled.length - 1; + const lastQueueIndex = state.queue.shuffled[lastShuffledIndex]; + previousSong = queue.items[lastQueueIndex]; + } + } else { + previousSong = queueIndex > 0 ? queue.items[queueIndex - 1] : undefined; + } + + // For nextSong calculation, we need to consider the shuffled order (only if not in priority mode) + let nextSong: QueueSong | undefined; + if (isShuffleEnabled(state)) { + // Calculate next in shuffled order + const nextShuffledIndex = index + 1; + if (nextShuffledIndex < state.queue.shuffled.length) { + const nextQueueIndex = state.queue.shuffled[nextShuffledIndex]; + nextSong = queue.items[nextQueueIndex]; + } else if (repeat === PlayerRepeat.ALL) { + // Wrap to first in shuffled order + const firstQueueIndex = state.queue.shuffled[0]; + nextSong = queue.items[firstQueueIndex]; + } + } else { + nextSong = calculateNextSong(queueIndex, queue.items, repeat); + } + + return { + currentSong, + index: queueIndex, // Return the actual queue position for display + muted: state.player.muted, + nextSong, + num: state.player.playerNum, + player1: state.player.playerNum === 1 ? currentSong : nextSong, + player2: state.player.playerNum === 2 ? currentSong : nextSong, + previousSong, + queue: state.queue, + queueLength: state.queue.default.length + state.queue.priority.length, + repeat: state.player.repeat, + shuffle: state.player.shuffle, + speed: state.player.speed, + status: state.player.status, + transitionType: state.player.transitionType, + volume: state.player.volume, + }; + }, getQueue: (groupBy?: QueueGroupingProperty) => { const queue = get().getQueueOrder(); const queueType = getQueueType(); @@ -1301,6 +1371,11 @@ export const usePlayerStoreBase = createWithEqualityFn()( state.player.playerNum = 1; setTimestampStore(0); }); + + eventEmitter.emit('MEDIA_NEXT', { + currentIndex, + nextIndex, + }); }, mediaPause: () => { set((state) => { @@ -1308,6 +1383,8 @@ export const usePlayerStoreBase = createWithEqualityFn()( }); }, mediaPlay: (id?: string) => { + let playIndex: number | undefined; + set((state) => { if (id) { const queue = state.getQueue(); @@ -1328,11 +1405,14 @@ export const usePlayerStoreBase = createWithEqualityFn()( ); if (shuffledPosition !== -1) { state.player.index = shuffledPosition; + playIndex = shuffledPosition; } else { state.player.index = queueIndex; + playIndex = queueIndex; } } else { state.player.index = queueIndex; + playIndex = queueIndex; } setTimestampStore(0); } @@ -1340,8 +1420,18 @@ export const usePlayerStoreBase = createWithEqualityFn()( state.player.status = PlayerStatus.PLAYING; }); + + if (id && playIndex !== undefined) { + eventEmitter.emit('PLAYER_PLAY', { + id, + index: playIndex, + }); + } }, mediaPlayByIndex: (index: number) => { + let playIndex: number | undefined; + let songId: string | undefined; + set((state) => { const queue = state.getQueue(); @@ -1350,6 +1440,12 @@ export const usePlayerStoreBase = createWithEqualityFn()( return; } + // Get the song's unique ID from the queue + const song = queue.items[index]; + if (song) { + songId = song._uniqueId; + } + // index is the position in the original queue if (isShuffleEnabled(state)) { // Find the shuffled position for this queue index @@ -1357,15 +1453,23 @@ export const usePlayerStoreBase = createWithEqualityFn()( index, state.queue.shuffled, ); - state.player.index = - shuffledPosition !== undefined ? shuffledPosition : index; + playIndex = shuffledPosition !== undefined ? shuffledPosition : index; + state.player.index = playIndex; } else { + playIndex = index; state.player.index = index; } setTimestampStore(0); state.player.status = PlayerStatus.PLAYING; }); + + if (songId && playIndex !== undefined) { + eventEmitter.emit('PLAYER_PLAY', { + id: songId, + index: playIndex, + }); + } }, mediaPrevious: () => { const currentIndex = get().player.index; @@ -1400,6 +1504,11 @@ export const usePlayerStoreBase = createWithEqualityFn()( state.player.playerNum = 1; setTimestampStore(0); }); + + eventEmitter.emit('MEDIA_PREV', { + currentIndex, + prevIndex: previousIndex, + }); }, mediaSeekToTimestamp: (timestamp: number) => { set((state) => {