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).
Episode search results gain d (download), D (delete), x (unsubscribe)
and enter (play for subscribed shows); downloads of unsubscribed shows
are recorded with the show's metadata under a deterministic synthetic
feed id and listed under an "Unsubscribed Show Downloads" section in
My Shows and the settings Download Manager. Classified at render time
by feed id or feed URL, so subscribing re-classifies the downloads and
unsubscribing purges them.
Search now covers individual episodes, not just shows: the iTunes
Search API (entity=podcastEpisode) matches episode titles and show
notes, so a guest or topic finds the episodes they appear in across
shows. Enter on an episode result subscribes to the parent show.
- types: SearchResult becomes a kind-discriminated union
(podcast | episode); EpisodeSearchResult carries the parent show so
existing consumers compile unchanged
- source-searcher: searchEpisodesByType (RSS/CUSTOM return []),
buildItunesEpisodeUrl, cleanDescription (HTML -> text),
mapItunesEpisodeResult (episode id/duration ms->s/audioUrl, reuses
mapItunesResult so delisted shows keep a directoryUrl)
- search: searchEpisodes with an 'episode' cache/dedupe namespace so
shows and episodes for the same query never mix
- stores/search: scope signal (podcast | episode) persisted to
podtui_search_scope; search() branches on scope
- SearchPage: Shows/Episodes pills row with 'tab to toggle', scope-
aware placeholder/empty state/result rows (episode row = title +
Show · date) and preview; toggling re-runs the current query
- keybinds: search-scope-toggle bound to tab (keybinds.jsonc AND the
runtime DEFAULT_KEYBINDS merge so the binding exists for users with
a pre-existing config file); while the input is focused the Shell
router never sees Tab, so the input handles it via onKeyDown +
preventDefault (no double-toggle: the router path only fires when
the input is defocused)
- Shell help overlay documents [tab] shows/episodes
Shows that left Apple Podcasts (e.g. Daily Wire's in 2021) come back from
the iTunes Search API as metadata-only stub records with feedUrl null.
mapItunesResult dropped them, so The Ben Shapiro Show — the #1 hit for
'ben shapiro' — never appeared in search while sibling shows did.
- Keep feedUrl-less results (feedUrl "" + directoryUrl pointing at the
Apple page) so delisted shows stay findable.
- Resolve the real feed from the Apple page at subscribe time
(itunes-feed-resolver: anchor on the collection's adamId, forward-scan
for the embedded feedUrl; Apple serves page variants where the
showOffer block sits thousands of chars after the adamId).
- addFeed refuses feedless stubs whose feed can't be resolved instead of
adding a broken feed; SearchPage surfaces the failure via toast.
- Tests: stub mapping, extractor variants, and an end-to-end subscribe
over a local HTTP server.
Adds a Selection Marker toggle under Settings → Preferences that
controls the ❯ cursor glyph on the focused row of every list.
Default off; rows keep a leading space for alignment either way.
Every list pane (tab strip, feed, my shows, discover, search,
settings, whitelist editor) reads the glyph through the shared
useSelectionMarker hook.
Also aligns the lead column across panes: page list rows drop
their extra box paddingLeft so labels start at the same column
as the tab strip (2 cells without nerd icons, 4 with), keeping
episode sub-rows aligned to the title.
Whitelist editor (Preferences > Auto Download Whitelist):
- input focus driven by nav.inputFocused() (SearchPage pattern) so Esc
deterministically defocuses and j/k/Space/Enter browse the suggestions
- suggestions react to mouse: click focuses the row and toggles membership
- no accent bg / no marker on any suggestion while the input is focused
- s re-enters typing mode; suggestion rows scroll into view
Search page: recent-searches rows get no bg and no marker while the query
input is focused (previously the focused row fell into the inactive branch
and rendered the border color as a background).
My Shows: w toggles the focused show in/out of the auto-download whitelist
at the show list (depth 0), with a whitelist marker on the row and a hint in
the preview pane (scope: whitelist).
Loading indicators: the braille spinner now carries a contextual label
(Refreshing…, Fetching…, Loading more…, Discovering…, Searching…) and is
shown in every loading state that previously rendered nothing — Discover
results, Search results fallback, and the empty Feed list.
Feed pagination: a focusable "[Fetch More]" row at the bottom of the flat
feed list advances every feed's loaded window by 50 episodes via the new
loadMoreAllFeeds/hasMoreAcrossAll store API. Behavior is a setting
(Fetch More: manual|auto, default manual) persisted in config.json; auto
fetches when focus reaches the bottom row. The button row is excluded
from episode focus so no episode is double-highlighted while it is active.
- Current pane gets muted left/right borders only (no full box, no accent
ring); border colors are passed only when a border is requested, since
opentui flips borderless boxes to bordered when borderColor is supplied.
- Remove the Up / <current tab> / Detail titles above the panes; the current
pane's title now renders once, top-left in the parent column's header slot.
- Drop the parentLabel/previewLabel props from PaneRow and all callers.
- Remove the tab/depth indicator from the bottom-left of the status bar.
- Tests measure column widths from the border glyphs and assert the
left/right edges render muted regardless of focus.