/** * 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) * {audio.isPlaying() ? "Playing" : "Paused"} * ``` */ 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 { useMediaRegistry } from "../utils/media-registry"; import type { Episode } from "../types/episode"; import type { Feed } from "../types/feed"; import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; import { useFeedStore } from "../stores/feed"; 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; pause: () => Promise; resume: () => Promise; togglePlayback: () => Promise; stop: () => Promise; seek: (seconds: number) => Promise; seekRelative: (delta: number) => Promise; setVolume: (volume: number) => Promise; setSpeed: (speed: number) => Promise; switchBackend: (name: BackendName) => Promise; prev: () => Promise; next: () => Promise; } // Singleton state — shared across all components that call useAudio() let backend: AudioBackend | null = null; let pollTimer: ReturnType | 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("none"); const [error, setError] = createSignal(null); const [currentEpisode, setCurrentEpisode] = createSignal(null); const [availablePlayers, setAvailablePlayers] = createSignal( [], ); function ensureBackend(): AudioBackend { if (!backend) { const detected = detectPlayers(); setAvailablePlayers(detected); backend = createAudioBackend(); setBackendName(backend.name); registerExitTeardown(); } return backend; } // ── Process-exit teardown ───────────────────────────────────────────── // `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's // onCleanup — where `backend.dispose()` would otherwise kill the spawned // player (mpv). Without this hook those child processes // survive the host and keep playing audio after the TUI has quit. The // `exit` event fires synchronously on `process.exit(N)`; the signal // handlers cover Ctrl-C / kill, which otherwise terminate without running // `exit` listeners. let exitTeardownRegistered = false; function registerExitTeardown(): void { if (exitTeardownRegistered) return; exitTeardownRegistered = true; const teardown = (): void => { stopPolling(); try { backend?.dispose(); } catch { /* best-effort at exit */ } try { useMediaRegistry().clearNowPlaying(); } catch { /* best-effort at exit */ } }; process.on("exit", teardown); for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { process.on(sig, () => { teardown(); process.exit(0); }); } } 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()); // Update platform media position const media = useMediaRegistry(); media.setPosition(pos); } } // 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 { 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); // Register with platform media controls const media = useMediaRegistry(); media.setNowPlaying({ title: episode.title, artist: episode.podcastId, duration: episode.duration, }); media.setPlaybackState(true); if (startPos > 0) media.setPosition(startPos); startPolling(); emit("player.play", { episodeId: episode.id }); } catch (err) { setError(err instanceof Error ? err.message : "Playback failed"); setIsPlaying(false); } } async function pause(): Promise { 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 }); // Update platform media controls const media = useMediaRegistry(); media.setPlaybackState(false); media.setPosition(position()); } } catch (err) { setError(err instanceof Error ? err.message : "Pause failed"); } } async function resume(): Promise { if (!backend) return; try { await backend.resume(); setIsPlaying(true); startPolling(); const ep = currentEpisode(); if (ep) { emit("player.play", { episodeId: ep.id }); const media = useMediaRegistry(); media.setPlaybackState(true); } } catch (err) { setError(err instanceof Error ? err.message : "Resume failed"); } } async function togglePlayback(): Promise { if (isPlaying()) { await pause(); } else if (currentEpisode()) { await resume(); } } async function stop(): Promise { 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", {}); // Clear platform media controls const media = useMediaRegistry(); media.clearNowPlaying(); } catch (err) { setError(err instanceof Error ? err.message : "Stop failed"); } } async function seek(seconds: number): Promise { 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 { await seek(position() + delta); } async function doSetVolume(vol: number): Promise { 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 { 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 { 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(); } }); // Listen for global multimedia key events (from useMultimediaKeys) const unsubMediaToggle = on("media.toggle", async () => { await togglePlayback(); }); const unsubMediaVolUp = on("media.volumeUp", async () => { await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2)))); }); const unsubMediaVolDown = on("media.volumeDown", async () => { await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2)))); }); const unsubMediaSeekFwd = on("media.seekForward", async () => { await seekRelative(10); }); const unsubMediaSeekBack = on("media.seekBackward", async () => { await seekRelative(-10); }); const unsubMediaSpeed = on("media.speedCycle", async () => { const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2)); await doSetSpeed(next); }); const audioNav = useAudioNavStore(); const feedStore = useFeedStore(); async function prev(): Promise { const current = currentEpisode(); if (!current) return; const currentPos = position(); const currentDur = duration(); const NAV_START_THRESHOLD = 30; if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { await seek(NAV_START_THRESHOLD); } else { const source = audioNav.getSource(); let episodes: Array<{ episode: Episode; feed: Feed }> = []; if (source === AudioSource.FEED) { episodes = feedStore.getAllEpisodesChronological(); } else if (source === AudioSource.MY_SHOWS) { const podcastId = audioNav.getPodcastId(); if (!podcastId) return; const feed = feedStore .getFilteredFeeds() .find((f) => f.podcast.id === podcastId); if (!feed) return; episodes = feed.episodes.map((ep) => ({ episode: ep, feed })); } const currentIndex = audioNav.getCurrentIndex(); const newIndex = Math.max(0, currentIndex - 1); if (newIndex < episodes.length && episodes[newIndex]) { const { episode } = episodes[newIndex]; await play(episode); audioNav.prev(newIndex); } } } async function next(): Promise { const current = currentEpisode(); if (!current) return; const source = audioNav.getSource(); let episodes: Array<{ episode: Episode; feed: Feed }> = []; if (source === AudioSource.FEED) { episodes = feedStore.getAllEpisodesChronological(); } else if (source === AudioSource.MY_SHOWS) { const podcastId = audioNav.getPodcastId(); if (!podcastId) return; const feed = feedStore .getFilteredFeeds() .find((f) => f.podcast.id === podcastId); if (!feed) return; episodes = feed.episodes.map((ep) => ({ episode: ep, feed })); } const currentIndex = audioNav.getCurrentIndex(); const newIndex = Math.min(episodes.length - 1, currentIndex + 1); if (newIndex >= 0 && episodes[newIndex]) { const { episode } = episodes[newIndex]; await play(episode); audioNav.next(newIndex); } } onCleanup(() => { refCount--; unsubPlay(); unsubStop(); unsubMediaToggle(); unsubMediaVolUp(); unsubMediaVolDown(); unsubMediaSeekFwd(); unsubMediaSeekBack(); unsubMediaSpeed(); if (refCount <= 0) { stopPolling(); if (backend) { backend.dispose(); backend = null; } // Clear media registry on full teardown const media = useMediaRegistry(); media.clearNowPlaying(); refCount = 0; } }); return { isPlaying, position, duration, volume, speed, backendName, error, currentEpisode, availablePlayers, play, pause, resume, togglePlayback, stop, seek, seekRelative, setVolume: doSetVolume, setSpeed: doSetSpeed, switchBackend, prev, next, }; }