/** * Reactive SolidJS hook over the module-level audio engine. * * Wraps utils/audio-engine: every useAudio() call shares ONE engine (all * playback logic, the 150ms poll, session restore, and the event-bus * commands live there). This hook keeps only what is tied to the Solid * lifecycle — the ref-counted last-owner dispose and the process-exit * teardown — and re-exposes the two controls the engine deliberately omits * (availablePlayers, switchBackend). * * Usage: * ```tsx * const audio = useAudio() * audio.play(episode) * {audio.isPlaying() ? "Playing" : "Paused"} * ``` */ import { onCleanup } from "solid-js"; import { availablePlayers, currentEpisode, speed, setSpeed, volume, setVolume, } from "../utils/audio-signals"; import { useAppStore } from "../stores/app"; import { useMediaRegistry } from "../utils/media-registry"; import { saveLastPlayerSync } from "../utils/app-persistence"; import type { BackendName, DetectedPlayer } from "../utils/audio-player"; import { createAudioEngine, ensureEngineBackend, disposeEngineBackend, stopEnginePolling, getEngineBackend, switchBackend, restoreLastSession, type AudioEngine, } from "../utils/audio-engine"; // Re-exported so the session-restore test can pull it from this module. export { restoreLastSession }; // useAudio() surface: the engine plus the two controls it doesn't expose. export type AudioControls = AudioEngine & { availablePlayers: () => DetectedPlayer[]; switchBackend: (name: BackendName) => Promise; }; const engine = createAudioEngine(); // Singleton ref count — how many live useAudio() owners there are. The engine // is shared; the last owner to unmount disposes the backend. let refCount = 0; // ── 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 => { stopEnginePolling(); // Persist "what's loaded in the player right now" synchronously — // process.exit(0) runs this handler synchronously and an async write // would never land. The next launch restores this episode paused. try { const ep = currentEpisode(); if (ep) { saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() }); } } catch { /* best-effort at exit */ } try { getEngineBackend()?.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); }); } } /** * Reactive audio controls hook. * * Returns the shared audio engine wrapped with the two extra controls, so * all components observe the same playback state. The first useAudio() * owner creates the backend, runs the one-time boot (volume/speed sync + * session restore) and registers the process-exit teardown; the last * owner disposes the backend. */ export function useAudio(): AudioControls { const engine = createAudioEngine(); ensureEngineBackend(); registerExitTeardown(); // First owner: sync speed/volume from the persisted settings and restore // the last player session once (loaded, not playing). Raw signal // accessors are used here on purpose — this is boot-only, not a user // volume/speed change, so it must not re-persist to the app store. if (refCount === 0) { const appStore = useAppStore(); const storeSpeed = appStore.state().settings.playbackSpeed; if (storeSpeed && storeSpeed !== speed()) { setSpeed(storeSpeed); } // Volume re-syncs once settings finish loading (async config read) // so a level persisted last session is applied at boot. appStore .whenReady() .then(() => { const storeVolume = appStore.state().settings.volume; if (storeVolume !== undefined && storeVolume !== volume()) { setVolume(storeVolume); } }) .catch(() => {}); // Restore the last player session once at boot (loaded, not playing). restoreLastSession().catch(() => {}); } refCount++; onCleanup(() => { refCount--; if (refCount <= 0) { disposeEngineBackend(); refCount = 0; } }); return { ...engine, availablePlayers, switchBackend }; }