feat: refactoring
This commit is contained in:
@@ -10,16 +10,14 @@ import {
|
|||||||
getTrackInfo,
|
getTrackInfo,
|
||||||
preloadStream,
|
preloadStream,
|
||||||
} from './furumiApi'
|
} from './furumiApi'
|
||||||
import { SearchDropdown } from './components/SearchDropdown'
|
import { fmt } from './utils'
|
||||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
import { Header } from './components/Header'
|
||||||
import { LibraryList } from './components/LibraryList'
|
import { MainPanel, type Crumb } from './components/MainPanel'
|
||||||
import { QueueList, type QueueItem } from './components/QueueList'
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
import { NowPlaying } from './components/NowPlaying'
|
import type { QueueItem } from './components/QueueList'
|
||||||
|
|
||||||
type Crumb = { label: string; action?: () => void }
|
|
||||||
|
|
||||||
export function FurumiPlayer() {
|
export function FurumiPlayer() {
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>(
|
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const [libraryLoading, setLibraryLoading] = useState(false)
|
const [libraryLoading, setLibraryLoading] = useState(false)
|
||||||
@@ -47,6 +45,7 @@ export function FurumiPlayer() {
|
|||||||
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
||||||
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
||||||
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
||||||
|
const [queue, setQueue] = useState<QueueItem[]>([])
|
||||||
|
|
||||||
const queueActionsRef = useRef<{
|
const queueActionsRef = useRef<{
|
||||||
playIndex: (i: number) => void
|
playIndex: (i: number) => void
|
||||||
@@ -54,12 +53,14 @@ export function FurumiPlayer() {
|
|||||||
moveQueueItem: (fromPos: number, toPos: number) => void
|
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(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 audioEl = audioRef.current
|
||||||
if (!audio) return
|
if (!audioEl) return
|
||||||
|
const audio = audioEl
|
||||||
|
|
||||||
let queue: QueueItem[] = []
|
|
||||||
let queueIndex = -1
|
let queueIndex = -1
|
||||||
let shuffle = false
|
let shuffle = false
|
||||||
let repeatAll = true
|
let repeatAll = true
|
||||||
@@ -253,7 +254,7 @@ export function FurumiPlayer() {
|
|||||||
if (playNow) playIndex(existing)
|
if (playNow) playIndex(existing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
queue.push(track)
|
setQueue((q) => [...q, track]);
|
||||||
updateQueueModel()
|
updateQueueModel()
|
||||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||||
playIndex(queue.length - 1)
|
playIndex(queue.length - 1)
|
||||||
@@ -267,13 +268,7 @@ export function FurumiPlayer() {
|
|||||||
let firstIdx = queue.length
|
let firstIdx = queue.length
|
||||||
list.forEach((t) => {
|
list.forEach((t) => {
|
||||||
if (queue.find((q) => q.slug === t.slug)) return
|
if (queue.find((q) => q.slug === t.slug)) return
|
||||||
queue.push({
|
setQueue((q) => [...q, t])
|
||||||
slug: t.slug,
|
|
||||||
title: t.title,
|
|
||||||
artist: t.artist_name,
|
|
||||||
album_slug: albumSlug,
|
|
||||||
duration: t.duration_secs,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
updateQueueModel()
|
updateQueueModel()
|
||||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||||
@@ -356,7 +351,7 @@ export function FurumiPlayer() {
|
|||||||
|
|
||||||
function updateQueueModel() {
|
function updateQueueModel() {
|
||||||
const order = currentOrder()
|
const order = currentOrder()
|
||||||
setQueueItemsView(queue.slice())
|
setQueueItemsView(queue)
|
||||||
setQueueOrderView(order.slice())
|
setQueueOrderView(order.slice())
|
||||||
setQueuePlayingOrigIdxView(queueIndex)
|
setQueuePlayingOrigIdxView(queueIndex)
|
||||||
}
|
}
|
||||||
@@ -370,7 +365,10 @@ export function FurumiPlayer() {
|
|||||||
} else if (queueIndex > idx) {
|
} else if (queueIndex > idx) {
|
||||||
queueIndex--
|
queueIndex--
|
||||||
}
|
}
|
||||||
queue.splice(idx, 1)
|
|
||||||
|
// queue.splice(idx, 1)
|
||||||
|
setQueue((q) => q.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
if (shuffle) {
|
if (shuffle) {
|
||||||
const si = shuffleOrder.indexOf(idx)
|
const si = shuffleOrder.indexOf(idx)
|
||||||
if (si !== -1) shuffleOrder.splice(si, 1)
|
if (si !== -1) shuffleOrder.splice(si, 1)
|
||||||
@@ -403,7 +401,7 @@ export function FurumiPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
function clearQueue() {
|
||||||
queue = []
|
setQueue([]);
|
||||||
queueIndex = -1
|
queueIndex = -1
|
||||||
shuffleOrder = []
|
shuffleOrder = []
|
||||||
audio.pause()
|
audio.pause()
|
||||||
@@ -526,21 +524,6 @@ export function FurumiPlayer() {
|
|||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
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 pad(n: number) {
|
|
||||||
return String(n).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg: string) {
|
function showToast(msg: string) {
|
||||||
const t = document.getElementById('toast')
|
const t = document.getElementById('toast')
|
||||||
if (!t) return
|
if (!t) return
|
||||||
@@ -652,115 +635,34 @@ export function FurumiPlayer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="furumi-root">
|
<div className="furumi-root">
|
||||||
<header className="header">
|
<Header
|
||||||
<div className="header-logo">
|
searchOpen={searchOpen}
|
||||||
<button className="btn-menu">☰</button>
|
searchResults={searchResults}
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
<circle cx="9" cy="18" r="3" />
|
|
||||||
<circle cx="18" cy="15" r="3" />
|
|
||||||
<path d="M12 18V6l9-3v3" />
|
|
||||||
</svg>
|
|
||||||
Furumi
|
|
||||||
<span className="header-version">v</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
||||||
<div className="search-wrap">
|
|
||||||
<input id="searchInput" placeholder="Search..." />
|
|
||||||
<SearchDropdown
|
|
||||||
isOpen={searchOpen}
|
|
||||||
results={searchResults}
|
|
||||||
onSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="main">
|
<MainPanel
|
||||||
<div className="sidebar-overlay" id="sidebarOverlay" />
|
breadcrumbs={breadcrumbs}
|
||||||
<aside className="sidebar" id="sidebar">
|
libraryLoading={libraryLoading}
|
||||||
<div className="sidebar-header">Library</div>
|
libraryError={libraryError}
|
||||||
<Breadcrumbs items={breadcrumbs} />
|
libraryItems={libraryItems}
|
||||||
<div className="file-list" id="fileList">
|
queueItemsView={queueItemsView}
|
||||||
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
queueOrderView={queueOrderView}
|
||||||
</div>
|
queuePlayingOrigIdxView={queuePlayingOrigIdxView}
|
||||||
</aside>
|
queueScrollSignal={queueScrollSignal}
|
||||||
|
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
<section className="queue-panel">
|
onQueueRemove={(origIdx) =>
|
||||||
<div className="queue-header">
|
|
||||||
<span>Queue</span>
|
|
||||||
<div className="queue-actions">
|
|
||||||
<button className="queue-btn active" id="btnShuffle">
|
|
||||||
Shuffle
|
|
||||||
</button>
|
|
||||||
<button className="queue-btn active" id="btnRepeat">
|
|
||||||
Repeat
|
|
||||||
</button>
|
|
||||||
<button className="queue-btn" id="btnClearQueue">
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="queue-list" id="queueList">
|
|
||||||
<QueueList
|
|
||||||
queue={queueItemsView}
|
|
||||||
order={queueOrderView}
|
|
||||||
playingOrigIdx={queuePlayingOrigIdxView}
|
|
||||||
scrollSignal={queueScrollSignal}
|
|
||||||
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
|
||||||
onRemove={(origIdx) =>
|
|
||||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
}
|
}
|
||||||
onMove={(fromPos, toPos) =>
|
onQueueMove={(fromPos, toPos) =>
|
||||||
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="player-bar">
|
<PlayerBar track={nowPlayingTrack} />
|
||||||
<NowPlaying track={nowPlayingTrack} />
|
|
||||||
<div className="controls">
|
|
||||||
<div className="ctrl-btns">
|
|
||||||
<button className="ctrl-btn" id="btnPrev">
|
|
||||||
⏮
|
|
||||||
</button>
|
|
||||||
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
<button className="ctrl-btn" id="btnNext">
|
|
||||||
⏭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="progress-row">
|
|
||||||
<span className="time" id="timeElapsed">
|
|
||||||
0:00
|
|
||||||
</span>
|
|
||||||
<div className="progress-bar" id="progressBar">
|
|
||||||
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
|
||||||
</div>
|
|
||||||
<span className="time" id="timeDuration">
|
|
||||||
0:00
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="volume-row">
|
|
||||||
<span className="vol-icon" id="volIcon">
|
|
||||||
🔊
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="volume-slider"
|
|
||||||
id="volSlider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
defaultValue={80}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toast" id="toast" />
|
<div className="toast" id="toast" />
|
||||||
<audio id="audioEl" />
|
<audio ref={audioRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
45
furumi-node-player/client/src/components/Header.tsx
Normal file
45
furumi-node-player/client/src/components/Header.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { SearchDropdown } from './SearchDropdown'
|
||||||
|
|
||||||
|
type SearchResultItem = {
|
||||||
|
result_type: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
searchOpen: boolean
|
||||||
|
searchResults: SearchResultItem[]
|
||||||
|
onSearchSelect: (type: string, slug: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
searchOpen,
|
||||||
|
searchResults,
|
||||||
|
onSearchSelect,
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-logo">
|
||||||
|
<button className="btn-menu">☰</button>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="9" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="15" r="3" />
|
||||||
|
<path d="M12 18V6l9-3v3" />
|
||||||
|
</svg>
|
||||||
|
Furumi
|
||||||
|
<span className="header-version">v</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<div className="search-wrap">
|
||||||
|
<input id="searchInput" placeholder="Search..." />
|
||||||
|
<SearchDropdown
|
||||||
|
isOpen={searchOpen}
|
||||||
|
results={searchResults}
|
||||||
|
onSelect={onSearchSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
furumi-node-player/client/src/components/MainPanel.tsx
Normal file
86
furumi-node-player/client/src/components/MainPanel.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs'
|
||||||
|
import { LibraryList } from './LibraryList'
|
||||||
|
import { QueueList, type QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
export type Crumb = { label: string; action?: () => void }
|
||||||
|
|
||||||
|
export type LibraryListItem = {
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
type MainPanelProps = {
|
||||||
|
breadcrumbs: Crumb[]
|
||||||
|
libraryLoading: boolean
|
||||||
|
libraryError: string | null
|
||||||
|
libraryItems: LibraryListItem[]
|
||||||
|
queueItemsView: QueueItem[]
|
||||||
|
queueOrderView: number[]
|
||||||
|
queuePlayingOrigIdxView: number
|
||||||
|
queueScrollSignal: number
|
||||||
|
onQueuePlay: (origIdx: number) => void
|
||||||
|
onQueueRemove: (origIdx: number) => void
|
||||||
|
onQueueMove: (fromPos: number, toPos: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainPanel({
|
||||||
|
breadcrumbs,
|
||||||
|
libraryLoading,
|
||||||
|
libraryError,
|
||||||
|
libraryItems,
|
||||||
|
queueItemsView,
|
||||||
|
queueOrderView,
|
||||||
|
queuePlayingOrigIdxView,
|
||||||
|
queueScrollSignal,
|
||||||
|
onQueuePlay,
|
||||||
|
onQueueRemove,
|
||||||
|
onQueueMove,
|
||||||
|
}: MainPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="main">
|
||||||
|
<div className="sidebar-overlay" id="sidebarOverlay" />
|
||||||
|
<aside className="sidebar" id="sidebar">
|
||||||
|
<div className="sidebar-header">Library</div>
|
||||||
|
<Breadcrumbs items={breadcrumbs} />
|
||||||
|
<div className="file-list" id="fileList">
|
||||||
|
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="queue-panel">
|
||||||
|
<div className="queue-header">
|
||||||
|
<span>Queue</span>
|
||||||
|
<div className="queue-actions">
|
||||||
|
<button className="queue-btn active" id="btnShuffle">
|
||||||
|
Shuffle
|
||||||
|
</button>
|
||||||
|
<button className="queue-btn active" id="btnRepeat">
|
||||||
|
Repeat
|
||||||
|
</button>
|
||||||
|
<button className="queue-btn" id="btnClearQueue">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="queue-list" id="queueList">
|
||||||
|
<QueueList
|
||||||
|
queue={queueItemsView}
|
||||||
|
order={queueOrderView}
|
||||||
|
playingOrigIdx={queuePlayingOrigIdxView}
|
||||||
|
scrollSignal={queueScrollSignal}
|
||||||
|
onPlay={onQueuePlay}
|
||||||
|
onRemove={onQueueRemove}
|
||||||
|
onMove={onQueueMove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
furumi-node-player/client/src/components/PlayerBar.tsx
Normal file
47
furumi-node-player/client/src/components/PlayerBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NowPlaying } from './NowPlaying'
|
||||||
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||||
|
return (
|
||||||
|
<div className="player-bar">
|
||||||
|
<NowPlaying track={track} />
|
||||||
|
<div className="controls">
|
||||||
|
<div className="ctrl-btns">
|
||||||
|
<button className="ctrl-btn" id="btnPrev">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn" id="btnNext">
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="progress-row">
|
||||||
|
<span className="time" id="timeElapsed">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
<div className="progress-bar" id="progressBar">
|
||||||
|
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
||||||
|
</div>
|
||||||
|
<span className="time" id="timeDuration">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="volume-row">
|
||||||
|
<span className="vol-icon" id="volIcon">
|
||||||
|
🔊
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="volume-slider"
|
||||||
|
id="volSlider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
defaultValue={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
furumi-node-player/client/src/utils.ts
Normal file
14
furumi-node-player/client/src/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user