Files
furumi-ng/furumi-node-player/client/src/audioPlaybackService.ts
T
Boris Cherepanov 30c6400354
Publish Metadata Agent Image / build-and-push-image (push) Successful in 7m58s
Publish Web Player Image / build-and-push-image (push) Has been cancelled
feat: create playback service
2026-04-02 00:38:30 +03:00

191 lines
5.5 KiB
TypeScript

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,
}
}