diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 31e4731..e6218a9 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -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(null) + const [queueItemsView, setQueueItemsView] = useState([]) + const [queueOrderView, setQueueOrderView] = useState([]) + const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState(-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 = `` - } - 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 = - '
🎵
Select an album to start
' - 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 - ? `` - : '🎵' - const dur = t.duration ? fmt(t.duration) : '' - - div.innerHTML = ` - ${isPlaying ? '' : pos + 1} -
${coverHtml}
-
${esc( - t.title, - )}
${esc(t.artist || '')}
- ${dur} - - ` - 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, ''') - } - 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) {
-
-
🎵
-
Select an album to start
-
+ queueActionsRef.current?.playIndex(origIdx)} + onRemove={(origIdx) => + queueActionsRef.current?.removeFromQueue(origIdx) + } + onMove={(fromPos, toPos) => + queueActionsRef.current?.moveQueueItem(fromPos, toPos) + } + />
-
-
- 🎵 -
-
-
- Nothing playing -
-
- — -
-
-
+
+
+ ) + })} + + ) +} +