feat: refactoring
This commit is contained in:
@@ -4,6 +4,8 @@ import { createFurumiApiClient } from './furumiApi'
|
||||
import { SearchDropdown } from './components/SearchDropdown'
|
||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||
import { LibraryList } from './components/LibraryList'
|
||||
import { QueueList, type QueueItem } from './components/QueueList'
|
||||
import { NowPlaying } from './components/NowPlaying'
|
||||
|
||||
type FurumiPlayerProps = {
|
||||
apiRoot: string
|
||||
@@ -35,18 +37,24 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
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 queueActionsRef = useRef<{
|
||||
playIndex: (i: number) => void
|
||||
removeFromQueue: (idx: number) => void
|
||||
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// --- Original player script adapted for React environment ---
|
||||
const audio = document.getElementById('audioEl') as HTMLAudioElement
|
||||
if (!audio) return
|
||||
|
||||
let queue: Array<{
|
||||
slug: string
|
||||
title: string
|
||||
artist: string
|
||||
album_slug: string | null
|
||||
duration: number | null
|
||||
}> = []
|
||||
let queue: QueueItem[] = []
|
||||
let queueIndex = -1
|
||||
let shuffle = false
|
||||
let repeatAll = true
|
||||
@@ -245,7 +253,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
return
|
||||
}
|
||||
queue.push(track)
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||
playIndex(queue.length - 1)
|
||||
}
|
||||
@@ -266,7 +274,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
duration: t.duration_secs,
|
||||
})
|
||||
})
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||
showToast(`Added ${list.length} tracks`)
|
||||
}
|
||||
@@ -285,7 +293,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
duration: t.duration_secs,
|
||||
})
|
||||
})
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
playIndex(0)
|
||||
showToast(`Added ${list.length} tracks`)
|
||||
}
|
||||
@@ -297,8 +305,8 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
audio.src = `${API}/stream/${track.slug}`
|
||||
void audio.play().catch(() => {})
|
||||
updateNowPlaying(track)
|
||||
renderQueue()
|
||||
scrollQueueToActive()
|
||||
updateQueueModel()
|
||||
setQueueScrollSignal((s) => s + 1)
|
||||
if (window.history && window.history.replaceState) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('t', track.slug)
|
||||
@@ -306,24 +314,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNowPlaying(track: (typeof queue)[number] | null) {
|
||||
const npTitle = document.getElementById('npTitle')
|
||||
const npArtist = document.getElementById('npArtist')
|
||||
if (!track) {
|
||||
if (npTitle) npTitle.textContent = 'Nothing playing'
|
||||
if (npArtist) npArtist.textContent = '—'
|
||||
return
|
||||
}
|
||||
if (npTitle) npTitle.textContent = track.title
|
||||
if (npArtist) npArtist.textContent = track.artist || '—'
|
||||
function updateNowPlaying(track: QueueItem | null) {
|
||||
setNowPlayingTrack(track)
|
||||
if (!track) return
|
||||
|
||||
document.title = `${track.title} — Furumi`
|
||||
|
||||
const cover = document.getElementById('npCover')
|
||||
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
||||
if (cover) {
|
||||
cover.innerHTML = `<img src="${coverUrl}" alt="" onerror="this.parentElement.innerHTML='🎵'">`
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
@@ -356,77 +353,11 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const el = document.getElementById('queueList')
|
||||
if (!el) return
|
||||
if (!queue.length) {
|
||||
el.innerHTML =
|
||||
'<div class="queue-empty"><div class="empty-icon">🎵</div><div>Select an album to start</div></div>'
|
||||
return
|
||||
}
|
||||
function updateQueueModel() {
|
||||
const order = currentOrder()
|
||||
el.innerHTML = ''
|
||||
order.forEach((origIdx, pos) => {
|
||||
const t = queue[origIdx]
|
||||
const isPlaying = origIdx === queueIndex
|
||||
const div = document.createElement('div')
|
||||
div.className = 'queue-item' + (isPlaying ? ' playing' : '')
|
||||
|
||||
const coverSrc = t.album_slug ? `${API}/tracks/${t.slug}/cover` : ''
|
||||
const coverHtml = coverSrc
|
||||
? `<img src="${coverSrc}" alt="" onerror="this.parentElement.innerHTML='🎵'">`
|
||||
: '🎵'
|
||||
const dur = t.duration ? fmt(t.duration) : ''
|
||||
|
||||
div.innerHTML = `
|
||||
<span class="qi-index">${isPlaying ? '' : pos + 1}</span>
|
||||
<div class="qi-cover">${coverHtml}</div>
|
||||
<div class="qi-info"><div class="qi-title">${esc(
|
||||
t.title,
|
||||
)}</div><div class="qi-artist">${esc(t.artist || '')}</div></div>
|
||||
<span class="qi-dur">${dur}</span>
|
||||
<button class="qi-remove">✕</button>
|
||||
`
|
||||
div.addEventListener('click', () => playIndex(origIdx))
|
||||
|
||||
const removeBtn = div.querySelector('.qi-remove') as HTMLButtonElement | null
|
||||
if (removeBtn) {
|
||||
removeBtn.onclick = (ev) => {
|
||||
ev.stopPropagation()
|
||||
removeFromQueue(origIdx)
|
||||
}
|
||||
}
|
||||
|
||||
div.draggable = true
|
||||
div.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer?.setData('text/plain', String(pos))
|
||||
div.classList.add('dragging')
|
||||
})
|
||||
div.addEventListener('dragend', () => {
|
||||
div.classList.remove('dragging')
|
||||
el
|
||||
.querySelectorAll('.queue-item')
|
||||
.forEach((x) => x.classList.remove('drag-over'))
|
||||
})
|
||||
div.addEventListener('dragover', (e) => {
|
||||
e.preventDefault()
|
||||
})
|
||||
div.addEventListener('dragenter', () => div.classList.add('drag-over'))
|
||||
div.addEventListener('dragleave', () => div.classList.remove('drag-over'))
|
||||
div.addEventListener('drop', (e) => {
|
||||
e.preventDefault()
|
||||
div.classList.remove('drag-over')
|
||||
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
|
||||
if (!Number.isNaN(from)) moveQueueItem(from, pos)
|
||||
})
|
||||
|
||||
el.appendChild(div)
|
||||
})
|
||||
}
|
||||
|
||||
function scrollQueueToActive() {
|
||||
const el = document.querySelector('.queue-item.playing') as HTMLElement | null
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
setQueueItemsView(queue.slice())
|
||||
setQueueOrderView(order.slice())
|
||||
setQueuePlayingOrigIdxView(queueIndex)
|
||||
}
|
||||
|
||||
function removeFromQueue(idx: number) {
|
||||
@@ -446,7 +377,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
||||
}
|
||||
}
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
function moveQueueItem(from: number, to: number) {
|
||||
@@ -461,7 +392,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
else if (from < queueIndex && to >= queueIndex) queueIndex--
|
||||
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
||||
}
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
queueActionsRef.current = {
|
||||
playIndex,
|
||||
removeFromQueue,
|
||||
moveQueueItem,
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
@@ -472,7 +409,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
audio.src = ''
|
||||
updateNowPlaying(null)
|
||||
document.title = 'Furumi Player'
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
// --- Playback controls ---
|
||||
@@ -514,7 +451,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
const btn = document.getElementById('btnShuffle')
|
||||
btn?.classList.toggle('active', shuffle)
|
||||
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
||||
renderQueue()
|
||||
updateQueueModel()
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
@@ -603,15 +540,6 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function esc(s: string | null | undefined) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
const t = document.getElementById('toast')
|
||||
if (!t) return
|
||||
@@ -716,6 +644,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
|
||||
// Cleanup: best-effort remove listeners on unmount
|
||||
return () => {
|
||||
queueActionsRef.current = null
|
||||
audio.pause()
|
||||
}
|
||||
}, [apiRoot])
|
||||
@@ -771,28 +700,26 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="queue-list" id="queueList">
|
||||
<div className="queue-empty">
|
||||
<div className="empty-icon">🎵</div>
|
||||
<div>Select an album to start</div>
|
||||
</div>
|
||||
<QueueList
|
||||
apiRoot={apiRoot}
|
||||
queue={queueItemsView}
|
||||
order={queueOrderView}
|
||||
playingOrigIdx={queuePlayingOrigIdxView}
|
||||
scrollSignal={queueScrollSignal}
|
||||
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||
onRemove={(origIdx) =>
|
||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||
}
|
||||
onMove={(fromPos, toPos) =>
|
||||
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="player-bar">
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
🎵
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
Nothing playing
|
||||
</div>
|
||||
<div className="np-artist" id="npArtist">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
|
||||
<div className="controls">
|
||||
<div className="ctrl-btns">
|
||||
<button className="ctrl-btn" id="btnPrev">
|
||||
|
||||
52
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
52
furumi-node-player/client/src/components/NowPlaying.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { QueueItem } from './QueueList'
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
|
||||
if (!track) {
|
||||
return (
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
🎵
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
Nothing playing
|
||||
</div>
|
||||
<div className="np-artist" id="npArtist">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
|
||||
|
||||
return (
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
<Cover src={coverUrl} />
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
{track.title}
|
||||
</div>
|
||||
<div className="np-artist" id="npArtist">
|
||||
{track.artist || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
144
furumi-node-player/client/src/components/QueueList.tsx
Normal file
144
furumi-node-player/client/src/components/QueueList.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type QueueItem = {
|
||||
slug: string
|
||||
title: string
|
||||
artist: string
|
||||
album_slug: string | null
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
type QueueListProps = {
|
||||
apiRoot: string
|
||||
queue: QueueItem[]
|
||||
order: number[]
|
||||
playingOrigIdx: number
|
||||
scrollSignal: number
|
||||
onPlay: (origIdx: number) => void
|
||||
onRemove: (origIdx: number) => void
|
||||
onMove: (fromPos: number, toPos: number) => void
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function fmt(secs: number) {
|
||||
if (!secs || Number.isNaN(secs)) return '0:00'
|
||||
const s = Math.floor(secs)
|
||||
const m = Math.floor(s / 60)
|
||||
const h = Math.floor(m / 60)
|
||||
if (h > 0) return `${h}:${pad(m % 60)}:${pad(s % 60)}`
|
||||
return `${m}:${pad(s % 60)}`
|
||||
}
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
export function QueueList({
|
||||
apiRoot,
|
||||
queue,
|
||||
order,
|
||||
playingOrigIdx,
|
||||
scrollSignal,
|
||||
onPlay,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: QueueListProps) {
|
||||
const playingRef = useRef<HTMLDivElement | null>(null)
|
||||
const [draggingPos, setDraggingPos] = useState<number | null>(null)
|
||||
const [dragOverPos, setDragOverPos] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (playingRef.current) {
|
||||
playingRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}, [playingOrigIdx, scrollSignal])
|
||||
|
||||
if (!queue.length) {
|
||||
return (
|
||||
<div className="queue-empty">
|
||||
<div className="empty-icon">🎵</div>
|
||||
<div>Select an album to start</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{order.map((origIdx, pos) => {
|
||||
const t = queue[origIdx]
|
||||
if (!t) return null
|
||||
|
||||
const isPlaying = origIdx === playingOrigIdx
|
||||
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
||||
const dur = t.duration ? fmt(t.duration) : ''
|
||||
const isDragging = draggingPos === pos
|
||||
const isDragOver = dragOverPos === pos
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${t.slug}:${pos}`}
|
||||
ref={isPlaying ? playingRef : null}
|
||||
className={`queue-item${isPlaying ? ' playing' : ''}${isDragging ? ' dragging' : ''}${
|
||||
isDragOver ? ' drag-over' : ''
|
||||
}`}
|
||||
draggable
|
||||
onClick={() => onPlay(origIdx)}
|
||||
onDragStart={(e) => {
|
||||
setDraggingPos(pos)
|
||||
e.dataTransfer?.setData('text/plain', String(pos))
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggingPos(null)
|
||||
setDragOverPos(null)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragEnter={() => {
|
||||
setDragOverPos(pos)
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setDragOverPos((cur) => (cur === pos ? null : cur))
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOverPos(null)
|
||||
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
|
||||
if (!Number.isNaN(from)) onMove(from, pos)
|
||||
setDraggingPos(null)
|
||||
}}
|
||||
>
|
||||
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||
<div className="qi-cover">
|
||||
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||
</div>
|
||||
<div className="qi-info">
|
||||
<div className="qi-title">{t.title}</div>
|
||||
<div className="qi-artist">{t.artist || ''}</div>
|
||||
</div>
|
||||
<span className="qi-dur">{dur}</span>
|
||||
<button
|
||||
className="qi-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(origIdx)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user