feat(audio): reconcile externally-initiated pause/resume via live mpv pause state
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.
This commit is contained in:
@@ -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,16 +214,26 @@ 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 {
|
||||
pollCount++;
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -190,14 +246,21 @@ function startPolling(): void {
|
||||
}
|
||||
|
||||
// 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());
|
||||
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<void> {
|
||||
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
|
||||
|
||||
226
tests/external-pause-reconcile.test.ts
Normal file
226
tests/external-pause-reconcile.test.ts
Normal file
@@ -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<void> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
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<void> {
|
||||
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 */
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user