Files
PodTui/src/stores/activity.ts
Michael Freno d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
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).
2026-08-12 21:02:19 -04:00

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;
}