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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user