feat(player): restore the last player session at boot

Reload the episode that was loaded in the player when the previous run
ended (persisted on play/load and synchronously at exit) into the Player
tab paused at its saved position — never autostarted. Episodes at or
above 98% completion are skipped, as are empty-player and unsubscribed
episodes.

- useAudio gains load(episode) (sets currentEpisode/position/Now Playing
  without starting the backend) and restoreLastSession(), triggered once
  at boot and serialized through a chain so a late-finishing boot restore
  can't clobber later state.
- togglePlayback branches on a startedPlayback flag: a restored episode
  starts the backend from the saved position; a paused one resumes.
- stop() clears the marker; the exit teardown writes it synchronously
  (process.exit bypasses async writes).
- feed/progress stores expose whenReady() so restore waits for the async
  boot loads; feed store gains findEpisode().
- app-persistence serializes last-player marker writes and exposes
  waitForLastPlayerWrite() for deterministic tests.
- tests/restore-session.test.ts: real modules + local RSS feed server;
  the real useAudio is imported via a ?restore-test query suffix to
  bypass the suite's mock.module leak across shared bun workers.
This commit is contained in:
2026-08-11 13:18:00 -04:00
parent 15f8a098b5
commit 1b55b7117c
6 changed files with 379 additions and 7 deletions

View File

@@ -26,7 +26,12 @@ 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 {
loadLastPlayerFromFile,
saveLastPlayerToFile,
saveLastPlayerSync,
} from "../utils/app-persistence";
import type { Episode, Progress } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useFeedStore } from "../stores/feed";
@@ -45,6 +50,8 @@ export interface AudioControls {
// Actions
play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
@@ -76,6 +83,23 @@ const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.
* restored at boot) and the first play action must start the backend
* instead of unpausing it. */
let startedPlayback = false;
/** Completion fraction at/above which an episode is NOT restored at boot. */
const RESTORE_COMPLETION_THRESHOLD = 0.98;
/** True when saved progress is below the restore cutoff. Episodes with no
* progress (never reached the persist threshold) or unknown duration count
* as eligible — they restore from the start. */
function isRestoreEligible(progress: Progress | undefined): boolean {
if (!progress || progress.duration <= 0) return true;
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
}
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers();
@@ -101,6 +125,17 @@ function registerExitTeardown(): void {
exitTeardownRegistered = true;
const teardown = (): void => {
stopPolling();
// 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 {
backend?.dispose();
} catch {
@@ -228,6 +263,11 @@ async function play(episode: Episode): Promise<void> {
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
// Remember this episode as "loaded in the player" so the next launch
// can restore it paused (cleared by stop()).
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
// Register with platform media controls
const media = useMediaRegistry();
@@ -250,6 +290,48 @@ async function play(episode: Episode): Promise<void> {
}
}
/**
* Load an episode into the player WITHOUT starting playback. The player tab
* renders it paused at its saved position; the first play action starts the
* backend from there (see togglePlayback). Used to restore the last player
* session at boot.
*/
async function load(episode: Episode): Promise<void> {
ensureBackend();
setError(null);
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
// Show the saved position so the player tab reflects where playback
// will resume; episodes at/above the completion threshold start from 0.
const progressStore = useProgressStore();
const saved = progressStore.get(episode.id);
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
setPosition(pos);
if (episode.duration) setDuration(episode.duration);
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
setSpeed(storeSpeed || speed());
// Surface the loaded-but-paused track to the OS media controls.
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (pos > 0) media.setPosition(pos);
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
}
async function pause(): Promise<void> {
if (!backend) return;
try {
@@ -294,7 +376,15 @@ async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
}
}
@@ -311,9 +401,13 @@ async function stop(): Promise<void> {
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
startedPlayback = false;
stopPolling();
emit("player.stop", {});
// Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
@@ -402,6 +496,7 @@ async function switchBackend(name: BackendName): Promise<void> {
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
@@ -410,6 +505,46 @@ async function switchBackend(name: BackendName): Promise<void> {
}
}
/** Serialized restore chain: the boot-triggered restore and any explicit
* call run one after another, so a late-finishing earlier restore can never
* overwrite state changed by a later one (and callers can await the latest
* attempt deterministically). */
let restoreChain: Promise<void> = Promise.resolve();
/**
* Boot-time session restore: reload the episode that was loaded in the
* player when the previous run ended (persisted on play/load and at exit),
* paused at its saved position — never autostarted. Episodes at/above the
* completion threshold are skipped. Silently no-ops when there is nothing
* to restore (empty player, unsubscribed show, or completed episode).
*/
export async function restoreLastSession(): Promise<void> {
const attempt = restoreChain.then(async () => {
const marker = await loadLastPlayerFromFile();
if (!marker?.episodeId) return;
// Feeds and progress load asynchronously at boot; wait for both
// before looking the episode up.
await Promise.all([
useProgressStore().whenReady(),
useFeedStore().whenReady(),
]);
const episode = useFeedStore().findEpisode(marker.episodeId);
if (!episode) return;
// Only restore episodes below the completion threshold.
const saved = useProgressStore().get(episode.id);
if (!isRestoreEligible(saved)) return;
await load(episode);
});
// Keep the chain alive even when an attempt fails; the caller awaiting
// this attempt still observes its own outcome.
restoreChain = attempt.catch(() => {});
await attempt;
}
/**
* Reactive audio controls hook.
*
@@ -427,6 +562,9 @@ export function useAudio(): AudioControls {
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
// Restore the last player session once at boot (loaded, not playing).
restoreLastSession().catch(() => {});
}
refCount++;
@@ -574,6 +712,7 @@ export function useAudio(): AudioControls {
availablePlayers,
play,
load,
pause,
resume,
togglePlayback,

View File

@@ -409,9 +409,15 @@ function createFeedStore() {
}
};
// Resolves once the persisted feeds are loaded and visible to feeds() —
// before the background refresh so boot-time consumers (player-session
// restore) don't wait on the network.
const { promise: feedsReady, resolve: resolveFeedsReady } =
Promise.withResolvers<void>();
(async () => {
const loadedFeeds = await loadFeedsFromFile();
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
resolveFeedsReady();
const loadedSources = await loadSourcesFromFile<PodcastSource>();
// The default "rss" placeholder source fabricated fake search results
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
@@ -565,6 +571,16 @@ function createFeedStore() {
return feeds().find((f) => f.id === feedId);
};
/** Find an episode by ID across all loaded feeds (undefined when the
* episode isn't in any loaded window, e.g. an unsubscribed show). */
const findEpisode = (episodeId: string): Episode | undefined => {
for (const feed of feeds()) {
const ep = feed.episodes.find((e) => e.id === episodeId);
if (ep) return ep;
}
return undefined;
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
@@ -670,10 +686,15 @@ function createFeedStore() {
selectedFeedId,
isLoadingMore,
/** Resolves once persisted feeds are loaded from disk (before the
* background refresh). */
whenReady: () => feedsReady,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
findEpisode,
getSelectedFeed,
hasMoreEpisodes,
isLoadingFeeds,

View File

@@ -53,11 +53,17 @@ async function initProgress(): Promise<void> {
setProgressMap(parsed);
}
// Fire-and-forget init
initProgress();
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
// consumers (e.g. player-session restore) can await the file load.
const progressInit = initProgress();
function createProgressStore() {
return {
/**
* Resolves once the persisted progress map has been loaded from disk.
*/
whenReady: () => progressInit,
/**
* Get progress for a specific episode.
*/

View File

@@ -219,9 +219,14 @@ export async function loadLastPlayerFromFile(): Promise<LastPlayerState | null>
}
}
/** Save the last-loaded-player marker (overwrite, fire-and-forget) */
/** Serialized marker-write chain: concurrent writes land in submission
* order, and callers can await the last one (tests read the file back
* deterministically). Mirrors updateConfig's write serialization. */
let lastPlayerWriteChain: Promise<void> = Promise.resolve();
/** Save the last-loaded-player marker (fire-and-forget) */
export function saveLastPlayerToFile(state: LastPlayerState): void {
(async () => {
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
try {
await ensureConfigDir();
await Bun.write(
@@ -231,7 +236,12 @@ export function saveLastPlayerToFile(state: LastPlayerState): void {
} catch {
// Silently ignore write errors
}
})();
});
}
/** Resolves once every marker write submitted so far has landed on disk. */
export function waitForLastPlayerWrite(): Promise<void> {
return lastPlayerWriteChain;
}
/** Synchronous variant for the process-exit teardown. `q` quits through

View File

@@ -0,0 +1,195 @@
/**
* restore-session.test.ts — "load the last player session at boot" feature.
*
* useAudio persists which episode is loaded in the player (on play/load/stop
* and synchronously at exit), and `restoreLastSession()` reloads it at boot
* PAUSED at its saved position — never autostarted — skipping episodes at or
* above 98% completion. The first play action must START the backend (a
* restored episode was never handed to it), not unpause a dead player.
*
* Strategy — the suite shares bun test workers across files, so OTHER test
* files' `mock.module("../src/hooks/useAudio")` leaks into this file's module
* registry, and store singletons may already exist. Hence:
* - the real useAudio is imported via a `?restore-test` query suffix, which
* bun treats as a distinct module identity and loads from disk, bypassing
* the suite's useAudio mock (verified: query-suffixed imports are not
* intercepted by mock.module);
* - feeds are injected through the REAL feed store's public addFeed() API
* against a local RSS server (no config seeding — works on whatever
* singleton state this worker holds), and progress through update().
* Audio is the no-op backend via PODTUI_AUDIO_BACKEND=none.
*/
import { test, expect, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-restore-"));
const DATA = mkdtempSync(join(tmpdir(), "podtui-restore-data-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "none";
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string {
const items = ["Episode One", "Episode Two"]
.map(
(title) => `<item>
<title>${title}</title>
<pubDate>2026-08-10T00:00:00Z</pubDate>
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Restore Test Show</title>
<description>restore-session test feed</description>
${items}
</channel></rss>`;
}
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
return new Response("not found", { status: 404 });
},
});
// ── Real modules (loaded after env + server are 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, restoreLastSession } = await import("../src/hooks/useAudio?restore-test");
const { useFeedStore } = await import("../src/stores/feed");
const { useProgressStore } = await import("../src/stores/progress");
const { saveLastPlayerToFile, waitForLastPlayerWrite } = await import(
"../src/utils/app-persistence"
);
const feedStore = useFeedStore();
const progressStore = useProgressStore();
// Subscribe to the local feed through the real store API.
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
const feed = await feedStore.addFeed(
{
id: feedUrl,
title: "Restore Test Show",
description: "restore-session test feed",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
"test-source",
);
if (!feed || feed.episodes.length < 2) {
throw new Error("test feed did not load two episodes");
}
const ep1 = feed.episodes[0];
const ep2 = feed.episodes[1];
// Simulate a previous session: ep1 loaded, 20% through, marker persisted.
progressStore.update(ep1.id, 120, 600);
saveLastPlayerToFile({ episodeId: ep1.id, timestamp: new Date() });
await waitForLastPlayerWrite();
/** Rewrite the marker as if a previous session had ended this way. */
async function writeMarker(episodeId: string | null): Promise<void> {
saveLastPlayerToFile({ episodeId, timestamp: new Date() });
await waitForLastPlayerWrite();
}
/** Read the current last-player marker from disk. */
async function readMarker(): Promise<{ episodeId: string | null } | null> {
const file = Bun.file(join(CONFIG, "podtui", "last-player.json"));
if (!(await file.exists())) return null;
return (await file.json()) as { episodeId: string | null };
}
afterAll(() => {
server?.stop(true);
rmSync(CONFIG, { recursive: true, force: true });
rmSync(DATA, { recursive: true, force: true });
});
// ── Tests ─────────────────────────────────────────────────────────────────
test("restore loads the last player episode paused, without autostart", async () => {
const audio = useAudio(); // boot trigger also fires restoreLastSession()
await restoreLastSession();
expect(audio.currentEpisode()?.id).toBe(ep1.id);
expect(audio.isPlaying()).toBe(false);
// Position reflects where playback will resume — the player tab shows it.
expect(audio.position()).toBe(120);
});
test("first play on a restored episode starts playback from saved progress", async () => {
const audio = useAudio();
expect(audio.currentEpisode()?.id).toBe(ep1.id);
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(true);
expect(audio.position()).toBe(120);
// A second toggle pauses (normal pause path) — no backend restart.
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(false);
});
test("restore skips episodes at or above 98% completion", async () => {
const audio = useAudio();
await audio.stop(); // clear the previously restored episode
await waitForLastPlayerWrite(); // stop()'s null marker has landed
await writeMarker(ep1.id); // stop() cleared the marker; restore needs it
// Exactly 98% — boundary: NOT restored.
progressStore.update(ep1.id, 588, 600);
await restoreLastSession();
expect(audio.currentEpisode()).toBeNull();
// Just under 98% — restored.
progressStore.update(ep1.id, 587, 600);
await restoreLastSession();
expect(audio.currentEpisode()?.id).toBe(ep1.id);
expect(audio.position()).toBe(587);
await audio.stop();
});
test("restore no-ops when the player was empty at last quit", async () => {
const audio = useAudio();
await writeMarker(null);
await restoreLastSession();
expect(audio.currentEpisode()).toBeNull();
});
test("restore no-ops when the episode is no longer in any feed", async () => {
const audio = useAudio();
await writeMarker("gone-ep");
await restoreLastSession();
expect(audio.currentEpisode()).toBeNull();
});
test("play persists the marker; stop clears it", async () => {
const audio = useAudio();
await audio.play(ep2);
await waitForLastPlayerWrite();
expect((await readMarker())?.episodeId).toBe(ep2.id);
await audio.stop();
await waitForLastPlayerWrite();
expect((await readMarker())?.episodeId).toBeNull();
});

View File

@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
currentEpisode: () => null,
availablePlayers: () => [],
play: async () => {},
load: async () => {},
pause: async () => {},
resume: async () => {},
togglePlayback: async () => {},