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).
Add episodeCacheMode/count/days preferences (default: date, 60 days).
Apply the bound when reading instead of writing, so a preference change
takes effect without a refetch; the full parse cache stays intact so
fetch-more can page beyond the bound. Thread the window through
load/saveFeedsToFile and update tests and task docs.
Two gaps left cover art missing for the most common play paths:
- Boot-restore preloaded the episode with cachedCoverPath??undefined racing
the fire-and-forget prefetch; a cold preload + fast-path play re-applied
art via video-add, which produces a NON-albumart track (verified) that
mpv's Now Playing artwork logic ignores. load() now awaits the bounded
fetch (covers ~300ms, 8s cap) so the cover is present at load-time.
- Removed the dead video-add re-apply (fast path + addCoverArt method +
interface) and the play() late-add fallback; a cold-cache play now
prefetches for next time instead.
- FeedPage prefetches covers for the focus window (single-flight, cached)
so ordinary plays land on a warm cache.
Verified: preload with awaited cover -> albumart track present (2 tracks).
Persisted feeds keep only episodes from the last 30 days (plus completed
downloads); older episodes live in volatile memory and survive refreshes
via union merge, with per-feed in-memory caches capped at 500. Refresh
batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no
Promise.all barrier), config.json writes are trailing-edge debounced
(250ms, immediate flushPendingSave for unsubscribes), and cold
fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store
powers a global top-right indicator covering refresh, fetch-more,
subscribe, search, and downloads.
Also includes the in-flight incremental RSS parsing (chunked with
event-loop yields) and refresh spinner work this tree already carried.
The pinned mpv binary breaks on brew ffmpeg major drift (libavcodec.62 vs
.63 shipped a dead player); making it self-contained costs ~100MB for an
app icon. Removing the whole machinery: build.ts app-bundle assembly,
audio-player bundled-binary resolver + probe (spawns PATH mpv again),
CI mpv install + bundle smoke checks, AppIcon.icns, README note.
Cover-art staging (curl + --cover-art-files) is unrelated and stays.
ensureDecodeAround let a running decode pass close ANY forward gap in
place — at 4x pacing, skipping 30 min ahead meant ~7.5 min for the
frontier to arrive: bars held their last frame indefinitely. Now a gap
beyond 15s restarts the pass at the seek target (network coverage there
in ~1.6s, verified); smaller gaps close in place (cheaper than a
reconnect + range request). Seek-key holds are debounced (400ms) so
rapid re-seeks don't reconnect-spam the stream's server, and pending
seek-decode cancels on pause/stop so no ffmpeg restarts while paused.
Also fixes a pre-existing config-write race that intermittently failed
visualizer-toggle.test.ts: updateConfig re-resolved the config path and
re-read the patch state when the deferred write-chain drained, so a
queued save could land in a directory XDG_CONFIG_HOME had since been
pointed at (or carry state mutated after queueing). Path and patch
snapshot are now captured eagerly at call time.
Two fragility points, rebuilt at the root:
Playback: one resident mpv daemon (--idle --keep-open) with a persistent
IPC connection and observe_property state instead of spawn-per-episode and
connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at
~20Hz; external pauses arrive as events. Boot session restore preloads the
episode paused (loadfile + paused time-pos seek, since mpv defers --start
stream work until playback) so first Play is a ~400ms unpause instead of a
cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload
cannot clobber an in-flight play.
Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB)
so a paused preload no longer races to its 150MiB default (measured
45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start
isn't starved by the visualizer ripping the whole episode.
Visualization: replaced the paced-ring reader (AudioStreamReader) with a
position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed
by absolute playback time; reads at the player position are always exact.
Pause freezes the render loop, resume re-arms it — no coverage guessing,
no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks
and speed changes need no pipeline restarts; uncovered reads return empty
and the last frame holds.
Cover art: persistent per-URL disk cache under XDG cache dir; play() no
longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses
apply late via mpv video-add.
Test suite: 161 pass. New tests pin the position-index contract (sample-
exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments),
the daemon contract (play/pause/resume/seek/stop, preload fast path,
EOF->replay), and cover cache/single-flight/404.
The waveform's ffmpeg decode + cavacore FFT pipeline lived inside
RealtimeWaveform, so switching away from the Player tab unmounted it and
killed the pipeline instantly — respawning ffmpeg on every return.
Move the pipeline into a module-level store (stores/visualizer.ts) that
outlives the page:
- losing Player-tab focus keeps the pipeline warm for
VISUALIZER_UNLOAD_DELAY_MS (30s), then tears it down (kills ffmpeg,
destroys the cava plan); regaining focus within the delay resumes the
warm pipeline with no restart churn; after an unload it restarts from
the current playback position.
- playback signals move to utils/audio-signals.ts (module-level, no
useAudio() owner needed) so the store reacts to play/pause/seek/speed
while no Player page is mounted; useAudio re-imports them.
- render a braille spinner as the loading state for the visualizer
(first play / after unload); stale bars stay on screen during warmup
restarts so the waveform never blanks out for the network-bound cold
start.
- seed the smooth position clock at pipeline start: with the position
still frozen at 0 while mpv opens the stream, the reader sampled a
1-sample window that could never fill, starving the bars until mpv's
first poll.
Pins the store contract in tests/visualizer-store.test.ts: loading→bars,
warm resume without restart, 30s unload, and bars while position is
frozen at 0.
- build.ts: darwin compile now exits 1 when mpv is absent — CI can no
longer ship a PodTui.app without its bundled player (0.4.0 did, killing
Now Playing attribution).
- audio-player.ts: the bundled mpv is probed (--version) at first resolve;
it links against brew's dylibs, and a Homebrew ffmpeg major upgrade can
break it — fall back to PATH mpv so audio survives (icon degrades to
blank instead of playback dying). Probed once per process.
- release.yml: brew install mpv on darwin runners; smoke test now asserts
the tarball's PodTui.app has a launchable mpv signed with the
com.mikefreno.podtui identifier.
Store the playback volume in app settings (config.json) whenever it
changes and re-apply the previous session's level at boot, instead of
always starting at the old 70% fallback.
- AppSettings gains volume (default 1 = 100%); both default-settings
copies and the volume signal default are raised from 0.7 to 1.
- doSetVolume persists via the app store (mirrors playbackSpeed).
- The boot sync awaits the app store's async config load (new
whenReady()) so a persisted level is applied even when settings load
finishes after useAudio mounts.
- tests/volume-persistence.test.ts: default, clamp, and cross-session
reuse (fresh module instance simulates the next launch).
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.
Self-rescheduling refresh timer (default 30 min, configurable via a
Preferences item, re-read on every tick, skips in-flight refreshes).
fetchEpisodes returns null on network failure/timeout so a failed
refresh can never wipe a feed's episodes (addFeed/refreshFeed/
refreshAllFeeds all treat null as unchanged); feeds still refresh on
launch.
History used localStorage, which never exists in the Bun TUI, so nothing
survived a restart. Store the 10 most recent queries in config-dir
search-history.json (same fire-and-forget file pattern as audio-nav.json),
loaded asynchronously at store init. Dedupe case-insensitively, cap at 10.
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
Podcast Index (api.podcastindex.org) ships as a disabled, key-less source
and is only consulted as a fallback when primary search results are fewer
than 3 — never on the hot path, never when disabled or credential-less.
A failed fallback leaves primary results intact.
Credentials are user-supplied: enabling the source pops a dialog that
asks for the free key+secret, prefilled masked (first 3 chars + "...")
when already stored; toggling off never clears them. Secrets prefer the
macOS keychain (security CLI, encrypted at rest) with a plaintext
config.json fallback when the keychain is unavailable; sources carry only
a hasCredentials/credentialStorage marker, and legacy plaintext keys in
existing configs are migrated on load.
Auth follows the documented scheme: X-Auth-Key, X-Auth-Date (epoch) and
Authorization = sha1(key + secret + date). Dead feeds are filtered, feed
URLs are used directly, and episode-scope search is a no-op (no endpoint).
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.
Change the parent|current|preview split from 1:2:2 (20/40/40) to
2:5:3 (20/50/30) so the focused list gets more room. 2-pane tabs
now give current the combined 80%. Updates ratio comments and the
PaneRow test expectations.
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.
The decode head lagged the player by a constant ε (ffmpeg startup
latency) because ffmpeg paced at -readrate <speed> started behind mpv
and, advancing at the same rate, never caught up — bars were a few
seconds behind for the entire playback.
Add -readrate_initial_burst LEAD_SECONDS so ffmpeg emits 3s of audio
immediately on start, then paces at realtime after. The decode head
leads the player by a stable ~3s from the very first frame; read()
samples at the exact player position and always finds fresh samples.
Add a sustained render-loop test that simulates ~5s of real playback,
asserting ffmpeg stays alive, the decode head maintains a positive
lead, and read() returns full windows. Uses real wall-clock time
(documented exception) since ffmpeg's decode pacing can't be tested
deterministically.
The cover was previously only wired through the UI hook; --play skipped it.
Also: Bun's fetch hangs in compiled binaries (Bun 1.3.8) — every cover
download silently failed in shipped builds, timing out against any host.
Cover staging now shells out to curl (present on macOS/Linux, 8s bound),
shared via src/utils/cover-art.ts so the UI and CLI paths stage the same
temp file and pass it to mpv as --cover-art-files (albumart track).
mpv owns the macOS Now Playing session (it plays the audio), and an
unbundled binary renders as a blank placeholder for the source-app icon.
macOS has no public API for a third party to claim session ownership
(MPNowPlayingSession is iOS-only; the private MRMediaRemoteSetNowPlayingApplication
was removed from the shared cache), so instead we make the OWNING process
carry our bundle: the darwin tarball now includes PodTui.app with mpv copied
into Contents/MacOS. AudioPlayer resolves the sibling mpv first (falling
back to PATH), LaunchServices attributes the process to com.mikefreno.podtui,
and Control Center shows the PodTui icon + name with podcast cover art.
AppIcon.icns generated from the Xcode icon-composer exports.
Tabs, Discover categories (replacing unused placeholder glyphs), Settings
sections, and the Feed 'Fetch More' row get Nerd Font glyphs (Font Awesome PUA
codepoints). When the terminal font isn't Nerd Font capable the glyphs render
nothing at all — no tofu, no layout gaps — via supportsNerdFonts() (env
allowlist + PODTUI_NERD_FONTS=1/0 override). Documented in README (Configuration
-> Fonts).
- mpv: --force-media-title '<podcast> — <episode>' so macOS Now Playing shows
the podcast name instead of the download-hash filename (mediaTitle PlayOption)
- media registry: artist is now the human podcast title (customName ||
podcast.title), falling back to podcastId
- downloads: write the podcast cover as a <base>.jpg sibling so mpv's
cover-art-auto=exact picks it up for artwork; delete it with the download
- ProgressBar: full-width played/remaining bar in the player pane, click-to-seek;
waveform no longer handles seeking or the played/future color split (pure visual)
- bars: 2 terminal rows per bar (16 levels) via barChars, partial block in the
top row so the column renders continuously
- fix bars maxing at audio start: disable cava autosens (silence gain-ramp),
pre-warm the malloc'd FFT window with zeros, skip partial FFT windows
- createBarScaler peak follower + power curve replaces cava autosens for
level-to-height mapping (src/utils/bar-mapping.ts, unit-tested)
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.
Move the keybind router out of Shell into a reusable createDispatcher
in @/utils/dispatch, covering tabs (digits 1-6, [/]), h/l depth
drill/pop + fixed-pane swipe, modes, audio transport, quit/help, and
command, delegating pane/list actions over the nav.action event bus.
Shell now renders the active page full-width with a bottom status bar
carrying the tab strip; the old sidebar pane and its leftover sidebar
action handling are removed. Adds tests covering the dispatcher's
routing behavior.
Add the shared parent | current | preview row primitive that implements
yazi's mgr.ratio = [1, 3, 3] contract via Yoga flexGrow, so every list
tab renders an identical, layout-stable shell regardless of terminal
size.
- New YaziPaneRow component: three bordered columns grow at 1/7 : 3/7
: 3/7 with flexBasis=0 (content can never stretch its slot). The
current column carries the accent focus ring when `focused` is
truthy; parent and preview stay muted. The blank parent keeps its
1/7 slot with a muted placeholder rather than collapsing.
- PANE_RATIO.current 4 -> 3 to match yazi's [1, 3, 3].
- Add render-based tests covering the 1:3:3 ratios (incl. null
parent/preview) and the focused/unfocused accent ring behavior.
- Configure the bun test preload for the solid JSX transform.
- Extract navigation state and logic into navigation-store.ts for
testability and separation of concerns
- NavigationContext now only wraps the store as a Solid provider
- Remove SIDEBAR_PANE from the model; depth-tabs have a single
focusable content pane (center/current column)
- Split LayerGraph and page imports into layer-graph.ts so the
navigation utils stay free of JSX (unit-testable)
- Update Shell keybind dispatch: remove sidebar pane handling,
simplify swipe to fixed-pane tabs only, clarify depth-tab h/l
- Add nav-model.test.ts covering depth stack, tab/pane focus, and
selection semantics