search-time parseRSSFeed once embedded the full episode history
inside Feed.podcast (2,100+ stale copies, 3.8 MB of config). Nothing
reads it — feed.episodes is the source of truth — so load and save
now strip podcast.episodes, and searchByFeedUrl drops them at the
parse site.
Splitter strips widen to border +/- 1 help-padded column so the thin
border is easy to target; mousedown records the cursor's offset from
the border column and drag subtracts it, so the border tracks the
cursor instead of jumping. Padded columns must never overlap
interactive content (rect-based hit grid). Dead one-off render
harness scripts/_hv.ts removed. Tests cover far/inner-edge grabs
and no-resize on padded-column clicks.
Each Solid setBarData costs a renderer diff pass. Cava already
smooths (noise reduction + peak release), so 3 of every 4 frames now
update only the pipeline; the UI signal writes at >=95ms intervals.
cava_init/destroy churn leaks the old plan's FFTW work buffers —
upstream frees only its own struct. init() now serializes the config
and no-ops when unchanged, so pipeline restarts (focus/episode churn)
keep the live plan instead of leaking a new one each cycle.
episode-windows.ts: keep-fn, date-band walk (was copy-pasted twice),
sameRefreshWindow — one pure module. source-registry.ts owns source
CRUD + its persistence. Dead members with zero callers deleted:
updateFeed, togglePinned, removeSource, setFilter/selectedFeedId.
Feed persistence routed through the persist scheduler.
useAudio shrinks 928→152: backend lifecycle, poll, session restore,
crash recovery, queue advance and event-bus commands live in
createAudioEngine; the hook is a thin Solid adapter. audio-player:
preload of a dead URL stalls the load mutex 5s — end-file (open
failure) now races file-loaded.
Terminals answer OSC 10/11/12 color queries but never push changes, so
detect theme flips with a 60 s poll (legacy-tmux fallback for servers
< 3.6). Re-queries palette + default fg/bg, updates the system palette
and re-detects dark/light mode.
When a track reaches natural EOF (player alive, no stream error), play the
next episode from the source that started it — search results, show, or
Feed — and stop at the end of the list. A crashed/killed daemon or failed
stream never auto-advances.
- add audio-queue.ts: pure next/prev selection from the navigation source
- audio-player: expose getPlaybackError() to distinguish EOF from failure
- useAudio: finalizeTrackEnd(autoAdvance) wiring, re-selecting the current
episode no longer reloads from stale saved progress
- tests: audio-queue units, auto-advance integration (real mpv + local
WAVs over HTTP), backend re-select no-reload test
Resume re-arms a pipeline whose ffmpeg pass was killed at pause, so the
pre-pause bars are stale until fresh frames flow. Three changes:
- resumeVisualization always sets the loading state (previously only for
positions outside decoded coverage) and records the resume point;
renderFrame clears it only once the position clock advances past that
point — a player still re-buffering after a long pause keeps the
spinner instead of serving static cached bars.
- stopVisualization clears barData so cold restarts (unload, disable,
episode change) show the spinner rather than stale bars, and never
suppress it.
- renderFrame detects a frozen position clock while playing (STALL_DETECT_MS)
and surfaces it as a loading state; recovery clears it.
Tests: resume-into-undecoded-audio shows loading until bars land; frozen
position clock surfaces a stall and recovery clears it; disable/enable
pins barData cleared on stop and the restart loading flash.
Replace the hand-rolled worker pool (mapWithConcurrency) with an Effect
program (src/effects/feed-refresh.ts): Effect.forEach bounds in-flight
fetches, Effect.timeout bounds each feed via the Clock service, and
failures fold to null so a bad feed never fails the batch. Per-feed
apply-as-it-lands is preserved — the apply callback runs inside each
feed's own fiber, so there is no Promise.all barrier.
Store boundary unchanged: refreshAllFeeds runs the program through
Effect.runPromise, keeping runAutoDownload + flushPendingSave after the
batch and isLoadingFeeds around it.
Adds TestClock-driven tests (tests/feed-refresh-effect.test.ts) that pin
concurrency, per-feed apply, timeout, and failure isolation without real
20s waits. Pins effect@^3 (V4 is in beta).
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.
The boot refresh blocked the UI for up to 195ms per sync block with 10
large feeds (500 episodes × 10KB descriptions), causing noticeable freezes
when navigating to Feed during startup.
Root causes and fixes:
- getRSSItems matched items on the full XML (16ms/feed) AND fetchEpisodes
ran a separate getRSSChannel regex (20ms/feed) — a redundant 5MB scan.
Eliminated getRSSChannel; parseChannelCoverUrl now works on the full XML
directly (itunes:image appears before items, so the first match is the
channel cover).
- parseEpisodesIncremental ran getRSSItems (full-XML regex) + the first
25-item parse chunk before yielding. Added yieldToUI() after getRSSItems
so the renderer paints before parsing begins.
- Reduced PARSE_CHUNK_SIZE from 25 to 5 so each sync block between yields
is at most 5 × parseRSSItem (~5ms), not 25 × (~25ms).
- Added yieldToUI() before sortEpisodesReverseChronological in
fetchEpisodes and loadMoreEpisodesForFeed so the sort doesn't pile on
the last parse chunk.
- Added yieldToUI() after response.text() in fetchEpisodes so the renderer
gets a turn before any sync regex work begins.
Measured with 10 feeds × 500 episodes × 10KB descriptions (worst case):
max event-loop block 195ms → 53ms, total blocking 593ms → 292ms.
The card put the podcast name in the title slot ('Podcast — Episode'
prefix truncating on long names) with an empty artist slot for local
playback — the downloaded files carry no artist tag. Now:
- mediaTitle is the episode title only (UI + CLI); streams keep their
artist from stream tags, matching Apple Podcasts' title/artist layout.
- downloads are tagged at completion (ffmpeg -c copy, atomic rename):
title=episode, artist=podcast — verified on a real 72MB file in 1.5s.
The download wrote its sibling .jpg with Bun fetch — which hangs in
compiled binaries, so shipped builds never produced offline cover art.
Now curl (same flags as the cover cache). Also falls back to the
episode's own image when the feed has no channel cover (URL-added
feeds like The Fifth Column). mpv --cover-art-auto=exact picks up the
same-basename .jpg (verified against mpv 0.41).
The Fifth Column (and any feed added by URL) had NO coverUrl — the RSS
parser never captured channel artwork, so Now Playing had nothing to show.
- rss-parser: parseChannelCoverUrl (<itunes:image href> / RSS2 <image><url>);
parseRSSFeed sets it on the Podcast.
- feed store: fetchEpisodes returns the channel cover; subscribe + both
refresh paths backfill coverUrl when missing (no second fetch).
- useAudio + CLI --play: cover resolves feed.podcast.coverUrl ?? episode.imageUrl,
so episodes without channel art still get their own image.
- useAudio play/load/switchBackend: prefer the downloaded file
(getDownloadedFilePath) over the stream URL — downloaded episodes now
play from disk.
Verified: Fifth Column episode, cold cache -> cover-art-files set at load
-> albumart track present.
loadMoreEpisodesForFeed's hot-cache path ran fully synchronously (no
await between getFeed and setFeeds), so the isLoadingMore spinner never
painted and keyboard input froze through every feed in a loadMoreAllFeeds
batch. Add await yieldToUI() before the setFeeds so the renderer gets a
macrotask turn to paint and process input between feeds.
Drop the "Loading…/Refreshing…/Downloading N" label text from the global
activity indicator — render the braille spinner only, per request.
Prefetch alone can't cover every first play (any surface, feeds outside
the FeedPage focus window, quick plays) — the live session showed
cover-art-files empty with zero albumart tracks on a cold-cache play.
play() now serves the disk cache synchronously and, on a miss, awaits the
single-flight fetch with a 1.2s cap (covers fetch in ~300ms typically);
past the cap it plays bare and warms the cache. Verified: cold-cache play
-> cover present at load -> albumart track.
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.