feat: create playback service
Publish Metadata Agent Image / build-and-push-image (push) Successful in 7m58s
Publish Web Player Image / build-and-push-image (push) Has been cancelled

This commit is contained in:
Boris Cherepanov
2026-04-02 00:38:30 +03:00
parent 480880f292
commit 30c6400354
2 changed files with 224 additions and 134 deletions
+34 -134
View File
@@ -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 ? '&#128263;' : '&#128266;'
}
function setVolume(v: number) {
audio.volume = v / 100
const volIcon = document.getElementById('volIcon')
if (volIcon) volIcon.innerHTML = v === 0 ? '&#128263;' : '&#128266;'
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])
@@ -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<void>
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 ? '&#9208;' : '&#9654;'
}
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 ? '&#128263;' : '&#128266;'
}
function setVolume(percent: number) {
audio.volume = percent / 100
const icon = document.getElementById('volIcon')
if (icon) icon.innerHTML = percent === 0 ? '&#128263;' : '&#128266;'
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,
}
}