The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled the whole remote file even when only minutes were listened to. - audio-pcm-cache: sliding window around the playback position — the decode head caps at maxAheadSec (600s) ahead of the cursor, segments older than keepBehindSec (300s) are pruned, and the tail refills as playback advances. Steady state ~40 MB regardless of episode length; a backward seek past the window restarts a segment there (the existing seek-hole mechanism, no new failure mode). - feed: cap the full-parse episode cache at 1000 episodes/feed so archive-heavy subscriptions can't pin their entire history in RAM; the visible list stays bounded by the user's cache preference and fetch-more keeps working within the ceiling. - tests: pin the new head-cap and prune contracts (8/8 in audio-pcm-cache.test.ts; full suite 193 pass). Also includes the in-flight cleanup/refactor pass (cover-art resolve helper, page and comment tightening, ESLint config removal).
158 lines
3.7 KiB
TypeScript
158 lines
3.7 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(episodeId: string): Progress | undefined {
|
|
return progressMap()[episodeId];
|
|
},
|
|
|
|
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();
|
|
},
|
|
|
|
isCompleted(episodeId: string): boolean {
|
|
const p = progressMap()[episodeId];
|
|
if (!p || p.duration <= 0) return false;
|
|
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
|
},
|
|
|
|
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(): void {
|
|
setProgressMap({});
|
|
persist();
|
|
},
|
|
};
|
|
}
|
|
|
|
// Singleton instance
|
|
let instance: ReturnType<typeof createProgressStore> | null = null;
|
|
|
|
export function useProgressStore() {
|
|
if (!instance) {
|
|
instance = createProgressStore();
|
|
}
|
|
return instance;
|
|
}
|