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]; } } export function getDualPlayerSongs( playerNum: 1 | 2, currentSong: QueueSong | undefined, nextSong: QueueSong | undefined, repeat: PlayerRepeat, ): { player1: QueueSong | undefined; player2: QueueSong | undefined } { if (repeat === PlayerRepeat.ONE) { return { player1: playerNum === 1 ? currentSong : undefined, player2: playerNum === 2 ? currentSong : undefined, }; } return { player1: playerNum === 1 ? currentSong : nextSong, player2: playerNum === 2 ? currentSong : nextSong, }; } // 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: currentIndex, 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()( 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(); 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); } const { player1, player2 } = getDualPlayerSongs( state.player.playerNum, currentSong, nextSong, repeat, ); return { currentSong, index: queueIndex, // Return the actual queue position for display nextSong, num: state.player.playerNum, player1, player2, 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(); // 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 { nextIndex: nextPlaybackIndex, shouldPause } = calculateNextIndex( currentIndex, playbackLength, repeat, ); const isRepeatOneSameTrack = repeat === PlayerRepeat.ONE && nextPlaybackIndex === currentIndex; // Dual web players alternate for gapless/crossfade between tracks. Repeat-one // replays the same track — keep playerNum so Chromium stays bound to the same //