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:
@@ -26,7 +26,12 @@ import { emit, on } from "../utils/event-bus";
|
|||||||
import { useAppStore } from "../stores/app";
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress";
|
import { useProgressStore } from "../stores/progress";
|
||||||
import { useMediaRegistry } from "../utils/media-registry";
|
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 type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
@@ -45,6 +50,8 @@ export interface AudioControls {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (episode: Episode) => Promise<void>;
|
play: (episode: Episode) => Promise<void>;
|
||||||
|
/** Load an episode into the player WITHOUT starting playback. */
|
||||||
|
load: (episode: Episode) => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
resume: () => Promise<void>;
|
resume: () => Promise<void>;
|
||||||
togglePlayback: () => 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 {
|
function ensureBackend(): AudioBackend {
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
const detected = detectPlayers();
|
const detected = detectPlayers();
|
||||||
@@ -101,6 +125,17 @@ function registerExitTeardown(): void {
|
|||||||
exitTeardownRegistered = true;
|
exitTeardownRegistered = true;
|
||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
stopPolling();
|
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 {
|
try {
|
||||||
backend?.dispose();
|
backend?.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -228,6 +263,11 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
setSpeed(spd);
|
setSpeed(spd);
|
||||||
if (episode.duration) setDuration(episode.duration);
|
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
|
// Register with platform media controls
|
||||||
const media = useMediaRegistry();
|
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> {
|
async function pause(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
@@ -294,7 +376,15 @@ async function togglePlayback(): Promise<void> {
|
|||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await pause();
|
await pause();
|
||||||
} else if (currentEpisode()) {
|
} else if (currentEpisode()) {
|
||||||
|
if (startedPlayback) {
|
||||||
await resume();
|
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);
|
setIsPlaying(false);
|
||||||
setPosition(0);
|
setPosition(0);
|
||||||
setCurrentEpisode(null);
|
setCurrentEpisode(null);
|
||||||
|
startedPlayback = false;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
|
// Player is empty again — nothing to restore on the next launch.
|
||||||
|
saveLastPlayerToFile({ episodeId: null, timestamp: null });
|
||||||
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -402,6 +496,7 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
startedPlayback = true;
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
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.
|
* Reactive audio controls hook.
|
||||||
*
|
*
|
||||||
@@ -427,6 +562,9 @@ export function useAudio(): AudioControls {
|
|||||||
if (storeSpeed && storeSpeed !== speed()) {
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
setSpeed(storeSpeed);
|
setSpeed(storeSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the last player session once at boot (loaded, not playing).
|
||||||
|
restoreLastSession().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
refCount++;
|
refCount++;
|
||||||
@@ -574,6 +712,7 @@ export function useAudio(): AudioControls {
|
|||||||
availablePlayers,
|
availablePlayers,
|
||||||
|
|
||||||
play,
|
play,
|
||||||
|
load,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
|
|||||||
@@ -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 () => {
|
(async () => {
|
||||||
const loadedFeeds = await loadFeedsFromFile();
|
const loadedFeeds = await loadFeedsFromFile();
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
|
resolveFeedsReady();
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
// The default "rss" placeholder source fabricated fake search results
|
// The default "rss" placeholder source fabricated fake search results
|
||||||
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
|
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
|
||||||
@@ -565,6 +571,16 @@ function createFeedStore() {
|
|||||||
return feeds().find((f) => f.id === feedId);
|
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 */
|
/** Get selected feed */
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
const id = selectedFeedId();
|
const id = selectedFeedId();
|
||||||
@@ -670,10 +686,15 @@ function createFeedStore() {
|
|||||||
selectedFeedId,
|
selectedFeedId,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
|
||||||
|
/** Resolves once persisted feeds are loaded from disk (before the
|
||||||
|
* background refresh). */
|
||||||
|
whenReady: () => feedsReady,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
getFilteredFeeds,
|
getFilteredFeeds,
|
||||||
getAllEpisodesChronological,
|
getAllEpisodesChronological,
|
||||||
getFeed,
|
getFeed,
|
||||||
|
findEpisode,
|
||||||
getSelectedFeed,
|
getSelectedFeed,
|
||||||
hasMoreEpisodes,
|
hasMoreEpisodes,
|
||||||
isLoadingFeeds,
|
isLoadingFeeds,
|
||||||
|
|||||||
@@ -53,11 +53,17 @@ async function initProgress(): Promise<void> {
|
|||||||
setProgressMap(parsed);
|
setProgressMap(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget init
|
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
|
||||||
initProgress();
|
// consumers (e.g. player-session restore) can await the file load.
|
||||||
|
const progressInit = initProgress();
|
||||||
|
|
||||||
function createProgressStore() {
|
function createProgressStore() {
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Resolves once the persisted progress map has been loaded from disk.
|
||||||
|
*/
|
||||||
|
whenReady: () => progressInit,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get progress for a specific episode.
|
* Get progress for a specific episode.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 {
|
export function saveLastPlayerToFile(state: LastPlayerState): void {
|
||||||
(async () => {
|
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await ensureConfigDir();
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
@@ -231,7 +236,12 @@ export function saveLastPlayerToFile(state: LastPlayerState): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// 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
|
/** Synchronous variant for the process-exit teardown. `q` quits through
|
||||||
|
|||||||
195
tests/restore-session.test.ts
Normal file
195
tests/restore-session.test.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
|
|||||||
currentEpisode: () => null,
|
currentEpisode: () => null,
|
||||||
availablePlayers: () => [],
|
availablePlayers: () => [],
|
||||||
play: async () => {},
|
play: async () => {},
|
||||||
|
load: async () => {},
|
||||||
pause: async () => {},
|
pause: async () => {},
|
||||||
resume: async () => {},
|
resume: async () => {},
|
||||||
togglePlayback: async () => {},
|
togglePlayback: async () => {},
|
||||||
|
|||||||
Reference in New Issue
Block a user