mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-16 08:24:16 +02:00
f3c0b68a0f
* 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>
2163 lines
91 KiB
TypeScript
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()}`;
|
|
}
|