import isElectron from 'is-electron'; import merge from 'lodash/merge'; import { nanoid } from 'nanoid'; import { useMemo } from 'react'; import { persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { useShallow } from 'zustand/react/shallow'; import { eventEmitter } from '/@/renderer/events/event-emitter'; import { createSelectors } from '/@/renderer/lib/zustand'; import { createSubscribedTraditionalStore } from '/@/renderer/lib/zustand-traditional'; import { isStructuredMusicContext, type MusicPlaybackContext, rememberMusicPlaybackSession, SONG_CONTEXT, } from '/@/renderer/store/last-playback-session.store'; import { usePlaybackOwnerStore } from '/@/renderer/store/playback-owner.store'; import { computePlayerData, getCurrentSongFromState, getPlaybackInputs, getPlayerDataFromState, getQueueFromState, getQueueOrderFromState, isFirstTrackInQueueFromState, isLastTrackInQueueFromState, playbackInputsEqual, QueueGroupingProperty, touchQueueRevision, } from '/@/renderer/store/player-derived'; import { applyAddToQueueLast, applyAddToQueueLastShuffle, applyAddToQueueNext, applyAddToQueueNextShuffle, applyAddToQueueNow, applyAddToQueueShuffle, applyShuffleQueue, applyUnshuffleQueue, registerQueueSongs, } from '/@/renderer/store/player-queue-actions'; import { calculateNextIndex, calculateNextSong } from '/@/renderer/store/player-queue-math'; import { emitPlayerSeek, subscribePlayerSeek } from '/@/renderer/store/player/seek'; import { createInitialPlayerTransportSlice, type PlayerTransportSlice, } from '/@/renderer/store/player/slices'; import { useSettingsStore } from '/@/renderer/store/settings.store'; import { setTimestamp as setTimestampStore, useTimestampStoreBase, } from '/@/renderer/store/timestamp.store'; import { migratePlayerStorePersist, playerStoreStorage, setPlayerStoreHydratedForPersistence, } from '/@/renderer/store/utils'; export type { GroupedQueue, QueueGroupingProperty } from '/@/renderer/store/player-derived'; import { shuffleInPlace } from '/@/renderer/utils/shuffle'; export { calculateNextSong } from '/@/renderer/store/player-queue-math'; import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types'; import { CrossfadeStyle, Play, PlayerRepeat, PlayerShuffle, PlayerStatus, PlayerStyle, PlayerType, } from '/@/shared/types/types'; export interface PlayerState extends Actions, PlayerDataState {} interface Actions { addToQueueByType: ( items: Song[], playType: Play, playSongId?: string, /** * Optional context describing the source (album/playlist) the user invoked. * Only consulted for fresh-start play types (`Play.NOW`, `Play.SHUFFLE`); additive * play types (LAST, NEXT, etc.) preserve the current context. When fresh-start * fires without an explicit context the player resets to `SONG_CONTEXT` — this is * what keeps an old album context from bleeding into an unrelated single-track play. */ context?: MusicPlaybackContext, ) => void; addToQueueByUniqueId: ( items: Song[], uniqueId: string, edge: 'bottom' | 'top', playSongId?: string, ) => void; clearQueue: () => void; clearSelected: (items: QueueSong[]) => void; decreaseVolume: (value: number) => void; increaseVolume: (value: number) => void; mediaAutoNext: () => PlayerData; mediaNext: () => void; mediaPause: () => void; mediaPlay: (id?: string) => void; mediaPlayByIndex: (index: number) => void; mediaPrevious: () => void; mediaSeekToTimestamp: (timestamp: number) => void; mediaSkipBackward: (offset?: number) => void; mediaSkipForward: (offset?: number) => void; /** * @param options.reset - When true (default), emits PLAYER_SEEK(0) so the engine seeks to start. * Timestamp display is always cleared to 0. Use false when the engine is already idle (e.g. mpv `stopped`) to skip that seek. */ mediaStop: (options?: { reset?: boolean }) => void; mediaToggleMute: () => void; mediaTogglePlayPause: () => void; moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => void; moveSelectedToBottom: (items: QueueSong[]) => void; moveSelectedToNext: (items: QueueSong[]) => void; moveSelectedToTop: (items: QueueSong[]) => void; setCrossfadeDuration: (duration: number) => void; setCrossfadeStyle: (style: CrossfadeStyle) => void; setMusicPlaybackContext: (context: MusicPlaybackContext) => void; setPauseOnNextSongEnd: (value: boolean) => void; setQueue: ( data: Song[], index?: number, position?: number, /** Defaults to `SONG_CONTEXT` — `setQueue` is always a fresh start. */ context?: MusicPlaybackContext, /** * When false, the queue is seeded paused — used by launch-time session restore * (one-track lifeboat) so the user can press play to resume rather than having * audio start unprompted. Defaults to true (user-initiated playback). */ autoPlay?: boolean, ) => void; setRepeat: (repeat: PlayerRepeat) => void; setShuffle: (shuffle: PlayerShuffle) => void; setSpeed: (speed: number) => void; setTransitionType: (transitionType: PlayerStyle) => void; setVolume: (volume: number) => void; shuffle: () => void; shuffleAll: () => void; shuffleSelected: (items: QueueSong[]) => void; toggleRepeat: () => void; toggleShuffle: () => void; } interface PlayerDataState { hydrated: boolean; playbackSnapshot: PlayerData; /** Transport slice (F8): status, volume, index, repeat, shuffle — not queue contents. */ player: PlayerTransportSlice; /** Queue slice (F8): song map, order, shuffle mapping. */ queue: QueueData; } function emitPlayerPlayEvent( targetSongUniqueId: string | undefined, set: (fn: (state: PlayerState) => void) => void, get: () => PlayerState, ): void { // If playSongId is provided, find the song and start playback on it if (targetSongUniqueId) { let playIndex: number | undefined; set((state) => { const queue = getQueueFromState(state); const queueIndex = queue.items.findIndex( (item) => item._uniqueId === targetSongUniqueId, ); if (queueIndex !== -1) { state.player.index = queueIndex; playIndex = queueIndex; state.player.status = PlayerStatus.PLAYING; setTimestampStore(0); } }); // Emit PLAYER_PLAY event if playback was started if (playIndex !== undefined) { eventEmitter.emit('PLAYER_PLAY', { id: targetSongUniqueId, index: playIndex, }); } } else { // Otherwise, emit PLAYER_PLAY event for current song if available const currentState = get(); const queue = getQueueFromState(currentState); const currentIndex = currentState.player.index; const currentSong = queue.items[currentIndex]; if (currentSong && currentIndex !== undefined && currentIndex >= 0) { eventEmitter.emit('PLAYER_PLAY', { id: currentSong._uniqueId, index: currentIndex, }); } } } const initialPlayerSlice = createInitialPlayerTransportSlice(); const initialQueue: QueueData = { default: [], revision: 0, songs: {}, unshuffled: null, }; const initialState: PlayerDataState = { hydrated: false, playbackSnapshot: computePlayerData({ player: initialPlayerSlice, queue: initialQueue, }), player: initialPlayerSlice, queue: initialQueue, }; const claimMusicPlayback = () => { const playbackType = useSettingsStore.getState().playback.type; usePlaybackOwnerStore.getState().claim('music', { engine: isElectron() && playbackType === PlayerType.LOCAL ? 'mpv-native' : 'web', }); }; export const usePlayerStoreBase = createSubscribedTraditionalStore()( persist( immer((set, get) => ({ addToQueueByType: (items, playType, playSongId, context) => { claimMusicPlayback(); // Fresh-start play types replace the listening intent. Default to // `SONG_CONTEXT` so an old album/playlist context can't bleed into a // single-track play; structured callers (album/playlist headers) pass // their own context. Additive play types (LAST, NEXT, SHUFFLE variants // for inserts) intentionally fall through and preserve the current // context — adding "Play next" to an album doesn't end the album. const isFreshStart = playType === Play.NOW || playType === Play.SHUFFLE; if (isFreshStart) { const nextContext = context ?? SONG_CONTEXT; set((state) => { state.player.context = nextContext; }); rememberMusicPlaybackSession({ context: nextContext }); } const newItems = items.map(toQueueSong); const newUniqueIds = newItems.map((item) => item._uniqueId); // Find the target song's uniqueId if playSongId is provided const targetSongUniqueId = playSongId ? newItems.find((item) => item.id === playSongId)?._uniqueId : undefined; switch (playType) { case Play.LAST: { set((state) => { registerQueueSongs(state, newItems); applyAddToQueueLast(state, newUniqueIds); }); break; } case Play.LAST_SHUFFLE: { set((state) => { registerQueueSongs(state, newItems); applyAddToQueueLastShuffle(state, shuffleInPlace([...newUniqueIds])); }); break; } case Play.NEXT: { set((state) => { registerQueueSongs(state, newItems); applyAddToQueueNext(state, newUniqueIds); }); break; } case Play.NEXT_SHUFFLE: { set((state) => { registerQueueSongs(state, newItems); applyAddToQueueNextShuffle(state, shuffleInPlace([...newUniqueIds])); }); break; } case Play.NOW: { set((state) => { registerQueueSongs(state, newItems); state.player.status = PlayerStatus.PLAYING; state.player.playerNum = 1; setTimestampStore(0); applyAddToQueueNow(state, newUniqueIds, targetSongUniqueId); }); emitPlayerPlayEvent(targetSongUniqueId, set, get); break; } case Play.SHUFFLE: { set((state) => { registerQueueSongs(state, newItems); state.player.status = PlayerStatus.PLAYING; state.player.playerNum = 1; setTimestampStore(0); applyAddToQueueShuffle( state, shuffleInPlace([...newUniqueIds]), newUniqueIds, ); }); emitPlayerPlayEvent(targetSongUniqueId, set, get); break; } } }, addToQueueByUniqueId: (items, uniqueId, edge, playSongId) => { const newItems = items.map(toQueueSong); const newUniqueIds = newItems.map((item) => item._uniqueId); // Find the target song's uniqueId if playSongId is provided const targetSongUniqueId = playSongId ? newItems.find((item) => item.id === playSongId)?._uniqueId : undefined; set((state) => { // Add new songs to songs object newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); const index = state.queue.default.findIndex((id) => id === uniqueId); const insertIndex = Math.max(0, edge === 'top' ? index : index + 1); const newQueue = [ ...state.queue.default.slice(0, insertIndex), ...newUniqueIds, ...state.queue.default.slice(insertIndex), ]; state.queue.default = newQueue; // Recalculate the player index if we're inserting items above the current index if (insertIndex <= state.player.index) { state.player.index = state.player.index + newUniqueIds.length; } recalculatePlayerIndex(state, newQueue); }); // If playSongId is provided, find the song and start playback on it if (targetSongUniqueId) { let playIndex: number | undefined; set((state) => { const queue = getQueueFromState(state); const queueIndex = queue.items.findIndex( (item) => item._uniqueId === targetSongUniqueId, ); if (queueIndex !== -1) { state.player.index = queueIndex; playIndex = queueIndex; state.player.status = PlayerStatus.PLAYING; setTimestampStore(0); } }); // Emit PLAYER_PLAY event if playback was started if (playIndex !== undefined) { eventEmitter.emit('PLAYER_PLAY', { id: targetSongUniqueId, index: playIndex, }); } } }, clearQueue: () => { set((state) => { state.player.index = -1; state.queue.default = []; state.queue.songs = {}; state.queue.unshuffled = null; }); }, clearSelected: (items: QueueSong[]) => { set((state) => { const uniqueIds = new Set(items.map((item) => item._uniqueId)); state.queue.default = state.queue.default.filter((id) => !uniqueIds.has(id)); cleanupOrphanedSongs(state); recalculatePlayerIndex(state, state.queue.default); }); }, decreaseVolume: (value: number) => { set((state) => { state.player.volume = Math.max(0, state.player.volume - value); }); }, increaseVolume: (value: number) => { set((state) => { state.player.volume = Math.min(100, state.player.volume + value); }); }, mediaAutoNext: () => { const stateSnapshot = get(); const currentIndex = stateSnapshot.player.index; const player = stateSnapshot.player; const repeat = player.repeat; const queue = getQueueOrderFromState(stateSnapshot); const playbackLength = queue.items.length; const newPlayerNum = player.playerNum === 1 ? 2 : 1; const { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex( currentIndex, playbackLength, repeat, ); const pauseOnNext = player.pauseOnNextSongEnd; const newStatus = shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING; set((state) => { state.player.index = nextPlaybackIndex; state.player.playerNum = newPlayerNum; setTimestampStore(0); state.player.status = newStatus; if (pauseOnNext) { state.player.pauseOnNextSongEnd = false; } }); if (repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex) { eventEmitter.emit('PLAYER_REPEATED', { index: nextPlaybackIndex, }); } const currentQueueIndex = nextPlaybackIndex; const currentSong = queue.items[currentQueueIndex]; const nextSong = calculateNextSong(currentQueueIndex, queue.items, repeat); const previousSong = currentQueueIndex > 0 ? queue.items[currentQueueIndex - 1] : undefined; return { currentSong, index: currentQueueIndex, nextSong, num: newPlayerNum, player1: newPlayerNum === 1 ? currentSong : nextSong, player2: newPlayerNum === 2 ? currentSong : nextSong, previousSong, queueLength: queue.items.length, status: newStatus, }; }, mediaNext: () => { const state = get(); const currentIndex = state.player.index; const player = state.player; const queue = getQueueOrderFromState(state); const isLastTrack = currentIndex === queue.items.length - 1; let nextIndex: number; if (player.repeat === PlayerRepeat.ALL && isLastTrack) { // Repeat all: wrap to first track when on last track nextIndex = 0; } else if (player.repeat === PlayerRepeat.NONE && isLastTrack) { // Repeat none: stay on last track if already there nextIndex = currentIndex; } else { // Otherwise, advance to next track (including repeat ONE for manual navigation) nextIndex = Math.min(queue.items.length - 1, currentIndex + 1); } set((state) => { state.player.index = nextIndex; state.player.playerNum = 1; setTimestampStore(0); }); eventEmitter.emit('MEDIA_NEXT', { currentIndex, nextIndex, }); }, mediaPause: () => { set((state) => { state.player.status = PlayerStatus.PAUSED; }); }, mediaPlay: (id?: string) => { if (id) { claimMusicPlayback(); } let playIndex: number | undefined; set((state) => { if (id) { const queue = getQueueFromState(state); // Find the song in the original queue const queueIndex = queue.items.findIndex((item) => item._uniqueId === id); if (queueIndex !== -1) { state.player.index = queueIndex; playIndex = queueIndex; setTimestampStore(0); } } state.player.status = PlayerStatus.PLAYING; }); if (id && playIndex !== undefined) { eventEmitter.emit('PLAYER_PLAY', { id, index: playIndex, }); } }, mediaPlayByIndex: (index: number) => { claimMusicPlayback(); let playIndex: number | undefined; let songId: string | undefined; set((state) => { const queue = getQueueFromState(state); if (index === -1 || index >= queue.items.length) { state.player.status = PlayerStatus.PAUSED; return; } // Get the song's unique ID from the queue const song = queue.items[index]; if (song) { songId = song._uniqueId; } 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; const player = get().player; const queue = getQueueOrderFromState(get()); const currentTimestamp = useTimestampStoreBase.getState().timestamp; const isFirstTrack = currentIndex === 0; // If timestamp is greater than 5 seconds, restart current song if (currentTimestamp > 5) { emitPlayerSeek(0); return; } let previousIndex: number; if (player.repeat === PlayerRepeat.ALL && isFirstTrack) { // Repeat all: wrap to last track when on first track previousIndex = queue.items.length - 1; } else if (player.repeat === PlayerRepeat.NONE && isFirstTrack) { // Repeat none: stay on first track if already there previousIndex = currentIndex; } else { // Otherwise, go to previous track previousIndex = Math.max(0, currentIndex - 1); } set((state) => { state.player.index = previousIndex; state.player.playerNum = 1; setTimestampStore(0); }); eventEmitter.emit('MEDIA_PREV', { currentIndex, prevIndex: previousIndex, }); }, mediaSeekToTimestamp: (timestamp: number) => { emitPlayerSeek(timestamp); }, mediaSkipBackward: (offset?: number) => { const offsetFromSettings = useSettingsStore.getState().general.skipButtons.skipBackwardSeconds; const timeToSkip = offset ?? offsetFromSettings ?? 5; const currentTimestamp = useTimestampStoreBase.getState().timestamp; const newTimestamp = Math.max(0, currentTimestamp - timeToSkip); emitPlayerSeek(newTimestamp); }, mediaSkipForward: (offset?: number) => { const state = get(); const queue = getQueueFromState(state); const index = state.player.index; const currentTrack = queue.items[index]; const duration = currentTrack?.duration; const offsetFromSettings = useSettingsStore.getState().general.skipButtons.skipForwardSeconds; const timeToSkip = offset ?? offsetFromSettings ?? 5; if (!duration) { return; } const currentTimestamp = useTimestampStoreBase.getState().timestamp; const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip); emitPlayerSeek(newTimestamp); }, mediaStop: (options?: { reset?: boolean }) => { const reset = options?.reset !== false; set((state) => { state.player.status = PlayerStatus.PAUSED; setTimestampStore(0); }); if (reset) { emitPlayerSeek(0); } }, mediaToggleMute: () => { set((state) => { state.player.muted = !state.player.muted; }); }, mediaTogglePlayPause: () => { set((state) => { if (state.player.status === PlayerStatus.PLAYING) { state.player.status = PlayerStatus.PAUSED; } else { state.player.status = PlayerStatus.PLAYING; } }); }, moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => { const itemUniqueIds = items.map((item) => item._uniqueId); set((state) => { const existingIds = new Set(Object.keys(state.queue.songs)); // Add new songs to songs object (avoiding duplicates) items.forEach((item) => { if (!existingIds.has(item._uniqueId)) { state.queue.songs[item._uniqueId] = item; } }); // Find the index of the drop target const index = state.queue.default.findIndex((id) => id === uniqueId); // Get the new index based on the edge const insertIndex = Math.max(0, edge === 'top' ? index : index + 1); const idsBefore = state.queue.default .slice(0, insertIndex) .filter((id) => !itemUniqueIds.includes(id)); const idsAfter = state.queue.default .slice(insertIndex) .filter((id) => !itemUniqueIds.includes(id)); const newQueue = [...idsBefore, ...itemUniqueIds, ...idsAfter]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; }); }, moveSelectedToBottom: (items: QueueSong[]) => { set((state) => { const uniqueIds = items.map((item) => item._uniqueId); // Add new songs to songs object items.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); const filtered = state.queue.default.filter((id) => !uniqueIds.includes(id)); const newQueue = [...filtered, ...uniqueIds]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; }); }, moveSelectedToNext: (items: QueueSong[]) => { set((state) => { const uniqueIds = items.map((item) => item._uniqueId); // Add new songs to songs object items.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); const currentIndex = state.player.index; let beforeCurrent = 0; const filtered = state.queue.default.filter((id, idx) => { const shouldMove = uniqueIds.includes(id); if (shouldMove && idx < currentIndex) { beforeCurrent++; } return !shouldMove; }); // For every item that is before the current item, subtract one as // these items will shift the queue up const insertIndex = currentIndex + 1 - beforeCurrent; const newQueue = [ ...filtered.slice(0, insertIndex), ...uniqueIds, ...filtered.slice(insertIndex), ]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; }); }, moveSelectedToTop: (items: QueueSong[]) => { set((state) => { const uniqueIds = items.map((item) => item._uniqueId); // Add new songs to songs object items.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); const filtered = state.queue.default.filter((id) => !uniqueIds.includes(id)); const newQueue = [...uniqueIds, ...filtered]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; }); }, setQueue: (items, index, position, context, autoPlay = true) => { claimMusicPlayback(); const nextContext = context ?? SONG_CONTEXT; const newItems = items.map(toQueueSong); const newUniqueIds = newItems.map((item) => item._uniqueId); set((state) => { newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); state.player.index = index ?? 0; state.player.status = autoPlay ? PlayerStatus.PLAYING : PlayerStatus.PAUSED; state.player.playerNum = 1; state.player.context = nextContext; state.queue.default = newUniqueIds; state.queue.unshuffled = null; }); rememberMusicPlaybackSession({ context: nextContext }); eventEmitter.emit('QUEUE_RESTORED', { data: items, index: index ?? 0, position: position ?? 0, }); }, ...initialState, setCrossfadeDuration: (duration: number) => { set((state) => { const normalizedDuration = Math.max(0, Math.min(10, duration)); state.player.crossfadeDuration = normalizedDuration; }); }, setCrossfadeStyle: (style: CrossfadeStyle) => { set((state) => { state.player.crossfadeStyle = style; }); }, setMusicPlaybackContext: (context: MusicPlaybackContext) => { set((state) => { state.player.context = context; }); rememberMusicPlaybackSession({ context }); }, setPauseOnNextSongEnd: (value: boolean) => { set((state) => { state.player.pauseOnNextSongEnd = value; }); }, setRepeat: (repeat: PlayerRepeat) => { set((state) => { state.player.repeat = repeat; }); }, setShuffle: (shuffle: PlayerShuffle) => { set((state) => { const wasShuffled = state.player.shuffle === PlayerShuffle.TRACK; const willBeShuffled = shuffle === PlayerShuffle.TRACK; state.player.shuffle = shuffle; if (willBeShuffled === wasShuffled) { return; } if (willBeShuffled) { applyShuffleQueue(state); } else { applyUnshuffleQueue(state); } cleanupOrphanedSongs(state); }); }, setSpeed: (speed: number) => { set((state) => { const normalizedSpeed = Math.max(0.5, Math.min(2, speed)); state.player.speed = normalizedSpeed; }); }, setTransitionType: (transitionType: PlayerStyle) => { set((state) => { state.player.transitionType = transitionType; }); }, setVolume: (volume: number) => { set((state) => { state.player.volume = volume; }); }, shuffle: () => { set((state) => { if (state.player.shuffle === PlayerShuffle.TRACK) { applyShuffleQueue(state, { keepRestorePoint: true }); } }); }, shuffleAll: () => { set((state) => { const queue = getQueueFromState(state); const currentIndex = state.player.index; const currentSong = queue.items[currentIndex]; // If there's a current song playing, keep it in place if (currentSong && currentIndex >= 0 && currentIndex < queue.items.length) { const currentUniqueId = currentSong._uniqueId; const currentQueueIndex = state.queue.default.findIndex( (id) => id === currentUniqueId, ); if (currentQueueIndex !== -1) { const beforeItems = state.queue.default.slice(0, currentQueueIndex); const afterItems = state.queue.default.slice(currentQueueIndex + 1); const shuffledBefore = shuffleInPlace([...beforeItems]); const shuffledAfter = shuffleInPlace([...afterItems]); state.queue.default = [ ...shuffledBefore, currentUniqueId, ...shuffledAfter, ]; } else { // Current song not in default queue, just shuffle everything state.queue.default = shuffleInPlace([...state.queue.default]); } } else { // No current song, shuffle everything state.queue.default = shuffleInPlace([...state.queue.default]); } recalculatePlayerIndex(state, state.queue.default); }); }, shuffleSelected: (items: QueueSong[]) => { set((state) => { const itemUniqueIds = items.map((item) => item._uniqueId); // Find positions of selected items in the default queue const selectedPositions = itemUniqueIds .map((id) => state.queue.default.findIndex((i) => i === id)) .filter((idx) => idx !== -1) .sort((a, b) => a - b); // Sort to maintain order if (selectedPositions.length === 0) { return; } // Get the selected items in their current order const selectedItems = selectedPositions.map((pos) => state.queue.default[pos]); // Shuffle the selected items const shuffledItems = shuffleInPlace([...selectedItems]); // Rebuild the default queue with shuffled selected items const newDefaultQueue = [...state.queue.default]; selectedPositions.forEach((pos, i) => { newDefaultQueue[pos] = shuffledItems[i]; }); state.queue.default = newDefaultQueue; recalculatePlayerIndex(state, state.queue.default); }); }, toggleRepeat: () => { set((state) => { if (state.player.repeat === PlayerRepeat.NONE) { state.player.repeat = PlayerRepeat.ONE; } else if (state.player.repeat === PlayerRepeat.ONE) { state.player.repeat = PlayerRepeat.ALL; } else { state.player.repeat = PlayerRepeat.NONE; } }); }, toggleShuffle: () => { set((state) => { const willBeShuffled = state.player.shuffle !== PlayerShuffle.TRACK; state.player.shuffle = willBeShuffled ? PlayerShuffle.TRACK : PlayerShuffle.NONE; if (willBeShuffled) { applyShuffleQueue(state); } else { applyUnshuffleQueue(state); } }); }, })), { merge: (persistedState: any, currentState: any) => { const merged = merge(currentState, persistedState) as PlayerState; merged.playbackSnapshot = computePlayerData(merged); return merged; }, migrate: async (persistedState, oldVersion) => { if (oldVersion < 3) { return {} as PlayerState; } if (oldVersion === 3) { await migratePlayerStorePersist('player-store'); } if (oldVersion < 5) { return migrateShuffledIndexMap(persistedState); } return persistedState as Partial; }, name: 'player-store', onRehydrateStorage: () => () => { setPlayerStoreHydratedForPersistence(true); usePlayerStoreBase.setState((state) => { state.hydrated = true; state.playbackSnapshot = computePlayerData(state); }); }, partialize: (state) => { // The `general.resume` setting is the master kill switch. When false, we never // persist the queue regardless of context — the user has opted out of resume // entirely and `RestoreLastPlaybackSessionHook` becomes the only restoration path // (one-track lifeboat for music, audiobook/podcast/radio still restore from their // own session metadata). const resumeEnabled = useSettingsStore.getState().general.resume; // Persist the queue only for "structured" contexts where the queue itself is the // listening intent (album, playlist). Ad-hoc song plays don't bloat the queue // across launches; their continuity comes from `last-playback-session.store`. const persistQueue = resumeEnabled && isStructuredMusicContext(state.player.context); // playerNum / status are ephemeral and never restored. const excludedPlayerKeys = ['playerNum', 'status']; // The current index only makes sense when its queue is being restored alongside. if (!persistQueue) { excludedPlayerKeys.push('index'); } const player = Object.fromEntries( Object.entries(state.player).filter( ([key]) => !excludedPlayerKeys.includes(key), ), ) as typeof state.player; if (!persistQueue) { return { player }; } // Queue pruning and IDB writes are handled in `playerStoreStorage` so we only // serialize the large queue when the queue slice reference actually changes. return { player, queue: state.queue }; }, storage: playerStoreStorage, version: 5, }, ), ); let lastPlaybackInputs = getPlaybackInputs(usePlayerStoreBase.getState()); usePlayerStoreBase.subscribe((state) => { const nextInputs = getPlaybackInputs(state); if (playbackInputsEqual(lastPlaybackInputs, nextInputs)) { return; } lastPlaybackInputs = nextInputs; usePlayerStoreBase.setState({ playbackSnapshot: computePlayerData(state) }); }); export const usePlayerStore = createSelectors(usePlayerStoreBase); export const getCurrentSong = (state: PlayerState = usePlayerStoreBase.getState()) => getCurrentSongFromState(state); export const getPlayerData = (state: PlayerState = usePlayerStoreBase.getState()) => getPlayerDataFromState(state); export const getQueue = ( groupBy?: QueueGroupingProperty, state: PlayerState = usePlayerStoreBase.getState(), ) => getQueueFromState(state, groupBy); export const getQueueOrder = (state: PlayerState = usePlayerStoreBase.getState()) => getQueueOrderFromState(state); export const isFirstTrackInQueue = (state: PlayerState = usePlayerStoreBase.getState()) => isFirstTrackInQueueFromState(state); export const isLastTrackInQueue = (state: PlayerState = usePlayerStoreBase.getState()) => isLastTrackInQueueFromState(state); export const usePlayerActions = () => { const actions = usePlayerStoreBase( useShallow((state) => ({ addToQueueByType: state.addToQueueByType, addToQueueByUniqueId: state.addToQueueByUniqueId, clearQueue: state.clearQueue, clearSelected: state.clearSelected, decreaseVolume: state.decreaseVolume, increaseVolume: state.increaseVolume, mediaAutoNext: state.mediaAutoNext, mediaNext: state.mediaNext, mediaPause: state.mediaPause, mediaPlay: state.mediaPlay, mediaPlayByIndex: state.mediaPlayByIndex, mediaPrevious: state.mediaPrevious, mediaSeekToTimestamp: state.mediaSeekToTimestamp, mediaSkipBackward: state.mediaSkipBackward, mediaSkipForward: state.mediaSkipForward, mediaStop: state.mediaStop, mediaToggleMute: state.mediaToggleMute, mediaTogglePlayPause: state.mediaTogglePlayPause, moveSelectedTo: state.moveSelectedTo, moveSelectedToBottom: state.moveSelectedToBottom, moveSelectedToNext: state.moveSelectedToNext, moveSelectedToTop: state.moveSelectedToTop, setCrossfadeDuration: state.setCrossfadeDuration, setCrossfadeStyle: state.setCrossfadeStyle, setMusicPlaybackContext: state.setMusicPlaybackContext, setPauseOnNextSongEnd: state.setPauseOnNextSongEnd, setQueue: state.setQueue, setRepeat: state.setRepeat, setShuffle: state.setShuffle, setSpeed: state.setSpeed, setTransitionType: state.setTransitionType, setVolume: state.setVolume, shuffle: state.shuffle, shuffleAll: state.shuffleAll, shuffleSelected: state.shuffleSelected, toggleRepeat: state.toggleRepeat, toggleShuffle: state.toggleShuffle, })), ); return useMemo( () => ({ ...actions, getQueue: (groupBy?: QueueGroupingProperty) => getQueueFromState(usePlayerStoreBase.getState(), groupBy), isFirstTrackInQueue: () => isFirstTrackInQueueFromState(usePlayerStoreBase.getState()), isLastTrackInQueue: () => isLastTrackInQueueFromState(usePlayerStoreBase.getState()), setTimestamp: setTimestampStore, }), [actions], ); }; export type AddToQueueByPlayType = Play; export type AddToQueueByUniqueId = { edge: 'bottom' | 'left' | 'right' | 'top' | null; uniqueId: string; }; export type AddToQueueType = AddToQueueByPlayType | AddToQueueByUniqueId; export async function addToQueueByData(type: AddToQueueType, data: Song[]) { claimMusicPlayback(); const items = data.map(toQueueSong); if (typeof type === 'string') { usePlayerStoreBase.getState().addToQueueByType(items, type); } else { const normalizedEdge = type.edge === 'top' ? 'top' : 'bottom'; usePlayerStoreBase.getState().addToQueueByUniqueId(items, type.uniqueId, normalizedEdge); } } export const subscribePlayerQueue = ( onChange: (queue: QueueData, prevQueue: QueueData) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.queue, (queue, prevQueue) => { onChange(queue, prevQueue); }, ); }; export const subscribeCurrentTrack = ( onChange: ( properties: { index: number; song: QueueSong | undefined }, prev: { index: number; song: QueueSong | undefined }, ) => void, ) => { return usePlayerStoreBase.subscribe( (state) => { const queue = getQueueFromState(state); const index = state.player.index; return { index, song: queue.items[index] }; }, (song, prevSong) => { onChange(song, prevSong); }, { equalityFn: (a, b) => { return a.song?._uniqueId === b.song?._uniqueId; }, }, ); }; export const subscribeNextSongInsertion = (onChange: (song: QueueSong | undefined) => void) => { return usePlayerStoreBase.subscribe( (state) => { const queue = getQueueFromState(state); const queueIndex = state.player.index; const repeat = state.player.repeat; const currentSong = queue.items[queueIndex]; const nextSong = calculateNextSong(queueIndex, queue.items, repeat); return { currentUniqueId: currentSong?._uniqueId, nextSong, }; }, (current, prev) => { if (!prev) { return; } // Still on the same track, but the upcoming song changed (queue edit: insert, reorder, etc.). // Do not require the current track's queue index to stay fixed — e.g. inserting *before* the // current item shifts its index in `queue.default`, and the old check missed that case. const sameTrackStillPlaying = current.currentUniqueId !== undefined && current.currentUniqueId === prev.currentUniqueId; if (sameTrackStillPlaying && current.nextSong?._uniqueId !== prev.nextSong?._uniqueId) { onChange(current.nextSong); } }, { // Always allow the subscription to fire so we can check conditions in the callback equalityFn: () => false, }, ); }; export const subscribePlayerVolume = ( onChange: (properties: { volume: number }, prev: { volume: number }) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.player.volume, (volume, prevVolume) => { onChange({ volume }, { volume: prevVolume }); }, ); }; export const subscribePlayerStatus = ( onChange: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.player.status, (status, prevStatus) => { onChange({ status }, { status: prevStatus }); }, ); }; /** @deprecated Use subscribePlayerSeek — seeks are event-bus driven (F8). */ export const subscribePlayerSeekToTimestamp = subscribePlayerSeek; export { subscribePlayerSeek } from '/@/renderer/store/player/seek'; export const subscribePlayerMute = ( onChange: (properties: { muted: boolean }, prev: { muted: boolean }) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.player.muted, (muted, prevMuted) => { onChange({ muted }, { muted: prevMuted }); }, ); }; export const subscribePlayerSpeed = ( onChange: (properties: { speed: number }, prev: { speed: number }) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.player.speed, (speed, prevSpeed) => { onChange({ speed }, { speed: prevSpeed }); }, ); }; export const subscribePlayerRepeat = ( onChange: (properties: { repeat: PlayerRepeat }, prev: { repeat: PlayerRepeat }) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.player.repeat, (repeat, prevRepeat) => { onChange({ repeat }, { repeat: prevRepeat }); }, ); }; export const subscribePlayerShuffle = ( onChange: (properties: { shuffle: PlayerShuffle }, prev: { shuffle: PlayerShuffle }) => void, ) => { return usePlayerStoreBase.subscribe( (state) => state.player.shuffle, (shuffle, prevShuffle) => { onChange({ shuffle }, { shuffle: prevShuffle }); }, ); }; export const subscribeQueueCleared = (onChange: () => void) => { return usePlayerStoreBase.subscribe( (state) => state.queue, (queue, prevQueue) => { // Detect if queue became empty const wasNotEmpty = prevQueue.default.length > 0; const isEmpty = queue.default.length === 0; if (wasNotEmpty && isEmpty) { onChange(); } }, ); }; export const usePlayerProperties = () => { return usePlayerStoreBase( useShallow((state) => ({ crossfadeDuration: state.player.crossfadeDuration, crossfadeStyle: state.player.crossfadeStyle, isMuted: state.player.muted, playerNum: state.player.playerNum, repeat: state.player.repeat, shuffle: state.player.shuffle, speed: state.player.speed, status: state.player.status, transitionType: state.player.transitionType, volume: state.player.volume, })), ); }; /** Single subscription for components that read multiple transport fields (F12). */ export const usePlayerVolumeState = () => usePlayerStoreBase( useShallow((state) => ({ muted: state.player.muted, volume: state.player.volume, })), ); export const usePlayerPlaybackControlsState = () => usePlayerStoreBase( useShallow((state) => ({ repeat: state.player.repeat, shuffle: state.player.shuffle, status: state.player.status, })), ); export const usePlayerMpvEngineState = () => usePlayerStoreBase( useShallow((state) => ({ muted: state.player.muted, speed: state.player.speed, volume: state.player.volume, })), ); export const usePlayerDuration = () => { return usePlayerStoreBase((state) => state.playbackSnapshot.currentSong?.duration); }; export const usePlayerData = (): PlayerData => { return usePlayerStoreBase(useShallow((state) => state.playbackSnapshot)); }; export const updateQueueFavorites = (ids: string[], favorite: boolean) => { usePlayerStoreBase.setState((state) => { Object.values(state.queue.songs).forEach((song) => { if (ids.includes(song.id)) { song.userFavorite = favorite; } }); touchQueueRevision(state.queue); }); }; export const incrementQueuePlayCount = (ids: string[]) => { usePlayerStoreBase.setState((state) => { Object.values(state.queue.songs).forEach((song) => { if (ids.includes(song.id)) { song.playCount = (song.playCount || 0) + 1; } }); touchQueueRevision(state.queue); }); }; export const updateQueueSong = (songId: string, updatedSong: Song) => { usePlayerStoreBase.setState((state) => { Object.values(state.queue.songs).forEach((song) => { if (song.id === songId) { const uniqueId = song._uniqueId; state.queue.songs[song._uniqueId] = { ...updatedSong, _uniqueId: uniqueId, }; } }); touchQueueRevision(state.queue); }); }; export const usePlayerMuted = () => { return usePlayerStoreBase((state) => state.player.muted); }; export const usePlayerRepeat = () => { return usePlayerStoreBase((state) => state.player.repeat); }; export const usePlayerShuffle = () => { return usePlayerStoreBase((state) => state.player.shuffle); }; export const usePlayerStatus = () => { return usePlayerStoreBase((state) => state.player.status); }; export const usePlayerHydrated = () => { return usePlayerStoreBase((state) => state.hydrated); }; export const usePlayerVolume = () => { return usePlayerStoreBase((state) => state.player.volume); }; export const usePlayerSpeed = () => { return usePlayerStoreBase((state) => state.player.speed); }; export const usePlayerSong = () => { return usePlayerStoreBase( (state) => { return state.playbackSnapshot.currentSong; }, (prev, next) => { return prev?._uniqueId === next?._uniqueId && prev?.userFavorite === next?.userFavorite; }, ); }; export const usePlayerSongProperties = ( properties: T[], ): Partial> => { return usePlayerStoreBase( useShallow((state) => { const song = state.playbackSnapshot.currentSong; if (!song) { return {}; } const result = {} as Pick; for (const prop of properties) { result[prop] = song[prop]; } return result; }), ); }; export const usePlayerNum = () => { return usePlayerStoreBase((state) => state.player.playerNum); }; export const usePlayerQueue = () => { return usePlayerStoreBase( useShallow((state) => { const songs = state.queue.songs; const queue = state.queue.default; const result: QueueSong[] = []; for (const id of queue) { const song = songs[id]; if (song) result.push(song); } return result; }), ); }; function cleanupOrphanedSongs(state: any): boolean { // Pruned against the live queue only. `queue.unshuffled` is a snapshot that can name // tracks already removed; `restoreQueueOrder` drops those, so they need no song record. const allQueueIds = new Set(state.queue.default); const songs = state.queue.songs; const songIds = Object.keys(songs); let hasOrphans = false; const orphanedIds: string[] = []; for (const songId of songIds) { if (!allQueueIds.has(songId)) { orphanedIds.push(songId); hasOrphans = true; } } if (hasOrphans) { const cleanedSongs: Record = {}; for (const songId of songIds) { if (!orphanedIds.includes(songId)) { cleanedSongs[songId] = songs[songId]; } } state.queue.songs = cleanedSongs; } return hasOrphans; } /** * v4 stored shuffle as a permutation read on top of the queue, so `player.index` was a * position in that permutation and the queue the user saw was never the play order. * Resolve the index through the old map, then reorder the queue for real so the * restored session lands on the track that was actually playing. */ function migrateShuffledIndexMap(persistedState: unknown): Partial { const state = persistedState as any; if (!state?.queue) { return state as Partial; } const shuffled: number[] = Array.isArray(state.queue.shuffled) ? state.queue.shuffled : []; delete state.queue.shuffled; state.queue.unshuffled = null; const wasShuffled = state.player?.shuffle === PlayerShuffle.TRACK && shuffled.length > 0; const index = state.player?.index; if (!wasShuffled || typeof index !== 'number' || index < 0 || index >= shuffled.length) { return state as Partial; } state.player.index = shuffled[index]; applyShuffleQueue(state); return state as Partial; } function recalculatePlayerIndex(state: any, queue: string[]) { const currentTrack = getCurrentSongFromState(state); if (!currentTrack) { return; } const index = queue.findIndex((id) => id === currentTrack._uniqueId); state.player.index = Math.max(0, index); } function toQueueSong(item: Song): QueueSong { return { ...item, _uniqueId: nanoid(), }; } export const usePlayerTransportSlice = () => usePlayerStoreBase( useShallow((state) => ({ crossfadeDuration: state.player.crossfadeDuration, crossfadeStyle: state.player.crossfadeStyle, index: state.player.index, muted: state.player.muted, pauseOnNextSongEnd: state.player.pauseOnNextSongEnd, playerNum: state.player.playerNum, repeat: state.player.repeat, shuffle: state.player.shuffle, speed: state.player.speed, status: state.player.status, transitionType: state.player.transitionType, volume: state.player.volume, })), ); export const usePlayerQueueSlice = () => usePlayerStoreBase( useShallow((state) => ({ default: state.queue.default, revision: state.queue.revision, songs: state.queue.songs, unshuffled: state.queue.unshuffled, })), );