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.
173 lines
4.0 KiB
TypeScript
173 lines
4.0 KiB
TypeScript
/**
|
|
* Episode progress store for PodTUI
|
|
*
|
|
* Persists per-episode playback progress to a JSON file in XDG_CONFIG_HOME.
|
|
* Tracks position, duration, completion, and last-played timestamp.
|
|
*/
|
|
|
|
import { createSignal } from "solid-js";
|
|
import type { Progress } from "../types/episode";
|
|
import {
|
|
loadProgressFromFile,
|
|
saveProgressToFile,
|
|
} from "../utils/app-persistence";
|
|
|
|
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
|
const COMPLETION_THRESHOLD = 0.95;
|
|
|
|
/** Minimum seconds of progress before persisting */
|
|
const MIN_POSITION_TO_SAVE = 5;
|
|
|
|
// --- Singleton store ---
|
|
|
|
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
|
|
{},
|
|
);
|
|
|
|
/** Persist current progress map to file (fire-and-forget) */
|
|
function persist(): void {
|
|
saveProgressToFile(progressMap());
|
|
}
|
|
|
|
/** Parse raw progress entries from file, reviving Date objects */
|
|
function parseProgressEntries(
|
|
raw: Record<string, unknown>,
|
|
): Record<string, Progress> {
|
|
const result: Record<string, Progress> = {};
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
const p = value as Record<string, unknown>;
|
|
result[key] = {
|
|
episodeId: p.episodeId as string,
|
|
position: p.position as number,
|
|
duration: p.duration as number,
|
|
timestamp: new Date(p.timestamp as string),
|
|
playbackSpeed: p.playbackSpeed as number | undefined,
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function initProgress(): Promise<void> {
|
|
const raw = await loadProgressFromFile();
|
|
const parsed = parseProgressEntries(raw as Record<string, unknown>);
|
|
setProgressMap(parsed);
|
|
}
|
|
|
|
// 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.
|
|
*/
|
|
get(episodeId: string): Progress | undefined {
|
|
return progressMap()[episodeId];
|
|
},
|
|
|
|
/**
|
|
* Get all progress entries.
|
|
*/
|
|
all(): Record<string, Progress> {
|
|
return progressMap();
|
|
},
|
|
|
|
/**
|
|
* Update progress for an episode. Only persists if position is meaningful.
|
|
*/
|
|
update(
|
|
episodeId: string,
|
|
position: number,
|
|
duration: number,
|
|
playbackSpeed?: number,
|
|
): void {
|
|
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
|
|
|
|
setProgressMap((prev) => ({
|
|
...prev,
|
|
[episodeId]: {
|
|
episodeId,
|
|
position,
|
|
duration,
|
|
timestamp: new Date(),
|
|
playbackSpeed,
|
|
},
|
|
}));
|
|
persist();
|
|
},
|
|
|
|
/**
|
|
* Check if an episode is completed.
|
|
*/
|
|
isCompleted(episodeId: string): boolean {
|
|
const p = progressMap()[episodeId];
|
|
if (!p || p.duration <= 0) return false;
|
|
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
|
},
|
|
|
|
/**
|
|
* Get progress percentage (0-100) for an episode.
|
|
*/
|
|
getPercent(episodeId: string): number {
|
|
const p = progressMap()[episodeId];
|
|
if (!p || p.duration <= 0) return 0;
|
|
return Math.min(100, Math.round((p.position / p.duration) * 100));
|
|
},
|
|
|
|
/**
|
|
* Mark an episode as completed (set position to duration).
|
|
*/
|
|
markCompleted(episodeId: string): void {
|
|
const p = progressMap()[episodeId];
|
|
const duration = p?.duration ?? 0;
|
|
setProgressMap((prev) => ({
|
|
...prev,
|
|
[episodeId]: {
|
|
episodeId,
|
|
position: duration,
|
|
duration,
|
|
timestamp: new Date(),
|
|
playbackSpeed: p?.playbackSpeed,
|
|
},
|
|
}));
|
|
persist();
|
|
},
|
|
|
|
/**
|
|
* Remove progress for an episode (e.g. "mark as new").
|
|
*/
|
|
remove(episodeId: string): void {
|
|
setProgressMap((prev) => {
|
|
const next = { ...prev };
|
|
delete next[episodeId];
|
|
return next;
|
|
});
|
|
persist();
|
|
},
|
|
|
|
/**
|
|
* Clear all progress data.
|
|
*/
|
|
clear(): void {
|
|
setProgressMap({});
|
|
persist();
|
|
},
|
|
};
|
|
}
|
|
|
|
// Singleton instance
|
|
let instance: ReturnType<typeof createProgressStore> | null = null;
|
|
|
|
export function useProgressStore() {
|
|
if (!instance) {
|
|
instance = createProgressStore();
|
|
}
|
|
return instance;
|
|
}
|