feat: refactoring
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m10s
Publish Web Player Image / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m7s

This commit is contained in:
Boris Cherepanov
2026-03-19 18:04:13 +03:00
parent 71d88bacf2
commit c30a3aff5d
3 changed files with 250 additions and 127 deletions

View File

@@ -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='&#127925;'">`
}
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">&#127925;</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='&#127925;'">`
: '&#127925;'
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">&#10005;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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">&#127925;</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">
&#127925;
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
Nothing playing
</div>
<div className="np-artist" id="npArtist">
&mdash;
</div>
</div>
</div>
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">

View 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 <>&#127925;</>
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">
&#127925;
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
Nothing playing
</div>
<div className="np-artist" id="npArtist">
&mdash;
</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>
)
}

View 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 <>&#127925;</>
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">&#127925;</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} /> : <>&#127925;</>}
</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)
}}
>
&#10005;
</button>
</div>
)
})}
</>
)
}