feat: refactoring
This commit is contained in:
@@ -4,6 +4,8 @@ import { createFurumiApiClient } from './furumiApi'
|
|||||||
import { SearchDropdown } from './components/SearchDropdown'
|
import { SearchDropdown } from './components/SearchDropdown'
|
||||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||||
import { LibraryList } from './components/LibraryList'
|
import { LibraryList } from './components/LibraryList'
|
||||||
|
import { QueueList, type QueueItem } from './components/QueueList'
|
||||||
|
import { NowPlaying } from './components/NowPlaying'
|
||||||
|
|
||||||
type FurumiPlayerProps = {
|
type FurumiPlayerProps = {
|
||||||
apiRoot: string
|
apiRoot: string
|
||||||
@@ -35,18 +37,24 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
|
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(() => {
|
useEffect(() => {
|
||||||
// --- Original player script adapted for React environment ---
|
// --- Original player script adapted for React environment ---
|
||||||
const audio = document.getElementById('audioEl') as HTMLAudioElement
|
const audio = document.getElementById('audioEl') as HTMLAudioElement
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
|
|
||||||
let queue: Array<{
|
let queue: QueueItem[] = []
|
||||||
slug: string
|
|
||||||
title: string
|
|
||||||
artist: string
|
|
||||||
album_slug: string | null
|
|
||||||
duration: number | null
|
|
||||||
}> = []
|
|
||||||
let queueIndex = -1
|
let queueIndex = -1
|
||||||
let shuffle = false
|
let shuffle = false
|
||||||
let repeatAll = true
|
let repeatAll = true
|
||||||
@@ -245,7 +253,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
queue.push(track)
|
queue.push(track)
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||||
playIndex(queue.length - 1)
|
playIndex(queue.length - 1)
|
||||||
}
|
}
|
||||||
@@ -266,7 +274,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
duration: t.duration_secs,
|
duration: t.duration_secs,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
@@ -285,7 +293,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
duration: t.duration_secs,
|
duration: t.duration_secs,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
playIndex(0)
|
playIndex(0)
|
||||||
showToast(`Added ${list.length} tracks`)
|
showToast(`Added ${list.length} tracks`)
|
||||||
}
|
}
|
||||||
@@ -297,8 +305,8 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
audio.src = `${API}/stream/${track.slug}`
|
audio.src = `${API}/stream/${track.slug}`
|
||||||
void audio.play().catch(() => {})
|
void audio.play().catch(() => {})
|
||||||
updateNowPlaying(track)
|
updateNowPlaying(track)
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
scrollQueueToActive()
|
setQueueScrollSignal((s) => s + 1)
|
||||||
if (window.history && window.history.replaceState) {
|
if (window.history && window.history.replaceState) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('t', track.slug)
|
url.searchParams.set('t', track.slug)
|
||||||
@@ -306,24 +314,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNowPlaying(track: (typeof queue)[number] | null) {
|
function updateNowPlaying(track: QueueItem | null) {
|
||||||
const npTitle = document.getElementById('npTitle')
|
setNowPlayingTrack(track)
|
||||||
const npArtist = document.getElementById('npArtist')
|
if (!track) return
|
||||||
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 || '—'
|
|
||||||
document.title = `${track.title} — Furumi`
|
document.title = `${track.title} — Furumi`
|
||||||
|
|
||||||
const cover = document.getElementById('npCover')
|
|
||||||
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
||||||
if (cover) {
|
|
||||||
cover.innerHTML = `<img src="${coverUrl}" alt="" onerror="this.parentElement.innerHTML='🎵'">`
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
@@ -356,77 +353,11 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue() {
|
function updateQueueModel() {
|
||||||
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
|
|
||||||
}
|
|
||||||
const order = currentOrder()
|
const order = currentOrder()
|
||||||
el.innerHTML = ''
|
setQueueItemsView(queue.slice())
|
||||||
order.forEach((origIdx, pos) => {
|
setQueueOrderView(order.slice())
|
||||||
const t = queue[origIdx]
|
setQueuePlayingOrigIdxView(queueIndex)
|
||||||
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' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFromQueue(idx: number) {
|
function removeFromQueue(idx: number) {
|
||||||
@@ -446,7 +377,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
if (shuffleOrder[i] > idx) shuffleOrder[i]--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveQueueItem(from: number, to: number) {
|
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--
|
||||||
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
else if (from > queueIndex && to <= queueIndex) queueIndex++
|
||||||
}
|
}
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
queueActionsRef.current = {
|
||||||
|
playIndex,
|
||||||
|
removeFromQueue,
|
||||||
|
moveQueueItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
function clearQueue() {
|
||||||
@@ -472,7 +409,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
audio.src = ''
|
audio.src = ''
|
||||||
updateNowPlaying(null)
|
updateNowPlaying(null)
|
||||||
document.title = 'Furumi Player'
|
document.title = 'Furumi Player'
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Playback controls ---
|
// --- Playback controls ---
|
||||||
@@ -514,7 +451,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
const btn = document.getElementById('btnShuffle')
|
const btn = document.getElementById('btnShuffle')
|
||||||
btn?.classList.toggle('active', shuffle)
|
btn?.classList.toggle('active', shuffle)
|
||||||
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
|
||||||
renderQueue()
|
updateQueueModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRepeat() {
|
function toggleRepeat() {
|
||||||
@@ -603,15 +540,6 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
return String(n).padStart(2, '0')
|
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) {
|
function showToast(msg: string) {
|
||||||
const t = document.getElementById('toast')
|
const t = document.getElementById('toast')
|
||||||
if (!t) return
|
if (!t) return
|
||||||
@@ -716,6 +644,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
|
|
||||||
// Cleanup: best-effort remove listeners on unmount
|
// Cleanup: best-effort remove listeners on unmount
|
||||||
return () => {
|
return () => {
|
||||||
|
queueActionsRef.current = null
|
||||||
audio.pause()
|
audio.pause()
|
||||||
}
|
}
|
||||||
}, [apiRoot])
|
}, [apiRoot])
|
||||||
@@ -771,28 +700,26 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="queue-list" id="queueList">
|
<div className="queue-list" id="queueList">
|
||||||
<div className="queue-empty">
|
<QueueList
|
||||||
<div className="empty-icon">🎵</div>
|
apiRoot={apiRoot}
|
||||||
<div>Select an album to start</div>
|
queue={queueItemsView}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="player-bar">
|
<div className="player-bar">
|
||||||
<div className="np-info">
|
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
|
||||||
<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>
|
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<div className="ctrl-btns">
|
<div className="ctrl-btns">
|
||||||
<button className="ctrl-btn" id="btnPrev">
|
<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