191 lines
5.5 KiB
TypeScript
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 ? '⏸' : '▶'
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
}
|