feat: work with order in state
This commit is contained in:
@@ -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<Crumb[]>(
|
||||
[],
|
||||
)
|
||||
@@ -47,13 +70,6 @@ export function FurumiPlayer() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
||||
|
||||
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
|
||||
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
|
||||
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
||||
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
||||
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
||||
const [queue, setQueue] = useState<QueueItem[]>([])
|
||||
|
||||
const queueActionsRef = useRef<{
|
||||
playIndex: (i: number) => void
|
||||
removeFromQueue: (idx: number) => void
|
||||
@@ -63,20 +79,48 @@ export function FurumiPlayer() {
|
||||
const audioRef = useRef<HTMLAudioElement>(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() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user