388 lines
9.6 KiB
TypeScript
388 lines
9.6 KiB
TypeScript
/**
|
|
* Reactive SolidJS hook wrapping the AudioBackend.
|
|
*
|
|
* Provides signals for playback state and methods for controlling
|
|
* audio. Integrates with the event bus and app store.
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* const audio = useAudio()
|
|
* audio.play(episode)
|
|
* <text>{audio.isPlaying() ? "Playing" : "Paused"}</text>
|
|
* ```
|
|
*/
|
|
|
|
import { createSignal, onCleanup } from "solid-js"
|
|
import {
|
|
createAudioBackend,
|
|
detectPlayers,
|
|
type AudioBackend,
|
|
type BackendName,
|
|
type DetectedPlayer,
|
|
} from "../utils/audio-player"
|
|
import { emit, on } from "../utils/event-bus"
|
|
import { useAppStore } from "../stores/app"
|
|
import { useProgressStore } from "../stores/progress"
|
|
import type { Episode } from "../types/episode"
|
|
|
|
export interface AudioControls {
|
|
// Signals (reactive getters)
|
|
isPlaying: () => boolean
|
|
position: () => number
|
|
duration: () => number
|
|
volume: () => number
|
|
speed: () => number
|
|
backendName: () => BackendName
|
|
error: () => string | null
|
|
currentEpisode: () => Episode | null
|
|
availablePlayers: () => DetectedPlayer[]
|
|
|
|
// Actions
|
|
play: (episode: Episode) => Promise<void>
|
|
pause: () => Promise<void>
|
|
resume: () => Promise<void>
|
|
togglePlayback: () => Promise<void>
|
|
stop: () => Promise<void>
|
|
seek: (seconds: number) => Promise<void>
|
|
seekRelative: (delta: number) => Promise<void>
|
|
setVolume: (volume: number) => Promise<void>
|
|
setSpeed: (speed: number) => Promise<void>
|
|
switchBackend: (name: BackendName) => Promise<void>
|
|
}
|
|
|
|
// Singleton state — shared across all components that call useAudio()
|
|
let backend: AudioBackend | null = null
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
let refCount = 0
|
|
let pollCount = 0 // Counts poll ticks for throttling progress saves
|
|
|
|
const [isPlaying, setIsPlaying] = createSignal(false)
|
|
const [position, setPosition] = createSignal(0)
|
|
const [duration, setDuration] = createSignal(0)
|
|
const [volume, setVolume] = createSignal(0.7)
|
|
const [speed, setSpeed] = createSignal(1)
|
|
const [backendName, setBackendName] = createSignal<BackendName>("none")
|
|
const [error, setError] = createSignal<string | null>(null)
|
|
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
|
|
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
|
|
|
|
function ensureBackend(): AudioBackend {
|
|
if (!backend) {
|
|
const detected = detectPlayers()
|
|
setAvailablePlayers(detected)
|
|
backend = createAudioBackend()
|
|
setBackendName(backend.name)
|
|
}
|
|
return backend
|
|
}
|
|
|
|
function startPolling(): void {
|
|
stopPolling()
|
|
pollCount = 0
|
|
pollTimer = setInterval(async () => {
|
|
if (!backend || !isPlaying()) return
|
|
try {
|
|
const pos = await backend.getPosition()
|
|
const dur = await backend.getDuration()
|
|
setPosition(pos)
|
|
if (dur > 0) setDuration(dur)
|
|
|
|
// Save progress every ~5 seconds (10 ticks * 500ms)
|
|
pollCount++
|
|
if (pollCount % 10 === 0) {
|
|
const ep = currentEpisode()
|
|
if (ep) {
|
|
const progressStore = useProgressStore()
|
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
|
}
|
|
}
|
|
|
|
// Check if backend stopped playing (track ended)
|
|
if (!backend.isPlaying() && isPlaying()) {
|
|
setIsPlaying(false)
|
|
stopPolling()
|
|
// Save final position on track end
|
|
const ep = currentEpisode()
|
|
if (ep) {
|
|
const progressStore = useProgressStore()
|
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
|
}
|
|
}
|
|
} catch {
|
|
// Backend may have been disposed
|
|
}
|
|
}, 500)
|
|
}
|
|
|
|
function stopPolling(): void {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
}
|
|
|
|
async function play(episode: Episode): Promise<void> {
|
|
const b = ensureBackend()
|
|
setError(null)
|
|
|
|
if (!episode.audioUrl) {
|
|
setError("No audio URL for this episode")
|
|
return
|
|
}
|
|
|
|
try {
|
|
const appStore = useAppStore()
|
|
const progressStore = useProgressStore()
|
|
const storeSpeed = appStore.state().settings.playbackSpeed
|
|
const vol = volume()
|
|
const spd = storeSpeed || speed()
|
|
|
|
// Resume from saved progress if available and not completed
|
|
const savedProgress = progressStore.get(episode.id)
|
|
let startPos = 0
|
|
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
|
startPos = savedProgress.position
|
|
}
|
|
|
|
await b.play(episode.audioUrl, {
|
|
volume: vol,
|
|
speed: spd,
|
|
startPosition: startPos > 0 ? startPos : undefined,
|
|
})
|
|
|
|
setCurrentEpisode(episode)
|
|
setIsPlaying(true)
|
|
setPosition(startPos)
|
|
setSpeed(spd)
|
|
if (episode.duration) setDuration(episode.duration)
|
|
|
|
startPolling()
|
|
emit("player.play", { episodeId: episode.id })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Playback failed")
|
|
setIsPlaying(false)
|
|
}
|
|
}
|
|
|
|
async function pause(): Promise<void> {
|
|
if (!backend) return
|
|
try {
|
|
await backend.pause()
|
|
setIsPlaying(false)
|
|
stopPolling()
|
|
const ep = currentEpisode()
|
|
if (ep) {
|
|
// Save progress on pause
|
|
const progressStore = useProgressStore()
|
|
progressStore.update(ep.id, position(), duration(), speed())
|
|
emit("player.pause", { episodeId: ep.id })
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Pause failed")
|
|
}
|
|
}
|
|
|
|
async function resume(): Promise<void> {
|
|
if (!backend) return
|
|
try {
|
|
await backend.resume()
|
|
setIsPlaying(true)
|
|
startPolling()
|
|
const ep = currentEpisode()
|
|
if (ep) emit("player.play", { episodeId: ep.id })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Resume failed")
|
|
}
|
|
}
|
|
|
|
async function togglePlayback(): Promise<void> {
|
|
if (isPlaying()) {
|
|
await pause()
|
|
} else if (currentEpisode()) {
|
|
await resume()
|
|
}
|
|
}
|
|
|
|
async function stop(): Promise<void> {
|
|
if (!backend) return
|
|
try {
|
|
// Save progress before stopping
|
|
const ep = currentEpisode()
|
|
if (ep) {
|
|
const progressStore = useProgressStore()
|
|
progressStore.update(ep.id, position(), duration(), speed())
|
|
}
|
|
await backend.stop()
|
|
setIsPlaying(false)
|
|
setPosition(0)
|
|
setCurrentEpisode(null)
|
|
stopPolling()
|
|
emit("player.stop", {})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Stop failed")
|
|
}
|
|
}
|
|
|
|
async function seek(seconds: number): Promise<void> {
|
|
if (!backend) return
|
|
const clamped = Math.max(0, Math.min(seconds, duration()))
|
|
try {
|
|
await backend.seek(clamped)
|
|
setPosition(clamped)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Seek failed")
|
|
}
|
|
}
|
|
|
|
async function seekRelative(delta: number): Promise<void> {
|
|
await seek(position() + delta)
|
|
}
|
|
|
|
async function doSetVolume(vol: number): Promise<void> {
|
|
const clamped = Math.max(0, Math.min(1, vol))
|
|
if (backend) {
|
|
try {
|
|
await backend.setVolume(clamped)
|
|
} catch {
|
|
// Some backends can't change volume at runtime
|
|
}
|
|
}
|
|
setVolume(clamped)
|
|
}
|
|
|
|
async function doSetSpeed(spd: number): Promise<void> {
|
|
const clamped = Math.max(0.25, Math.min(3, spd))
|
|
if (backend) {
|
|
try {
|
|
await backend.setSpeed(clamped)
|
|
} catch {
|
|
// Some backends can't change speed at runtime
|
|
}
|
|
}
|
|
setSpeed(clamped)
|
|
|
|
// Sync back to app store
|
|
try {
|
|
const appStore = useAppStore()
|
|
appStore.updateSettings({ playbackSpeed: clamped })
|
|
} catch {
|
|
// Store may not be available
|
|
}
|
|
}
|
|
|
|
async function switchBackend(name: BackendName): Promise<void> {
|
|
const wasPlaying = isPlaying()
|
|
const ep = currentEpisode()
|
|
const pos = position()
|
|
const vol = volume()
|
|
const spd = speed()
|
|
|
|
// Stop current backend
|
|
if (backend) {
|
|
stopPolling()
|
|
backend.dispose()
|
|
backend = null
|
|
}
|
|
|
|
// Create new backend
|
|
backend = createAudioBackend(name)
|
|
setBackendName(backend.name)
|
|
setAvailablePlayers(detectPlayers())
|
|
|
|
// Resume playback if we were playing
|
|
if (wasPlaying && ep && ep.audioUrl) {
|
|
try {
|
|
await backend.play(ep.audioUrl, {
|
|
startPosition: pos,
|
|
volume: vol,
|
|
speed: spd,
|
|
})
|
|
setIsPlaying(true)
|
|
startPolling()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Backend switch failed")
|
|
setIsPlaying(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reactive audio controls hook.
|
|
*
|
|
* Returns a singleton — all components share the same playback state.
|
|
* Registers event bus listeners and cleans them up with onCleanup.
|
|
*/
|
|
export function useAudio(): AudioControls {
|
|
// Initialize backend on first use
|
|
ensureBackend()
|
|
|
|
// Sync initial speed from app store
|
|
if (refCount === 0) {
|
|
try {
|
|
const appStore = useAppStore()
|
|
const storeSpeed = appStore.state().settings.playbackSpeed
|
|
if (storeSpeed && storeSpeed !== speed()) {
|
|
setSpeed(storeSpeed)
|
|
}
|
|
} catch {
|
|
// Store may not be available yet
|
|
}
|
|
}
|
|
|
|
refCount++
|
|
|
|
// Listen for event bus commands (e.g. from other components)
|
|
const unsubPlay = on("player.play", async (data) => {
|
|
// External play requests — currently just tracks episodeId.
|
|
// Episode lookup would require feed store integration.
|
|
})
|
|
|
|
const unsubStop = on("player.stop", async () => {
|
|
if (backend && isPlaying()) {
|
|
await backend.stop()
|
|
setIsPlaying(false)
|
|
setPosition(0)
|
|
setCurrentEpisode(null)
|
|
stopPolling()
|
|
}
|
|
})
|
|
|
|
onCleanup(() => {
|
|
refCount--
|
|
unsubPlay()
|
|
unsubStop()
|
|
|
|
if (refCount <= 0) {
|
|
stopPolling()
|
|
if (backend) {
|
|
backend.dispose()
|
|
backend = null
|
|
}
|
|
refCount = 0
|
|
}
|
|
})
|
|
|
|
return {
|
|
isPlaying,
|
|
position,
|
|
duration,
|
|
volume,
|
|
speed,
|
|
backendName,
|
|
error,
|
|
currentEpisode,
|
|
availablePlayers,
|
|
|
|
play,
|
|
pause,
|
|
resume,
|
|
togglePlayback,
|
|
stop,
|
|
seek,
|
|
seekRelative,
|
|
setVolume: doSetVolume,
|
|
setSpeed: doSetSpeed,
|
|
switchBackend,
|
|
}
|
|
}
|