import merge from 'lodash/merge'; import { nanoid } from 'nanoid'; import { create } from 'zustand'; import { createJSONStorage, persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { useShallow } from 'zustand/react/shallow'; import { createSelectors } from '/@/renderer/lib/zustand'; import { useSettingsStore } from '/@/renderer/store/settings.store'; import { setTimestamp as setTimestampStore, useTimestampStoreBase, } from '/@/renderer/store/timestamp.store'; import { idbStateStorage } from '/@/renderer/store/utils'; import { shuffleInPlace } from '/@/renderer/utils/shuffle'; import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types'; import { CrossfadeStyle, Play, PlayerQueueType, 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) => void; addToQueueByUniqueId: (items: Song[], uniqueId: string, edge: 'bottom' | 'top') => void; clearQueue: () => void; clearSelected: (items: QueueSong[]) => void; decreaseVolume: (value: number) => void; getCurrentSong: () => QueueSong | undefined; getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue; getQueueOrder: () => { groups: { count: number; name: string }[]; items: QueueSong[]; }; increaseVolume: (value: number) => void; mediaAutoNext: () => PlayerData; mediaNext: () => void; mediaPause: () => void; mediaPlay: (id?: string) => void; mediaPlayByIndex: (index: number) => void; mediaPrevious: () => void; mediaSeekToTimestamp: (timestamp: number) => void; mediaSkipBackward: (offset?: number) => void; mediaSkipForward: (offset?: number) => void; mediaStop: () => 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; setQueueType: (queueType: PlayerQueueType) => 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 { player: { crossfadeDuration: number; crossfadeStyle: CrossfadeStyle; index: number; muted: boolean; playerNum: 1 | 2; queueType: PlayerQueueType; repeat: PlayerRepeat; seekToTimestamp: string; shuffle: PlayerShuffle; speed: number; status: PlayerStatus; transitionType: PlayerStyle; volume: number; }; queue: QueueData; } const initialState: State = { player: { crossfadeDuration: 5, crossfadeStyle: CrossfadeStyle.EQUAL_POWER, index: -1, muted: false, playerNum: 1, queueType: PlayerQueueType.DEFAULT, repeat: PlayerRepeat.NONE, seekToTimestamp: uniqueSeekToTimestamp(0), shuffle: PlayerShuffle.NONE, speed: 1, status: PlayerStatus.PAUSED, transitionType: PlayerStyle.GAPLESS, volume: 30, }, queue: { default: [], priority: [], shuffled: [], songs: {}, }, }; export const usePlayerStoreBase = create()( persist( subscribeWithSelector( immer((set, get) => ({ addToQueueByType: (items, playType) => { const newItems = items.map(toQueueSong); const newUniqueIds = newItems.map((item) => item._uniqueId); const queueType = getQueueType(); switch (queueType) { case PlayerQueueType.DEFAULT: { switch (playType) { case Play.LAST: { set((state) => { const currentIndex = state.player.index; newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); state.queue.default = [ ...state.queue.default, ...newUniqueIds, ]; if (state.player.shuffle === PlayerShuffle.TRACK) { state.queue.shuffled = [ ...state.queue.shuffled.slice(0, currentIndex), state.queue.shuffled[currentIndex], ...shuffleInPlace([ ...state.queue.shuffled.slice(currentIndex + 1), ...newUniqueIds, ]), ]; } }); break; } case Play.NEXT: { set((state) => { const currentIndex = state.player.index; newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); state.queue.default = [ ...state.queue.default.slice(0, currentIndex + 1), ...newUniqueIds, ...state.queue.default.slice(currentIndex + 1), ]; if (state.player.shuffle === PlayerShuffle.TRACK) { state.queue.shuffled = [ ...state.queue.shuffled.slice(0, currentIndex), state.queue.shuffled[currentIndex], ...shuffleInPlace([ ...state.queue.shuffled.slice(currentIndex + 1), ...newUniqueIds, ]), ]; } }); break; } case Play.NOW: { 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) { state.queue.shuffled = shuffleInPlace(newUniqueIds); } }); break; } case Play.SHUFFLE: { 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 = shuffleInPlace([...shuffledIds]); }); break; } } break; } case PlayerQueueType.PRIORITY: { switch (playType) { case Play.LAST: { set((state) => { // Add new songs to songs object newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); state.queue.priority = [ ...state.queue.priority, ...newUniqueIds, ]; state.queue.shuffled = [ ...state.queue.shuffled, ...newUniqueIds, ]; }); break; } case Play.NEXT: { set((state) => { const currentIndex = state.player.index; const isInPriority = currentIndex < state.queue.priority.length; // Add new songs to songs object newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); if (isInPriority) { state.queue.priority = [ ...state.queue.priority.slice(0, currentIndex + 1), ...newUniqueIds, ...state.queue.priority.slice(currentIndex + 1), ]; } else { state.queue.priority = [ ...state.queue.priority, ...newUniqueIds, ]; } state.queue.shuffled = [ ...state.queue.shuffled.slice(0, currentIndex), state.queue.shuffled[currentIndex], ...shuffleInPlace([ ...state.queue.shuffled.slice(currentIndex + 1), ...newUniqueIds, ]), ]; }); break; } case Play.NOW: { set((state) => { // Add new songs to songs object newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); state.queue.default = []; state.player.status = PlayerStatus.PLAYING; state.player.playerNum = 1; setTimestampStore(0); // Add the first item after the current playing track const currentIndex = state.player.index; const queue = state.getQueue(); const currentTrack = queue.items[currentIndex]; if (queue.items.length === 0) { state.queue.priority = [newUniqueIds[0]]; state.queue.default = newUniqueIds.slice(1); state.player.index = 0; } else if (currentTrack) { const priorityIndex = state.queue.priority.findIndex( (id) => id === currentTrack._uniqueId, ); // If the current track is in the priority queue, add the first item after the current track if (priorityIndex !== -1) { state.queue.priority = [ ...state.queue.priority.slice( 0, priorityIndex + 1, ), newUniqueIds[0], ...state.queue.priority.slice( priorityIndex + 1, ), ]; state.player.index = priorityIndex + 1; state.queue.default = [ ...state.queue.default, ...newUniqueIds.slice(1), ]; } else { // If the current track is not in the priority queue, add it to the end of the priority queue state.queue.priority = [ ...state.queue.priority.slice(0, currentIndex), newUniqueIds[0], ...state.queue.priority.slice(currentIndex), ]; state.queue.default = [ ...state.queue.default, ...newUniqueIds.slice(1), ]; state.player.index = state.queue.priority.length - 1; } } if (state.player.shuffle === PlayerShuffle.TRACK) { state.queue.shuffled = shuffleInPlace(newUniqueIds); } }); break; } case Play.SHUFFLE: { set((state) => { // Add new songs to songs object 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.queue.priority = []; state.player.index = 0; state.player.status = PlayerStatus.PLAYING; state.player.playerNum = 1; setTimestampStore(0); // Add first item to priority queue, rest to default state.queue.priority = [shuffledIds[0]]; state.queue.default = shuffledIds.slice(1); // Always maintain shuffled array when using Play.SHUFFLE state.queue.shuffled = shuffleInPlace([...shuffledIds]); }); break; } } break; } } }, addToQueueByUniqueId: (items, uniqueId, edge) => { const newItems = items.map(toQueueSong); const newUniqueIds = newItems.map((item) => item._uniqueId); const queueType = getQueueType(); set((state) => { // Add new songs to songs object newItems.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); if (queueType === PlayerQueueType.DEFAULT) { const index = state.queue.default.findIndex((id) => id === uniqueId); const insertIndex = Math.max(0, edge === 'top' ? index : index + 1); // 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; } const newQueue = [ ...state.queue.default.slice(0, insertIndex), ...newUniqueIds, ...state.queue.default.slice(insertIndex), ]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; } else { const currentTrack = state.getCurrentSong() as QueueSong | undefined; const currentTrackUniqueId = currentTrack?._uniqueId; const priorityIndex = state.queue.priority.findIndex( (id) => id === uniqueId, ); if (priorityIndex !== -1) { const insertIndex = Math.max( 0, edge === 'top' ? priorityIndex : priorityIndex + 1, ); state.queue.priority = [ ...state.queue.priority.slice(0, insertIndex), ...newUniqueIds, ...state.queue.priority.slice(insertIndex), ]; } else { const defaultIndex = state.queue.default.findIndex( (id) => id === uniqueId, ); if (defaultIndex !== -1) { const insertIndex = Math.max( 0, edge === 'top' ? defaultIndex : defaultIndex + 1, ); state.queue.default = [ ...state.queue.default.slice(0, insertIndex), ...newUniqueIds, ...state.queue.default.slice(insertIndex), ]; } } const combinedQueue = [...state.queue.priority, ...state.queue.default]; recalculatePlayerIndexByUniqueId( state, currentTrackUniqueId, combinedQueue, ); if (state.player.shuffle === PlayerShuffle.TRACK) { const currentIndex = state.player.index; state.queue.shuffled = [ ...state.queue.shuffled.slice(0, currentIndex), state.queue.shuffled[currentIndex], ...shuffleInPlace([ ...state.queue.shuffled.slice(currentIndex + 1), ...newUniqueIds, ]), ]; } } }); }, clearQueue: () => { set((state) => { state.player.index = -1; state.queue.default = []; state.queue.priority = []; state.queue.shuffled = []; state.queue.songs = {}; }); }, clearSelected: (items: QueueSong[]) => { set((state) => { const uniqueIds = new Set(items.map((item) => item._uniqueId)); state.queue.default = state.queue.default.filter( (id) => !uniqueIds.has(id), ); state.queue.priority = state.queue.priority.filter( (id) => !uniqueIds.has(id), ); state.queue.shuffled = state.queue.shuffled.filter( (id) => !uniqueIds.has(id), ); cleanupOrphanedSongs(state); const newQueue = [...state.queue.priority, ...state.queue.default]; recalculatePlayerIndex(state, newQueue); }); }, decreaseVolume: (value: number) => { set((state) => { state.player.volume = Math.max(0, state.player.volume - value); }); }, getCurrentSong: () => { const queue = get().getQueue(); return queue.items[get().player.index]; }, getQueue: (groupBy?: QueueGroupingProperty) => { const queue = get().getQueueOrder(); const queueType = getQueueType(); if (!groupBy || queueType === PlayerQueueType.PRIORITY) { 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 queueType = getQueueType(); const state = get(); const songs = state.queue.songs; if (queueType === PlayerQueueType.PRIORITY) { const defaultIds = state.queue.default; const priorityIds = state.queue.priority; const defaultQueue: QueueSong[] = []; const priorityQueue: QueueSong[] = []; for (const id of priorityIds) { const song = songs[id]; if (song) priorityQueue.push(song); } for (const id of defaultIds) { const song = songs[id]; if (song) defaultQueue.push(song); } return { groups: [ { count: priorityQueue.length, name: 'Priority' }, { count: defaultQueue.length, name: 'Default' }, ], items: [...priorityQueue, ...defaultQueue], }; } const defaultIds = state.queue.default; const defaultQueue: QueueSong[] = []; for (const id of defaultIds) { const song = songs[id]; if (song) defaultQueue.push(song); } return { groups: [{ count: defaultQueue.length, name: 'All' }], items: defaultQueue, }; }, increaseVolume: (value: number) => { set((state) => { state.player.volume = Math.min(100, state.player.volume + value); }); }, mediaAutoNext: () => { const currentIndex = get().player.index; const player = get().player; const repeat = player.repeat; const queue = get().getQueueOrder(); const newPlayerNum = player.playerNum === 1 ? 2 : 1; let newIndex = Math.min(queue.items.length - 1, currentIndex + 1); let newStatus = PlayerStatus.PLAYING; if (repeat === PlayerRepeat.ONE) { newIndex = currentIndex; } if (newIndex === queue.items.length - 1) { newStatus = PlayerStatus.PAUSED; } set((state) => { state.player.index = newIndex; state.player.playerNum = newPlayerNum; setTimestampStore(0); state.player.status = newStatus; }); return { currentSong: queue.items[newIndex], index: newIndex, muted: player.muted, nextSong: queue.items[newIndex + 1], num: newPlayerNum, player1: newPlayerNum === 1 ? queue.items[newIndex] : queue.items[newIndex + 1], player2: newPlayerNum === 2 ? queue.items[newIndex] : queue.items[newIndex + 1], previousSong: queue.items[newIndex - 1], queue: get().queue, queueLength: queue.items.length, repeat: player.repeat, shuffle: player.shuffle, speed: player.speed, status: newStatus, transitionType: player.transitionType, volume: player.volume, }; }, mediaNext: () => { const currentIndex = get().player.index; const queue = get().getQueueOrder(); set((state) => { state.player.index = Math.min(queue.items.length - 1, currentIndex + 1); state.player.playerNum = 1; setTimestampStore(0); }); }, mediaPause: () => { set((state) => { state.player.status = PlayerStatus.PAUSED; }); }, mediaPlay: (id?: string) => { set((state) => { if (id) { const queue = state.getQueue(); const index = queue.items.findIndex((item) => item._uniqueId === id); if (index !== -1) { state.player.index = index; setTimestampStore(0); } } state.player.status = PlayerStatus.PLAYING; }); }, mediaPlayByIndex: (index: number) => { set((state) => { const queue = state.getQueue(); if (index === -1 || index >= queue.items.length) { state.player.status = PlayerStatus.PAUSED; return; } state.player.index = index; setTimestampStore(0); state.player.status = PlayerStatus.PLAYING; }); }, mediaPrevious: () => { const currentIndex = get().player.index; set((state) => { // Only decrement if we're not at the start state.player.index = Math.max(0, currentIndex - 1); setTimestampStore(0); }); }, 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: () => { set((state) => { state.player.status = PlayerStatus.PAUSED; setTimestampStore(0); state.player.index = -1; state.queue.default = []; state.queue.priority = []; state.queue.shuffled = []; state.queue.songs = {}; }); }, 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 queueType = getQueueType(); 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; } }); if (queueType == PlayerQueueType.DEFAULT) { // 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; } else { const currentTrack = state.getCurrentSong() as QueueSong | undefined; const currentTrackUniqueId = currentTrack?._uniqueId; const priorityIndex = state.queue.priority.findIndex( (id) => id === uniqueId, ); // If the item is in the priority queue if (priorityIndex !== -1) { const newIndex = Math.max( 0, edge === 'top' ? priorityIndex : priorityIndex + 1, ); const idsBefore = state.queue.priority .slice(0, newIndex) .filter((id) => !itemUniqueIds.includes(id)); const idsAfter = state.queue.priority .slice(newIndex) .filter((id) => !itemUniqueIds.includes(id)); const newPriorityQueue = [ ...idsBefore, ...itemUniqueIds, ...idsAfter, ]; const newDefaultQueue = state.queue.default.filter( (id) => !itemUniqueIds.includes(id), ); const combinedQueue = [...newPriorityQueue, ...newDefaultQueue]; recalculatePlayerIndexByUniqueId( state, currentTrackUniqueId, combinedQueue, ); state.queue.priority = newPriorityQueue; state.queue.default = newDefaultQueue; } else { const defaultIndex = state.queue.default.findIndex( (id) => id === uniqueId, ); if (defaultIndex !== -1) { const newIndex = Math.max( 0, edge === 'top' ? defaultIndex : defaultIndex + 1, ); const idsBefore = state.queue.default .slice(0, newIndex) .filter((id) => !itemUniqueIds.includes(id)); const idsAfter = state.queue.default .slice(newIndex) .filter((id) => !itemUniqueIds.includes(id)); const newDefaultQueue = [ ...idsBefore, ...itemUniqueIds, ...idsAfter, ]; const newPriorityQueue = state.queue.priority.filter( (id) => !itemUniqueIds.includes(id), ); const combinedQueue = [...newPriorityQueue, ...newDefaultQueue]; recalculatePlayerIndexByUniqueId( state, currentTrackUniqueId, combinedQueue, ); state.queue.default = newDefaultQueue; state.queue.priority = newPriorityQueue; } } } }); }, 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; }); if (state.player.queueType === PlayerQueueType.PRIORITY) { const priorityFiltered = state.queue.priority.filter( (id) => !uniqueIds.includes(id), ); const newPriorityQueue = [...priorityFiltered, ...uniqueIds]; const filtered = state.queue.default.filter( (id) => !uniqueIds.includes(id), ); const newDefaultQueue = [...filtered]; const combinedQueue = [...newPriorityQueue, ...newDefaultQueue]; recalculatePlayerIndex(state, combinedQueue); state.queue.default = newDefaultQueue; state.queue.priority = newPriorityQueue; } else { const filtered = state.queue.default.filter( (id) => !uniqueIds.includes(id), ); const newQueue = [...filtered, ...uniqueIds]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; } }); }, moveSelectedToNext: (items: QueueSong[]) => { const queueType = getQueueType(); set((state) => { const queue = state.getQueue(); const index = state.player.index; const currentTrack = queue.items[index]; const uniqueId = currentTrack?._uniqueId; const uniqueIds = items.map((item) => item._uniqueId); // Add new songs to songs object items.forEach((item) => { state.queue.songs[item._uniqueId] = item; }); if (queueType === PlayerQueueType.DEFAULT) { const currentIndex = state.player.index; const filtered = state.queue.default.filter( (id) => !uniqueIds.includes(id), ); const newQueue = [ ...filtered.slice(0, currentIndex + 1), ...uniqueIds, ...filtered.slice(currentIndex + 1), ]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; } else { const priorityIndex = state.queue.priority.findIndex( (id) => id === uniqueId, ); // If the item is in the priority queue if (priorityIndex !== -1) { const newIndex = Math.max(0, priorityIndex + 1); const idsBefore = state.queue.priority .slice(0, newIndex) .filter((id) => !uniqueIds.includes(id)); const idsAfter = state.queue.priority .slice(newIndex) .filter((id) => !uniqueIds.includes(id)); const newPriorityQueue = [...idsBefore, ...uniqueIds, ...idsAfter]; const newDefaultQueue = state.queue.default.filter( (id) => !uniqueIds.includes(id), ); const combinedQueue = [...newPriorityQueue, ...newDefaultQueue]; recalculatePlayerIndex(state, combinedQueue); state.queue.priority = newPriorityQueue; state.queue.default = newDefaultQueue; } else { const defaultIndex = state.queue.default.findIndex( (id) => id === uniqueId, ); if (defaultIndex !== -1) { const newIndex = Math.max(0, defaultIndex + 1); const idsBefore = state.queue.default .slice(0, newIndex) .filter((id) => !uniqueIds.includes(id)); const idsAfter = state.queue.default .slice(newIndex) .filter((id) => !uniqueIds.includes(id)); const newDefaultQueue = [ ...idsBefore, ...uniqueIds, ...idsAfter, ]; const newPriorityQueue = state.queue.priority.filter( (id) => !uniqueIds.includes(id), ); const combinedQueue = [...newPriorityQueue, ...newDefaultQueue]; recalculatePlayerIndex(state, combinedQueue); state.queue.default = newDefaultQueue; state.queue.priority = newPriorityQueue; } } } }); }, 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; }); if (state.player.queueType === PlayerQueueType.PRIORITY) { const priorityFiltered = state.queue.priority.filter( (id) => !uniqueIds.includes(id), ); const newPriorityQueue = [...uniqueIds, ...priorityFiltered]; const filtered = state.queue.default.filter( (id) => !uniqueIds.includes(id), ); const newDefaultQueue = [...filtered]; const combinedQueue = [...newPriorityQueue, ...newDefaultQueue]; recalculatePlayerIndex(state, combinedQueue); state.queue.default = newDefaultQueue; state.queue.priority = newPriorityQueue; } else { const filtered = state.queue.default.filter( (id) => !uniqueIds.includes(id), ); const newQueue = [...uniqueIds, ...filtered]; recalculatePlayerIndex(state, newQueue); state.queue.default = newQueue; } }); }, ...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; }); }, setQueueType: (queueType: PlayerQueueType) => { set((state) => { // From default -> priority, move all items from default to priority if (queueType === PlayerQueueType.PRIORITY) { state.queue.priority = [ ...state.queue.default, ...state.queue.priority, ]; state.queue.default = []; } else { // From priority -> default, move all items from priority to the start of default state.queue.default = [...state.queue.priority, ...state.queue.default]; state.queue.priority = []; } state.player.queueType = queueType; cleanupOrphanedSongs(state); }); }, setRepeat: (repeat: PlayerRepeat) => { set((state) => { state.player.repeat = repeat; }); }, setShuffle: (shuffle: PlayerShuffle) => { set((state) => { state.player.shuffle = shuffle; const queue = state.queue.default; state.queue.shuffled = shuffleInPlace([...queue]); 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) => { const queue = state.queue.default; state.queue.shuffled = shuffleInPlace([...queue]); }); }, shuffleAll: () => { set((state) => { const queue = state.queue.default; const currentIndex = state.player.index; // If there's a current song playing, keep it in place if (currentIndex >= 0 && currentIndex < queue.length) { const currentSong = queue[currentIndex]; const beforeCurrent = queue.slice(0, currentIndex); const afterCurrent = queue.slice(currentIndex + 1); const shuffledBefore = shuffleInPlace([...beforeCurrent]); const shuffledAfter = shuffleInPlace([...afterCurrent]); state.queue.default = [ ...shuffledBefore, currentSong, ...shuffledAfter, ]; } else { state.queue.default = shuffleInPlace([...queue]); } }); }, shuffleSelected: (items: QueueSong[]) => { set((state) => { const itemUniqueIds = items.map((item) => item._uniqueId); const indices = itemUniqueIds.map((id) => state.queue.default.findIndex((i) => i === id), ); const shuffledIds = shuffleInPlace([...itemUniqueIds]); indices.forEach((i, index) => { if (i !== -1) { state.queue.default[i] = shuffledIds[index]; } }); }); }, 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) => { state.player.shuffle = state.player.shuffle === PlayerShuffle.NONE ? PlayerShuffle.TRACK : PlayerShuffle.NONE; }); }, })), ), { merge: (persistedState: any, currentState: any) => { return merge(currentState, persistedState); }, migrate: (persistedState, version) => { if (version === 1) { // Replace the old player store state with the new one persistedState = { ...initialState }; } return persistedState; }, name: 'player-store', 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'); } // Filter top-level state entries const filteredStateEntries = Object.entries(state).filter(([key]) => { // Exclude queue if shouldRestorePlayQueue is false if (!shouldRestorePlayQueue && key === 'queue') { return false; } return true; }); const filteredState = Object.fromEntries( filteredStateEntries, ) as Partial; // Filter player object if (filteredState.player) { filteredState.player = Object.fromEntries( Object.entries(filteredState.player).filter( ([key]) => !excludedPlayerKeys.includes(key), ), ) as typeof filteredState.player; } if (filteredState.queue) { const allQueueIds = new Set([ ...(filteredState.queue.default || []), ...(filteredState.queue.priority || []), ...(filteredState.queue.shuffled || []), ]); const songs = filteredState.queue.songs || {}; const cleanedSongs: Record = {}; for (const [id, song] of Object.entries(songs)) { if (allQueueIds.has(id)) { cleanedSongs[id] = song; } } filteredState.queue = { ...filteredState.queue, songs: cleanedSongs, }; } return filteredState; }, storage: createJSONStorage(() => idbStateStorage), version: 2, }, ), ); 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, 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, setQueueType: state.setQueueType, 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 { ...actions, setTimestamp: setTimestampStore, }; }; 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(); const index = state.player.index; return { index, song: queue.items[index] }; }, (song, prevSong) => { onChange(song, prevSong); }, { equalityFn: (a, b) => { return a.song?._uniqueId === b.song?._uniqueId; }, }, ); }; export const 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 usePlayerProperties = () => { return usePlayerStoreBase( useShallow((state) => ({ crossfadeDuration: state.player.crossfadeDuration, crossfadeStyle: state.player.crossfadeStyle, isMuted: state.player.muted, playerNum: state.player.playerNum, queueType: state.player.queueType, 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(); const index = state.player.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; const currentSong = queue.items[index]; const nextSong = queue.items[index + 1]; const previousSong = queue.items[index - 1]; return { currentSong, index, muted: state.player.muted, nextSong, num: state.player.playerNum, player1: state.player.playerNum === 1 ? currentSong : nextSong, player2: state.player.playerNum === 2 ? currentSong : nextSong, previousSong, queue: state.queue, queueLength: state.queue.default.length + state.queue.priority.length, 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 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 usePlayerMuted = () => { return usePlayerStoreBase((state) => state.player.muted); }; export const usePlayerQueueType = () => { return usePlayerStoreBase((state) => state.player.queueType); }; 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 usePlayerVolume = () => { return usePlayerStoreBase((state) => state.player.volume); }; export const usePlayerSpeed = () => { return usePlayerStoreBase((state) => state.player.speed); }; export const usePlayerSong = () => { return usePlayerStoreBase((state) => { const queue = state.getQueue(); const index = state.player.index; return queue.items[index]; }); }; export const usePlayerNum = () => { return usePlayerStoreBase((state) => state.player.playerNum); }; export const usePlayerQueue = () => { return usePlayerStoreBase( useShallow((state) => { const queueType = state.player.queueType; const songs = state.queue.songs; switch (queueType) { case PlayerQueueType.DEFAULT: { const queue = state.queue.default; const result: QueueSong[] = []; for (const id of queue) { const song = songs[id]; if (song) result.push(song); } return result; } case PlayerQueueType.PRIORITY: { const priorityQueue = state.queue.priority; const result: QueueSong[] = []; for (const id of priorityQueue) { const song = songs[id]; if (song) result.push(song); } return result; } default: { const defaultQueue = state.queue.default; const result: QueueSong[] = []; for (const id of defaultQueue) { const song = songs[id]; if (song) result.push(song); } return result; } } }), ); }; function cleanupOrphanedSongs(state: any): boolean { const allQueueIds = new Set([ ...state.queue.default, ...state.queue.priority, ...state.queue.shuffled, ]); const songs = state.queue.songs; const songIds = Object.keys(songs); let hasOrphans = false; const orphanedIds: string[] = []; for (const songId of songIds) { if (!allQueueIds.has(songId)) { orphanedIds.push(songId); hasOrphans = true; } } if (hasOrphans) { const cleanedSongs: Record = {}; for (const songId of songIds) { if (!orphanedIds.includes(songId)) { cleanedSongs[songId] = songs[songId]; } } state.queue.songs = cleanedSongs; } return hasOrphans; } function getQueueType() { const queueType: PlayerQueueType = usePlayerStore.getState().player.queueType; return queueType; } 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 recalculatePlayerIndexByUniqueId( state: any, currentTrackUniqueId: string | undefined, queue: string[], ) { if (!currentTrackUniqueId) { return; } const recalculatedIndex = queue.findIndex((id) => id === currentTrackUniqueId); if (recalculatedIndex !== -1) { state.player.index = recalculatedIndex; } } 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()}`; }