Files
feishin/src/renderer/store/player.store.ts
T
Braelyn Boynton f3c0b68a0f fix(player): stop radio before starting track playback (#2039)
* fix(player): stop radio before starting track playback

When internet radio is streaming, clicking a track to play does nothing
because the MPV engine guards check currentStreamUrl and bail early.
Stop the radio stream before setting up the new queue on Play.NOW and
Play.SHUFFLE so the audio engine proceeds normally.

Fixes #2038

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct import ordering for lint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 17:58:08 -07:00

2163 lines
91 KiB
TypeScript

import merge from 'lodash/merge';
import { nanoid } from 'nanoid';
import { useMemo } from 'react';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useRadioStore as useRadioPlayerStore } from '/@/renderer/features/radio/hooks/use-radio-player';
import { createSelectors } from '/@/renderer/lib/zustand';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import {
setTimestamp as setTimestampStore,
useTimestampStoreBase,
} from '/@/renderer/store/timestamp.store';
import { migratePlayerStorePersist, playerStoreStorage } from '/@/renderer/store/utils';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
import {
CrossfadeStyle,
Play,
PlayerRepeat,
PlayerShuffle,
PlayerStatus,
PlayerStyle,
} from '/@/shared/types/types';
export interface PlayerState extends Actions, State {}
export type QueueGroupingProperty = keyof QueueSong;
interface Actions {
addToQueueByType: (items: Song[], playType: Play, playSongId?: string) => void;
addToQueueByUniqueId: (
items: Song[],
uniqueId: string,
edge: 'bottom' | 'top',
playSongId?: string,
) => void;
clearQueue: () => void;
clearSelected: (items: QueueSong[]) => void;
decreaseVolume: (value: number) => void;
getCurrentSong: () => QueueSong | undefined;
getPlayerData: () => PlayerData;
getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue;
getQueueOrder: () => {
groups: { count: number; name: string }[];
items: QueueSong[];
};
increaseVolume: (value: number) => void;
isFirstTrackInQueue: () => boolean;
isLastTrackInQueue: () => boolean;
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), sets seekToTimestamp(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;
setPauseOnNextSongEnd: (value: boolean) => void;
setQueue: (data: Song[], index?: number, position?: number) => 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 GroupedQueue {
groups: { count: number; name: string }[];
items: QueueSong[];
}
interface State {
hydrated: boolean;
player: {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
index: number;
muted: boolean;
pauseOnNextSongEnd: boolean;
playerNum: 1 | 2;
repeat: PlayerRepeat;
seekToTimestamp: string;
shuffle: PlayerShuffle;
speed: number;
status: PlayerStatus;
transitionType: PlayerStyle;
volume: number;
};
queue: QueueData;
}
// Calculates the next song based on repeat mode and current position
export function calculateNextSong(
currentIndex: number,
queueItems: QueueSong[],
repeat: PlayerRepeat,
): QueueSong | undefined {
if (queueItems.length === 0) {
return undefined;
}
if (repeat === PlayerRepeat.ONE) {
// When repeating one, next song is the same as current
return queueItems[currentIndex];
} else if (repeat === PlayerRepeat.ALL) {
// When repeating all, next song wraps to first if at the end
const isLastTrack = currentIndex === queueItems.length - 1;
if (isLastTrack) {
return queueItems[0];
} else {
return queueItems[currentIndex + 1];
}
} else {
// When repeat is none, next song is undefined if at the end
return queueItems[currentIndex + 1];
}
}
// Helper function to check if shuffle is enabled
export function isShuffleEnabled(state: {
player: { shuffle: PlayerShuffle };
queue: { shuffled: number[] };
}): boolean {
return state.player.shuffle === PlayerShuffle.TRACK && state.queue.shuffled.length > 0;
}
// Helper function to map shuffled position to actual queue position
export function mapShuffledToQueueIndex(shuffledIndex: number, shuffled: number[]): number {
if (shuffledIndex >= 0 && shuffledIndex < shuffled.length) {
return shuffled[shuffledIndex];
}
return shuffledIndex;
}
// Helper function to add new indexes to shuffled array after current position
function addIndexesToShuffled(
shuffled: number[],
currentShuffledIndex: number,
newIndexes: number[],
): number[] {
// Keep everything before and including current position
const beforeCurrent = shuffled.slice(0, currentShuffledIndex + 1);
// Shuffle everything after current position plus new indexes
const afterCurrent = shuffled.slice(currentShuffledIndex + 1);
const toShuffle = [...afterCurrent, ...newIndexes];
return [...beforeCurrent, ...shuffleInPlace(toShuffle)];
}
// Helper function to adjust shuffled indexes when items are inserted
function adjustShuffledIndexesForInsertion(
shuffled: number[],
insertPosition: number,
insertCount: number,
): number[] {
return shuffled.map((idx) => {
if (idx >= insertPosition) {
return idx + insertCount;
}
return idx;
});
}
// Calculates the next index based on repeat mode and current position
function calculateNextIndex(
currentIndex: number,
queueLength: number,
repeat: PlayerRepeat,
): { nextIndex: number; shouldPause: boolean } {
const isLastTrack = currentIndex === queueLength - 1;
if (repeat === PlayerRepeat.ONE) {
// Repeat one: stay on the same track
return { nextIndex: currentIndex, shouldPause: false };
} else if (repeat === PlayerRepeat.ALL) {
// Repeat all: loop to first track if at the end
if (isLastTrack) {
return { nextIndex: 0, shouldPause: false };
} else {
return { nextIndex: currentIndex + 1, shouldPause: false };
}
} else {
// Repeat none: move to next track, or pause if at the end
if (isLastTrack) {
return { nextIndex: 0, shouldPause: true };
} else {
return { nextIndex: currentIndex + 1, shouldPause: false };
}
}
}
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 = state.getQueue();
const queueIndex = queue.items.findIndex(
(item) => item._uniqueId === targetSongUniqueId,
);
if (queueIndex !== -1) {
if (
state.player.shuffle === PlayerShuffle.TRACK &&
state.queue.shuffled.length > 0
) {
// Find the shuffled position for this queue index
const shuffledPosition = state.queue.shuffled.findIndex(
(idx) => idx === queueIndex,
);
if (shuffledPosition !== -1) {
state.player.index = shuffledPosition;
playIndex = shuffledPosition;
} else {
state.player.index = queueIndex;
playIndex = queueIndex;
}
} else {
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 = currentState.getQueue();
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,
});
}
}
}
// Helper function to find shuffled position for a given queue index
function findShuffledPositionForQueueIndex(
queueIndex: number,
shuffled: number[],
): number | undefined {
const shuffledPosition = shuffled.findIndex((idx) => idx === queueIndex);
return shuffledPosition !== -1 ? shuffledPosition : undefined;
}
// Helper function to generate shuffled indexes for a queue of given length
function generateShuffledIndexes(length: number): number[] {
const indexes = Array.from({ length }, (_, i) => i);
return shuffleInPlace(indexes);
}
// Helper function to regenerate shuffled indexes if shuffle is enabled
function regenerateShuffledIndexesIfNeeded(state: {
player: { shuffle: PlayerShuffle };
queue: { default: string[]; shuffled: number[] };
}): void {
if (isShuffleEnabled(state)) {
state.queue.shuffled = generateShuffledIndexes(state.queue.default.length);
}
}
const initialState: State = {
hydrated: false,
player: {
crossfadeDuration: 5,
crossfadeStyle: CrossfadeStyle.EQUAL_POWER,
index: -1,
muted: false,
pauseOnNextSongEnd: false,
playerNum: 1,
repeat: PlayerRepeat.NONE,
seekToTimestamp: uniqueSeekToTimestamp(0),
shuffle: PlayerShuffle.NONE,
speed: 1,
status: PlayerStatus.PAUSED,
transitionType: PlayerStyle.GAPLESS,
volume: 30,
},
queue: {
default: [],
shuffled: [],
songs: {},
},
};
export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
persist(
subscribeWithSelector(
immer((set, get) => ({
addToQueueByType: (items, playType, 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;
switch (playType) {
case Play.LAST: {
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
const oldQueueLength = state.queue.default.length;
state.queue.default = [...state.queue.default, ...newUniqueIds];
if (isShuffleEnabled(state)) {
// New items will be at indexes starting from oldQueueLength
const newIndexes = Array.from(
{ length: newUniqueIds.length },
(_, i) => oldQueueLength + i,
);
// Shuffle the new indexes and add to the end of shuffled array
const shuffledNewIndexes = shuffleInPlace([...newIndexes]);
state.queue.shuffled = [
...state.queue.shuffled,
...shuffledNewIndexes,
];
}
});
break;
}
case Play.LAST_SHUFFLE: {
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
// Shuffle the new items before appending
const shuffledIds = shuffleInPlace([...newUniqueIds]);
const oldQueueLength = state.queue.default.length;
state.queue.default = [...state.queue.default, ...shuffledIds];
if (state.player.shuffle === PlayerShuffle.TRACK) {
// New items will be at indexes starting from oldQueueLength
const newIndexes = Array.from(
{ length: shuffledIds.length },
(_, i) => oldQueueLength + i,
);
// Shuffle the new indexes and add to the end of shuffled array
const shuffledNewIndexes = shuffleInPlace([...newIndexes]);
state.queue.shuffled = [
...state.queue.shuffled,
...shuffledNewIndexes,
];
}
});
break;
}
case Play.NEXT: {
set((state) => {
const currentShuffledIndex = state.player.index;
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
const insertPosition =
state.player.shuffle === PlayerShuffle.TRACK
? state.queue.shuffled[currentShuffledIndex] + 1
: currentShuffledIndex + 1;
state.queue.default = [
...state.queue.default.slice(0, insertPosition),
...newUniqueIds,
...state.queue.default.slice(insertPosition),
];
if (isShuffleEnabled(state)) {
// Adjust existing indexes that are >= insertPosition
const adjustedShuffled = adjustShuffledIndexesForInsertion(
state.queue.shuffled,
insertPosition,
newUniqueIds.length,
);
// New items will be at indexes starting from insertPosition
const newIndexes = Array.from(
{ length: newUniqueIds.length },
(_, i) => insertPosition + i,
);
// Shuffle the new indexes and add directly after current shuffled index
const shuffledNewIndexes = shuffleInPlace([...newIndexes]);
state.queue.shuffled = [
...adjustedShuffled.slice(0, currentShuffledIndex + 1),
...shuffledNewIndexes,
...adjustedShuffled.slice(currentShuffledIndex + 1),
];
}
});
break;
}
case Play.NEXT_SHUFFLE: {
set((state) => {
const currentShuffledIndex = state.player.index;
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
// Shuffle the new items before inserting
const shuffledIds = shuffleInPlace([...newUniqueIds]);
const insertPosition = isShuffleEnabled(state)
? state.queue.shuffled[currentShuffledIndex] + 1
: currentShuffledIndex + 1;
state.queue.default = [
...state.queue.default.slice(0, insertPosition),
...shuffledIds,
...state.queue.default.slice(insertPosition),
];
if (isShuffleEnabled(state)) {
// Adjust existing indexes that are >= insertPosition
const adjustedShuffled = adjustShuffledIndexesForInsertion(
state.queue.shuffled,
insertPosition,
shuffledIds.length,
);
// New items will be at indexes starting from insertPosition
const newIndexes = Array.from(
{ length: shuffledIds.length },
(_, i) => insertPosition + i,
);
// Shuffle the new indexes and add directly after current shuffled index
const shuffledNewIndexes = shuffleInPlace([...newIndexes]);
state.queue.shuffled = [
...adjustedShuffled.slice(0, currentShuffledIndex + 1),
...shuffledNewIndexes,
...adjustedShuffled.slice(currentShuffledIndex + 1),
];
}
});
break;
}
case Play.NOW: {
if (useRadioPlayerStore.getState().currentStreamUrl) {
useRadioPlayerStore.getState().actions.stop();
}
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
state.queue.default = [];
state.player.index = 0;
state.player.status = PlayerStatus.PLAYING;
state.player.playerNum = 1;
setTimestampStore(0);
state.queue.default = newUniqueIds;
if (state.player.shuffle === PlayerShuffle.TRACK) {
// If targetSongUniqueId is provided, ensure it's at position 0 in shuffled array
if (targetSongUniqueId) {
const initialIndex = newUniqueIds.findIndex(
(id) => id === targetSongUniqueId,
);
if (initialIndex !== -1) {
const allIndexes = Array.from(
{ length: newUniqueIds.length },
(_, i) => i,
);
const remainingIndexes = allIndexes.filter(
(idx) => idx !== initialIndex,
);
const shuffledRemaining = shuffleInPlace([
...remainingIndexes,
]);
state.queue.shuffled = [
initialIndex,
...shuffledRemaining,
];
} else {
// Fallback: if initial song not found, generate normally
state.queue.shuffled = generateShuffledIndexes(
newUniqueIds.length,
);
}
} else {
state.queue.shuffled = generateShuffledIndexes(
newUniqueIds.length,
);
}
}
});
emitPlayerPlayEvent(targetSongUniqueId, set, get);
break;
}
case Play.SHUFFLE: {
if (useRadioPlayerStore.getState().currentStreamUrl) {
useRadioPlayerStore.getState().actions.stop();
}
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
// Shuffle the new items before adding to queue
const shuffledIds = shuffleInPlace([...newUniqueIds]);
state.queue.default = [];
state.player.index = 0;
state.player.status = PlayerStatus.PLAYING;
state.player.playerNum = 1;
setTimestampStore(0);
state.queue.default = shuffledIds;
// Always maintain shuffled array when using Play.SHUFFLE
state.queue.shuffled = generateShuffledIndexes(shuffledIds.length);
});
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;
if (state.player.shuffle === PlayerShuffle.TRACK) {
const currentTrack = state.getCurrentSong() as QueueSong | undefined;
const currentTrackUniqueId = currentTrack?._uniqueId;
if (currentTrackUniqueId) {
// Adjust existing shuffled indexes that are >= insertIndex
const adjustedShuffled = state.queue.shuffled.map((idx) => {
if (idx >= insertIndex) {
return idx + newUniqueIds.length;
}
return idx;
});
// New items will be at indexes starting from insertIndex
const newIndexes = Array.from(
{ length: newUniqueIds.length },
(_, i) => insertIndex + i,
);
const currentShuffledIndex = state.player.index;
state.queue.shuffled = addIndexesToShuffled(
adjustedShuffled,
currentShuffledIndex,
newIndexes,
);
// Recalculate player index to the shuffled position
const queueIndex = newQueue.findIndex(
(id) => id === currentTrackUniqueId,
);
if (queueIndex !== -1) {
const shuffledPosition = state.queue.shuffled.findIndex(
(idx) => idx === queueIndex,
);
if (shuffledPosition !== -1) {
state.player.index = shuffledPosition;
}
}
} else {
// No current track, regenerate shuffled indexes
state.queue.shuffled = generateShuffledIndexes(newQueue.length);
}
} else {
// 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 = state.getQueue();
const queueIndex = queue.items.findIndex(
(item) => item._uniqueId === targetSongUniqueId,
);
if (queueIndex !== -1) {
if (
state.player.shuffle === PlayerShuffle.TRACK &&
state.queue.shuffled.length > 0
) {
// Find the shuffled position for this queue index
const shuffledPosition = state.queue.shuffled.findIndex(
(idx) => idx === queueIndex,
);
if (shuffledPosition !== -1) {
state.player.index = shuffledPosition;
playIndex = shuffledPosition;
} else {
state.player.index = queueIndex;
playIndex = queueIndex;
}
} else {
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.shuffled = [];
state.queue.songs = {};
});
},
clearSelected: (items: QueueSong[]) => {
set((state) => {
const uniqueIds = new Set(items.map((item) => item._uniqueId));
const indexesToRemove = new Set<number>();
state.queue.default.forEach((id, index) => {
if (uniqueIds.has(id)) {
indexesToRemove.add(index);
}
});
state.queue.default = state.queue.default.filter(
(id) => !uniqueIds.has(id),
);
if (isShuffleEnabled(state)) {
// Remove indexes from shuffled array and adjust remaining indexes
const newShuffled = state.queue.shuffled
.filter((idx) => !indexesToRemove.has(idx))
.map((idx) => {
// Count how many removed indexes are before this index
let adjustment = 0;
for (const removedIdx of indexesToRemove) {
if (removedIdx < idx) {
adjustment++;
}
}
return idx - adjustment;
});
state.queue.shuffled = newShuffled;
} else {
state.queue.shuffled = [];
}
cleanupOrphanedSongs(state);
recalculatePlayerIndex(state, state.queue.default);
});
},
decreaseVolume: (value: number) => {
set((state) => {
state.player.volume = Math.max(0, state.player.volume - value);
});
},
getCurrentSong: () => {
const state = get();
const queue = state.getQueue();
let index = state.player.index;
// If shuffle is enabled, map shuffled position to actual queue position
if (isShuffleEnabled(state)) {
index = mapShuffledToQueueIndex(index, state.queue.shuffled);
}
return queue.items[index];
},
getPlayerData: () => {
const state = get();
const queue = state.getQueue();
const index = state.player.index;
// If shuffle is enabled, 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
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
let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state) && repeat !== PlayerRepeat.ONE) {
// 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
nextSong,
num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong,
player2: state.player.playerNum === 2 ? currentSong : nextSong,
previousSong,
queueLength: state.queue.default.length,
status: state.player.status,
};
},
getQueue: (groupBy?: QueueGroupingProperty) => {
const queue = get().getQueueOrder();
if (!groupBy) {
return queue;
}
// Track groups in order of appearance
const groups: { count: number; name: string }[] = [];
const seenGroups = new Set<string>();
// Process items and build groups in order
queue.items.forEach((item) => {
const groupValue = String(item[groupBy] || 'Unknown');
if (!seenGroups.has(groupValue)) {
seenGroups.add(groupValue);
groups.push({ count: 1, name: groupValue });
} else {
// Find the last occurrence of this group value
const lastIndex = [...groups]
.reverse()
.findIndex((g) => g.name === groupValue);
if (lastIndex === -1) return;
// If the previous group is different, create a new group
const previousGroup = groups[groups.length - 1];
if (previousGroup.name !== groupValue) {
groups.push({ count: 1, name: groupValue });
} else {
// Increment the count of the last matching group
groups[groups.length - 1].count++;
}
}
});
return { groups, items: queue.items };
},
getQueueOrder: () => {
const state = get();
const songs = state.queue.songs;
const defaultIds = state.queue.default;
const defaultQueue: QueueSong[] = [];
for (const id of defaultIds) {
const song = songs[id];
if (song) defaultQueue.push(song);
}
// Always return original order (shuffle only affects playback, not display)
return {
groups: [{ count: defaultQueue.length, name: 'All' }],
items: defaultQueue,
};
},
increaseVolume: (value: number) => {
set((state) => {
state.player.volume = Math.min(100, state.player.volume + value);
});
},
isFirstTrackInQueue: () => {
const state = get();
const currentIndex = state.player.index;
return currentIndex === 0;
},
isLastTrackInQueue: () => {
const state = get();
const queue = state.getQueueOrder();
const currentIndex = state.player.index;
return currentIndex === queue.items.length - 1;
},
mediaAutoNext: () => {
const stateSnapshot = get();
const currentIndex = stateSnapshot.player.index;
const player = stateSnapshot.player;
const repeat = player.repeat;
const queue = stateSnapshot.getQueueOrder();
const isShuffle = isShuffleEnabled(stateSnapshot);
const playbackLength = isShuffle
? stateSnapshot.queue.shuffled.length
: 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,
});
}
// Compute current/next/previous using the same shuffle-aware mapping as getPlayerData().
let currentQueueIndex = nextPlaybackIndex;
if (isShuffle) {
currentQueueIndex = mapShuffledToQueueIndex(
nextPlaybackIndex,
stateSnapshot.queue.shuffled,
);
}
const currentSong = queue.items[currentQueueIndex];
let nextSong: QueueSong | undefined;
if (isShuffle && repeat !== PlayerRepeat.ONE) {
const nextShuffledIndex = nextPlaybackIndex + 1;
if (nextShuffledIndex < stateSnapshot.queue.shuffled.length) {
const nextQueueIndex = stateSnapshot.queue.shuffled[nextShuffledIndex];
nextSong = queue.items[nextQueueIndex];
} else if (repeat === PlayerRepeat.ALL) {
const firstQueueIndex = stateSnapshot.queue.shuffled[0];
nextSong = queue.items[firstQueueIndex];
}
} else {
nextSong = calculateNextSong(currentQueueIndex, queue.items, repeat);
}
let previousSong: QueueSong | undefined;
if (isShuffle) {
const prevShuffledIndex = nextPlaybackIndex - 1;
if (prevShuffledIndex >= 0) {
const prevQueueIndex = stateSnapshot.queue.shuffled[prevShuffledIndex];
previousSong = queue.items[prevQueueIndex];
} else if (repeat === PlayerRepeat.ALL) {
const lastShuffledIndex = stateSnapshot.queue.shuffled.length - 1;
const lastQueueIndex = stateSnapshot.queue.shuffled[lastShuffledIndex];
previousSong = queue.items[lastQueueIndex];
}
} else {
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 = state.getQueueOrder();
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)
// When shuffle is enabled, currentIndex is already the position in the shuffled array
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) => {
let playIndex: number | undefined;
set((state) => {
if (id) {
const queue = state.getQueue();
// Find the song in the original queue
const queueIndex = queue.items.findIndex(
(item) => item._uniqueId === id,
);
if (queueIndex !== -1) {
if (
state.player.shuffle === PlayerShuffle.TRACK &&
state.queue.shuffled.length > 0
) {
// Find the shuffled position for this queue index
const shuffledPosition = state.queue.shuffled.findIndex(
(idx) => idx === queueIndex,
);
if (shuffledPosition !== -1) {
state.player.index = shuffledPosition;
playIndex = shuffledPosition;
} else {
state.player.index = queueIndex;
playIndex = queueIndex;
}
} else {
state.player.index = queueIndex;
playIndex = queueIndex;
}
setTimestampStore(0);
}
}
state.player.status = PlayerStatus.PLAYING;
});
if (id && playIndex !== undefined) {
eventEmitter.emit('PLAYER_PLAY', {
id,
index: playIndex,
});
}
},
mediaPlayByIndex: (index: number) => {
let playIndex: number | undefined;
let songId: string | undefined;
set((state) => {
const queue = state.getQueue();
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;
}
// index is the position in the original queue
if (isShuffleEnabled(state)) {
// Find the shuffled position for this queue index
const shuffledPosition = findShuffledPositionForQueueIndex(
index,
state.queue.shuffled,
);
playIndex = shuffledPosition !== undefined ? shuffledPosition : index;
state.player.index = playIndex;
} else {
playIndex = index;
state.player.index = index;
}
setTimestampStore(0);
state.player.status = PlayerStatus.PLAYING;
});
if (songId && playIndex !== undefined) {
eventEmitter.emit('PLAYER_PLAY', {
id: songId,
index: playIndex,
});
}
},
mediaPrevious: () => {
const currentIndex = get().player.index;
const player = get().player;
const queue = get().getQueueOrder();
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const isFirstTrack = currentIndex === 0;
// If timestamp is greater than 10 seconds, restart current song
if (currentTimestamp > 10) {
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(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) => {
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(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);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
},
mediaSkipForward: (offset?: number) => {
const state = get();
const queue = state.getQueue();
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);
set((state) => {
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
},
mediaStop: (options?: { reset?: boolean }) => {
const reset = options?.reset !== false;
set((state) => {
state.player.status = PlayerStatus.PAUSED;
setTimestampStore(0);
if (reset) {
state.player.seekToTimestamp = uniqueSeekToTimestamp(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) => {
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 = PlayerStatus.PLAYING;
state.player.playerNum = 1;
state.queue.default = newUniqueIds;
});
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;
});
},
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;
const currentIndex = state.player.index;
state.player.shuffle = shuffle;
if (willBeShuffled) {
state.queue.shuffled = generateShuffledIndexes(
state.queue.default.length,
);
// Convert current index to shuffled position if there's a current song
if (currentIndex >= 0 && currentIndex < state.queue.default.length) {
// Find the shuffled position that corresponds to the current queue position
const shuffledPosition = findShuffledPositionForQueueIndex(
currentIndex,
state.queue.shuffled,
);
if (shuffledPosition !== undefined) {
state.player.index = shuffledPosition;
}
}
} else {
// When disabling shuffle, convert shuffled position back to queue position
if (
wasShuffled &&
currentIndex >= 0 &&
currentIndex < state.queue.shuffled.length
) {
const queuePosition = state.queue.shuffled[currentIndex];
if (queuePosition !== undefined) {
state.player.index = queuePosition;
}
}
state.queue.shuffled = [];
}
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) {
state.queue.shuffled = generateShuffledIndexes(
state.queue.default.length,
);
}
});
},
shuffleAll: () => {
set((state) => {
const queue = state.getQueue();
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]);
}
// Regenerate shuffled indexes if shuffle is enabled
regenerateShuffledIndexesIfNeeded(state);
});
},
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;
// Regenerate shuffled indexes if shuffle is enabled
regenerateShuffledIndexesIfNeeded(state);
});
},
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 wasShuffled = state.player.shuffle === PlayerShuffle.TRACK;
const willBeShuffled = state.player.shuffle !== PlayerShuffle.TRACK;
const currentIndex = state.player.index;
state.player.shuffle =
state.player.shuffle === PlayerShuffle.NONE
? PlayerShuffle.TRACK
: PlayerShuffle.NONE;
if (willBeShuffled) {
// Enabling shuffle: create shuffled indexes with current track as first
const combinedLength = state.queue.default.length;
if (
combinedLength > 0 &&
currentIndex >= 0 &&
currentIndex < combinedLength
) {
// Get the current queue position (actual index in combined queue)
const currentQueuePosition = currentIndex;
// Create shuffled indexes with current track first
const remainingIndexes = Array.from(
{ length: combinedLength },
(_, i) => i,
).filter((idx) => idx !== currentQueuePosition);
const shuffledRemaining = shuffleInPlace([...remainingIndexes]);
state.queue.shuffled = [currentQueuePosition, ...shuffledRemaining];
// Set player index to 0 since current track is now first in shuffled array
state.player.index = 0;
} else {
// No current track, just generate shuffled indexes normally
state.queue.shuffled = generateShuffledIndexes(combinedLength);
}
} else {
// Disabling shuffle: clear shuffled indexes and convert index back
if (
wasShuffled &&
currentIndex >= 0 &&
currentIndex < state.queue.shuffled.length
) {
const queuePosition = state.queue.shuffled[currentIndex];
if (queuePosition !== undefined) {
state.player.index = queuePosition;
}
}
state.queue.shuffled = [];
}
});
},
})),
),
{
merge: (persistedState: any, currentState: any) => {
return merge(currentState, persistedState);
},
migrate: async (persistedState, oldVersion) => {
if (oldVersion < 3) {
return {} as PlayerState;
}
if (oldVersion === 3) {
await migratePlayerStorePersist('player-store');
return persistedState as Partial<PlayerState>;
}
return persistedState as Partial<PlayerState>;
},
name: 'player-store',
onRehydrateStorage: () => () => {
usePlayerStoreBase.setState({ hydrated: true });
},
partialize: (state) => {
const shouldRestorePlayQueue = useSettingsStore.getState().general.resume;
// Exclude playerNum, seekToTimestamp, and status from stored player object
// These are not needed to be stored since they are ephemeral properties
// Note: timestamp is now in a separate store and doesn't need to be excluded here
const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status'];
// If we're not restoring the play queue, we don't need the index property
if (!shouldRestorePlayQueue) {
excludedPlayerKeys.push('index');
}
const player = Object.fromEntries(
Object.entries(state.player).filter(
([key]) => !excludedPlayerKeys.includes(key),
),
) as typeof state.player;
if (!shouldRestorePlayQueue) {
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: 4,
},
),
);
export const usePlayerStore = createSelectors(usePlayerStoreBase);
export const usePlayerActions = () => {
const actions = usePlayerStoreBase(
useShallow((state) => ({
addToQueueByType: state.addToQueueByType,
addToQueueByUniqueId: state.addToQueueByUniqueId,
clearQueue: state.clearQueue,
clearSelected: state.clearSelected,
decreaseVolume: state.decreaseVolume,
getQueue: state.getQueue,
increaseVolume: state.increaseVolume,
isFirstTrackInQueue: state.isFirstTrackInQueue,
isLastTrackInQueue: state.isLastTrackInQueue,
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,
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,
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[]) {
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 = state.getQueue();
let index = state.player.index;
if (isShuffleEnabled(state)) {
index = mapShuffledToQueueIndex(index, state.queue.shuffled);
}
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 = state.getQueue();
let queueIndex = state.player.index;
const repeat = state.player.repeat;
// If shuffle is enabled, map shuffled position to actual queue position
if (isShuffleEnabled(state)) {
queueIndex = mapShuffledToQueueIndex(queueIndex, state.queue.shuffled);
}
const currentSong = queue.items[queueIndex];
// Calculate next song based on shuffle and repeat settings
let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state) && repeat !== PlayerRepeat.ONE) {
// Calculate next in shuffled order
const nextShuffledIndex = state.player.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 {
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 });
},
);
};
export const subscribePlayerSeekToTimestamp = (
onChange: (properties: { timestamp: number }, prev: { timestamp: number }) => void,
) => {
return usePlayerStoreBase.subscribe(
(state) => state.player.seekToTimestamp,
(timestamp, prevTimestamp) => {
onChange(
{ timestamp: parseUniqueSeekToTimestamp(timestamp) },
{ timestamp: parseUniqueSeekToTimestamp(prevTimestamp) },
);
},
);
};
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,
})),
);
};
export const usePlayerDuration = () => {
return usePlayerStoreBase((state) => {
const queue = state.getQueue();
let index = state.player.index;
// If shuffle is enabled, map shuffled position to actual queue position
if (state.player.shuffle === PlayerShuffle.TRACK && state.queue.shuffled.length > 0) {
if (index >= 0 && index < state.queue.shuffled.length) {
index = state.queue.shuffled[index];
}
}
const currentTrack = queue.items[index];
return currentTrack?.duration;
});
};
export const usePlayerData = (): PlayerData => {
return usePlayerStoreBase(
useShallow((state) => {
const queue = state.getQueue();
const index = state.player.index;
// If shuffle is enabled, 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
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
let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state) && repeat !== PlayerRepeat.ONE) {
// 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
nextSong,
num: state.player.playerNum,
player1: state.player.playerNum === 1 ? currentSong : nextSong,
player2: state.player.playerNum === 2 ? currentSong : nextSong,
previousSong,
queueLength: state.queue.default.length,
status: state.player.status,
};
}),
);
};
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;
}
});
});
};
export const updateQueueRatings = (ids: string[], rating: null | number) => {
usePlayerStoreBase.setState((state) => {
Object.values(state.queue.songs).forEach((song) => {
if (ids.includes(song.id)) {
song.userRating = rating;
}
});
});
};
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;
}
});
});
};
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,
};
}
});
});
};
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.getCurrentSong();
},
(prev, next) => {
return (
prev?._uniqueId === next?._uniqueId &&
prev?.userFavorite === next?.userFavorite &&
prev?.userRating === next?.userRating
);
},
);
};
export const usePlayerSongProperties = <T extends keyof QueueSong>(
properties: T[],
): Partial<Pick<QueueSong, T>> => {
return usePlayerStoreBase(
useShallow((state) => {
const song = state.getCurrentSong();
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 {
const allQueueIds = new Set([
...state.queue.default,
// shuffled now contains indexes, not uniqueIds, so we don't include it here
]);
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;
}
function parseUniqueSeekToTimestamp(timestamp: string) {
return Number(timestamp.split('-')[0]);
}
function recalculatePlayerIndex(state: any, queue: string[]) {
const currentTrack = state.getCurrentSong() as QueueSong | undefined;
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(),
};
}
// We need to use a unique id so that the equalityFn can work if attempting to set the same timestamp
function uniqueSeekToTimestamp(timestamp: number) {
return `${timestamp}-${nanoid()}`;
}