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