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).
74 lines
2.0 KiB
TypeScript
74 lines
2.0 KiB
TypeScript
/**
|
|
* Activity store for PodTUI
|
|
*
|
|
* Shared leak-proof activity counter: any store can surface "something is
|
|
* loading/downloading" to the global top-right indicator. beginActivity
|
|
* returns an end token that removes exactly THAT instance, so concurrent
|
|
* overlapping activities compose correctly; prefer track() so callers
|
|
* cannot strand the counter.
|
|
*/
|
|
|
|
import { createSignal } from "solid-js";
|
|
|
|
function createActivityStore() {
|
|
const [count, setCount] = createSignal(0);
|
|
const [labels, setLabels] = createSignal<string[]>([]);
|
|
|
|
/** Begin a tracked activity and return its end function. Every begin
|
|
* MUST be paired with exactly one call of the returned end (via the
|
|
* token); prefer track() so the pairing is automatic. Duplicate labels
|
|
* are allowed — each end removes exactly one instance (found by
|
|
* indexOf). */
|
|
const beginActivity = (label: string): (() => void) => {
|
|
setLabels((prev) => [...prev, label]);
|
|
setCount((c) => c + 1);
|
|
let ended = false;
|
|
return () => {
|
|
if (ended) return;
|
|
ended = true;
|
|
setLabels((prev) => {
|
|
const idx = prev.indexOf(label);
|
|
if (idx === -1) return prev;
|
|
const next = [...prev];
|
|
next.splice(idx, 1);
|
|
return next;
|
|
});
|
|
setCount((c) => Math.max(0, c - 1));
|
|
};
|
|
};
|
|
|
|
/** Track a promise: begin an activity, auto-end when it settles, and
|
|
* re-throw on rejection so the caller's error handling is untouched. */
|
|
const track = async <T,>(p: Promise<T>, label: string): Promise<T> => {
|
|
const end = beginActivity(label);
|
|
try {
|
|
return await p;
|
|
} finally {
|
|
end();
|
|
}
|
|
};
|
|
|
|
/** True while at least one activity is in flight */
|
|
const isActive = (): boolean => count() > 0;
|
|
|
|
return {
|
|
// State
|
|
count,
|
|
labels,
|
|
// Actions
|
|
beginActivity,
|
|
track,
|
|
// Getters
|
|
isActive,
|
|
};
|
|
}
|
|
|
|
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
|
|
|
export function useActivityStore() {
|
|
if (!activityStoreInstance) {
|
|
activityStoreInstance = createActivityStore();
|
|
}
|
|
return activityStoreInstance;
|
|
}
|