diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 7a38ac5..513f29e 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,10 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { - API_ROOT, - searchTracks, - preloadStream, -} from './furumiApi' +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' @@ -28,6 +24,7 @@ import { 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' @@ -117,50 +114,16 @@ export function FurumiPlayer() { let searchTimer: number | null = null let toastTimer: number | null = null - let muted = false - let playbackErrorSkips = 0 - const MAX_PLAYBACK_ERROR_SKIPS = 5 - 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 - } - } catch { - // ignore + 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) } - 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('playing', () => { - playbackErrorSkips = 0 - }) - 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') - if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return - playbackErrorSkips += 1 - nextTrack() - }) - async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) try { @@ -328,23 +291,18 @@ export function FurumiPlayer() { 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] - // TODO remove after auth refactor - preloadStream(track.slug).then(response => { - console.log('response', response) - audio.src = URL.createObjectURL(response?.data) - void audio.play().catch(() => { }) - // Optionally revoke old object URL if needed to avoid memory leaks - // if (oldSrc?.startsWith('blob:')) { - // URL.revokeObjectURL(oldSrc) - // } - }) - // audio.src = `${API_ROOT}/stream/${track.slug}` - // void audio.play().catch(() => {}) + void playback.loadStreamForTrack(track.slug) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -355,36 +313,16 @@ export function FurumiPlayer() { function removeFromQueue(idx: number) { const wasPlaying = store.getState().queue.currentIndex === idx dispatch(removeFromQueueAt(idx)) - if (wasPlaying) { - audio.pause() - audio.src = '' - } + if (wasPlaying) playback.pauseAndClearSource() } function moveQueueItem(fromPos: number, toPos: number) { dispatch(moveQueueItemInOrder({ fromPos, toPos })) } - queueActionsRef.current = { - playIndex, - removeFromQueue, - moveQueueItem, - } - function clearQueuePlayback() { dispatch(clearQueue()) - audio.pause() - audio.src = '' - } - - function togglePlay() { - const q = store.getState().queue - if (!audio.src && q.items.length) { - playIndex(q.currentIndex === -1 ? 0 : q.currentIndex) - return - } - if (audio.paused) void audio.play() - else audio.pause() + playback.pauseAndClearSource() } function nextTrack() { @@ -403,16 +341,26 @@ export function FurumiPlayer() { function prevTrack() { const q = store.getState().queue if (!q.items.length) return - if (audio.currentTime > 3) { - audio.currentTime = 0 - 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()) } @@ -421,29 +369,6 @@ export function FurumiPlayer() { dispatch(toggleRepeat()) } - 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)) - } - function onSearch(q: string) { if (searchTimer) { window.clearTimeout(searchTimer) @@ -482,15 +407,6 @@ export function FurumiPlayer() { } searchSelectRef.current = onSearchSelect - 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') @@ -506,7 +422,7 @@ export function FurumiPlayer() { navigator.mediaSession.setActionHandler('nexttrack', nextTrack) navigator.mediaSession.setActionHandler('seekto', (d: any) => { if (typeof d.seekTime === 'number') { - audio.currentTime = d.seekTime + playback.seekToTime(d.seekTime) } }) } catch { @@ -544,22 +460,6 @@ export function FurumiPlayer() { 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', () => clearQueuePlayback()) - ; (async () => { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') @@ -585,7 +485,7 @@ export function FurumiPlayer() { return () => { queueActionsRef.current = null - audio.pause() + playback.dispose() } }, [dispatch]) diff --git a/furumi-node-player/client/src/audioPlaybackService.ts b/furumi-node-player/client/src/audioPlaybackService.ts new file mode 100644 index 0000000..e064fbd --- /dev/null +++ b/furumi-node-player/client/src/audioPlaybackService.ts @@ -0,0 +1,190 @@ +import { preloadStream } from './furumiApi' +import { fmt } from './utils' + +const MAX_PLAYBACK_ERROR_SKIPS = 5 + +/** Seconds from track start above which "previous" rewinds current track instead. */ +const PREV_TRACK_REWIND_THRESHOLD_SEC = 3 + +export interface AudioPlaybackCallbacks { + onEnded: () => void + /** Called after a recoverable playback error (to advance queue). */ + onErrorSkip: () => void + onToast: (msg: string) => void +} + +export interface AudioPlaybackHandle { + loadStreamForTrack(slug: string): Promise + pauseAndClearSource(): void + togglePlay(whenNoSource: () => void): void + seekFromProgressBarClick(e: MouseEvent): void + toggleMute(): void + setVolume(percent: number): void + seekToTime(seconds: number): void + /** If current time is past the threshold, seeks to 0 and returns true (caller should skip prev-track logic). */ + rewindCurrentTrackIfPastThreshold(): boolean + dispose(): void +} + +function syncVolumeFromStorage(audio: HTMLAudioElement) { + 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 + } + } catch { + // ignore + } +} + +export function attachAudioPlayback( + audio: HTMLAudioElement, + callbacks: AudioPlaybackCallbacks, +): AudioPlaybackHandle { + let muted = false + let playbackErrorSkips = 0 + + syncVolumeFromStorage(audio) + + function onTimeUpdate() { + if (!audio.duration) return + 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) + } + + function setPlayPauseButtonPlaying(playing: boolean) { + const btn = document.getElementById('btnPlayPause') + if (btn) btn.innerHTML = playing ? '⏸' : '▶' + } + + function onPlaying() { + playbackErrorSkips = 0 + } + + function onPlay() { + setPlayPauseButtonPlaying(true) + } + + function onPause() { + setPlayPauseButtonPlaying(false) + } + + function onError() { + callbacks.onToast('Playback error') + if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return + playbackErrorSkips += 1 + callbacks.onErrorSkip() + } + + function onEnded() { + callbacks.onEnded() + } + + audio.addEventListener('timeupdate', onTimeUpdate) + audio.addEventListener('ended', onEnded) + audio.addEventListener('playing', onPlaying) + audio.addEventListener('play', onPlay) + audio.addEventListener('pause', onPause) + audio.addEventListener('error', onError) + + const progressBar = document.getElementById('progressBar') + const onProgressClick = (e: Event) => seekFromProgressBarClick(e as MouseEvent) + progressBar?.addEventListener('click', onProgressClick) + + const volIcon = document.getElementById('volIcon') + const onVolIconClick = () => toggleMute() + volIcon?.addEventListener('click', onVolIconClick) + + const volSlider = document.getElementById('volSlider') as HTMLInputElement | null + const onVolInput = (e: Event) => { + const v = Number((e.target as HTMLInputElement).value) + setVolume(v) + } + volSlider?.addEventListener('input', onVolInput) + + async function loadStreamForTrack(slug: string) { + const response = await preloadStream(slug) + audio.src = URL.createObjectURL(response?.data) + await audio.play().catch(() => { }) + } + + function pauseAndClearSource() { + audio.pause() + audio.src = '' + } + + function togglePlay(whenNoSource: () => void) { + if (!audio.src) { + whenNoSource() + return + } + if (audio.paused) void audio.play() + else audio.pause() + } + + function seekFromProgressBarClick(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 icon = document.getElementById('volIcon') + if (icon) icon.innerHTML = muted ? '🔇' : '🔊' + } + + function setVolume(percent: number) { + audio.volume = percent / 100 + const icon = document.getElementById('volIcon') + if (icon) icon.innerHTML = percent === 0 ? '🔇' : '🔊' + window.localStorage.setItem('furumi_vol', String(percent)) + } + + function seekToTime(seconds: number) { + audio.currentTime = seconds + } + + function rewindCurrentTrackIfPastThreshold(): boolean { + if (audio.currentTime > PREV_TRACK_REWIND_THRESHOLD_SEC) { + audio.currentTime = 0 + return true + } + return false + } + + function dispose() { + audio.removeEventListener('timeupdate', onTimeUpdate) + audio.removeEventListener('ended', onEnded) + audio.removeEventListener('playing', onPlaying) + audio.removeEventListener('play', onPlay) + audio.removeEventListener('pause', onPause) + audio.removeEventListener('error', onError) + progressBar?.removeEventListener('click', onProgressClick) + volIcon?.removeEventListener('click', onVolIconClick) + volSlider?.removeEventListener('input', onVolInput) + audio.pause() + } + + return { + loadStreamForTrack, + pauseAndClearSource, + togglePlay, + seekFromProgressBarClick, + toggleMute, + setVolume, + seekToTime, + rewindCurrentTrackIfPastThreshold, + dispose, + } +}