From 5e3ad48a2da5859fd775c5c43bcd80cc44cdfa1c Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 14:07:43 -0400 Subject: [PATCH] feat(audio): reconcile externally-initiated pause/resume via live mpv pause state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mpv can pause or resume OUTSIDE PodTUI — system sleep/lock, AirPod removal, device swap, OS media keys, the Now Playing center. The poll previously only reflected commands PodTUI sent, so the UI stayed stuck on "playing" (or "paused") with a frozen position clock. The poll now reads mpv's live pause state each tick: an external pause reconciles the UI to paused (persisting progress, syncing media controls) while keeping the poll armed; an external resume brings the UI back to playing. A paused player is polled at a throttled rate (PAUSE_WATCH_TICKS) so the watch costs ~1 IPC read per second instead of hammering mpv; a dead process (track end / crash) finalizes the track. --- src/hooks/useAudio.ts | 117 ++++++++++--- tests/external-pause-reconcile.test.ts | 226 +++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 tests/external-pause-reconcile.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 7ffdcaf..343238d 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -161,6 +161,52 @@ function registerExitTeardown(): void { } } +/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the + * UI believes playback is paused we only need to catch an external + * resume (AirPod play tap, lock-screen/media-center play); checking every + * tick would just hammer mpv IPC for nothing. */ +const PAUSE_WATCH_TICKS = 7; + +/** The player process died while we believed playback was live — track + * ended (mpv quits at EOF) or the process crashed. Persist the final + * position and stop polling. */ +function finalizeTrackEnd(): void { + setIsPlaying(false); + stopPolling(); + const ep = currentEpisode(); + if (ep) { + const progressStore = useProgressStore(); + progressStore.update(ep.id, position(), duration(), speed()); + } +} + +/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal, + * device swap, OS media keys, the Now Playing center. Bring the UI in + * sync; the poll stays armed so an external resume is caught too. */ +function reconcileExternalPause(): void { + setIsPlaying(false); + const ep = currentEpisode(); + if (ep) { + const progressStore = useProgressStore(); + progressStore.update(ep.id, position(), duration(), speed()); + emit("player.pause", { episodeId: ep.id }); + const media = useMediaRegistry(); + media.setPlaybackState(false); + media.setPosition(position()); + } +} + +/** Playback was restarted from outside PodTUI (AirPods, lock-screen or + * media-center play, OS media keys). Bring the UI back to "playing". */ +function reconcileExternalResume(): void { + setIsPlaying(true); + const ep = currentEpisode(); + if (ep) { + emit("player.play", { episodeId: ep.id }); + useMediaRegistry().setPlaybackState(true); + } +} + function startPolling(): void { stopPolling(); pollCount = 0; @@ -168,36 +214,53 @@ function startPolling(): void { // interval (getPosition opens a fresh mpv IPC connection per call). let pollInFlight = false; pollTimer = setInterval(async () => { - if (!backend || !isPlaying() || pollInFlight) return; + if (!backend || pollInFlight) return; pollInFlight = true; try { - const pos = await backend.getPosition(); - const dur = await backend.getDuration(); - setPosition(pos); - if (dur > 0) setDuration(dur); - - // Save progress every ~5 seconds (33 ticks * 150ms) pollCount++; - if (pollCount % 33 === 0) { - const ep = currentEpisode(); - if (ep) { - const progressStore = useProgressStore(); - progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); - - const media = useMediaRegistry(); - media.setPosition(pos); + if (isPlaying()) { + // mpv can pause itself outside PodTUI. Reconcile instead of + // staying stuck on "playing" with a frozen waveform + // (getPosition would just re-read the same frozen time-pos). + const paused = await backend.getPauseState(); + if (paused === true) { + reconcileExternalPause(); + return; } - } - // 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()); + const pos = await backend.getPosition(); + const dur = await backend.getDuration(); + setPosition(pos); + if (dur > 0) setDuration(dur); + + // Save progress every ~5 seconds (33 ticks * 150ms) + if (pollCount % 33 === 0) { + const ep = currentEpisode(); + if (ep) { + const progressStore = useProgressStore(); + progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); + + const media = useMediaRegistry(); + media.setPosition(pos); + } + } + + // Check if backend stopped playing (track ended) + if (!backend.isPlaying()) { + finalizeTrackEnd(); + } + } else if (pollCount % PAUSE_WATCH_TICKS === 0) { + // Paused — watch for playback restarted from outside (AirPods, + // lock-screen/media-center play). Only while the player is + // still alive: a dead player while we thought we were paused + // means the track ended (mpv quits at EOF) or it crashed. + if (!backend.isAlive()) { + finalizeTrackEnd(); + return; + } + const paused = await backend.getPauseState(); + if (paused === false) { + reconcileExternalResume(); } } } catch { @@ -337,7 +400,9 @@ async function pause(): Promise { try { await backend.pause(); setIsPlaying(false); - stopPolling(); + // Polling stays armed (paused-watch mode): playback can be resumed + // from OUTSIDE PodTUI — AirPods, lock-screen/media-center play — + // and the poll must be live to catch it. const ep = currentEpisode(); if (ep) { // Save progress on pause diff --git a/tests/external-pause-reconcile.test.ts b/tests/external-pause-reconcile.test.ts new file mode 100644 index 0000000..bfe2f81 --- /dev/null +++ b/tests/external-pause-reconcile.test.ts @@ -0,0 +1,226 @@ +/** + * external-pause-reconcile.test.ts — "audio paused outside PodTUI must not + * freeze the player tab" regression test. + * + * The OS can pause the player without PodTUI knowing: system sleep/lock, + * AirPod removal / device swap, OS media keys, the Now Playing center. mpv + * flips its own `pause` property and keeps it there. Before the fix, + * useAudio's signals stayed on "playing" — [Pause] button shown while + * silent, a poll that only re-read the same frozen `time-pos` (stuck + * waveform), and no way to catch an external RESUME either (the poll was + * stopped whenever the UI thought it was paused). + * + * Integration style (like restore-session.test.ts): real stores and real + * persistence files in a temp XDG_CONFIG_HOME — but with the REAL mpv + * backend driven over its actual IPC socket. The test flips mpv's pause + * property the same way the OS does and asserts useAudio reconciles in + * both directions. Skipped when mpv isn't installed. + * + * Real-timer note: the reconcile path runs on useAudio's real 150ms poll + * interval against a real mpv process, with no injectable clock — the + * deliberate-exception case from the no-real-timers rule (same as + * visualizer-store.test.ts). `waitFor` polls with Bun.sleep. + * + * Shared-worker note (same as restore-session.test.ts): the suite reuses + * bun test workers, so other files' `mock.module("../src/hooks/useAudio")` + * leaks into this file's module registry. The REAL useAudio is therefore + * imported via a `?external-pause-test` query suffix — a distinct module + * identity bun loads from disk, bypassing the leaked mock. + */ +import { test, expect, afterAll } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const hasMpv = !!Bun.which("mpv"); + +// ── Sandbox BEFORE any app module evaluates (mirrors restore-session) ──── +const CONFIG = mkdtempSync(join(tmpdir(), "podtui-extpause-")); +process.env.XDG_CONFIG_HOME = CONFIG; +process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-extpause-data-")); +process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; the test drives mpv's IPC +const APP_CONFIG = join(CONFIG, "podtui"); +mkdirSync(APP_CONFIG, { recursive: true }); + +// Seed one feed so the app store boots cleanly. No coverUrl — the play() +// path skips cover-art fetching. The RSS URL is unreachable so the +// background refresh fails fast and leaves the seeded data untouched. +const ISO = "2026-08-10T00:00:00.000Z"; +const feed = { + id: "feed1", + podcast: { + id: "pod1", + title: "Pod One", + description: "", + feedUrl: "http://127.0.0.1:1/show.xml", + lastUpdated: ISO, + isSubscribed: true, + }, + episodes: [], + visibility: "public", + sourceId: "test", + lastUpdated: ISO, + isPinned: false, +}; + +await Bun.write( + join(APP_CONFIG, "config.json"), + JSON.stringify({ feeds: [feed] }, null, 2), +); + +// ── Local 60s WAV so playback is hermetic (no network, no early EOF) ───── +const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`); +{ + const SAMPLE_RATE = 44100; + const DURATION = 60; + const dataLen = SAMPLE_RATE * DURATION; // mono 16-bit + const buf = Buffer.alloc(44 + dataLen * 2); + buf.write("RIFF", 0); + buf.writeUInt32LE(36 + dataLen * 2, 4); + buf.write("WAVE", 8); + buf.write("fmt ", 12); + buf.writeUInt32LE(16, 16); // fmt chunk size + buf.writeUInt16LE(1, 20); // PCM + buf.writeUInt16LE(1, 22); // mono + buf.writeUInt32LE(SAMPLE_RATE, 24); + buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate + buf.writeUInt16LE(2, 32); // block align + buf.writeUInt16LE(16, 34); // bits per sample + buf.write("data", 36); + buf.writeUInt32LE(dataLen * 2, 40); + for (let i = 0; i < dataLen; i++) { + const sample = Math.round( + Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE) * 8000, + ); + buf.writeInt16LE(sample, 44 + i * 2); + } + writeFileSync(wavPath, buf); +} + +// ── Real modules (loaded after env + sandbox are set up) ────────────────── +// @ts-expect-error — bun-only query suffix: distinct module identity that +// loads the real file instead of a leaked mock.module from another test file. +const { useAudio } = await import("../src/hooks/useAudio?external-pause-test"); + +/** The pid-derived socket path the backend tells mpv to bind. */ +function mpvSocket(): string { + return join(tmpdir(), `podtui-mpv-${process.pid}.sock`); +} + +/** + * Send a raw mpv IPC command over the unix socket — exactly how the OS + * media session pauses/resumes mpv without PodTUI's involvement. + */ +async function mpvCommand(command: unknown[]): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + const settle = (err: Error | null): void => { + if (settled) return; + settled = true; + if (err) reject(err); + else resolve(); + }; + Bun.connect({ + unix: mpvSocket(), + socket: { + open(s) { + s.write(JSON.stringify({ command }) + "\n"); + }, + data() {}, + error() { + settle(new Error("mpv IPC connect failed")); + }, + close() { + settle(null); + }, + }, + }).then((s) => + setTimeout(() => { + try { + s.end(); + } catch {} + }, 150), + ); + // Never hang the test on a vanished socket. + setTimeout(() => settle(null), 1000); + await promise; +} + +/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */ +async function waitFor( + check: () => boolean, + timeoutMs = 10000, +): Promise { + const start = Date.now(); + while (!check()) { + if (Date.now() - start > timeoutMs) { + throw new Error("condition not met in time"); + } + await Bun.sleep(25); + } +} + +const episode = { + id: "ep1", + podcastId: "pod1", + title: "Episode One", + description: "desc", + audioUrl: wavPath, + duration: 60, + pubDate: new Date(), +}; + +// ── Tests ──────────────────────────────────────────────────────────────── + +test.skipIf(!hasMpv)( + "external pause flips the UI to paused; external resume recovers", + async () => { + const audio = useAudio(); + await audio.play(episode); + expect(audio.isPlaying()).toBe(true); + + // Simulate the OS pausing the session (lock/sleep, AirPod removal, + // device swap, media-center pause): flip mpv's own pause property + // over IPC. PodTUI is never told. + await mpvCommand(["set_property", "pause", true]); + await waitFor(() => !audio.isPlaying()); + expect(audio.isPlaying()).toBe(false); + // The episode stays loaded — nothing was torn down. + expect(audio.currentEpisode()?.id).toBe("ep1"); + + // Simulate an external resume (AirPod play tap, media-center play). + await mpvCommand(["set_property", "pause", false]); + await waitFor(() => audio.isPlaying()); + expect(audio.isPlaying()).toBe(true); + + // The TUI transport still works from the reconciled state. + await audio.togglePlayback(); + expect(audio.isPlaying()).toBe(false); + await audio.togglePlayback(); + expect(audio.isPlaying()).toBe(true); + + await audio.stop(); + expect(audio.isPlaying()).toBe(false); + }, + { timeout: 30000 }, +); + +// ── Teardown ────────────────────────────────────────────────────────────── + +afterAll(async () => { + try { + useAudio().stop(); + } catch { + /* best-effort */ + } + try { + rmSync(mpvSocket(), { force: true }); + } catch { + /* best-effort */ + } + try { + rmSync(wavPath, { force: true }); + } catch { + /* best-effort */ + } +});