From 71d88bacf2d98857119814b738e6e3fbe18cd8df Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Thu, 19 Mar 2026 17:31:09 +0300 Subject: [PATCH] feat: refactoring modules --- furumi-node-player/client/src/App.tsx | 126 +-- .../client/src/FurumiPlayer.tsx | 840 ++++++++++++++++++ .../client/src/components/Breadcrumbs.tsx | 30 + .../client/src/components/LibraryList.tsx | 54 ++ .../client/src/components/SearchDropdown.tsx | 30 + .../client/src/furumi-player.css | 754 ++++++++++++++++ furumi-node-player/client/src/furumiApi.ts | 12 + 7 files changed, 1787 insertions(+), 59 deletions(-) create mode 100644 furumi-node-player/client/src/FurumiPlayer.tsx create mode 100644 furumi-node-player/client/src/components/Breadcrumbs.tsx create mode 100644 furumi-node-player/client/src/components/LibraryList.tsx create mode 100644 furumi-node-player/client/src/components/SearchDropdown.tsx create mode 100644 furumi-node-player/client/src/furumi-player.css create mode 100644 furumi-node-player/client/src/furumiApi.ts diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index cb90f52..a0245dd 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { FurumiPlayer } from './FurumiPlayer' import './App.css' type UserProfile = { @@ -60,74 +61,81 @@ function App() { const loginUrl = `${apiBase}/api/login` const logoutUrl = `${apiBase}/api/logout` + const playerApiRoot = `${apiBase}/api` return ( -
-
-

OIDC Login

-

Авторизация обрабатывается на Express сервере.

+ <> + {!loading && (user || runWithoutAuth) ? ( + + ) : ( +
+
+

OIDC Login

+

Авторизация обрабатывается на Express сервере.

-
- -
+
+ +
- {loading &&

Проверяю сессию...

} - {error &&

Ошибка: {error}

} + {loading &&

Проверяю сессию...

} + {error &&

Ошибка: {error}

} - {!loading && runWithoutAuth && ( -

- Режим без авторизации включён. Для входа отключи настройку выше. -

- )} - - {!loading && !user && ( - - Войти через OIDC - - )} - - {!loading && user && ( -
-

- ID: {user.sub} -

- {user.name && ( -

- Имя: {user.name} + {!loading && runWithoutAuth && ( +

+ Режим без авторизации включён. Для входа отключи настройку выше.

)} - {user.email && ( -

- Email: {user.email} -

- )} - {!runWithoutAuth && ( - - Выйти + + {!loading && !user && ( + + Войти через OIDC )} -
- )} -
-
+ + {!loading && user && ( +
+

+ ID: {user.sub} +

+ {user.name && ( +

+ Имя: {user.name} +

+ )} + {user.email && ( +

+ Email: {user.email} +

+ )} + {!runWithoutAuth && ( + + Выйти + + )} +
+ )} +
+
+ )} + ) } diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx new file mode 100644 index 0000000..31e4731 --- /dev/null +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -0,0 +1,840 @@ +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import './furumi-player.css' +import { createFurumiApiClient } from './furumiApi' +import { SearchDropdown } from './components/SearchDropdown' +import { Breadcrumbs } from './components/Breadcrumbs' +import { LibraryList } from './components/LibraryList' + +type FurumiPlayerProps = { + apiRoot: string +} + +type Crumb = { label: string; action?: () => void } + +export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) { + const [breadcrumbs, setBreadcrumbs] = useState void }>>( + [], + ) + const [libraryLoading, setLibraryLoading] = useState(false) + const [libraryError, setLibraryError] = useState(null) + const [libraryItems, setLibraryItems] = useState< + Array<{ + key: string + className: string + icon: string + name: string + detail?: string + nameClassName?: string + onClick: () => void + button?: { title: string; onClick: (ev: ReactMouseEvent) => void } + }> + >([]) + const [searchResults, setSearchResults] = useState< + Array<{ result_type: string; slug: string; name: string; detail?: string }> + >([]) + const [searchOpen, setSearchOpen] = useState(false) + const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {}) + + 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 queueIndex = -1 + let shuffle = false + let repeatAll = true + let shuffleOrder: number[] = [] + let searchTimer: number | null = null + let toastTimer: number | null = null + let muted = false + + // Restore prefs + try { + const v = window.localStorage.getItem('furumi_vol') + const volSlider = document.getElementById('volSlider') as HTMLInputElement | null + if (v !== null && volSlider) { + audio.volume = Number(v) / 100 + volSlider.value = v + } + const btnShuffle = document.getElementById('btnShuffle') + const btnRepeat = document.getElementById('btnRepeat') + shuffle = window.localStorage.getItem('furumi_shuffle') === '1' + repeatAll = window.localStorage.getItem('furumi_repeat') !== '0' + btnShuffle?.classList.toggle('active', shuffle) + btnRepeat?.classList.toggle('active', repeatAll) + } catch { + // ignore + } + + // --- Audio events --- + audio.addEventListener('timeupdate', () => { + if (audio.duration) { + const fill = document.getElementById('progressFill') + const timeElapsed = document.getElementById('timeElapsed') + const timeDuration = document.getElementById('timeDuration') + if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%` + if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime) + if (timeDuration) timeDuration.textContent = fmt(audio.duration) + } + }) + audio.addEventListener('ended', () => nextTrack()) + audio.addEventListener('play', () => { + const btn = document.getElementById('btnPlayPause') + if (btn) btn.innerHTML = '⏸' + }) + audio.addEventListener('pause', () => { + const btn = document.getElementById('btnPlayPause') + if (btn) btn.innerHTML = '▶' + }) + audio.addEventListener('error', () => { + showToast('Playback error') + nextTrack() + }) + + // --- API helper --- + const API = apiRoot + const api = createFurumiApiClient(API) + + // --- Library navigation --- + async function showArtists() { + setBreadcrumb([{ label: 'Artists', action: showArtists }]) + setLibraryLoading(true) + setLibraryError(null) + const artists = await api('/artists') + if (!artists) { + setLibraryLoading(false) + setLibraryError('Error') + return + } + setLibraryLoading(false) + setLibraryItems( + (artists as any[]).map((a) => ({ + key: `artist:${a.slug}`, + className: 'file-item dir', + icon: '👤', + name: a.name, + detail: `${a.album_count} albums`, + onClick: () => void showArtistAlbums(a.slug, a.name), + })), + ) + } + + async function showArtistAlbums(artistSlug: string, artistName: string) { + setBreadcrumb([ + { label: 'Artists', action: showArtists }, + { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, + ]) + setLibraryLoading(true) + setLibraryError(null) + const albums = await api('/artists/' + artistSlug + '/albums') + if (!albums) { + setLibraryLoading(false) + setLibraryError('Error') + return + } + setLibraryLoading(false) + const allTracksItem = { + key: `artist-all:${artistSlug}`, + className: 'file-item', + icon: '▶', + name: 'Play all tracks', + nameClassName: 'name', + onClick: () => void playAllArtistTracks(artistSlug), + } + const albumItems = (albums as any[]).map((a) => { + const year = a.year ? ` (${a.year})` : '' + return { + key: `album:${a.slug}`, + className: 'file-item dir', + icon: '💿', + name: `${a.name}${year}`, + detail: `${a.track_count} tracks`, + onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName), + button: { + title: 'Add album to queue', + onClick: (ev: ReactMouseEvent) => { + ev.stopPropagation() + void addAlbumToQueue(a.slug) + }, + }, + } + }) + setLibraryItems([allTracksItem, ...albumItems]) + } + + async function showAlbumTracks( + albumSlug: string, + albumName: string, + artistSlug: string, + artistName: string, + ) { + setBreadcrumb([ + { label: 'Artists', action: showArtists }, + { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, + { label: albumName }, + ]) + setLibraryLoading(true) + setLibraryError(null) + const tracks = await api('/albums/' + albumSlug) + if (!tracks) { + setLibraryLoading(false) + setLibraryError('Error') + return + } + setLibraryLoading(false) + const playAlbumItem = { + key: `album-play:${albumSlug}`, + className: 'file-item', + icon: '▶', + name: 'Play album', + onClick: () => { + void addAlbumToQueue(albumSlug, true) + }, + } + const trackItems = (tracks as any[]).map((t) => { + const num = t.track_number ? `${t.track_number}. ` : '' + const dur = t.duration_secs ? fmt(t.duration_secs) : '' + return { + key: `track:${t.slug}`, + className: 'file-item', + icon: '🎵', + name: `${num}${t.title}`, + detail: dur, + onClick: () => { + addTrackToQueue( + { + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: albumSlug, + duration: t.duration_secs, + }, + true, + ) + }, + } + }) + setLibraryItems([playAlbumItem, ...trackItems]) + } + + function setBreadcrumb(parts: Crumb[]) { + setBreadcrumbs(parts) + } + + // --- Queue management --- + function addTrackToQueue( + track: { + slug: string + title: string + artist: string + album_slug: string | null + duration: number | null + }, + playNow?: boolean, + ) { + const existing = queue.findIndex((t) => t.slug === track.slug) + if (existing !== -1) { + if (playNow) playIndex(existing) + return + } + queue.push(track) + renderQueue() + if (playNow || (queueIndex === -1 && queue.length === 1)) { + playIndex(queue.length - 1) + } + } + + async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { + const tracks = await api('/albums/' + albumSlug) + if (!tracks || !(tracks as any[]).length) return + const list = tracks as any[] + let firstIdx = queue.length + list.forEach((t) => { + if (queue.find((q) => q.slug === t.slug)) return + queue.push({ + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: albumSlug, + duration: t.duration_secs, + }) + }) + renderQueue() + if (playFirst || queueIndex === -1) playIndex(firstIdx) + showToast(`Added ${list.length} tracks`) + } + + async function playAllArtistTracks(artistSlug: string) { + const tracks = await api('/artists/' + artistSlug + '/tracks') + if (!tracks || !(tracks as any[]).length) return + const list = tracks as any[] + clearQueue() + list.forEach((t) => { + queue.push({ + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + }) + }) + renderQueue() + playIndex(0) + showToast(`Added ${list.length} tracks`) + } + + function playIndex(i: number) { + if (i < 0 || i >= queue.length) return + queueIndex = i + const track = queue[i] + audio.src = `${API}/stream/${track.slug}` + void audio.play().catch(() => {}) + updateNowPlaying(track) + renderQueue() + scrollQueueToActive() + if (window.history && window.history.replaceState) { + const url = new URL(window.location.href) + url.searchParams.set('t', track.slug) + window.history.replaceState(null, '', url.toString()) + } + } + + 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 || '—' + 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({ + title: track.title, + artist: track.artist || '', + album: '', + artwork: [{ src: coverUrl, sizes: '512x512' }], + }) + } + } + + function currentOrder() { + if (!shuffle) return [...Array(queue.length).keys()] + if (shuffleOrder.length !== queue.length) buildShuffleOrder() + return shuffleOrder + } + + function buildShuffleOrder() { + shuffleOrder = [...Array(queue.length).keys()] + for (let i = shuffleOrder.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]] + } + if (queueIndex !== -1) { + const ci = shuffleOrder.indexOf(queueIndex) + if (ci > 0) { + shuffleOrder.splice(ci, 1) + shuffleOrder.unshift(queueIndex) + } + } + } + + function renderQueue() { + const el = document.getElementById('queueList') + if (!el) return + if (!queue.length) { + el.innerHTML = + '
🎵
Select an album to start
' + return + } + 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' }) + } + + function removeFromQueue(idx: number) { + if (idx === queueIndex) { + queueIndex = -1 + audio.pause() + audio.src = '' + updateNowPlaying(null) + } else if (queueIndex > idx) { + queueIndex-- + } + queue.splice(idx, 1) + if (shuffle) { + const si = shuffleOrder.indexOf(idx) + if (si !== -1) shuffleOrder.splice(si, 1) + for (let i = 0; i < shuffleOrder.length; i++) { + if (shuffleOrder[i] > idx) shuffleOrder[i]-- + } + } + renderQueue() + } + + function moveQueueItem(from: number, to: number) { + if (from === to) return + if (shuffle) { + const item = shuffleOrder.splice(from, 1)[0] + shuffleOrder.splice(to, 0, item) + } else { + const item = queue.splice(from, 1)[0] + queue.splice(to, 0, item) + if (queueIndex === from) queueIndex = to + else if (from < queueIndex && to >= queueIndex) queueIndex-- + else if (from > queueIndex && to <= queueIndex) queueIndex++ + } + renderQueue() + } + + function clearQueue() { + queue = [] + queueIndex = -1 + shuffleOrder = [] + audio.pause() + audio.src = '' + updateNowPlaying(null) + document.title = 'Furumi Player' + renderQueue() + } + + // --- Playback controls --- + function togglePlay() { + if (!audio.src && queue.length) { + playIndex(queueIndex === -1 ? 0 : queueIndex) + return + } + if (audio.paused) void audio.play() + else audio.pause() + } + + function nextTrack() { + if (!queue.length) return + const order = currentOrder() + const pos = order.indexOf(queueIndex) + if (pos < order.length - 1) playIndex(order[pos + 1]) + else if (repeatAll) { + if (shuffle) buildShuffleOrder() + playIndex(currentOrder()[0]) + } + } + + function prevTrack() { + if (!queue.length) return + if (audio.currentTime > 3) { + audio.currentTime = 0 + return + } + const order = currentOrder() + const pos = order.indexOf(queueIndex) + if (pos > 0) playIndex(order[pos - 1]) + else if (repeatAll) playIndex(order[order.length - 1]) + } + + function toggleShuffle() { + shuffle = !shuffle + if (shuffle) buildShuffleOrder() + const btn = document.getElementById('btnShuffle') + btn?.classList.toggle('active', shuffle) + window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0') + renderQueue() + } + + function toggleRepeat() { + repeatAll = !repeatAll + const btn = document.getElementById('btnRepeat') + btn?.classList.toggle('active', repeatAll) + window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0') + } + + // --- Seek & Volume --- + function seekTo(e: MouseEvent) { + if (!audio.duration) return + const bar = document.getElementById('progressBar') as HTMLDivElement | null + if (!bar) return + const rect = bar.getBoundingClientRect() + const pct = (e.clientX - rect.left) / rect.width + audio.currentTime = pct * audio.duration + } + + function toggleMute() { + muted = !muted + audio.muted = muted + const volIcon = document.getElementById('volIcon') + if (volIcon) volIcon.innerHTML = muted ? '🔇' : '🔊' + } + + function setVolume(v: number) { + audio.volume = v / 100 + const volIcon = document.getElementById('volIcon') + if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊' + window.localStorage.setItem('furumi_vol', String(v)) + } + + // --- Search --- + function onSearch(q: string) { + if (searchTimer) { + window.clearTimeout(searchTimer) + } + if (q.length < 2) { + closeSearch() + return + } + searchTimer = window.setTimeout(async () => { + const results = await api('/search?q=' + encodeURIComponent(q)) + if (!results || !(results as any[]).length) { + closeSearch() + return + } + setSearchResults(results as any[]) + setSearchOpen(true) + }, 250) + } + + function closeSearch() { + setSearchOpen(false) + setSearchResults([]) + } + + function onSearchSelect(type: string, slug: string) { + closeSearch() + if (type === 'artist') void showArtistAlbums(slug, '') + else if (type === 'album') void addAlbumToQueue(slug, true) + else if (type === 'track') { + addTrackToQueue( + { slug, title: '', artist: '', album_slug: null, duration: null }, + true, + ) + void api('/stream/' + slug).catch(() => null) + } + } + searchSelectRef.current = onSearchSelect + + // --- 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 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 + t.textContent = msg + t.classList.add('show') + if (toastTimer) window.clearTimeout(toastTimer) + toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500) + } + + function toggleSidebar() { + const sidebar = document.getElementById('sidebar') + const overlay = document.getElementById('sidebarOverlay') + sidebar?.classList.toggle('open') + overlay?.classList.toggle('show') + } + + // --- MediaSession --- + if ('mediaSession' in navigator) { + try { + navigator.mediaSession.setActionHandler('play', togglePlay) + navigator.mediaSession.setActionHandler('pause', togglePlay) + navigator.mediaSession.setActionHandler('previoustrack', prevTrack) + navigator.mediaSession.setActionHandler('nexttrack', nextTrack) + navigator.mediaSession.setActionHandler('seekto', (d: any) => { + if (typeof d.seekTime === 'number') { + audio.currentTime = d.seekTime + } + }) + } catch { + // ignore + } + } + + // --- Wire DOM events that were inline in HTML --- + const btnMenu = document.querySelector('.btn-menu') + btnMenu?.addEventListener('click', () => toggleSidebar()) + + const sidebarOverlay = document.getElementById('sidebarOverlay') + sidebarOverlay?.addEventListener('click', () => toggleSidebar()) + + const searchInput = document.getElementById('searchInput') as HTMLInputElement | null + if (searchInput) { + searchInput.addEventListener('input', (e) => { + onSearch((e.target as HTMLInputElement).value) + }) + searchInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') closeSearch() + }) + } + + const btnShuffle = document.getElementById('btnShuffle') + btnShuffle?.addEventListener('click', () => toggleShuffle()) + const btnRepeat = document.getElementById('btnRepeat') + btnRepeat?.addEventListener('click', () => toggleRepeat()) + const btnClear = document.getElementById('btnClearQueue') + btnClear?.addEventListener('click', () => clearQueue()) + + const btnPrev = document.getElementById('btnPrev') + btnPrev?.addEventListener('click', () => prevTrack()) + const btnPlay = document.getElementById('btnPlayPause') + btnPlay?.addEventListener('click', () => togglePlay()) + const btnNext = document.getElementById('btnNext') + btnNext?.addEventListener('click', () => nextTrack()) + + const progressBar = document.getElementById('progressBar') + progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent)) + + const volIcon = document.getElementById('volIcon') + volIcon?.addEventListener('click', () => toggleMute()) + const volSlider = document.getElementById('volSlider') as HTMLInputElement | null + if (volSlider) { + volSlider.addEventListener('input', (e) => { + const v = Number((e.target as HTMLInputElement).value) + setVolume(v) + }) + } + + const clearQueueBtn = document.getElementById('btnClearQueue') + clearQueueBtn?.addEventListener('click', () => clearQueue()) + + // --- Init --- + ;(async () => { + const url = new URL(window.location.href) + const urlSlug = url.searchParams.get('t') + if (urlSlug) { + const info = await api('/tracks/' + urlSlug) + if (info) { + addTrackToQueue( + { + slug: (info as any).slug, + title: (info as any).title, + artist: (info as any).artist_name, + album_slug: (info as any).album_slug, + duration: (info as any).duration_secs, + }, + true, + ) + } + } + void showArtists() + })() + + // Cleanup: best-effort remove listeners on unmount + return () => { + audio.pause() + } + }, [apiRoot]) + + return ( +
+
+
+ + + + + + + Furumi + v +
+
+
+ + searchSelectRef.current(type, slug)} + /> +
+
+
+ +
+
+ + +
+
+ Queue +
+ + + +
+
+
+
+
🎵
+
Select an album to start
+
+
+
+
+ +
+
+
+ 🎵 +
+
+
+ Nothing playing +
+
+ — +
+
+
+
+
+ + + +
+
+ + 0:00 + +
+
+
+ + 0:00 + +
+
+
+ + 🔊 + + +
+
+ +
+
+ ) +} + diff --git a/furumi-node-player/client/src/components/Breadcrumbs.tsx b/furumi-node-player/client/src/components/Breadcrumbs.tsx new file mode 100644 index 0000000..0b095a3 --- /dev/null +++ b/furumi-node-player/client/src/components/Breadcrumbs.tsx @@ -0,0 +1,30 @@ +type Crumb = { + label: string + action?: () => void +} + +type BreadcrumbsProps = { + items: Crumb[] +} + +export function Breadcrumbs({ items }: BreadcrumbsProps) { + if (!items.length) return null + + return ( +
+ {items.map((item, index) => { + const isLast = index === items.length - 1 + return ( + + {!isLast && item.action ? ( + {item.label} + ) : ( + {item.label} + )} + {!isLast ? ' / ' : ''} + + ) + })} +
+ ) +} diff --git a/furumi-node-player/client/src/components/LibraryList.tsx b/furumi-node-player/client/src/components/LibraryList.tsx new file mode 100644 index 0000000..5e09dfb --- /dev/null +++ b/furumi-node-player/client/src/components/LibraryList.tsx @@ -0,0 +1,54 @@ +import type { MouseEvent } from 'react' + +type LibraryListButton = { + title: string + onClick: (ev: MouseEvent) => void +} + +type LibraryListItem = { + key: string + className: string + icon: string + name: string + detail?: string + nameClassName?: string + onClick: () => void + button?: LibraryListButton +} + +type LibraryListProps = { + loading: boolean + error: string | null + items: LibraryListItem[] +} + +export function LibraryList({ loading, error, items }: LibraryListProps) { + if (loading) { + return ( +
+
+
+ ) + } + + if (error) { + return
{error}
+ } + + return ( + <> + {items.map((item) => ( +
+ {item.icon} + {item.name} + {item.detail ? {item.detail} : null} + {item.button ? ( + + ) : null} +
+ ))} + + ) +} diff --git a/furumi-node-player/client/src/components/SearchDropdown.tsx b/furumi-node-player/client/src/components/SearchDropdown.tsx new file mode 100644 index 0000000..ee9be26 --- /dev/null +++ b/furumi-node-player/client/src/components/SearchDropdown.tsx @@ -0,0 +1,30 @@ +type SearchResultItem = { + result_type: string + slug: string + name: string + detail?: string +} + +type SearchDropdownProps = { + isOpen: boolean + results: SearchResultItem[] + onSelect: (type: string, slug: string) => void +} + +export function SearchDropdown({ isOpen, results, onSelect }: SearchDropdownProps) { + return ( +
+ {results.map((r) => ( +
onSelect(r.result_type, r.slug)} + > + {r.result_type} + {r.name} + {r.detail ? {r.detail} : null} +
+ ))} +
+ ) +} diff --git a/furumi-node-player/client/src/furumi-player.css b/furumi-node-player/client/src/furumi-player.css new file mode 100644 index 0000000..12c80f1 --- /dev/null +++ b/furumi-node-player/client/src/furumi-player.css @@ -0,0 +1,754 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +.furumi-root, +.furumi-root * { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.furumi-root { + height: 100%; + display: flex; + flex-direction: column; + font-family: 'Inter', system-ui, sans-serif; +} + +:root { + --bg-base: #0a0c12; + --bg-panel: #111520; + --bg-card: #161d2e; + --bg-hover: #1e2740; + --bg-active: #252f4a; + --border: #1f2c45; + --accent: #7c6af7; + --accent-dim: #5a4fcf; + --accent-glow: rgba(124, 106, 247, 0.3); + --text: #e2e8f0; + --text-muted: #64748b; + --text-dim: #94a3b8; + --success: #34d399; + --danger: #f87171; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + z-index: 10; +} + +.header-logo { + display: flex; + align-items: center; + gap: 0.75rem; + font-weight: 700; + font-size: 1.1rem; +} + +.header-logo svg { + width: 22px; + height: 22px; +} + +.header-version { + font-size: 0.7rem; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.05); + padding: 0.1rem 0.4rem; + border-radius: 4px; + margin-left: 0.25rem; + font-weight: 500; + text-decoration: none; +} + +.btn-menu { + display: none; + background: none; + border: none; + color: var(--text); + font-size: 1.2rem; + cursor: pointer; + padding: 0.1rem 0.5rem; + margin-right: 0.2rem; + border-radius: 4px; +} + +.search-wrap { + position: relative; +} + +.search-wrap input { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px 6px 30px; + color: var(--text); + font-size: 13px; + width: 220px; + font-family: inherit; +} + +.search-wrap::before { + content: '🔍'; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 0 0 6px 6px; + max-height: 300px; + overflow-y: auto; + z-index: 50; + display: none; +} + +.search-dropdown.open { + display: block; +} + +.search-result { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + border-bottom: 1px solid var(--border); +} + +.search-result:hover { + background: var(--bg-hover); +} + +.search-result .sr-type { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + margin-right: 6px; +} + +.search-result .sr-detail { + font-size: 11px; + color: var(--text-muted); + margin-left: 4px; +} + +.main { + display: flex; + flex: 1; + overflow: hidden; + position: relative; + background: var(--bg-base); + color: var(--text); +} + +.sidebar-overlay { + display: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 20; +} + +.sidebar-overlay.show { + display: block; +} + +.sidebar { + width: 280px; + min-width: 200px; + max-width: 400px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-panel); + border-right: 1px solid var(--border); + overflow: hidden; + resize: horizontal; +} + +.sidebar-header { + padding: 0.85rem 1rem 0.6rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.breadcrumb { + padding: 0.5rem 1rem; + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.breadcrumb span { + color: var(--accent); + cursor: pointer; +} + +.breadcrumb span:hover { + text-decoration: underline; +} + +.file-list { + flex: 1; + overflow-y: auto; + padding: 0.3rem 0; +} + +.file-list::-webkit-scrollbar { + width: 4px; +} + +.file-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.file-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.45rem 1rem; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-dim); + user-select: none; + transition: background 0.12s; +} + +.file-item:hover { + background: var(--bg-hover); + color: var(--text); +} + +.file-item.dir { + color: var(--accent); +} + +.file-item .icon { + font-size: 0.95rem; + flex-shrink: 0; + opacity: 0.8; +} + +.file-item .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-item .detail { + font-size: 0.7rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.file-item .add-btn { + opacity: 0; + font-size: 0.75rem; + background: var(--bg-hover); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.2rem 0.4rem; + cursor: pointer; + flex-shrink: 0; +} + +.file-item:hover .add-btn { + opacity: 1; +} + +.file-item .add-btn:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.queue-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-base); +} + +.queue-header { + padding: 0.85rem 1.25rem 0.6rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.queue-actions { + display: flex; + gap: 0.5rem; +} + +.queue-btn { + font-size: 0.7rem; + padding: 0.2rem 0.55rem; + background: none; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-muted); + cursor: pointer; +} + +.queue-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.queue-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.queue-list { + flex: 1; + overflow-y: auto; + padding: 0.3rem 0; +} + +.queue-list::-webkit-scrollbar { + width: 4px; +} + +.queue-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.queue-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + cursor: pointer; + border-left: 2px solid transparent; + transition: background 0.12s; +} + +.queue-item:hover { + background: var(--bg-hover); +} + +.queue-item.playing { + background: var(--bg-active); + border-left-color: var(--accent); +} + +.queue-item.playing .qi-title { + color: var(--accent); +} + +.queue-item .qi-index { + font-size: 0.75rem; + color: var(--text-muted); + width: 1.5rem; + text-align: right; + flex-shrink: 0; +} + +.queue-item.playing .qi-index::before { + content: '▶'; + font-size: 0.6rem; + color: var(--accent); +} + +.queue-item .qi-cover { + width: 36px; + height: 36px; + border-radius: 5px; + background: var(--bg-card); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.queue-item .qi-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.queue-item .qi-info { + flex: 1; + overflow: hidden; +} + +.queue-item .qi-title { + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-item .qi-artist { + font-size: 0.75rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-item .qi-dur { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: auto; + margin-right: 0.5rem; +} + +.qi-remove { + background: none; + border: none; + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + padding: 0.3rem; + border-radius: 4px; + opacity: 0; +} + +.queue-item:hover .qi-remove { + opacity: 1; +} + +.qi-remove:hover { + background: rgba(248, 113, 113, 0.15); + color: var(--danger); +} + +.queue-item.dragging { + opacity: 0.5; +} + +.queue-item.drag-over { + border-top: 2px solid var(--accent); + margin-top: -2px; +} + +.queue-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.875rem; + gap: 0.5rem; + padding: 2rem; +} + +.queue-empty .empty-icon { + font-size: 2.5rem; + opacity: 0.3; +} + +.player-bar { + background: var(--bg-panel); + border-top: 1px solid var(--border); + padding: 0.9rem 1.5rem; + flex-shrink: 0; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + align-items: center; + gap: 1rem; +} + +.np-info { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.np-cover { + width: 44px; + height: 44px; + border-radius: 6px; + background: var(--bg-card); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; +} + +.np-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.np-text { + min-width: 0; +} + +.np-title { + font-size: 0.875rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.np-artist { + font-size: 0.75rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ctrl-btns { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ctrl-btn { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0.35rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.ctrl-btn:hover { + color: var(--text); + background: var(--bg-hover); +} + +.ctrl-btn.active { + color: var(--accent); +} + +.ctrl-btn-main { + width: 38px; + height: 38px; + background: var(--accent); + color: #fff !important; + font-size: 1.1rem; + box-shadow: 0 0 14px var(--accent-glow); +} + +.ctrl-btn-main:hover { + background: var(--accent-dim) !important; +} + +.progress-row { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; +} + +.time { + font-size: 0.7rem; + color: var(--text-muted); + flex-shrink: 0; + font-variant-numeric: tabular-nums; + min-width: 2.5rem; + text-align: center; +} + +.progress-bar { + flex: 1; + height: 4px; + background: var(--bg-hover); + border-radius: 2px; + cursor: pointer; + position: relative; +} + +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + pointer-events: none; +} + +.progress-fill::after { + content: ''; + position: absolute; + right: -5px; + top: 50%; + transform: translateY(-50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + opacity: 0; + transition: opacity 0.15s; +} + +.progress-bar:hover .progress-fill::after { + opacity: 1; +} + +.volume-row { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; +} + +.vol-icon { + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; +} + +.volume-slider { + -webkit-appearance: none; + appearance: none; + width: 80px; + height: 4px; + border-radius: 2px; + background: var(--bg-hover); + cursor: pointer; + outline: none; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.toast { + position: fixed; + bottom: 90px; + right: 1.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem 1rem; + font-size: 0.8rem; + color: var(--text-dim); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + opacity: 0; + transform: translateY(8px); + transition: all 0.25s; + pointer-events: none; + z-index: 100; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 768px) { + .btn-menu { + display: inline-block; + } + + .header { + padding: 0.75rem 1rem; + } + + .sidebar { + position: absolute; + top: 0; + bottom: 0; + left: -100%; + width: 85%; + max-width: 320px; + z-index: 30; + transition: left 0.3s; + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.6); + } + + .sidebar.open { + left: 0; + } + + .player-bar { + grid-template-columns: 1fr; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .volume-row { + display: none; + } + + .search-wrap input { + width: 140px; + } +} + diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts new file mode 100644 index 0000000..412b656 --- /dev/null +++ b/furumi-node-player/client/src/furumiApi.ts @@ -0,0 +1,12 @@ +export type FurumiApiClient = (path: string) => Promise + +export function createFurumiApiClient(apiRoot: string): FurumiApiClient { + const API = apiRoot + + return async function api(path: string) { + const r = await fetch(API + path) + if (!r.ok) return null + return r.json() + } +} +