src/renderer/store/player.store.ts
Rama main · réplica de lectura del repositorio original.
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<PlayerState>()(
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<PlayerState>;
},
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 = <T extends keyof QueueSong>(
properties: T[],
): Partial<Pick<QueueSong, T>> => {
return usePlayerStoreBase(
useShallow((state) => {
const song = state.playbackSnapshot.currentSong;
if (!song) {
return {};
}
const result = {} as Pick<QueueSong, T>;
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<string>(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<string, QueueSong> = {};
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<PlayerState> {
const state = persistedState as any;
if (!state?.queue) {
return state as Partial<PlayerState>;
}
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<PlayerState>;
}
state.player.index = shuffled[index];
applyShuffleQueue(state);
return state as Partial<PlayerState>;
}
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,
})),
);