import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' import { API_ROOT, searchTracks, preloadStream } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' import { fetchArtistTracks } from './store/slices/artistTracksSlice' import { fetchAlbumTracks } from './store/slices/albumTracksSlice' import { fetchTrackDetail } from './store/slices/trackDetailSlice' import { addTrack, addTracksBatch, replaceQueue, clearQueue, playAtIndex, removeFromQueueAt, moveQueueItemInOrder, toggleShuffle, toggleRepeat, rebuildShuffleOrder, selectQueueOrder, selectPlayingOrigIdx, selectQueueScrollSignal, selectNowPlayingTrack, selectQueueItems, } from './store/slices/queueSlice' import { attachAudioPlayback } from './audioPlaybackService' import { fmt } from './utils' import { Header } from './components/header' import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' import type { Track } from './types' export function FurumiPlayer() { const dispatch = useAppDispatch() const artistsLoading = useAppSelector((s) => s.artists.loading) const artistsError = useAppSelector((s) => s.artists.error) const albumsLoading = useAppSelector((s) => s.albums.loading) const albumsError = useAppSelector((s) => s.albums.error) const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading) const albumTracksError = useAppSelector((s) => s.albumTracks.error) const queueItemsView = useAppSelector(selectQueueItems) const queueOrderView = useAppSelector(selectQueueOrder) const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx) const queueScrollSignal = useAppSelector(selectQueueScrollSignal) const nowPlayingTrack = useAppSelector(selectNowPlayingTrack) const [breadcrumbs, setBreadcrumbs] = useState( [], ) 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>(() => { }) const queueActionsRef = useRef<{ playIndex: (i: number) => void removeFromQueue: (idx: number) => void moveQueueItem: (fromPos: number, toPos: number) => void } | null>(null) const audioRef = useRef(null) useEffect(() => { if (!nowPlayingTrack) { document.title = 'Furumi Player' return } document.title = `${nowPlayingTrack.title} — Furumi` const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` if ('mediaSession' in navigator) { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment navigator.mediaSession.metadata = new window.MediaMetadata({ title: nowPlayingTrack.title, artist: nowPlayingTrack.artist || '', album: '', artwork: [{ src: coverUrl, sizes: '512x512' }], }) } catch { // ignore } } }, [nowPlayingTrack]) const shuffle = useAppSelector((s) => s.queue.shuffle) const repeatAll = useAppSelector((s) => s.queue.repeatAll) useEffect(() => { const btnShuffle = document.getElementById('btnShuffle') const btnRepeat = document.getElementById('btnRepeat') btnShuffle?.classList.toggle('active', shuffle) btnRepeat?.classList.toggle('active', repeatAll) }, [shuffle, repeatAll]) useEffect(() => { const audioEl = audioRef.current if (!audioEl) return const audio = audioEl let searchTimer: number | null = null let toastTimer: number | null = null 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) } async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) try { const artists = await dispatch(fetchArtists()).unwrap() setLibraryItems( artists.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), })), ) } catch { // Error is stored in artists.error } } async function showArtistAlbums(artistSlug: string, artistName: string) { setBreadcrumb([ { label: 'Artists', action: showArtists }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, ]) try { const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap() const allTracksItem = { key: `artist-all:${artistSlug}`, className: 'file-item', icon: '▶', name: 'Play all tracks', nameClassName: 'name', onClick: () => void playAllArtistTracks(artistSlug), } const albumItems = albums.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]) } catch { // Error is stored in albums.error } } async function showAlbumTracks( albumSlug: string, albumName: string, artistSlug: string, artistName: string, ) { setBreadcrumb([ { label: 'Artists', action: showArtists }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: albumName }, ]) const result = await dispatch(fetchAlbumTracks(albumSlug)) if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } const playAlbumItem = { key: `album-play:${albumSlug}`, className: 'file-item', icon: '▶', name: 'Play album', onClick: () => { void addAlbumToQueue(albumSlug, true) }, } const trackItems = tracks.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) } function addTrackToQueue( track: { slug: string title: string artist: string album_slug: string | null duration: number | null }, playNow?: boolean, ) { const prevIdx = store.getState().queue.currentIndex dispatch(addTrack({ track, playNow })) const q = store.getState().queue if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) { playIndex(q.currentIndex) } } async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { const result = await dispatch(fetchAlbumTracks(albumSlug)) if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } if (!tracks || !tracks.length) return const list = tracks.map((t) => ({ slug: t.slug, title: t.title, artist: t.artist_name, album_slug: t.album_slug, duration: t.duration_secs, })) const prevIdx = store.getState().queue.currentIndex dispatch(addTracksBatch({ tracks: list, playFirst })) const q = store.getState().queue if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) { playIndex(q.currentIndex) } showToast(`Added ${list.length} tracks`) } async function playAllArtistTracks(artistSlug: string) { const result = await dispatch(fetchArtistTracks(artistSlug)) if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { artistSlug: string; tracks: Track[] } if (!tracks || !tracks.length) return const list = tracks.map((t) => ({ slug: t.slug, title: t.title, artist: t.artist_name, album_slug: t.album_slug, duration: t.duration_secs, })) dispatch(replaceQueue({ items: list, playFromIndex: 0 })) playIndex(0) showToast(`Added ${list.length} tracks`) } const playback = attachAudioPlayback(audio, { onEnded: nextTrack, onErrorSkip: nextTrack, onToast: showToast, }) function playIndex(i: number) { const q = store.getState().queue if (i < 0 || i >= q.items.length) return dispatch(playAtIndex(i)) const track = store.getState().queue.items[i] void playback.loadStreamForTrack(track.slug) 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 removeFromQueue(idx: number) { const wasPlaying = store.getState().queue.currentIndex === idx dispatch(removeFromQueueAt(idx)) if (wasPlaying) playback.pauseAndClearSource() } function moveQueueItem(fromPos: number, toPos: number) { dispatch(moveQueueItemInOrder({ fromPos, toPos })) } function clearQueuePlayback() { dispatch(clearQueue()) playback.pauseAndClearSource() } function nextTrack() { const q = store.getState().queue if (!q.items.length) return const order = selectQueueOrder(store.getState()) const pos = order.indexOf(q.currentIndex) if (pos < order.length - 1) playIndex(order[pos + 1]) else if (q.repeatAll) { if (q.shuffle) dispatch(rebuildShuffleOrder()) const first = selectQueueOrder(store.getState())[0] if (first !== undefined) playIndex(first) } } function prevTrack() { const q = store.getState().queue if (!q.items.length) return if (playback.rewindCurrentTrackIfPastThreshold()) return const order = selectQueueOrder(store.getState()) const pos = order.indexOf(q.currentIndex) if (pos > 0) playIndex(order[pos - 1]) else if (q.repeatAll) playIndex(order[order.length - 1]) } function togglePlay() { const q = store.getState().queue playback.togglePlay(() => { playIndex(q.currentIndex === -1 ? 0 : q.currentIndex) }) } queueActionsRef.current = { playIndex, removeFromQueue, moveQueueItem, } function onToggleShuffle() { dispatch(toggleShuffle()) } function onToggleRepeat() { dispatch(toggleRepeat()) } function onSearch(q: string) { if (searchTimer) { window.clearTimeout(searchTimer) } if (q.length < 2) { closeSearch() return } searchTimer = window.setTimeout(async () => { const results = await searchTracks(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 preloadStream(slug) } } searchSelectRef.current = onSearchSelect function toggleSidebar() { const sidebar = document.getElementById('sidebar') const overlay = document.getElementById('sidebarOverlay') sidebar?.classList.toggle('open') overlay?.classList.toggle('show') } const onMediaSeekTo = (d: { seekTime?: number }) => { if (typeof d.seekTime === 'number') { playback.seekToTime(d.seekTime) } } 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', onMediaSeekTo as (d: any) => void) } catch { // ignore } } const onMenuClick = () => toggleSidebar() const btnMenu = document.querySelector('.btn-menu') btnMenu?.addEventListener('click', onMenuClick) const onSidebarOverlayClick = () => toggleSidebar() const sidebarOverlay = document.getElementById('sidebarOverlay') sidebarOverlay?.addEventListener('click', onSidebarOverlayClick) const searchInput = document.getElementById('searchInput') as HTMLInputElement | null const onSearchInput = (e: Event) => { onSearch((e.target as HTMLInputElement).value) } const onSearchKeydown = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSearch() } if (searchInput) { searchInput.addEventListener('input', onSearchInput) searchInput.addEventListener('keydown', onSearchKeydown) } const onShuffleClick = () => onToggleShuffle() const onRepeatClick = () => onToggleRepeat() const onClearClick = () => clearQueuePlayback() const onPrevClick = () => prevTrack() const onPlayClick = () => togglePlay() const onNextClick = () => nextTrack() const btnShuffle = document.getElementById('btnShuffle') btnShuffle?.addEventListener('click', onShuffleClick) const btnRepeat = document.getElementById('btnRepeat') btnRepeat?.addEventListener('click', onRepeatClick) const btnClear = document.getElementById('btnClearQueue') btnClear?.addEventListener('click', onClearClick) const btnPrev = document.getElementById('btnPrev') btnPrev?.addEventListener('click', onPrevClick) const btnPlay = document.getElementById('btnPlayPause') btnPlay?.addEventListener('click', onPlayClick) const btnNext = document.getElementById('btnNext') btnNext?.addEventListener('click', onNextClick) ; (async () => { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') if (urlSlug) { try { const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap() addTrackToQueue( { slug: detail.slug, title: detail.title, artist: detail.artist_name, album_slug: detail.album_slug, duration: detail.duration_secs, }, true, ) } catch { // fetchTrackDetail rejected — track not found or error } } void showArtists() })() return () => { queueActionsRef.current = null playback.dispose() btnMenu?.removeEventListener('click', onMenuClick) sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick) searchInput?.removeEventListener('input', onSearchInput) searchInput?.removeEventListener('keydown', onSearchKeydown) btnShuffle?.removeEventListener('click', onShuffleClick) btnRepeat?.removeEventListener('click', onRepeatClick) btnClear?.removeEventListener('click', onClearClick) btnPrev?.removeEventListener('click', onPrevClick) btnPlay?.removeEventListener('click', onPlayClick) btnNext?.removeEventListener('click', onNextClick) if ('mediaSession' in navigator) { try { navigator.mediaSession.setActionHandler('play', null) navigator.mediaSession.setActionHandler('pause', null) navigator.mediaSession.setActionHandler('previoustrack', null) navigator.mediaSession.setActionHandler('nexttrack', null) navigator.mediaSession.setActionHandler('seekto', null) } catch { // ignore } } } }, [dispatch]) const libraryLoading = breadcrumbs.length === 1 ? artistsLoading : breadcrumbs.length === 2 ? albumsLoading : albumTracksLoading const libraryError = breadcrumbs.length === 1 ? artistsError : breadcrumbs.length === 2 ? albumsError : albumTracksError return (
searchSelectRef.current(type, slug)} /> queueActionsRef.current?.playIndex(origIdx)} onQueueRemove={(origIdx) => queueActionsRef.current?.removeFromQueue(origIdx) } onQueueMove={(fromPos, toPos) => queueActionsRef.current?.moveQueueItem(fromPos, toPos) } /> queueActionsRef.current?.playIndex(origIdx)} onQueueRemove={(origIdx) => queueActionsRef.current?.removeFromQueue(origIdx) } onQueueMove={(fromPos, toPos) => queueActionsRef.current?.moveQueueItem(fromPos, toPos) } />
) }