From 1b55b7117ccce3f79c27eb82465d414fbe6f1fc7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 13:18:00 -0400 Subject: [PATCH] feat(player): restore the last player session at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/useAudio.ts | 143 ++++++++++++++++++++++++- src/stores/feed.ts | 21 ++++ src/stores/progress.ts | 10 +- src/utils/app-persistence.ts | 16 ++- tests/restore-session.test.ts | 195 ++++++++++++++++++++++++++++++++++ tests/search-focus.test.tsx | 1 + 6 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 tests/restore-session.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index b762d91..a3f21f8 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -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; + /** Load an episode into the player WITHOUT starting playback. */ + load: (episode: Episode) => Promise; pause: () => Promise; resume: () => Promise; togglePlayback: () => Promise; @@ -76,6 +83,23 @@ const [availablePlayers, setAvailablePlayers] = createSignal( [], ); +/** 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 { 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 { } } +/** + * 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 { + 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 { if (!backend) return; try { @@ -294,7 +376,15 @@ async function togglePlayback(): Promise { if (isPlaying()) { await pause(); } else if (currentEpisode()) { - await resume(); + 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 { 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 { 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 { } } +/** 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 = 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 { + 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, diff --git a/src/stores/feed.ts b/src/stores/feed.ts index f3fb9fa..d03b277 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -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(); (async () => { const loadedFeeds = await loadFeedsFromFile(); if (loadedFeeds.length > 0) setFeeds(loadedFeeds); + resolveFeedsReady(); const loadedSources = await loadSourcesFromFile(); // 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, diff --git a/src/stores/progress.ts b/src/stores/progress.ts index 7d80f31..a97f2cb 100644 --- a/src/stores/progress.ts +++ b/src/stores/progress.ts @@ -53,11 +53,17 @@ async function initProgress(): Promise { 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. */ diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 7cda74d..8aa9cd3 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -219,9 +219,14 @@ export async function loadLastPlayerFromFile(): Promise } } -/** 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 = 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 { + return lastPlayerWriteChain; } /** Synchronous variant for the process-exit teardown. `q` quits through diff --git a/tests/restore-session.test.ts b/tests/restore-session.test.ts new file mode 100644 index 0000000..efaa3cd --- /dev/null +++ b/tests/restore-session.test.ts @@ -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 | null = null; +function feedXml(origin: string): string { + const items = ["Episode One", "Episode Two"] + .map( + (title) => ` + ${title} + 2026-08-10T00:00:00Z + +`, + ) + .join("\n"); + return ` + +Restore Test Show +restore-session test feed +${items} +`; +} + +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 { + 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(); +}); diff --git a/tests/search-focus.test.tsx b/tests/search-focus.test.tsx index b02734c..00e4a18 100644 --- a/tests/search-focus.test.tsx +++ b/tests/search-focus.test.tsx @@ -51,6 +51,7 @@ const stubAudio: AudioControls = { currentEpisode: () => null, availablePlayers: () => [], play: async () => {}, + load: async () => {}, pause: async () => {}, resume: async () => {}, togglePlayback: async () => {},