From 83a145d0a8d71fbdb96189f4fd323a19af31a837 Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Thu, 2 Apr 2026 00:13:30 +0300 Subject: [PATCH] feat: work with order in state --- .../client/src/FurumiPlayer.tsx | 303 +++++++----------- furumi-node-player/client/src/furumiApi.ts | 2 +- furumi-node-player/client/src/store/index.ts | 2 + .../client/src/store/slices/queueSlice.ts | 273 ++++++++++++++++ 4 files changed, 397 insertions(+), 183 deletions(-) create mode 100644 furumi-node-player/client/src/store/slices/queueSlice.ts diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 96c41d2..c960250 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -5,17 +5,33 @@ import { searchTracks, preloadStream, } from './furumiApi' -import { useAppDispatch, useAppSelector } from './store' +import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' import { fetchArtistTracks } from './store/slices/artistTracksSlice' import { fetchAlbumTracks } from './store/slices/albumTracksSlice' import { fetchTrackDetail } from './store/slices/trackDetailSlice' +import { + addTrack, + addTracksBatch, + replaceQueue, + clearQueue, + playAtIndex, + removeFromQueueAt, + moveQueueItemInOrder, + toggleShuffle, + toggleRepeat, + rebuildShuffleOrder, + selectQueueOrder, + selectPlayingOrigIdx, + selectQueueScrollSignal, + selectNowPlayingTrack, + selectQueueItems, +} from './store/slices/queueSlice' import { fmt } from './utils' import { Header } from './components/Header' import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' -import type { QueueItem } from './components/QueueList' import type { Track } from './types' export function FurumiPlayer() { @@ -26,6 +42,13 @@ export function FurumiPlayer() { const albumsError = useAppSelector((s) => s.albums.error) const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading) const albumTracksError = useAppSelector((s) => s.albumTracks.error) + + const queueItemsView = useAppSelector(selectQueueItems) + const queueOrderView = useAppSelector(selectQueueOrder) + const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx) + const queueScrollSignal = useAppSelector(selectQueueScrollSignal) + const nowPlayingTrack = useAppSelector(selectNowPlayingTrack) + const [breadcrumbs, setBreadcrumbs] = useState( [], ) @@ -47,13 +70,6 @@ export function FurumiPlayer() { const [searchOpen, setSearchOpen] = useState(false) const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {}) - const [nowPlayingTrack, setNowPlayingTrack] = useState(null) - const [queueItemsView, setQueueItemsView] = useState([]) - const [queueOrderView, setQueueOrderView] = useState([]) - const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState(-1) - const [queueScrollSignal, setQueueScrollSignal] = useState(0) - const [queue, setQueue] = useState([]) - const queueActionsRef = useRef<{ playIndex: (i: number) => void removeFromQueue: (idx: number) => void @@ -63,20 +79,48 @@ export function FurumiPlayer() { const audioRef = useRef(null) useEffect(() => { - // --- Original player script adapted for React environment --- + if (!nowPlayingTrack) { + document.title = 'Furumi Player' + return + } + document.title = `${nowPlayingTrack.title} — Furumi` + const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` + if ('mediaSession' in navigator) { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + navigator.mediaSession.metadata = new window.MediaMetadata({ + title: nowPlayingTrack.title, + artist: nowPlayingTrack.artist || '', + album: '', + artwork: [{ src: coverUrl, sizes: '512x512' }], + }) + } catch { + // ignore + } + } + }, [nowPlayingTrack]) + + const shuffle = useAppSelector((s) => s.queue.shuffle) + const repeatAll = useAppSelector((s) => s.queue.repeatAll) + + useEffect(() => { + const btnShuffle = document.getElementById('btnShuffle') + const btnRepeat = document.getElementById('btnRepeat') + btnShuffle?.classList.toggle('active', shuffle) + btnRepeat?.classList.toggle('active', repeatAll) + }, [shuffle, repeatAll]) + + useEffect(() => { const audioEl = audioRef.current if (!audioEl) return const audio = audioEl - let queueIndex = -1 - let shuffle = false - let repeatAll = true - let shuffleOrder: number[] = [] let searchTimer: number | null = null let toastTimer: number | null = null let muted = false + let playbackErrorSkips = 0 + const MAX_PLAYBACK_ERROR_SKIPS = 5 - // Restore prefs try { const v = window.localStorage.getItem('furumi_vol') const volSlider = document.getElementById('volSlider') as HTMLInputElement | null @@ -84,17 +128,10 @@ export function FurumiPlayer() { audio.volume = Number(v) / 100 volSlider.value = v } - const btnShuffle = document.getElementById('btnShuffle') - const btnRepeat = document.getElementById('btnRepeat') - shuffle = window.localStorage.getItem('furumi_shuffle') === '1' - repeatAll = window.localStorage.getItem('furumi_repeat') !== '0' - btnShuffle?.classList.toggle('active', shuffle) - btnRepeat?.classList.toggle('active', repeatAll) } catch { // ignore } - // --- Audio events --- audio.addEventListener('timeupdate', () => { if (audio.duration) { const fill = document.getElementById('progressFill') @@ -106,6 +143,9 @@ export function FurumiPlayer() { } }) audio.addEventListener('ended', () => nextTrack()) + audio.addEventListener('playing', () => { + playbackErrorSkips = 0 + }) audio.addEventListener('play', () => { const btn = document.getElementById('btnPlayPause') if (btn) btn.innerHTML = '⏸' @@ -116,10 +156,11 @@ export function FurumiPlayer() { }) audio.addEventListener('error', () => { showToast('Playback error') + if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return + playbackErrorSkips += 1 nextTrack() }) - // --- Library navigation --- async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) try { @@ -231,7 +272,6 @@ export function FurumiPlayer() { setBreadcrumbs(parts) } - // --- Queue management --- function addTrackToQueue( track: { slug: string @@ -242,15 +282,11 @@ export function FurumiPlayer() { }, playNow?: boolean, ) { - const existing = queue.findIndex((t) => t.slug === track.slug) - if (existing !== -1) { - if (playNow) playIndex(existing) - return - } - setQueue((q) => [...q, track]); - updateQueueModel() - if (playNow || (queueIndex === -1 && queue.length === 1)) { - playIndex(queue.length - 1) + const prevIdx = store.getState().queue.currentIndex + dispatch(addTrack({ track, playNow })) + const q = store.getState().queue + if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) { + playIndex(q.currentIndex) } } @@ -259,23 +295,19 @@ export function FurumiPlayer() { if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } if (!tracks || !tracks.length) return - const list = tracks - let firstIdx = queue.length - list.forEach((t) => { - if (queue.find((q) => q.slug === t.slug)) return - setQueue((q) => [ - ...q, - { - slug: t.slug, - title: t.title, - artist: t.artist_name, - album_slug: t.album_slug, - duration: t.duration_secs, - }, - ]) - }) - updateQueueModel() - if (playFirst || queueIndex === -1) playIndex(firstIdx) + const list = tracks.map((t) => ({ + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + })) + const prevIdx = store.getState().queue.currentIndex + dispatch(addTracksBatch({ tracks: list, playFirst })) + const q = store.getState().queue + if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) { + playIndex(q.currentIndex) + } showToast(`Added ${list.length} tracks`) } @@ -284,29 +316,25 @@ export function FurumiPlayer() { if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { artistSlug: string; tracks: Track[] } if (!tracks || !tracks.length) return - const list = tracks - clearQueue() - setQueue(list.map((t) => ({ + const list = tracks.map((t) => ({ slug: t.slug, title: t.title, artist: t.artist_name, album_slug: t.album_slug, duration: t.duration_secs, - }))) - updateQueueModel() + })) + dispatch(replaceQueue({ items: list, playFromIndex: 0 })) playIndex(0) showToast(`Added ${list.length} tracks`) } function playIndex(i: number) { - if (i < 0 || i >= queue.length) return - queueIndex = i - const track = queue[i] + const q = store.getState().queue + if (i < 0 || i >= q.items.length) return + dispatch(playAtIndex(i)) + const track = store.getState().queue.items[i] audio.src = `${API_ROOT}/stream/${track.slug}` void audio.play().catch(() => {}) - updateNowPlaying(track) - updateQueueModel() - setQueueScrollSignal((s) => s + 1) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -314,88 +342,17 @@ export function FurumiPlayer() { } } - function updateNowPlaying(track: QueueItem | null) { - setNowPlayingTrack(track) - if (!track) return - - document.title = `${track.title} — Furumi` - - const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` - if ('mediaSession' in navigator) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - navigator.mediaSession.metadata = new window.MediaMetadata({ - title: track.title, - artist: track.artist || '', - album: '', - artwork: [{ src: coverUrl, sizes: '512x512' }], - }) - } - } - - function currentOrder() { - if (!shuffle) return [...Array(queue.length).keys()] - if (shuffleOrder.length !== queue.length) buildShuffleOrder() - return shuffleOrder - } - - function buildShuffleOrder() { - shuffleOrder = [...Array(queue.length).keys()] - for (let i = shuffleOrder.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)) - ;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]] - } - if (queueIndex !== -1) { - const ci = shuffleOrder.indexOf(queueIndex) - if (ci > 0) { - shuffleOrder.splice(ci, 1) - shuffleOrder.unshift(queueIndex) - } - } - } - - function updateQueueModel() { - const order = currentOrder() - setQueueItemsView(queue) - setQueueOrderView(order.slice()) - setQueuePlayingOrigIdxView(queueIndex) - } - function removeFromQueue(idx: number) { - if (idx === queueIndex) { - queueIndex = -1 + const wasPlaying = store.getState().queue.currentIndex === idx + dispatch(removeFromQueueAt(idx)) + if (wasPlaying) { audio.pause() audio.src = '' - updateNowPlaying(null) - } else if (queueIndex > idx) { - queueIndex-- } - - // queue.splice(idx, 1) - setQueue((q) => q.filter((_, i) => i !== idx)); - - if (shuffle) { - const si = shuffleOrder.indexOf(idx) - if (si !== -1) shuffleOrder.splice(si, 1) - for (let i = 0; i < shuffleOrder.length; i++) { - if (shuffleOrder[i] > idx) shuffleOrder[i]-- - } - } - updateQueueModel() } - function moveQueueItem(from: number, to: number) { - if (from === to) return - if (shuffle) { - const item = shuffleOrder.splice(from, 1)[0] - shuffleOrder.splice(to, 0, item) - } else { - const item = queue.splice(from, 1)[0] - queue.splice(to, 0, item) - if (queueIndex === from) queueIndex = to - else if (from < queueIndex && to >= queueIndex) queueIndex-- - else if (from > queueIndex && to <= queueIndex) queueIndex++ - } - updateQueueModel() + function moveQueueItem(fromPos: number, toPos: number) { + dispatch(moveQueueItemInOrder({ fromPos, toPos })) } queueActionsRef.current = { @@ -404,21 +361,16 @@ export function FurumiPlayer() { moveQueueItem, } - function clearQueue() { - setQueue([]); - queueIndex = -1 - shuffleOrder = [] + function clearQueuePlayback() { + dispatch(clearQueue()) audio.pause() audio.src = '' - updateNowPlaying(null) - document.title = 'Furumi Player' - updateQueueModel() } - // --- Playback controls --- function togglePlay() { - if (!audio.src && queue.length) { - playIndex(queueIndex === -1 ? 0 : queueIndex) + const q = store.getState().queue + if (!audio.src && q.items.length) { + playIndex(q.currentIndex === -1 ? 0 : q.currentIndex) return } if (audio.paused) void audio.play() @@ -426,45 +378,39 @@ export function FurumiPlayer() { } function nextTrack() { - if (!queue.length) return - const order = currentOrder() - const pos = order.indexOf(queueIndex) + const q = store.getState().queue + if (!q.items.length) return + const order = selectQueueOrder(store.getState()) + const pos = order.indexOf(q.currentIndex) if (pos < order.length - 1) playIndex(order[pos + 1]) - else if (repeatAll) { - if (shuffle) buildShuffleOrder() - playIndex(currentOrder()[0]) + else if (q.repeatAll) { + if (q.shuffle) dispatch(rebuildShuffleOrder()) + const first = selectQueueOrder(store.getState())[0] + if (first !== undefined) playIndex(first) } } function prevTrack() { - if (!queue.length) return + const q = store.getState().queue + if (!q.items.length) return if (audio.currentTime > 3) { audio.currentTime = 0 return } - const order = currentOrder() - const pos = order.indexOf(queueIndex) + const order = selectQueueOrder(store.getState()) + const pos = order.indexOf(q.currentIndex) if (pos > 0) playIndex(order[pos - 1]) - else if (repeatAll) playIndex(order[order.length - 1]) + else if (q.repeatAll) playIndex(order[order.length - 1]) } - function toggleShuffle() { - shuffle = !shuffle - if (shuffle) buildShuffleOrder() - const btn = document.getElementById('btnShuffle') - btn?.classList.toggle('active', shuffle) - window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0') - updateQueueModel() + function onToggleShuffle() { + dispatch(toggleShuffle()) } - function toggleRepeat() { - repeatAll = !repeatAll - const btn = document.getElementById('btnRepeat') - btn?.classList.toggle('active', repeatAll) - window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0') + function onToggleRepeat() { + dispatch(toggleRepeat()) } - // --- Seek & Volume --- function seekTo(e: MouseEvent) { if (!audio.duration) return const bar = document.getElementById('progressBar') as HTMLDivElement | null @@ -488,7 +434,6 @@ export function FurumiPlayer() { window.localStorage.setItem('furumi_vol', String(v)) } - // --- Search --- function onSearch(q: string) { if (searchTimer) { window.clearTimeout(searchTimer) @@ -527,7 +472,6 @@ export function FurumiPlayer() { } searchSelectRef.current = onSearchSelect - // --- Helpers --- function showToast(msg: string) { const t = document.getElementById('toast') if (!t) return @@ -544,7 +488,6 @@ export function FurumiPlayer() { overlay?.classList.toggle('show') } - // --- MediaSession --- if ('mediaSession' in navigator) { try { navigator.mediaSession.setActionHandler('play', togglePlay) @@ -561,7 +504,6 @@ export function FurumiPlayer() { } } - // --- Wire DOM events that were inline in HTML --- const btnMenu = document.querySelector('.btn-menu') btnMenu?.addEventListener('click', () => toggleSidebar()) @@ -579,11 +521,11 @@ export function FurumiPlayer() { } const btnShuffle = document.getElementById('btnShuffle') - btnShuffle?.addEventListener('click', () => toggleShuffle()) + btnShuffle?.addEventListener('click', () => onToggleShuffle()) const btnRepeat = document.getElementById('btnRepeat') - btnRepeat?.addEventListener('click', () => toggleRepeat()) + btnRepeat?.addEventListener('click', () => onToggleRepeat()) const btnClear = document.getElementById('btnClearQueue') - btnClear?.addEventListener('click', () => clearQueue()) + btnClear?.addEventListener('click', () => clearQueuePlayback()) const btnPrev = document.getElementById('btnPrev') btnPrev?.addEventListener('click', () => prevTrack()) @@ -606,9 +548,8 @@ export function FurumiPlayer() { } const clearQueueBtn = document.getElementById('btnClearQueue') - clearQueueBtn?.addEventListener('click', () => clearQueue()) + clearQueueBtn?.addEventListener('click', () => clearQueuePlayback()) - // --- Init --- ;(async () => { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') @@ -632,12 +573,11 @@ export function FurumiPlayer() { void showArtists() })() - // Cleanup: best-effort remove listeners on unmount return () => { queueActionsRef.current = null audio.pause() } - }, []) + }, [dispatch]) const libraryLoading = breadcrumbs.length === 1 @@ -686,4 +626,3 @@ export function FurumiPlayer() { ) } - diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index e7518ec..135e573 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -44,6 +44,6 @@ export async function getTrackInfo(trackSlug: string): Promise null) + await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'arraybuffer' }).catch(() => null) } diff --git a/furumi-node-player/client/src/store/index.ts b/furumi-node-player/client/src/store/index.ts index 49b5321..37ff304 100644 --- a/furumi-node-player/client/src/store/index.ts +++ b/furumi-node-player/client/src/store/index.ts @@ -5,6 +5,7 @@ import albumsReducer from './slices/albumsSlice' import albumTracksReducer from './slices/albumTracksSlice' import artistTracksReducer from './slices/artistTracksSlice' import trackDetailReducer from './slices/trackDetailSlice' +import queueReducer from './slices/queueSlice' export const store = configureStore({ reducer: { @@ -13,6 +14,7 @@ export const store = configureStore({ albumTracks: albumTracksReducer, artistTracks: artistTracksReducer, trackDetail: trackDetailReducer, + queue: queueReducer, }, }) diff --git a/furumi-node-player/client/src/store/slices/queueSlice.ts b/furumi-node-player/client/src/store/slices/queueSlice.ts new file mode 100644 index 0000000..50e479f --- /dev/null +++ b/furumi-node-player/client/src/store/slices/queueSlice.ts @@ -0,0 +1,273 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import type { QueueItem } from '../../components/QueueList' + +export interface QueueState { + items: QueueItem[] + currentIndex: number + shuffle: boolean + repeatAll: boolean + shuffleOrder: number[] + scrollSignal: number +} + +function readShufflePref(): boolean { + try { + return window.localStorage.getItem('furumi_shuffle') === '1' + } catch { + return false + } +} + +function readRepeatPref(): boolean { + try { + return window.localStorage.getItem('furumi_repeat') !== '0' + } catch { + return true + } +} + +function buildShuffleOrder(state: QueueState) { + const n = state.items.length + if (n === 0) { + state.shuffleOrder = [] + return + } + const order = [...Array(n).keys()] + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[order[i], order[j]] = [order[j], order[i]] + } + if (state.currentIndex !== -1) { + const ci = order.indexOf(state.currentIndex) + if (ci > 0) { + order.splice(ci, 1) + order.unshift(state.currentIndex) + } + } + state.shuffleOrder = order +} + +function ensureShuffleOrder(state: QueueState) { + if (!state.shuffle) return + if (state.shuffleOrder.length !== state.items.length) { + buildShuffleOrder(state) + } +} + +const initialState: QueueState = { + items: [], + currentIndex: -1, + shuffle: typeof window !== 'undefined' ? readShufflePref() : false, + repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true, + shuffleOrder: [], + scrollSignal: 0, +} + +const queueSlice = createSlice({ + name: 'queue', + initialState, + reducers: { + addTrack( + state, + action: PayloadAction<{ + track: QueueItem + playNow?: boolean + }>, + ) { + const { track, playNow } = action.payload + const existing = state.items.findIndex((t) => t.slug === track.slug) + if (existing !== -1) { + if (playNow) { + state.currentIndex = existing + state.scrollSignal += 1 + } + return + } + const oldLen = state.items.length + const idle = state.currentIndex === -1 + state.items.push(track) + ensureShuffleOrder(state) + if (playNow || (oldLen === 0 && idle)) { + state.currentIndex = state.items.length - 1 + state.scrollSignal += 1 + } + }, + + addTracksBatch( + state, + action: PayloadAction<{ + tracks: QueueItem[] + playFirst?: boolean + }>, + ) { + const { tracks, playFirst } = action.payload + let firstNewIdx: number | null = null + for (const t of tracks) { + if (state.items.some((q) => q.slug === t.slug)) continue + if (firstNewIdx === null) firstNewIdx = state.items.length + state.items.push(t) + } + ensureShuffleOrder(state) + if (firstNewIdx === null) return + if (playFirst || state.currentIndex === -1) { + state.currentIndex = firstNewIdx + state.scrollSignal += 1 + } + }, + + replaceQueue( + state, + action: PayloadAction<{ + items: QueueItem[] + playFromIndex?: number + }>, + ) { + const { items, playFromIndex = 0 } = action.payload + state.items = items + state.currentIndex = + items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1 + state.shuffleOrder = [] + ensureShuffleOrder(state) + }, + + clearQueue(state) { + state.items = [] + state.currentIndex = -1 + state.shuffleOrder = [] + state.scrollSignal += 1 + }, + + playAtIndex(state, action: PayloadAction) { + const i = action.payload + if (i < 0 || i >= state.items.length) return + state.currentIndex = i + state.scrollSignal += 1 + }, + + removeFromQueueAt(state, action: PayloadAction) { + const idx = action.payload + if (idx < 0 || idx >= state.items.length) return + + if (idx === state.currentIndex) { + state.currentIndex = -1 + } else if (state.currentIndex > idx) { + state.currentIndex -= 1 + } + + state.items.splice(idx, 1) + + if (state.shuffle) { + const si = state.shuffleOrder.indexOf(idx) + if (si !== -1) state.shuffleOrder.splice(si, 1) + for (let i = 0; i < state.shuffleOrder.length; i++) { + if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1 + } + } + + ensureShuffleOrder(state) + }, + + moveQueueItemInOrder( + state, + action: PayloadAction<{ fromPos: number; toPos: number }>, + ) { + const { fromPos, toPos } = action.payload + if (fromPos === toPos) return + + if (state.shuffle) { + const order = state.shuffleOrder + if (fromPos < 0 || fromPos >= order.length) return + if (toPos < 0 || toPos >= order.length) return + const item = order.splice(fromPos, 1)[0] + order.splice(toPos, 0, item) + return + } + + const items = state.items + if (fromPos < 0 || fromPos >= items.length) return + if (toPos < 0 || toPos >= items.length) return + const qIdx = state.currentIndex + const item = items.splice(fromPos, 1)[0] + items.splice(toPos, 0, item) + if (qIdx === fromPos) state.currentIndex = toPos + else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1 + else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1 + }, + + toggleShuffle(state) { + state.shuffle = !state.shuffle + try { + window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0') + } catch { + // ignore + } + if (state.shuffle) buildShuffleOrder(state) + else state.shuffleOrder = [] + }, + + toggleRepeat(state) { + state.repeatAll = !state.repeatAll + try { + window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0') + } catch { + // ignore + } + }, + + rebuildShuffleOrder(state) { + if (state.shuffle) buildShuffleOrder(state) + }, + }, +}) + +export const { + addTrack, + addTracksBatch, + replaceQueue, + clearQueue, + playAtIndex, + removeFromQueueAt, + moveQueueItemInOrder, + toggleShuffle, + toggleRepeat, + rebuildShuffleOrder, +} = queueSlice.actions + +type QueueSliceRoot = { queue: QueueState } + +export function selectQueueItems(state: QueueSliceRoot) { + return state.queue.items +} + +export function selectQueueOrder(state: QueueSliceRoot): number[] { + const q = state.queue + if (!q.shuffle) return q.items.map((_, i) => i) + if (q.shuffleOrder.length !== q.items.length) { + return q.items.map((_, i) => i) + } + return q.shuffleOrder +} + +export function selectPlayingOrigIdx(state: QueueSliceRoot) { + return state.queue.currentIndex +} + +export function selectQueueScrollSignal(state: QueueSliceRoot) { + return state.queue.scrollSignal +} + +export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null { + const q = state.queue + if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null + return q.items[q.currentIndex] +} + +export function selectShuffle(state: QueueSliceRoot) { + return state.queue.shuffle +} + +export function selectRepeatAll(state: QueueSliceRoot) { + return state.queue.repeatAll +} + +export default queueSlice.reducer