fix mpv player queue behavior to handle gapless playback

This commit is contained in:
jeffvli
2025-12-11 20:36:47 -08:00
parent f7d488ba84
commit 61e70342a4
7 changed files with 283 additions and 102 deletions
+1 -10
View File
@@ -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 // Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, url?: string) => { ipcMain.on('player-set-queue-next', async (_event, url?: string) => {
try { try {
const size = await getMpvInstance()?.getPlaylistSize();
if (!size) {
return;
}
if (size > 1) {
await getMpvInstance()?.playlistRemove(1);
}
if (url) { if (url) {
await getMpvInstance()?.load(url, 'append'); 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 // 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 // This allows us to easily set update the next song in the queue without
// disturbing the currently playing song // disturbing the currently playing song
try { try {
await getMpvInstance() await getMpvInstance()
?.playlistRemove(0) ?.playlistRemove(0)
+18
View File
@@ -3,6 +3,9 @@ import { LibraryItem } from '/@/shared/types/domain-types';
export type EventMap = { export type EventMap = {
ITEM_LIST_REFRESH: ItemListRefreshEventPayload; ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload; ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload;
MEDIA_NEXT: MediaNextEventPayload;
MEDIA_PREV: MediaPrevEventPayload;
PLAYER_PLAY: PlayerPlayEventPayload;
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload; PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
@@ -22,6 +25,21 @@ export type ItemListUpdateItemEventPayload = {
key: string; 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 = { export type PlaylistMoveEventPayload = {
playlistId: string; playlistId: string;
sourceIds: string[]; sourceIds: string[];
@@ -3,18 +3,23 @@ import type { RefObject } from 'react';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useEffect, useImperativeHandle, useRef, useState } from 'react'; 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 { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; 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'; import { PlayerStatus } from '/@/shared/types/types';
export interface MpvPlayerEngineHandle extends AudioPlayer {} export interface MpvPlayerEngineHandle extends AudioPlayer {}
interface MpvPlayerEngineProps { interface MpvPlayerEngineProps {
currentSrc: string | undefined;
isMuted: boolean; isMuted: boolean;
isTransitioning: boolean; isTransitioning: boolean;
nextSrc: string | undefined;
onEnded: () => void; onEnded: () => void;
onProgress: (e: PlayerOnProgressProps) => void; onProgress: (e: PlayerOnProgressProps) => void;
playerRef: RefObject<MpvPlayerEngineHandle | null>; playerRef: RefObject<MpvPlayerEngineHandle | null>;
@@ -28,14 +33,11 @@ const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
const PROGRESS_UPDATE_INTERVAL = 250; const PROGRESS_UPDATE_INTERVAL = 250;
const TRANSITION_PROGRESS_INTERVAL = 10;
export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const { const {
currentSrc,
isMuted, isMuted,
isTransitioning, isTransitioning,
nextSrc,
onEnded, onEnded,
onProgress, onProgress,
playerRef, playerRef,
@@ -46,49 +48,74 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const [internalVolume, setInternalVolume] = useState(volume / 100 || 0); const [internalVolume, setInternalVolume] = useState(volume / 100 || 0);
const [duration] = useState(0); const [duration] = useState(0);
const [previousCurrentSrc, setPreviousCurrentSrc] = useState<string | undefined>(currentSrc);
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null); const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isInitializedRef = useRef<boolean>(false); const isInitializedRef = useRef<boolean>(false);
const hasPopulatedQueueRef = useRef<boolean>(false); const hasPopulatedQueueRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true); const isMountedRef = useRef<boolean>(true);
const currentSrcRef = useRef<string | undefined>(currentSrc); // const currentSrcRef = useRef<string | undefined>(currentSrc);
const nextSrcRef = useRef<string | undefined>(nextSrc); // const nextSrcRef = useRef<string | undefined>(nextSrc);
const { transcode } = usePlaybackSettings();
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters); const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties); const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties);
// const [previousCurrentSrc, setPreviousCurrentSrc] = useState<string | undefined>(currentSrc);
// const [previousNextSrc, setPreviousNextSrc] = useState<string | undefined>(nextSrc);
// Start the mpv instance on startup // Start the mpv instance on startup
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
const initializeMpv = async () => { const initializeMpv = async () => {
// Always quit mpv first to ensure clean state, especially during HMR remounts
const isRunning: boolean | undefined = await mpvPlayer?.isRunning(); const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
if (isRunning) {
mpvPlayer?.quit();
if (!isRunning) { let attempts = 0;
const properties: Record<string, any> = { const maxAttempts = 20;
// speed: usePlayerStore.getState().speed, while (attempts < maxAttempts) {
...getMpvProperties(mpvProperties), await new Promise((resolve) => setTimeout(resolve, 100));
}; const stillRunning = await mpvPlayer?.isRunning();
if (!stillRunning) {
await mpvPlayer?.initialize({ break;
extraParameters: mpvExtraParameters, }
properties, attempts++;
}); }
mpvPlayer?.volume(properties.volume);
isInitializedRef.current = true;
} else {
isInitializedRef.current = true;
} }
// Reset initialization state
isInitializedRef.current = false;
hasPopulatedQueueRef.current = false;
// Initialize mpv with fresh state
const properties: Record<string, any> = {
...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 // After initialization, populate the queue if currentSrc is available
const latestCurrentSrc = currentSrcRef.current; const playerData = usePlayerStore.getState().getPlayerData();
const latestNextSrc = nextSrcRef.current; const currentSongUrl = playerData.currentSong
if (latestCurrentSrc && !hasPopulatedQueueRef.current && mpvPlayer) { ? getSongUrl(playerData.currentSong, transcode)
mpvPlayer.setQueue(latestCurrentSrc, latestNextSrc, true); : undefined;
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
: undefined;
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true);
hasPopulatedQueueRef.current = true; hasPopulatedQueueRef.current = true;
setPreviousCurrentSrc(latestCurrentSrc);
} }
}; };
@@ -96,16 +123,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
// Quit mpv on unmount
mpvPlayer?.quit(); mpvPlayer?.quit();
isInitializedRef.current = false; isInitializedRef.current = false;
hasPopulatedQueueRef.current = false; hasPopulatedQueueRef.current = false;
}; };
}, [mpvExtraParameters, mpvProperties]); }, [mpvExtraParameters, mpvProperties, speed, transcode, volume]);
useEffect(() => {
currentSrcRef.current = currentSrc;
nextSrcRef.current = nextSrc;
}, [currentSrc, nextSrc]);
// Update volume // Update volume
useEffect(() => { useEffect(() => {
@@ -142,35 +165,6 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
mpvPlayer.setProperties({ speed }); mpvPlayer.setProperties({ speed });
}, [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 // Handle play/pause status
useEffect(() => { useEffect(() => {
if (!mpvPlayer) { if (!mpvPlayer) {
@@ -208,13 +202,9 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
} }
}; };
if (currentSrc) { const interval = PROGRESS_UPDATE_INTERVAL;
const interval = isTransitioning progressIntervalRef.current = setInterval(updateProgress, interval);
? TRANSITION_PROGRESS_INTERVAL updateProgress();
: PROGRESS_UPDATE_INTERVAL;
progressIntervalRef.current = setInterval(updateProgress, interval);
updateProgress();
}
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
@@ -223,23 +213,66 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
progressIntervalRef.current = null; progressIntervalRef.current = null;
} }
}; };
}, [currentSrc, isTransitioning, duration, onProgress]); }, [isTransitioning, duration, onProgress]);
const { mediaAutoNext } = usePlayerActions();
useEffect(() => { useEffect(() => {
if (!mpvPlayerListener) { if (!mpvPlayerListener) {
return; return;
} }
const handleOnEnded = () => { const handleOnAutoNext = () => {
onEnded(); 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 () => { return () => {
ipc?.removeAllListeners('renderer-player-auto-next'); 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<MpvPlayerEngineHandle, MpvPlayerEngineHandle>(playerRef, () => ({ useImperativeHandle<MpvPlayerEngineHandle, MpvPlayerEngineHandle>(playerRef, () => ({
decreaseVolume(by: number) { decreaseVolume(by: number) {
@@ -22,10 +22,13 @@ interface PlayerEvents {
interface PlayerEventsCallbacks { interface PlayerEventsCallbacks {
onCurrentSongChange?: ( onCurrentSongChange?: (
properties: { index: number; remaining: number; song: QueueSong | undefined }, properties: { index: number; song: QueueSong | undefined },
prev: { index: number; remaining: number; song: QueueSong | undefined }, prev: { index: number; song: QueueSong | undefined },
) => void; ) => void;
onMediaNext?: (properties: { currentIndex: number; nextIndex: number }) => void;
onMediaPrev?: (properties: { currentIndex: number; prevIndex: number }) => void;
onPlayerMute?: (properties: { muted: boolean }, prev: { muted: boolean }) => void; onPlayerMute?: (properties: { muted: boolean }, prev: { muted: boolean }) => void;
onPlayerPlay?: (properties: { id: string; index: number }) => void;
onPlayerProgress?: (properties: { timestamp: number }, prev: { timestamp: number }) => void; onPlayerProgress?: (properties: { timestamp: number }, prev: { timestamp: number }) => void;
onPlayerQueueChange?: (queue: QueueData, prev: QueueData) => void; onPlayerQueueChange?: (queue: QueueData, prev: QueueData) => void;
onPlayerRepeat?: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void; onPlayerRepeat?: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void;
@@ -129,23 +132,44 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
unsubscribers.push(unsubscribe); unsubscribers.push(unsubscribe);
} }
if (callbacks.onUserRating) { if (callbacks.onMediaNext) {
eventEmitter.on('USER_RATING', callbacks.onUserRating); 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) { if (callbacks.onUserFavorite) {
eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite); eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite);
} }
if (callbacks.onUserRating) {
eventEmitter.on('USER_RATING', callbacks.onUserRating);
}
return { return {
cleanup: () => { cleanup: () => {
unsubscribers.forEach((unsubscribe) => unsubscribe()); unsubscribers.forEach((unsubscribe) => unsubscribe());
if (callbacks.onUserRating) { if (callbacks.onMediaNext) {
eventEmitter.off('USER_RATING', callbacks.onUserRating); 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) { if (callbacks.onUserFavorite) {
eventEmitter.off('USER_FAVORITE', callbacks.onUserFavorite); eventEmitter.off('USER_FAVORITE', callbacks.onUserFavorite);
} }
if (callbacks.onUserRating) {
eventEmitter.off('USER_RATING', callbacks.onUserRating);
}
}, },
}; };
} }
@@ -47,3 +47,15 @@ export function useSongUrl(
transcode.enabled, 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,
},
});
};
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine'; import { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; 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 { import {
usePlaybackSettings, usePlaybackSettings,
usePlayerActions, usePlayerActions,
@@ -22,12 +21,12 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
export function MpvPlayer() { export function MpvPlayer() {
const playerRef = useRef<MpvPlayerEngineHandle>(null); const playerRef = useRef<MpvPlayerEngineHandle>(null);
const { currentSong, nextSong, status } = usePlayerData(); const { status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions(); const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { speed } = usePlayerProperties(); const { speed } = usePlayerProperties();
const isMuted = usePlayerMuted(); const isMuted = usePlayerMuted();
const volume = usePlayerVolume(); const volume = usePlayerVolume();
const { audioFadeOnStatusChange, transcode } = usePlaybackSettings(); const { audioFadeOnStatusChange } = usePlaybackSettings();
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status); const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
@@ -163,15 +162,10 @@ export function MpvPlayer() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [localPlayerStatus, setTimestamp]); }, [localPlayerStatus, setTimestamp]);
const currentUrl = useSongUrl(currentSong, true, transcode);
const nextUrl = useSongUrl(nextSong, false, transcode);
return ( return (
<MpvPlayerEngine <MpvPlayerEngine
currentSrc={currentUrl}
isMuted={isMuted} isMuted={isMuted}
isTransitioning={isTransitioning} isTransitioning={isTransitioning}
nextSrc={nextUrl}
onEnded={handleOnEnded} onEnded={handleOnEnded}
onProgress={onProgress} onProgress={onProgress}
playerRef={playerRef} playerRef={playerRef}
+111 -2
View File
@@ -5,6 +5,7 @@ import { immer } from 'zustand/middleware/immer';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { createSelectors } from '/@/renderer/lib/zustand'; import { createSelectors } from '/@/renderer/lib/zustand';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStore } from '/@/renderer/store/settings.store';
import { import {
@@ -40,6 +41,7 @@ interface Actions {
clearSelected: (items: QueueSong[]) => void; clearSelected: (items: QueueSong[]) => void;
decreaseVolume: (value: number) => void; decreaseVolume: (value: number) => void;
getCurrentSong: () => QueueSong | undefined; getCurrentSong: () => QueueSong | undefined;
getPlayerData: () => PlayerData;
getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue; getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue;
getQueueOrder: () => { getQueueOrder: () => {
groups: { count: number; name: string }[]; groups: { count: number; name: string }[];
@@ -1130,6 +1132,74 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
return queue.items[index]; 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) => { getQueue: (groupBy?: QueueGroupingProperty) => {
const queue = get().getQueueOrder(); const queue = get().getQueueOrder();
const queueType = getQueueType(); const queueType = getQueueType();
@@ -1301,6 +1371,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
state.player.playerNum = 1; state.player.playerNum = 1;
setTimestampStore(0); setTimestampStore(0);
}); });
eventEmitter.emit('MEDIA_NEXT', {
currentIndex,
nextIndex,
});
}, },
mediaPause: () => { mediaPause: () => {
set((state) => { set((state) => {
@@ -1308,6 +1383,8 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
}); });
}, },
mediaPlay: (id?: string) => { mediaPlay: (id?: string) => {
let playIndex: number | undefined;
set((state) => { set((state) => {
if (id) { if (id) {
const queue = state.getQueue(); const queue = state.getQueue();
@@ -1328,11 +1405,14 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
); );
if (shuffledPosition !== -1) { if (shuffledPosition !== -1) {
state.player.index = shuffledPosition; state.player.index = shuffledPosition;
playIndex = shuffledPosition;
} else { } else {
state.player.index = queueIndex; state.player.index = queueIndex;
playIndex = queueIndex;
} }
} else { } else {
state.player.index = queueIndex; state.player.index = queueIndex;
playIndex = queueIndex;
} }
setTimestampStore(0); setTimestampStore(0);
} }
@@ -1340,8 +1420,18 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
state.player.status = PlayerStatus.PLAYING; state.player.status = PlayerStatus.PLAYING;
}); });
if (id && playIndex !== undefined) {
eventEmitter.emit('PLAYER_PLAY', {
id,
index: playIndex,
});
}
}, },
mediaPlayByIndex: (index: number) => { mediaPlayByIndex: (index: number) => {
let playIndex: number | undefined;
let songId: string | undefined;
set((state) => { set((state) => {
const queue = state.getQueue(); const queue = state.getQueue();
@@ -1350,6 +1440,12 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
return; 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 // index is the position in the original queue
if (isShuffleEnabled(state)) { if (isShuffleEnabled(state)) {
// Find the shuffled position for this queue index // Find the shuffled position for this queue index
@@ -1357,15 +1453,23 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
index, index,
state.queue.shuffled, state.queue.shuffled,
); );
state.player.index = playIndex = shuffledPosition !== undefined ? shuffledPosition : index;
shuffledPosition !== undefined ? shuffledPosition : index; state.player.index = playIndex;
} else { } else {
playIndex = index;
state.player.index = index; state.player.index = index;
} }
setTimestampStore(0); setTimestampStore(0);
state.player.status = PlayerStatus.PLAYING; state.player.status = PlayerStatus.PLAYING;
}); });
if (songId && playIndex !== undefined) {
eventEmitter.emit('PLAYER_PLAY', {
id: songId,
index: playIndex,
});
}
}, },
mediaPrevious: () => { mediaPrevious: () => {
const currentIndex = get().player.index; const currentIndex = get().player.index;
@@ -1400,6 +1504,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
state.player.playerNum = 1; state.player.playerNum = 1;
setTimestampStore(0); setTimestampStore(0);
}); });
eventEmitter.emit('MEDIA_PREV', {
currentIndex,
prevIndex: previousIndex,
});
}, },
mediaSeekToTimestamp: (timestamp: number) => { mediaSeekToTimestamp: (timestamp: number) => {
set((state) => { set((state) => {