66 Commits

Author SHA1 Message Date
48076fcef5 bump VERSION to 0.9.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 8s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-09-06 18:50:47 -04:00
06c5cc9184 chore(scroll): typed alias for wrapped wheel handler
onMouseEvent is untyped on ScrollBoxRenderable.prototype; cast once
at capture instead of loosening call sites.
2026-09-06 18:46:42 -04:00
a3641d100e perf(download): stream response body straight to file
Bun.file().writer() per chunk replaces the in-memory chunk array +
final concat copy: no more whole-episode buffering.
2026-09-06 18:46:42 -04:00
8a173a5180 fix(feed): stop persisting legacy podcast.episodes
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.
2026-09-06 18:46:42 -04:00
132d2079f7 fix(pane): 3-column grab zones with cursor offset
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.
2026-09-06 18:46:42 -04:00
b53d4add29 test(scratch): leak-hunt probes for cava, render loop, decode stream
One-off diagnostics behind the FFTW leak fix: init/destroy x50 RSS
bound, Mach VM region walker, render-churn region growth, ffmpeg
decode-stream region/rss sampling.
2026-09-06 18:46:42 -04:00
6c3ad5d925 perf(visualizer): write bar data at ~10fps
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.
2026-09-06 18:46:42 -04:00
09d5732b55 fix(cava): identical init re-uses the live plan
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.
2026-09-06 18:46:42 -04:00
9e2f232d27 chore(harness): drop dead selectedFeedId read 2026-09-03 08:33:50 -04:00
9143078b12 refactor(feed): episode windows, source registry, dead interface
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.
2026-09-03 08:33:32 -04:00
ca46de4d70 refactor(audio): engine module absorbs the hook
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.
2026-09-03 08:33:24 -04:00
3e90f9e783 refactor(feed): single RSS client closes search timeout gap
fetchFeedXml owns headers + 20s timeout; both hand-rolled fetches in
feed.ts (fetchEpisodes, load-more cold path) and searchByFeedUrl route
through it — direct-URL search hung indefinitely before.
2026-09-03 08:33:19 -04:00
c2ec356a5f refactor(persist): one per-domain persist scheduler
stores/persist.ts: createPersistScheduler — trailing-edge debounce,
flush, per-domain isolation. search-history writes collapse from
per-keystroke to one debounced write; scope save stays direct.
2026-09-03 08:33:11 -04:00
6aac138629 bump VERSION to 0.8.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5s
release / Attach to GitHub Release (push) Has been cancelled
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
2026-08-28 21:58:31 -04:00
3f0001b0d5 fix inner scroll behavior 2026-08-28 21:58:11 -04:00
74158d75d4 fix border resizing ui 2026-08-28 19:45:14 -04:00
4ff8aabb51 bump VERSION to 0.7.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 3s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-27 13:47:06 -04:00
895138357f visualizer fixes 2026-08-27 13:46:41 -04:00
02c957f584 fix curl flag 2026-08-26 21:56:26 -04:00
d7ceb9d045 bump VERSION to 0.7.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m17s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-19 09:42:57 -04:00
0677f82c44 docs(readme): document auto-advance and mpv tarball install note 2026-08-17 20:50:14 -04:00
4990eae60f feat(theme): poll terminal OSC colors to track live theme changes
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.
2026-08-17 20:50:14 -04:00
9ddfd21685 feat(audio): auto-advance to next episode in source queue on track end
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
2026-08-17 20:50:05 -04:00
22059c24ca fix(visualizer): show loading spinner on resume until fresh bars arrive
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.
2026-08-13 21:04:12 -04:00
0aac0a157f bump VERSION to 0.7.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m15s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-13 19:35:13 -04:00
df4701957b refactor(feed): port refresh batch to Effect for concurrency and timeout
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).
2026-08-13 19:34:30 -04:00
4b44623891 fix(tests): stop feed-store mock.module leak that broke the full suite
discover-store-preview mocked src/stores/feed via mock.module, which bun
applies process-globally: with workers reused across test files, every file
that later shared a worker imported the stub (fetchEpisodes only) and failed
with 'addFeed is not a function' — ~35 tests, drifting run to run with worker
scheduling. Rewrote the test against the REAL feed store and a local gated
Bun.serve server (repo-dominant harness), importing the discover store via a
query-suffixed specifier so a sibling discover-store mock cannot leak in.
Full suite: 215 pass, 0 fail (baseline: 194).
2026-08-13 18:01:40 -04:00
4ef9ab7e59 perf(ui): render only a bounded window around the focused row 2026-08-13 17:46:30 -04:00
9df8eebf6c feat(discover): drill into show episode previews without subscribing 2026-08-13 17:46:30 -04:00
badbc6a037 fix(audio): recover playback when the mpv daemon dies or restarts 2026-08-13 17:46:30 -04:00
878d1e01ab feat(feed): signature-aware volatile merge with date-banded fetch-more 2026-08-13 17:46:27 -04:00
42c48e59fb feat(feed): stable episode ids derived from guid or enclosure URL 2026-08-13 17:46:26 -04:00
91d4acca90 fix: fetch more respects episode cache mode 2026-08-12 22:28:13 -04:00
20d5b57cb6 bump VERSION to 0.6.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m9s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 22:12:40 -04:00
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
77531ce41d bump VERSION to 0.6.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m21s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 16:34:54 -04:00
26729fa5e6 feat(feed): make episode cache bound user-configurable (date window or count)
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.
2026-08-12 15:41:22 -04:00
4127fd1181 bump VERSION to 0.6.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 28s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 12:57:57 -04:00
6c99b96b12 fix(feed): eliminate event-loop blocking during feed refresh
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.
2026-08-12 12:50:01 -04:00
acbaf2ed1c fix(download): keep .mp3 extension on the ffmpeg tag temp file
ffmpeg infers the muxer from the extension; '.tag' made every download's
tag step fail with 'Unable to choose an output format'.
2026-08-12 11:11:10 -04:00
e7ed89056e fix(now-playing): episode as title, podcast as artist on both paths
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.
2026-08-12 11:10:07 -04:00
b5432e3e5f fix(download): sibling cover via curl + episode image fallback
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).
2026-08-12 10:59:06 -04:00
13664c3cec fix(cover+downloads): channel art parsing, episode image fallback, local playback
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.
2026-08-12 10:52:54 -04:00
0b4a551744 fix(feed): unblock UI during fetch-more and drop indicator label text
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.
2026-08-12 10:47:02 -04:00
ed75c2fff7 fix(cover): await bounded cover fetch on cold-cache play
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.
2026-08-12 10:44:20 -04:00
bd7d988741 fix(cover): art reaches Now Playing on first play (restore + cold cache)
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).
2026-08-12 10:22:49 -04:00
deac6081ca feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh
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.
2026-08-12 10:13:20 -04:00
e09ae15e32 bump VERSION to 0.5.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m7s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 08:10:12 -04:00
33af131b77 revert(macos): drop PodTui.app / bundled-mpv Now Playing attribution
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.
2026-08-11 23:09:44 -04:00
3d6d4918bc bump VERSION to 0.5.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m14s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 21:13:01 -04:00
35ad858d0d Default fetch-more mode to auto 2026-08-11 21:12:27 -04:00
af827a9a96 fix(visualizer): bars recover after far-forward seeks into undecoded audio
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.
2026-08-11 21:08:09 -04:00
2cf9559b0b bump VERSION to 0.5.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m25s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 19:54:36 -04:00
20336ea716 feat(audio): rebuild playback + visualization on resident daemon and PCM cache
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.
2026-08-11 19:54:05 -04:00
8b7b38276e feat(player): toggleable waveform visualizer in settings, default on 2026-08-11 14:15:48 -04:00
8496922aaf feat(player): waveform pipeline survives tab switches — 30s unload grace + braille spinner loading
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.
2026-08-11 14:07:58 -04:00
5e3ad48a2d feat(audio): reconcile externally-initiated pause/resume via live mpv pause state
mpv can pause or resume OUTSIDE PodTUI — system sleep/lock, AirPod
removal, device swap, OS media keys, the Now Playing center. The poll
previously only reflected commands PodTUI sent, so the UI stayed stuck
on "playing" (or "paused") with a frozen position clock.

The poll now reads mpv's live pause state each tick: an external pause
reconciles the UI to paused (persisting progress, syncing media
controls) while keeping the poll armed; an external resume brings the
UI back to playing. A paused player is polled at a throttled rate
(PAUSE_WATCH_TICKS) so the watch costs ~1 IPC read per second instead
of hammering mpv; a dead process (track end / crash) finalizes the
track.
2026-08-11 14:07:43 -04:00
005ac8fde3 feat(search): stream unsubscribed episodes directly; a subscribes in place 2026-08-11 14:01:46 -04:00
1f0b9de456 fix(macos): derive bundle Info.plist version from VERSION constant
The release tag bumps VERSION in src/index.tsx, but build.ts hardcoded
0.3.1 in the app bundle's plist — shipped bundles reported a stale version
(lsappinfo/System Settings). Extract VERSION at bundle-assembly time and
interpolate into CFBundleShortVersionString/CFBundleVersion.
2026-08-11 13:48:23 -04:00
3388757185 fix(macos): hard-fail bundle without mpv, PATH fallback, CI mpv install
- 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.
2026-08-11 13:37:43 -04:00
8049d02457 feat(player): persist volume across sessions, default to 100%
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).
2026-08-11 13:30:01 -04:00
1b55b7117c feat(player): restore the last player session at boot
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.
2026-08-11 13:18:00 -04:00
15f8a098b5 feat(download): unsubscribed-show downloads from search
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.
2026-08-11 13:11:32 -04:00
2d7d49b91c feat(feed): periodic background refresh with failed-fetch guard
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.
2026-08-11 13:11:20 -04:00
df9c519439 feat(search): persist recent searches between sessions
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.
2026-08-11 12:45:43 -04:00
2bf1c229c7 Require shift for speed cycle keybind (s -> S) 2026-08-11 12:22:44 -04:00
122 changed files with 13483 additions and 2939 deletions

View File

@@ -1,11 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: {
es2022: true,
node: true,
},
ignorePatterns: ["dist", "node_modules"],
}

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.harness/
.ralpi
notes.md
# pygienium run-state and check artifacts
.pygienium/

View File

@@ -15,7 +15,7 @@
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting
- `bun run lint` - Run ESLint with TypeScript rules
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
## Code Style Guidelines

View File

@@ -16,6 +16,8 @@ external player with full transport control — all from your terminal.
- **Search** across your subscribed shows.
- **Audio playback** through an external player with full transport control:
play/pause, next/previous, seek, speed, and per-episode resume progress.
When an episode finishes, the next one plays automatically, continuing
down the list you started it from (search results, a show, or the Feed).
- **Themeable** and **remappable keybindings**.
- Ships as a **standalone compiled binary** — no runtime or install step beyond
a system audio player.
@@ -54,14 +56,6 @@ Linux (arm64/x64). Pick whichever fits your platform.
brew install mikefreno/tap/podtui
```
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
through a copy of mpv that lives **inside the bundle**, so macOS attributes
the Now Playing session to PodTui — the Control Center / lock-screen entry
shows the PodTui name and icon, and podcast cover art as its artwork —
rather than a blank placeholder for an unbundled binary. Installers can drop
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
### 2. Standalone tarball (all platforms)
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
@@ -69,7 +63,7 @@ Grab `podtui-<platform>-<arch>.tar.gz` from the latest
put `podtui` on your `PATH`:
```bash
curl -sS -o /tmp/podtui.tar.gz \
curl -sSL -o /tmp/podtui.tar.gz \
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
sudo mkdir -p /opt/podtui
sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1
@@ -216,7 +210,9 @@ entry. Releases are compiled with bunfig autoload disabled
entirely. If you still hit it, you're on an old release — upgrade.
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
`PATH`. Homebrew and AUR installs pull it in automatically; if you used the
standalone tarball, install it yourself (`brew install mpv`, `pacman -S mpv`,
…) and relaunch.
**Homebrew prints a dylib warning** — “load commands do not fit in the header
… needs `-headerpad`” is benign: the app loads its libraries by path, the

Binary file not shown.

111
build.ts
View File

@@ -131,117 +131,6 @@ if (COMPILE) {
}
}
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
// Playing session to PodTui — the source-app icon + name in Control
// Center / lock screen — instead of a blank placeholder for an
// unbundled binary. AudioPlayer's resolver prefers this sibling.
if (platform === "darwin") {
const appRoot = join(tarRoot, "PodTui.app");
const macosDir = join(appRoot, "Contents", "MacOS");
const resDir = join(appRoot, "Contents", "Resources");
mkdirSync(macosDir, { recursive: true });
mkdirSync(resDir, { recursive: true });
copyFileSync(outfile, join(macosDir, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
}
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
const mpvPath =
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
if (mpvPath) {
copyFileSync(mpvPath, join(macosDir, "mpv"));
} else {
console.warn(
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
);
}
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
if (existsSync(icnsSrc)) {
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
} else {
console.warn(
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
);
}
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
Bun.write(
join(appRoot, "Contents", "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PodTui</string>
<key>CFBundleDisplayName</key>
<string>PodTui</string>
<key>CFBundleIdentifier</key>
<string>com.mikefreno.podtui</string>
<key>CFBundleExecutable</key>
<string>podtui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>0.3.1</string>
<key>CFBundleVersion</key>
<string>0.3.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
// ID cert for release builds); default ad-hoc.
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
signIdentity,
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
// resolves the Now Playing client from the registering process's
// code-signing identifier — without an explicit --identifier codesign
// stamps "mpv" (its basename) and the audio center shows a blank
// placeholder. Must run after the bundle sign above (a later bundle
// re-seal would re-derive the basename identifier).
const signMpv = Bun.spawnSync([
"codesign",
"--force",
"-s",
signIdentity,
"--identifier",
"com.mikefreno.podtui",
join(macosDir, "mpv"),
]);
if (signMpv.exitCode !== 0) {
console.warn(
`Warning: nested mpv signing failed (${signMpv.stderr
.toString()
.trim()}) — Now Playing attribution won't work`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([
"tar",
"-czf",

BIN
bun.lockb

Binary file not shown.

View File

@@ -18,15 +18,13 @@
},
"devDependencies": {
"@types/bun": "latest",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77",
"date-fns": "^4.1.0",
"effect": "^3",
"solid-js": "^1.9.9"
}
}

View File

@@ -26,6 +26,8 @@
* bun scripts/tui-harness.tsx type "<text>"
* bun scripts/tui-harness.tsx wait <ms>
* bun scripts/tui-harness.tsx resize <w> <h>
* bun scripts/tui-harness.tsx mouse <x> <y> <down|up|click>
* bun scripts/tui-harness.tsx mdrag <x1> <y1> <x2> <y2> # border/seek drag
* bun scripts/tui-harness.tsx frame # re-render, no new action
* bun scripts/tui-harness.tsx state [all|nav|audio|feed|app]
* bun scripts/tui-harness.tsx actions # print action log
@@ -79,7 +81,9 @@ type Action =
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
| { t: "type"; s: string }
| { t: "wait"; ms: number }
| { t: "resize"; w: number; h: number };
| { t: "resize"; w: number; h: number }
| { t: "mouse"; kind: "down" | "up" | "click"; x: number; y: number }
| { t: "mdrag"; x1: number; y1: number; x2: number; y2: number };
function loadActions(): Action[] {
try {
@@ -258,12 +262,32 @@ const BUILDERS: Record<string, (positional: string[]) => Action> = {
if (!p[0]) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
},
resize: (p) => {
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
return {
t: "resize",
w: parseInt(p[0], 10) || 100,
h: parseInt(p[1], 10) || 30,
w: parseInt(p[0], 10) || 0,
h: parseInt(p[1], 10) || 0,
};
},
mouse: (p) => {
if (!p[0] || !p[1] || !p[2]) throw new Error("mouse requires <x> <y> <down|up|click>");
return {
t: "mouse",
kind: p[2] as "down" | "up" | "click",
x: parseInt(p[0], 10),
y: parseInt(p[1], 10),
};
},
mdrag: (p) => {
if (p.length < 4) throw new Error("mdrag requires <x1> <y1> <x2> <y2>");
return {
t: "mdrag",
x1: parseInt(p[0], 10),
y1: parseInt(p[1], 10),
x2: parseInt(p[2], 10),
y2: parseInt(p[3], 10),
};
},
};
@@ -324,8 +348,13 @@ async function execAction(setup: any, a: Action): Promise<void> {
case "wait":
await new Promise((r) => setTimeout(r, a.ms));
break;
case "resize":
setup.resize(a.w, a.h);
case "mouse":
if (a.kind === "click") await setup.mockMouse.click(a.x, a.y);
else if (a.kind === "down") await setup.mockMouse.pressDown(a.x, a.y);
else await setup.mockMouse.release(a.x, a.y);
break;
case "mdrag":
await setup.mockMouse.drag(a.x1, a.y1, a.x2, a.y2);
break;
}
await setup.renderOnce();
@@ -566,9 +595,7 @@ async function snapshotState(audioControls: any): Promise<Record<string, unknown
const feeds = fs_.feeds ? fs_.feeds() : [];
state.feed = {
count: feeds?.length ?? 0,
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
};
} catch (e) {
state.feed = "ERR: " + String(e);

View File

@@ -74,37 +74,83 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return undefined
}
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
const description = cleanField(getTagValue(channel, "description"))
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions
* (unlike Bun.hash) — used to derive stable episode ids from audio URLs so
* a feed's episode ids never change between refreshes. */
const fnv1a = (input: string): number => {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return hash >>> 0
}
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, index) => {
/**
* Stable per-episode identity. The old positional id (`feedUrl#index`) was
* invalidated by ANY feed change: a new episode or a pruned one shifted
* every episode's index, so progress/downloads saved under `feedUrl#5`
* attached to whatever episode now sat at index 5 — new episodes resumed
* minutes in. Identity derives from stable content instead:
* 1. `<guid>` — the canonical per-episode identifier (required by Apple
* Podcasts; nearly universal).
* 2. The enclosure URL, hashed to keep the id compact (hosts serve
* permanent per-episode URLs; guids can be absent in hand-rolled feeds).
* 3. Positional index as a last resort: no guid AND no audio URL means
* the episode cannot be played, so nothing persistent keys off it.
*/
const stableEpisodeId = (
feedUrl: string,
item: string,
audioUrl: string,
index: number,
): string => {
const guid = getTagValue(item, "guid")
if (guid) return `${feedUrl}#guid:${guid}`
if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}`
return `${feedUrl}#${index}`
}
/** Extract the `<item>` blocks from an RSS document. Matches items directly
* on the full XML string — scoping to <channel> first is a redundant 5MB
* regex pass that doubles parse cost with no practical benefit (well-formed
* RSS has no items outside <channel>). */
export const getRSSItems = (xml: string): string[] => {
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
}
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
* `<image><url>`. Works on the full XML — channel-level tags precede
* <item> blocks in RSS, so the first match is the channel image. */
export const parseChannelCoverUrl = (xml: string): string | undefined => {
const itunesHref = getAttr(xml, "itunes:image", "href")
if (itunesHref) return itunesHref
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
return url?.trim() || undefined
}
/** Parse a single `<item>` into an Episode. Exported so the feed store can
* parse large feeds in bounded chunks (yielding to the event loop between
* chunks) instead of one synchronous block. */
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = cleanField(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
const audioUrl = enclosure?.[1] ?? ""
const fileSizeStr = getAttr(item, "enclosure", "length")
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
const mimeType = getAttr(item, "enclosure", "type") || undefined
// Duration from <itunes:duration>
const durationRaw = getTagValue(item, "itunes:duration")
const duration = parseDuration(durationRaw)
// Episode & season numbers
const episodeNumRaw = getTagValue(item, "itunes:episode")
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
const seasonNumRaw = getTagValue(item, "itunes:season")
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
// Episode type & explicit
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
@@ -113,7 +159,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: `${feedUrl}#${index}`,
id: stableEpisodeId(feedUrl, item, audioUrl, index),
podcastId: feedUrl,
title: epTitle,
description: epDescription,
@@ -122,7 +168,6 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
pubDate,
}
// Only set optional fields if present
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
if (episodeType) ep.episodeType = episodeType
@@ -132,7 +177,20 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
if (mimeType) ep.mimeType = mimeType
return ep
})
}
/** Parse a full RSS document (channel metadata + all episodes). The sync
* whole-feed variant — callers that parse potentially huge feeds on a UI
* thread should prefer the store's chunked incremental parse instead. */
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
const description = cleanField(getTagValue(channel, "description"))
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = getRSSItems(xml)
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
return {
id: feedUrl,
@@ -142,6 +200,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
feedUrl,
lastUpdated,
isSubscribed: true,
coverUrl: parseChannelCoverUrl(channel),
episodes,
}
}

View File

@@ -0,0 +1,249 @@
/**
* Shared list-row and preview components for the Feed and My Shows pages.
*
* Both pages render the same episode rows (marker + title, optional subtitle
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
* hovered-episode / fetch-more preview panes; the pages differ only in the
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
* so the previously 3-4-level-nested render blocks run as flat named
* components.
*
* Anything that can change at runtime arrives as a signal getter: Solid
* components do not re-render, so only props that are called inside the
* component's own JSX stay reactive (focus, selection, download state).
*/
import { Show } from "solid-js";
import { format } from "date-fns";
import type { RGBA } from "@opentui/core";
import { useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { NF_ICONS } from "@/utils/nerd-fonts";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import type { Episode } from "@/types/episode";
// ── formatting helpers ──────────────────────────────────────────────────────
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
export const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
// ── EpisodeRow ──────────────────────────────────────────────────────────────
export function EpisodeRow(props: {
/** The episode this row renders. */
episode: Episode;
/** Optional second line under the title (podcast/show name). */
subtitle?: () => string | undefined;
/** For index signal (row position). */
index: () => number;
/** Focused row index in this list (-1 while the Fetch More row is
* focused, so no episode row draws the cursor). */
focused: () => number;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
/** Whether this episode is selection-marked. */
selected: () => boolean;
downloadLabel: () => string;
downloadColor: () => RGBA;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
{props.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the 50%
current pane fits it in full for typical names, and truncate
keeps the row one line tall either way */}
<Show when={props.subtitle?.()}>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.subtitle?.()}
</text>
</box>
</Show>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
{formatDate(props.episode.pubDate)}
</text>
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
{formatDuration(props.episode.duration)}
</text>
<Show when={props.selected()}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
</box>
);
}
// ── FetchMoreRow ────────────────────────────────────────────────────────────
export function FetchMoreRow(props: {
/** Row index of the Fetch More button within the list. */
index: () => number;
/** Focused row index. */
focused: () => number;
/** True while the Fetch More row itself is focused. */
onMore: () => boolean;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
isLoadingMore: () => boolean;
nerd: boolean;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(props.onMore);
const bg = () =>
props.index() === props.focused() && props.active()
? theme.primary
: props.index() === props.focused()
? theme.border
: undefined;
const fg = () =>
props.index() === props.focused() && props.active()
? theme.surface
: props.index() === props.focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
{props.nerd && (
<text fg={fg()}>{NF_ICONS.more}</text>
)}
<Show
when={!props.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={fg()}>[Fetch More]</text>
</Show>
</box>
);
}
// ── EpisodePreview ──────────────────────────────────────────────────────────
export function EpisodePreview(props: {
episode: () => Episode;
/** Optional line under the meta row (podcast/show name). */
subtitle?: () => string | undefined;
author: () => string | undefined;
downloadLabel: () => string;
downloadColor: () => RGBA;
/** Page-specific action-hint line. */
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const dims = useTerminalDimensions();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
{props.episode().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
</Show>
</box>
<Show when={props.subtitle?.()}>
<text fg={muted()}>{props.subtitle?.()}</text>
</Show>
<Show when={props.author()}>
<text fg={muted()}>by {props.author()}</text>
</Show>
<box height={1} />
<Show
when={props.episode().description}
fallback={<text fg={theme.textSecondary}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={theme.textSecondary}>{props.episode().description}</text>
</scrollbox>
</Show>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
// ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: {
isLoadingMore: () => boolean;
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
manualText: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{props.isLoadingMore()
? "Loading the next batch of episodes…"
: props.manualText()}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
);
}

View File

@@ -0,0 +1,35 @@
import { Show } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useSearchStore } from "@/stores/search";
import { useDownloadStore } from "@/stores/download";
import { useActivityStore } from "@/stores/activity";
import { LoadingIndicator } from "@/components/LoadingIndicator";
/**
* GlobalActivityIndicator — one global top-right signal that ANY feed
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
* Per-page spinners are unchanged; this overlays the content row and status
* bar as a single app-wide "something is happening" indicator.
*/
export function GlobalActivityIndicator() {
const feedStore = useFeedStore();
const searchStore = useSearchStore();
const downloadStore = useDownloadStore();
const activity = useActivityStore();
/** True while any tracked activity is in flight */
const isActive = () =>
feedStore.isLoadingFeeds() ||
feedStore.isLoadingMore() ||
searchStore.isSearching() ||
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
activity.isActive();
return (
<Show when={isActive()}>
<box position="absolute" top={0} right={0} paddingRight={1}>
<LoadingIndicator />
</box>
</Show>
);
}

View File

@@ -1,28 +1,25 @@
/**
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
*
* Implements yazi's `mgr.ratio` contract: three columns grow at
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
* so every list tab renders an identical, layout-stable shell. Columns use
* `flexBasis={0}` so the ratio is exact regardless of content width — a
* column's content can never stretch its slot.
* Implements yazi's resizable `mgr.ratio` contract: the two borders of the
* CENTER (current) column are draggable and resize the neighboring panes.
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
* as fractions of the row width; this component resolves them to pixel
* columns, gives each column an explicit width (so the grab zones sit
* exactly on the drawn borders), and renders two 3-column invisible grab
* zones over the borders.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 20% slot when blank (never collapses to width 0).
* Borderless (no left/right/top/bottom edge). Carries the single
* header row: the CURRENT column's title renders top-left in the
* parent's slot (the panes above current/preview were removed).
* current — the current-depth list. The only focusable content column; it
* is the ONLY bordered column — left/right edges only, always
* muted (no active-border highlight, focused or not).
* preview — detail of the hovered item in `current`. Borderless, no header.
* keeps a minimum 15-col slot. Borderless.
* current — the current-depth list. The only focusable content column; the
* ONLY bordered column — left/right edges only, always muted.
* preview — detail of the hovered item in `current`. Borderless.
*
* The primitive is purely structural: callers pass their own JSX per column
* (static elements or accessors) plus the current-column title. Theme colors
* are resolved internally via `useTheme()`. Only the current column's
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
* stay in the current pane).
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor.
*
* Example:
* <PaneRow
@@ -34,11 +31,16 @@
* />
*/
import { createMemo, Show } from "solid-js";
import { createMemo, createSignal, Show } from "solid-js";
import type { JSX } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import type { RGBA, BorderSides } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
import {
MIN_PANE_WIDTH,
splitPixels,
usePaneLayout,
} from "@/stores/pane-layout";
// ── Types ───────────────────────────────────────────────────────────────────
type PaneContent = JSX.Element | (() => JSX.Element);
@@ -46,7 +48,7 @@ type PaneLabel = string | (() => string);
export type PaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted
* placeholder — the 1/5 slot is always preserved). */
* placeholder — a minimum slot is always preserved). */
parent?: PaneContent;
/** Current column content (the focused list). */
current?: PaneContent;
@@ -63,6 +65,9 @@ export type PaneRowProps = {
/** Number of visible columns. `3` (default) = parent|current|preview;
* `2` = parent|current (preview omitted, current grows to fill). */
panes?: 2 | 3;
/** Which sides of the current column's border render. Defaults to
* `["left", "right"]` (the standard focused-list frame). */
currentBorder?: boolean | BorderSides[];
};
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -96,7 +101,7 @@ function Placeholder(props: { color: () => RGBA }) {
// ── Pane column ─────────────────────────────────────────────────────────────
function Pane(props: {
grow: number;
width: number;
label: () => string;
content: () => JSX.Element | undefined;
border: boolean | BorderSides[];
@@ -113,8 +118,8 @@ function Pane(props: {
return (
<box
flexDirection="column"
flexGrow={props.grow}
flexBasis={0}
width={props.width}
flexShrink={0}
height="100%"
>
{/* ── title row: rendered only when the pane carries a label ────────── */}
@@ -155,6 +160,52 @@ function Pane(props: {
);
}
/** A 3-column invisible grab zone centered on one border of the current
* pane: the border column plus one column of help padding on each side,
* so the thin border is easy to target with a mouse. `onBegin` is called
* on mousedown with the cursor's x; the row records that grab offset so
* the border stays glued to the cursor while dragging. On hover or while
* dragging it overdraws just the border column with a full-height accent
* `│` line (a bordered box would render as a blocky rectangle instead).
* The two padding columns are transparent; the hit grid is rect-based, so
* they capture clicks too — they must never overlap interactive content. */
function Splitter(props: {
/** Column of the border itself. The strip spans `left - 1` .. `left + 1`
* (the border plus one help-padded column each side); the highlight
* renders at `left`. */
left: number;
active: boolean;
onBegin: (x: number) => void;
}) {
const { theme } = useTheme();
const dims = useTerminalDimensions();
const [hovered, setHovered] = createSignal(false);
const highlighted = () => props.active || hovered();
return (
<box
position="absolute"
left={props.left - 1}
top={0}
width={3}
height="100%"
onMouseDown={(e) => {
e.preventDefault?.();
props.onBegin(e.x);
}}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<Show when={highlighted()}>
{/* Draw the accent edge down the full pane height; the box clips
* any excess rows below the row's bottom edge. */}
<text fg={theme.primary} selectable={false}>
{" │\n".repeat(dims().height)}
</text>
</Show>
</box>
);
}
// ── Row primitive ───────────────────────────────────────────────────────────
export function PaneRow(props: PaneRowProps) {
/** true → the current column's scrollbox is focused (scroll follows cursor). */
@@ -176,17 +227,72 @@ export function PaneRow(props: PaneRowProps) {
// 2-pane mode (parent|current) grows the current column to fill the
// preview slot. Defaults to 3 (parent|current|preview).
const panes = createMemo(() => props.panes ?? 3);
const currentGrow = createMemo(() =>
panes() === 2
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
const currentBorder = createMemo<boolean | BorderSides[]>(
() => props.currentBorder ?? ["left", "right"],
);
// Shared split state + terminal width drive explicit column widths so the
// drag strips sit exactly on the drawn borders.
const layout = usePaneLayout();
const dims = useTerminalDimensions();
const width = () => dims().width;
const pixels = createMemo(() => splitPixels(width(), layout.splits()));
const hasRoom = () =>
width() >=
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
// Column widths in pixels (sum to the row width).
const parentWidth = () => pixels().leftPx;
const currentWidth = () =>
panes() === 2
? width() - pixels().leftPx
: pixels().rightPx - pixels().leftPx;
const previewWidth = () => width() - pixels().rightPx;
// ── Drag state ──────────────────────────────────────────────────────────
// onMouseDown on a Splitter records which border is being dragged and
// the cursor's grab offset from that border's column; the row then
// lives-updates the split from the drag x (minus the offset, so the
// border stays glued to the cursor) and commits on release.
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
null,
);
// Column of the border a strip centers on (the current pane's edge).
const borderCol = (which: "left" | "right") =>
which === "left" ? pixels().leftPx : pixels().rightPx - 1;
// Cursor x relative to the grabbed border column. Set on mousedown and
// subtracted from every drag x so the border tracks the cursor rather
// than jumping to it.
let grabOffset = 0;
const beginDrag = (which: "left" | "right") => (x: number) => {
grabOffset = x - borderCol(which);
setActiveSplit(which);
};
const handleDrag = (e: { x: number }) => {
const which = activeSplit();
if (!which) return;
if (which === "left") layout.setLeft(e.x - grabOffset, width());
else layout.setRight(e.x - grabOffset, width());
};
const handleDragEnd = () => {
if (activeSplit()) layout.commit();
setActiveSplit(null);
grabOffset = 0;
};
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
<box
flexDirection="row"
width="100%"
height="100%"
flexGrow={1}
onMouseDrag={handleDrag}
onMouseDragEnd={handleDragEnd}
onMouseUp={handleDragEnd}
>
{/* ── parent — previous-depth list; title row top-left ─────────────── */}
<Pane
grow={PANE_RATIO.parent}
width={parentWidth()}
label={currentLabel}
content={parentContent}
border={false}
@@ -194,22 +300,37 @@ export function PaneRow(props: PaneRowProps) {
/>
{/* ── current — the focused list; left/right borders only ─────────── */}
<Pane
grow={currentGrow()}
width={currentWidth()}
label={() => ""}
content={currentContent}
border={["left", "right"]}
border={currentBorder()}
scrollFocused={() => focused()}
/>
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
{/* ── preview (optional) — hovered-item detail; no border ─────────── */}
<Show when={panes() === 3}>
<Pane
grow={PANE_RATIO.preview}
width={previewWidth()}
label={() => ""}
content={previewContent}
border={false}
scrollFocused={() => false}
/>
</Show>
{/* ── drag handles over the current pane's borders ───────────────── */}
<Show when={hasRoom()}>
<Splitter
left={borderCol("left")}
active={activeSplit() === "left"}
onBegin={beginDrag("left")}
/>
<Show when={panes() === 3}>
<Splitter
left={borderCol("right")}
active={activeSplit() === "right"}
onBegin={beginDrag("right")}
/>
</Show>
</Show>
</box>
);
}

View File

@@ -22,11 +22,13 @@ import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast";
import { emit, on } from "@/utils/event-bus";
import { feedForEpisode } from "@/utils/feed-resolve";
import { LayerGraph } from "@/utils/layer-graph";
import { TABS } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { PaneRow } from "@/components/PaneRow";
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
export function Shell() {
const theme = useTheme();
@@ -168,7 +170,6 @@ export function Shell() {
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);
@@ -222,9 +223,7 @@ export function Shell() {
const ep = audio.currentEpisode();
if (!ep) return null;
const feeds = feedStore.getFilteredFeeds();
const feed =
feeds.find((f) => f.podcast.id === ep.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
const feed = feedForEpisode(feeds, ep);
return feed
? `${feed.customName || feed.podcast.title}${ep.title}`
: `${ep.title}`;
@@ -396,6 +395,8 @@ export function Shell() {
theme={t as any}
/>
</Show>
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
<GlobalActivityIndicator />
</box>
);
}

View File

@@ -67,6 +67,7 @@
"sort": [","],
"toggle-hidden": ["."],
"refresh": ["r"],
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
// ── Downloads & auto-download whitelist ───────────────────────────────────

View File

@@ -68,6 +68,7 @@ export type KeybindActionName =
| "sort"
| "toggle-hidden"
| "refresh"
| "subscribe"
| "unsubscribe"
| "download"
| "delete-download"

View File

@@ -10,7 +10,8 @@ import {
generateSyntax,
generateSubtleSyntax,
} from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { resolveTerminalTheme } from "../utils/theme";
import { getCustomThemes } from "../utils/custom-themes";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper";
import {
@@ -119,6 +120,14 @@ const EMPTY_TERMINAL_COLORS: TerminalColors = {
/** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null;
/**
* How often to re-query the terminal for theme changes (OSC 10/11/12).
* Terminals only answer these queries — they never push a color change —
* so detection is a slow poll. 60 s keeps CPU cost unmeasurable while
* still tracking theme flips within a reasonable delay.
*/
const SYSTEM_THEME_POLL_MS = 60_000;
/**
* Detect the terminal's dark/light mode.
*
@@ -175,7 +184,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme();
loadThemes()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
@@ -187,7 +196,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
if (store.active !== "system") {
setStore("ready", true);
}
@@ -215,7 +223,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
});
}
async function resolveSystemTheme() {
/**
* Query the terminal's colors via OSC (palette + default fg/bg), with a
* legacy-tmux fallback for servers < 3.6 that don't forward OSC replies.
* Returns null when the terminal cannot answer.
*/
async function queryTerminalColors(): Promise<TerminalColors | null> {
if (process.env.TMUX) {
await waitForCapabilities();
}
@@ -254,6 +267,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
}
return colors;
}
async function resolveSystemTheme() {
const colors = await queryTerminalColors();
// ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
// real one from the terminal's background color (OSC 11) or, when that
@@ -299,8 +318,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
}
/**
* Poll for terminal theme changes: re-query OSC colors, update the
* system palette when it differs, and re-detect dark/light mode.
* Runs on a slow timer (see SYSTEM_THEME_POLL_MS); most polls change
* nothing and only pay the idle query round-trip.
*/
async function pollSystemTheme() {
if (!store.ready) return;
const colors = await queryTerminalColors();
if (!colors) return;
const current = store.system;
const changed =
!current ||
current.defaultBackground !== colors.defaultBackground ||
current.defaultForeground !== colors.defaultForeground ||
current.palette.join(",") !== colors.palette.join(",");
if (changed) {
setStore(
produce((draft) => {
draft.system = colors;
}),
);
}
// Refresh the OS-appearance fallback only when the terminal cannot
// report a background (e.g. tmux without OSC forwarding), so the
// common path never spawns a subprocess.
if (process.platform === "darwin" && !colors.defaultBackground) {
cachedOsMode = null;
}
const detectedMode = detectSystemMode(colors);
if (detectedMode && detectedMode !== store.mode) {
setStore("mode", detectedMode);
emitThemeModeChanged(detectedMode);
}
}
onMount(init);
// Poll the terminal for theme changes (see pollSystemTheme). Registered
// once per provider init — SIGUSR2 re-runs the inner `init`, not this
// closure, so the timer cannot stack.
const pollTimer = setInterval(() => {
void pollSystemTheme();
}, SYSTEM_THEME_POLL_MS);
onCleanup(() => clearInterval(pollTimer));
// Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>`

View File

@@ -13,10 +13,10 @@
*
* parent | current | preview
*
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused.
* Pane sizes are user-resizable (draggable borders in `PaneRow`).
* This module owns only the *focusable* nav model — which column is focused
* and where its list cursor lives. The parent/preview columns are always
* derived, never focused.
*
* The tab list is the app's ROOT and participates in the same pane flow as
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:

View File

@@ -0,0 +1,85 @@
/**
* Feed-refresh batch as an Effect program.
*
* Replaces the hand-rolled worker pool (mapWithConcurrency) + per-feed
* fetch/apply plumbing in stores/feed.ts with Effect's structured
* concurrency:
* - `Effect.forEach(..., { concurrency })` bounds in-flight fetches to
* `concurrency` (starts exactly that many fibers; each completion pulls
* the next feed — identical semantics to the old shared-counter pool).
* - `Effect.timeout` bounds each feed's fetch to `timeoutMs`. It runs
* through the `Clock` service, so under `TestContext` the TestClock
* drives it deterministically (no real 20s wait in tests).
* - Failures are folded to a null result: a failed or timed-out feed is
* left untouched instead of failing the batch.
* - The apply callback runs inside each feed's own fiber, so a feed's
* refreshed episodes land AS ITS OWN FETCH COMPLETES — the
* per-feed-apply-as-it-lands contract, no Promise.all barrier.
*
* The store boundary (stores/feed.ts) supplies the real fetch and apply
* closures and runs the program with Effect.runPromise.
*/
import { Duration, Effect } from "effect"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
/** Result of fetching one feed's RSS. `episodes: null` means the fetch
* failed or timed out — callers must leave that feed untouched. */
export interface RefreshFetchResult {
episodes: Episode[] | null
coverUrl: string | undefined
}
/** Result guaranteed to have parsed episodes (the apply path only). */
export interface RefreshSuccess {
episodes: Episode[]
coverUrl: string | undefined
}
export interface RefreshBatchOptions {
/** Max simultaneous in-flight fetches. */
concurrency: number
/** Per-feed fetch timeout in milliseconds. */
timeoutMs: number
}
/** Fold any failure (network error, timeout, rejection) to a null result so
* one bad feed can never fail the batch. */
const failedResult: RefreshFetchResult = { episodes: null, coverUrl: undefined }
/** Fetch one feed with a timeout, applying its result as its own fetch
* lands. A failed or timed-out fetch yields null — the feed is untouched. */
const refreshOne = (
feed: Feed,
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
applyOne: (feed: Feed, result: RefreshSuccess) => void,
timeoutMs: number,
): Effect.Effect<void> =>
Effect.tryPromise(() => fetchOne(feed)).pipe(
Effect.timeout(Duration.millis(timeoutMs)),
Effect.catchAll(() => Effect.succeed(failedResult)),
Effect.flatMap((result) => {
if (result.episodes === null) return Effect.void
// Capture the narrowed array before the closure — TS drops the
// `episodes !== null` narrowing inside Effect.sync's callback.
const episodes = result.episodes
return Effect.sync(() => applyOne(feed, { episodes, coverUrl: result.coverUrl }))
}),
)
/** Refresh every feed with bounded concurrency. Each feed's refreshed
* episodes are applied as its own fetch lands (no barrier); a failed or
* timed-out feed is left untouched. The program never fails — failures
* are folded to per-feed no-ops. */
export const refreshFeedsBatch = (
feeds: readonly Feed[],
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
applyOne: (feed: Feed, result: RefreshSuccess) => void,
options: RefreshBatchOptions,
): Effect.Effect<void> =>
Effect.forEach(
feeds,
(feed) => refreshOne(feed, fetchOne, applyOne, options.timeoutMs),
{ concurrency: options.concurrency, discard: true },
)

View File

@@ -1,8 +1,12 @@
/**
* Reactive SolidJS hook wrapping the AudioBackend.
* Reactive SolidJS hook over the module-level audio engine.
*
* Provides signals for playback state and methods for controlling
* audio. Integrates with the event bus and app store.
* Wraps utils/audio-engine: every useAudio() call shares ONE engine (all
* playback logic, the 150ms poll, session restore, and the event-bus
* commands live there). This hook keeps only what is tied to the Solid
* lifecycle — the ref-counted last-owner dispose and the process-exit
* teardown — and re-exposes the two controls the engine deliberately omits
* (availablePlayers, switchBackend).
*
* Usage:
* ```tsx
@@ -12,80 +16,44 @@
* ```
*/
import { createSignal, onCleanup } from "solid-js";
import { unlinkSync } from "fs";
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
import { onCleanup } from "solid-js";
import {
createAudioBackend,
detectPlayers,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import { emit, on } from "../utils/event-bus";
availablePlayers,
currentEpisode,
speed,
setSpeed,
volume,
setVolume,
} from "../utils/audio-signals";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useFeedStore } from "../stores/feed";
import { saveLastPlayerSync } from "../utils/app-persistence";
import type { BackendName, DetectedPlayer } from "../utils/audio-player";
import {
createAudioEngine,
ensureEngineBackend,
disposeEngineBackend,
stopEnginePolling,
getEngineBackend,
switchBackend,
restoreLastSession,
type AudioEngine,
} from "../utils/audio-engine";
export interface AudioControls {
// Signals (reactive getters)
isPlaying: () => boolean;
position: () => number;
duration: () => number;
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
// Re-exported so the session-restore test can pull it from this module.
export { restoreLastSession };
// useAudio() surface: the engine plus the two controls it doesn't expose.
export type AudioControls = AudioEngine & {
availablePlayers: () => DetectedPlayer[];
// Actions
play: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
switchBackend: (name: BackendName) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
}
};
// Singleton state — shared across all components that call useAudio()
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const engine = createAudioEngine();
// Singleton ref count — how many live useAudio() owners there are. The engine
// is shared; the last owner to unmount disposes the backend.
let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(0.7);
const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null);
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
registerExitTeardown();
}
return backend;
}
// ── Process-exit teardown ─────────────────────────────────────────────
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
@@ -100,9 +68,20 @@ function registerExitTeardown(): void {
if (exitTeardownRegistered) return;
exitTeardownRegistered = true;
const teardown = (): void => {
stopPolling();
stopEnginePolling();
// Persist "what's loaded in the player right now" synchronously —
// process.exit(0) runs this handler synchronously and an async write
// would never land. The next launch restores this episode paused.
try {
backend?.dispose();
const ep = currentEpisode();
if (ep) {
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
}
} catch {
/* best-effort at exit */
}
try {
getEngineBackend()?.dispose();
} catch {
/* best-effort at exit */
}
@@ -111,11 +90,6 @@ function registerExitTeardown(): void {
} catch {
/* best-effort at exit */
}
try {
unlinkSync(coverTempPath());
} catch {
/* best-effort at exit */
}
};
process.on("exit", teardown);
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
@@ -126,464 +100,56 @@ function registerExitTeardown(): void {
}
}
function startPolling(): void {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying() || pollInFlight) return;
pollInFlight = true;
try {
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (33 ticks * 150ms)
pollCount++;
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
// Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false);
stopPolling();
// Save final position on track end
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
}
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 150);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ── Cover art for system Now Playing ─────────────────────────────────────────
// macOS shows the media session's albumart in the audio center; mpv reads it
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
if (!episode.audioUrl) {
setError("No audio URL for this episode");
return;
}
try {
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverArtPath = feed?.podcast.coverUrl
? await fetchCoverArt(feed.podcast.coverUrl)
: null;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
await b.play(episode.audioUrl, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
coverArtPath: coverArtPath ?? undefined,
});
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
// Register with platform media controls
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed");
}
}
async function resume(): Promise<void> {
if (!backend) return;
try {
await backend.resume();
setIsPlaying(true);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Resume failed");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
await resume();
}
}
async function stop(): Promise<void> {
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
emit("player.stop", {});
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped);
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped);
// Sync back to app store
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
const feedStore = useFeedStore();
const feed = feedStore
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverArtPath = feed?.podcast.coverUrl
? await fetchCoverArt(feed.podcast.coverUrl)
: null;
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
/**
* Reactive audio controls hook.
*
* Returns a singleton — all components share the same playback state.
* Registers event bus listeners and cleans them up with onCleanup.
* Returns the shared audio engine wrapped with the two extra controls, so
* all components observe the same playback state. The first useAudio()
* owner creates the backend, runs the one-time boot (volume/speed sync +
* session restore) and registers the process-exit teardown; the last
* owner disposes the backend.
*/
export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend();
const engine = createAudioEngine();
ensureEngineBackend();
registerExitTeardown();
// Sync initial speed from app store
// First owner: sync speed/volume from the persisted settings and restore
// the last player session once (loaded, not playing). Raw signal
// accessors are used here on purpose — this is boot-only, not a user
// volume/speed change, so it must not re-persist to the app store.
if (refCount === 0) {
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
// Volume re-syncs once settings finish loading (async config read)
// so a level persisted last session is applied at boot.
appStore
.whenReady()
.then(() => {
const storeVolume = appStore.state().settings.volume;
if (storeVolume !== undefined && storeVolume !== volume()) {
setVolume(storeVolume);
}
})
.catch(() => {});
// Restore the last player session once at boot (loaded, not playing).
restoreLastSession().catch(() => {});
}
refCount++;
// Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
});
const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
});
// Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback();
});
const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
});
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const currentPos = position();
const currentDur = duration();
const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
} else {
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.prev(newIndex);
}
}
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.next(newIndex);
}
}
onCleanup(() => {
refCount--;
unsubPlay();
unsubStop();
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSpeed();
if (refCount <= 0) {
stopPolling();
if (backend) {
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
const media = useMediaRegistry();
media.clearNowPlaying();
disposeEngineBackend();
refCount = 0;
}
});
return {
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
availablePlayers,
play,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
switchBackend,
prev,
next,
};
return { ...engine, availablePlayers, switchBackend };
}

View File

@@ -57,6 +57,8 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
break;
case "s":
// Speed is shift+s (S) so plain `s` stays free for search.
if (!key.shift) return;
emit("media.speedCycle", {});
break;

View File

@@ -1,7 +1,10 @@
import { onCleanup } from "solid-js";
import { setupTerminalRecovery } from "./utils/terminal-recovery";
import { installNestedScrollBehavior } from "./utils/nested-scroll";
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.4.0";
const VERSION = "0.9.0";
interface CliArgs {
version: boolean;
@@ -42,7 +45,6 @@ if (cliArgs.version) {
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
@@ -186,12 +188,16 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
// Stage the podcast cover so the system Now Playing shows
// artwork (mpv --cover-art-files), like the UI path does.
const coverArtPath = feedResult.podcast.coverUrl
? await fetchCoverArt(feedResult.podcast.coverUrl)
// artwork (mpv --cover-art-files), like the UI path does. Falls
// back to the episode's own image when the feed has no channel
// cover (URL-added feeds).
const coverUrl =
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
const coverArtPath = coverUrl
? await fetchCoverArt(coverUrl)
: null
await backend.play(episodeResult.audioUrl, {
mediaTitle: `${feedResult.podcast.title}${episodeResult.title}`,
mediaTitle: episodeResult.title,
coverArtPath: coverArtPath ?? undefined,
})
console.log("Playback started (use the UI to control)")
@@ -231,10 +237,13 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command");
// Nested scroll sections favor the innermost one under the cursor.
installNestedScrollBehavior();
function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer();
renderer.disableStdoutInterception();
onCleanup(setupTerminalRecovery(renderer));
return props.children;
}

View File

@@ -5,18 +5,28 @@
* placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list.
* preview — detail of the hovered item (category summary, or
* podcast detail + subscribe action).
* depth 2 (current) — episodes of the drilled show, fetched on demand
* WITHOUT subscribing. Parent pane = the results list.
* preview — detail of the hovered item (category summary,
* podcast detail, or episode detail).
*
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
* column. Moving through categories at depth 0 updates the store's selected
* category so the preview follows.
* remains. `l`/Enter drills in (category → results → episodes); `a`
* subscribes the focused show (enter/l never subscribe — they open the
* episode list); `h` pops a depth (noop at 0). j/k move only within the
* current column. Moving through categories at depth 0 updates the store's
* selected category so the preview follows.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { DownloadStatus } from "@/types/episode";
import type { Episode } from "@/types/episode";
import type { Podcast } from "@/types/podcast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
@@ -41,11 +52,16 @@ function DiscoverPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
@@ -60,14 +76,37 @@ function DiscoverPage() {
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
// depth-2 frame ctx = the drilled podcast id (episode preview, no
// subscription). Episodes come from the discover store's session cache.
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
const drilledPodcast = (): Podcast | undefined =>
podcasts().find((p) => p.id === drilledPodcastId());
const episodes = createMemo<Episode[]>(() => {
if (depth() < 2) return [];
return discoverStore.episodesForPodcast(drilledPodcastId());
});
const episodesLoading = () =>
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
const episodesError = () =>
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
const focusedEpisode = () => episodes()[focusedEpIdx()];
const curLen = () =>
depth() === 0 ? categories().length : podcasts().length;
depth() === 0
? categories().length
: depth() === 1
? podcasts().length
: episodes().length;
const ensureFocus = () => {
if (categories().length > 0 && focus(0) >= categories().length)
nav.setDepthFocus(categories().length - 1, 0);
if (podcasts().length > 0 && focus(1) >= podcasts().length)
nav.setDepthFocus(podcasts().length - 1, 1);
if (episodes().length > 0 && focus(2) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 2);
};
onMount(ensureFocus);
@@ -80,13 +119,56 @@ function DiscoverPage() {
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id;
if (depth() === 1) return podcasts()[i]?.id;
return episodes()[i]?.id;
});
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
/** The subscribed feed backing a podcast, if any (matched by directory id
* or feed URL — a Discover show may already be subscribed). */
const feedForPodcast = (p: Podcast) =>
feedStore.feeds().find(
(f) =>
f.podcast.id === p.id ||
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
);
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
};
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
@@ -97,9 +179,19 @@ function DiscoverPage() {
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 1) {
if (depth() === 1) {
const pod = focusedPodcast();
if (pod) discoverStore.toggleSubscription(pod.id);
if (!pod) return;
// Drill into the show's episode list WITHOUT subscribing — `l`,
// right, and Enter open the episodes; `a` is the subscribe key.
discoverStore.openEpisodes(pod).catch(() => {});
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 2) {
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
}
@@ -115,12 +207,59 @@ function DiscoverPage() {
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
if (depth() === 1) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
if (depth() >= 2) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
download: () => {
if (depth() !== 2) return;
const pod = drilledPodcast();
const ep = focusedEpisode();
if (!pod || !ep) return;
// Under its subscribed feed when already subscribed, otherwise as
// an "unsubscribed show" download (mirrors Search).
const feed = feedForPodcast(pod);
if (feed) downloadStore.startDownload(ep, feed.id);
else downloadStore.startUnsubscribedDownload(ep, pod);
},
"delete-download": () => {
if (depth() !== 2) return;
const ep = focusedEpisode();
if (!ep) return;
const id = ep.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
// the episode list, so subscribing moved off open).
subscribe: () => {
if (depth() === 1) {
const pod = focusedPodcast();
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
return;
}
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
}
},
unsubscribe: () => {
if (depth() !== 1) return;
const pod = focusedPodcast();
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
},
refresh: () => {
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
return;
}
discoverStore.refresh().catch(() => {});
},
};
@@ -161,7 +300,9 @@ function DiscoverPage() {
const currentLabel = () =>
depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
: depth() === 1
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal
@@ -174,7 +315,7 @@ function DiscoverPage() {
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<Show when={depth() === 1}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
@@ -203,6 +344,33 @@ function DiscoverPage() {
}}
</For>
</Show>
<Show when={depth() >= 2}>
<For each={podcasts()}>
{(podcast, index) => {
const lf = () => nav.depthFocus(1);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
</text>
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text flexShrink={0} fg={muted()}>[+]</text>
</Show>
</box>
);
}}
</For>
</Show>
</>
);
@@ -243,7 +411,7 @@ function DiscoverPage() {
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show when={depth() === 1}>
<Show
when={podcasts().length > 0}
fallback={
@@ -304,11 +472,59 @@ function DiscoverPage() {
</For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
<Show when={depth() >= 2}>
<Show when={episodesLoading()}>
<box padding={1}>
<LoadingIndicator label="Loading episodes…" />
</box>
</Show>
<Show when={episodesError() && !episodesLoading()}>
<box padding={1}>
<text fg={theme.error}>{episodesError()}</text>
<box height={1} />
<text fg={muted()}>r: retry · h: back</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length === 0
}
>
<box padding={1}>
<text fg={muted()}>No episodes found. :refresh</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length > 0
}
>
<For each={episodes()}>
{(ep, index) => (
<EpisodeRow
episode={ep}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 2);
}}
/>
)}
</For>
</Show>
</Show>
</>
);
@@ -357,8 +573,8 @@ function DiscoverPage() {
</box>
)}
</Show>
) : (
// depth 1 preview: hovered podcast + subscribe
) : depth() === 1 ? (
// depth 1 preview: hovered podcast + episode-list hint
<Show
when={focusedPodcast()}
fallback={
@@ -376,10 +592,10 @@ function DiscoverPage() {
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
<text fg={theme.success}> Subscribed · x: unsubscribe</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
<text fg={theme.primary}>a: subscribe</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
@@ -398,10 +614,67 @@ function DiscoverPage() {
</Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
<text fg={muted()}>enter/l: episodes · h: back · r: refresh</text>
</box>
)}
</Show>
) : (
// depth ≥2 preview: hovered episode (or loading/error/empty)
<>
<Show when={episodesLoading()}>
<box padding={1}>
<LoadingIndicator label="Loading episodes…" />
</box>
</Show>
<Show when={episodesError() && !episodesLoading()}>
<box padding={1}>
<text fg={theme.error}>{episodesError()}</text>
<box height={1} />
<text fg={muted()}>r: retry · h: back</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length === 0
}
>
<box padding={1}>
<text fg={muted()}>No episodes found.</text>
</box>
</Show>
<Show
when={
!episodesLoading() &&
!episodesError() &&
episodes().length > 0 &&
focusedEpisode()
}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<EpisodePreview
episode={() => ep()}
author={() => drilledPodcast()?.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() =>
`enter: play · d: download${
downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""
}${
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
} · h: back`
}
/>
)}
</Show>
</>
);
return (

View File

@@ -19,9 +19,8 @@
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -32,14 +31,19 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
} from "@/components/EpisodeList";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1;
@@ -63,14 +67,26 @@ function FeedPage() {
() => feedStore.getAllEpisodesChronological() as EpItem[],
);
// ── Cover warm-up ────────────────────────────────────────────────────────
// Prefetch covers for episodes around the focus (plus the top of the
// list) so plays land on a warm cache: cover-art-files only applies at
// file load, and there is no working runtime fallback. Single-flight +
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
createEffect(() => {
const list = episodes();
const focusIdx = focusedEpIdx();
const start = Math.max(0, focusIdx - 10);
const end = Math.min(list.length, focusIdx + 11);
for (let i = start; i < end; i++) {
const item = list[i];
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
}
});
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
// loaded window by 50 episodes. Enter on the row to load the next batch.
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0);
const focusedRow = () =>
@@ -86,7 +102,30 @@ function FeedPage() {
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
const moreRef = useScrollIntoView(() => focusedOnMore());
// ── Render window ────────────────────────────────────────────────────────
// The union grows to thousands of episodes after repeated fetch-more
// presses; rendering every row per frame froze the UI. Render only a
// bounded slice around the focus (real indexes preserved) — the scrollbox
// still keeps the focused row in view. Spacers above/below the window
// restore the full content height so the scrollbar tracks the real list.
// Each EpisodeRow is 3 lines tall (title, subtitle, date).
const LIST_WINDOW = 30;
const ROW_HEIGHT = 3;
const listWindow = createMemo<[number, number]>(() => {
const len = episodes().length;
// Focusing the Fetch More button keeps the window anchored at the
// last episode — no jump when the focus crosses onto the button.
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
return [
Math.max(0, f - LIST_WINDOW),
Math.min(len, f + LIST_WINDOW + 1),
];
});
const visibleEpisodes = createMemo(() => {
const [start, end] = listWindow();
return episodes().slice(start, end);
});
const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount())
@@ -94,16 +133,6 @@ function FeedPage() {
};
onMount(ensureFocus);
// Auto mode: reaching the bottom row loads the next batch. Guarded by
// isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreAllFeeds().catch(() => {});
});
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -112,12 +141,6 @@ function FeedPage() {
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -212,19 +235,6 @@ function FeedPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
@@ -236,7 +246,7 @@ function FeedPage() {
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<box padding={1} alignItems="center">
<Show
when={feedStore.isLoadingFeeds()}
fallback={
@@ -245,141 +255,78 @@ function FeedPage() {
</text>
}
>
<LoadingIndicator label="Refreshing…" />
<LoadingIndicator />
</Show>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === fi());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
{/* Spacers keep the scrollbox content at the FULL list height so
the scrollbar reflects the real list, not the render window. */}
<Show when={listWindow()[0] > 0}>
<box height={listWindow()[0] * ROW_HEIGHT} />
</Show>
<For each={visibleEpisodes()}>
{(item, index) => (
<EpisodeRow
episode={item.episode}
subtitle={() => item.feed.customName || item.feed.podcast.title}
index={() => listWindow()[0] + index()}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(item.episode.id)}
downloadLabel={() => downloadLabel(item.episode.id)}
downloadColor={() => downloadColor(item.episode.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), fi(), isActive())}
>
{index() === fi() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), fi(), isActive())}
>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the
50% current pane fits it in full for typical names, and
truncate keeps the row one line tall either way */}
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={index() === fi() ? theme.surface : theme.textSecondary}
>
{item.feed.customName || item.feed.podcast.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : theme.info}
>
{formatDate(item.episode.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : muted()}
>
{formatDuration(item.episode.duration)}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
nav.setDepthFocus(listWindow()[0] + index(), 0);
}}
/>
)}
</For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
<box alignItems="center" paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
);
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const episodeHint = (item: EpItem) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
? " · D: delete"
: ""
} · space: select · h back`;
const previewContent = () => (
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -391,46 +338,16 @@ function FeedPage() {
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(item().episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· space: select · h back
</text>
</box>
<EpisodePreview
episode={() => item().episode}
subtitle={() =>
item().feed.customName || item().feed.podcast.title
}
author={() => item().feed.podcast.author}
downloadLabel={() => downloadLabel(item().episode.id)}
downloadColor={() => downloadColor(item().episode.id)}
hint={() => episodeHint(item())}
/>
)}
</Show>
</Show>

View File

@@ -6,20 +6,21 @@
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column.
*
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
* as the Feed tab) that loads the next batch of episodes for that show.
* Depth 0's shows list and depth 1's episode list both end with a
* "[Fetch More]" row that loads the next batch of episodes — depth 0 for
* every subscribed show, depth 1 for the drilled show.
*
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column.
*/
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -31,16 +32,218 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Episode, DownloadedEpisode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
formatDate,
} from "@/components/EpisodeList";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
// ── render components ────────────────────────────────────────────────────────
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
// preview panes are My Shows-specific; episode rows/previews are shared with
// the Feed page (see EpisodeList.tsx).
/** A subscribed-show row (depth 0). */
function ShowRow(props: {
feed: Feed;
title: string;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
wlScope: () => boolean;
wlInList: () => boolean;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
{/* Long titles truncate with middle-ellipsis instead of wrapping —
a wrapped title grows the row to 2+ lines and shifts every row
below (see EpisodeList for the same guard). The episode-count
and watchlist cells are flexShrink=0 so they never shrink or
wrap; the flexible title takes the remaining width. */}
<text wrapMode="none" truncate fg={fg()}>
{props.title}
</text>
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
({props.feed.episodes.length})
</text>
<Show when={props.wlScope()}>
<text
flexShrink={0}
fg={
isFocused()
? theme.surface
: props.wlInList()
? theme.warning
: muted()
}
>
{props.wlInList() ? "●" : "○"}
</text>
</Show>
</box>
);
}
/** An unsubscribed-show download row (depth 0, below the shows list). */
function UnsubscribedRow(props: {
d: DownloadedEpisode;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
downloadLabel: () => string;
downloadColor: () => RGBA;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.d.episodeTitle ?? props.d.episodeId}
</text>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.d.podcastTitle ?? props.d.feedId}
</text>
</box>
</box>
);
}
/** Depth-0 preview: the hovered subscribed show. */
function ShowPreview(props: {
show: () => Feed;
title: () => string;
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const show = props.show;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{props.title()}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
/** Depth-0 preview: the hovered unsubscribed-show download. */
function UnsubscribedPreview(props: {
d: () => DownloadedEpisode;
downloadLabel: () => string;
downloadColor: () => RGBA;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const d = props.d;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
</Show>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not subscribed.
</text>
<box height={1} />
<text fg={muted()}>enter: play · D: delete download · h: back</text>
</box>
);
}
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
@@ -62,9 +265,42 @@ export function MyShowsPage() {
const shows = () => feedStore.getFilteredFeeds();
// Downloads of shows that are NOT subscribed (made from episode search) —
// listed as their own section under the shows list. Reads feeds() so an
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
/** True while some subscribed show has more episodes to load — shows the
* depth-0 "[Fetch More]" row. */
const showLoadMore = () => feedStore.hasMoreAcrossAll();
const depth0Count = () =>
shows().length + unsubs().length + (showLoadMore() ? 1 : 0);
const focusedRow0 = () =>
depth0Count() === 0 ? 0 : Math.min(focus(0), depth0Count() - 1);
/** True while the depth-0 cursor sits on the "[Fetch More]" row. */
const focusedOnMore0 = () =>
showLoadMore() && focusedRow0() === shows().length + unsubs().length;
const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
focusedOnMore0()
? -1
: Math.min(focusedRow0(), Math.max(shows().length - 1, 0));
/** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */
const focusedOnUnsub = () =>
!focusedOnMore0() &&
depth() === 0 &&
focusedRow0() >= shows().length &&
unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined;
return unsubs()[
Math.min(focusedRow0() - shows().length, unsubs().length - 1)
];
};
const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub() || focusedOnMore0()) return undefined;
return shows()[focusedShowIdx()];
};
// depth-1 frame ctx = the drilled feed id
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
@@ -80,15 +316,11 @@ export function MyShowsPage() {
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of a drilled show's episode list
// advances that show's loaded window by 50 episodes — the per-show
// counterpart to the Feed page's row (which loads every feed). manual
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
// counterpart to the Feed page's row (which loads every feed).
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
@@ -102,13 +334,36 @@ export function MyShowsPage() {
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
const curLen = () => (depth() === 0 ? shows().length : rowCount());
// ── Render window ────────────────────────────────────────────────────────
// The drilled show's list grows deep after repeated fetch-more presses;
// rendering every row per frame froze the UI. Render only a bounded slice
// around the focus (real indexes preserved) — the scrollbox still keeps
// the focused row in view. Spacers above/below the window restore the
// full content height so the scrollbar tracks the real list.
// Each episode row is 2 lines tall (title, date) — no subtitle here.
const LIST_WINDOW = 30;
const ROW_HEIGHT = 2;
const listWindow = createMemo<[number, number]>(() => {
const len = episodes().length;
// Focusing the Fetch More button keeps the window anchored at the
// last episode — no jump when the focus crosses onto the button.
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
return [
Math.max(0, f - LIST_WINDOW),
Math.min(len, f + LIST_WINDOW + 1),
];
});
const visibleEpisodes = createMemo(() => {
const [start, end] = listWindow();
return episodes().slice(start, end);
});
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
const ensureFocus = () => {
if (shows().length > 0 && focus(0) >= shows().length)
nav.setDepthFocus(shows().length - 1, 0);
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
nav.setDepthFocus(depth0Count() - 1, 0);
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
nav.setDepthFocus(rowCount() - 1, 1);
};
@@ -116,29 +371,15 @@ export function MyShowsPage() {
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return shows()[i]?.id;
if (depth() === 0) {
if (i < shows().length) return shows()[i]?.id;
return unsubs()[i - shows().length]?.episodeId;
}
return episodes()[i]?.id;
});
});
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -172,9 +413,35 @@ export function MyShowsPage() {
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
/** Stream an unsubscribed-show download. The record carries only what was
* persisted at download time, so a minimal Episode is reconstructed. */
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
audio
.play({
id: d.episodeId,
podcastId: d.feedId,
title: d.episodeTitle ?? d.episodeId,
description: "",
audioUrl: d.audioUrl ?? "",
duration: 0,
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
})
.catch(() => {});
audioNav.setSource(AudioSource.SEARCH, d.feedId);
};
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
if (focusedOnMore0()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
const d = focusedUnsub();
if (d) {
playUnsubscribedDownload(d);
return;
}
const show = selectedShow();
if (!show) return;
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
@@ -215,6 +482,14 @@ export function MyShowsPage() {
if (ep) downloadStore.startDownload(ep, drilledShowId());
},
"delete-download": () => {
if (depth() === 0) {
const d = focusedUnsub();
if (d) {
downloadStore.cancelDownload(d.episodeId);
downloadStore.removeDownload(d.episodeId).catch(() => {});
}
return;
}
if (depth() < 1) return;
const ep = focusedEpisode();
if (!ep) return;
@@ -271,19 +546,13 @@ export function MyShowsPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
depth() === 0
? `Shows (${shows().length})`
? `Shows (${shows().length})${
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
}`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
@@ -295,19 +564,30 @@ export function MyShowsPage() {
{(feed, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
const focused = () => index() === lf();
const fg = () =>
focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
backgroundColor={focused() ? theme.border : undefined}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
<text flexShrink={0} fg={fg()}>
{focused() ? marker() : " "}
</text>
{/* 20%-wide parent pane truncates hard — same
middle-ellipsis guard as the depth-0 rows. */}
<text wrapMode="none" truncate fg={fg()}>
{showTitle(feed)}
</text>
<text flexShrink={0} fg={muted()}>
({feed.episodes.length})
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
@@ -321,7 +601,7 @@ export function MyShowsPage() {
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
when={depth0Count() > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
@@ -331,52 +611,68 @@ export function MyShowsPage() {
}
>
<For each={shows()}>
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
{(feed, index) => (
<ShowRow
feed={feed}
title={showTitle(feed)}
index={index}
focused={focusedShowIdx}
active={isActive}
marker={marker}
wlScope={() =>
app.state().preferences.autoDownloadScope === "whitelist"
}
wlInList={() =>
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
feed.id,
)
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
/>
)}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
<text fg={theme.textSecondary}>
Unsubscribed Show Downloads
</text>
</box>
<For each={unsubs()}>
{(d, index) => (
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={focusedRow0}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
downloadColor={() => downloadColor(d.episodeId)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(shows().length + index(), 0);
}}
/>
)}
</For>
</Show>
<Show when={showLoadMore()}>
<FetchMoreRow
index={() => shows().length + unsubs().length}
focused={focusedRow0}
onMore={focusedOnMore0}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(shows().length + unsubs().length, 0);
}}
/>
</Show>
</Show>
</Show>
{/* depth ≥1: episodes */}
@@ -389,99 +685,47 @@ export function MyShowsPage() {
</box>
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
{/* Spacers keep the scrollbox content at the FULL list
height so the scrollbar reflects the real list, not the
render window. */}
<Show when={listWindow()[0] > 0}>
<box height={listWindow()[0] * ROW_HEIGHT} />
</Show>
<For each={visibleEpisodes()}>
{(ep, index) => (
<EpisodeRow
episode={ep}
index={() => listWindow()[0] + index()}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
nav.setDepthFocus(listWindow()[0] + index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), lf(), isActive())}
>
{index() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(
episodes().length,
focusedRow(),
isActive(),
/>
)}
</For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}>
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
</Show>
</Show>
@@ -489,9 +733,47 @@ export function MyShowsPage() {
);
// ── preview pane ───────────────────────────────────────────────────────────
const episodeHint = (epId: string) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
? " · D: delete"
: ""
}${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
} · space: select · h: back`;
const showHint = (show: Feed) =>
`enter/l: open · h: back · x: unsubscribe${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
}`;
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered show
// depth 0 preview: hovered "[Fetch More]" row, else the
// unsubscribed-show download, else the hovered show.
<>
<Show when={focusedOnMore0()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"Load the next batch of older episodes across all subscribed shows (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore0()}>
<Show
when={focusedUnsub()}
fallback={
<Show
when={selectedShow()}
fallback={
@@ -501,51 +783,35 @@ export function MyShowsPage() {
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}
</text>
</box>
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
</Show>
</>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes for this show (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -557,47 +823,13 @@ export function MyShowsPage() {
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}{" "}
· space: select · h: back
</text>
</box>
<EpisodePreview
episode={() => ep()}
author={() => selectedShow()?.podcast.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() => episodeHint(ep().id)}
/>
)}
</Show>
</Show>

View File

@@ -68,7 +68,7 @@ export function PlaybackControls(props: PlaybackControlsProps) {
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Speed</text>
<text fg={theme.text}>{props.speed}x</text>
<text fg={theme.textMuted}>s</text>
<text fg={theme.textMuted}>S</text>
</box>
</box>
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}

View File

@@ -10,14 +10,16 @@
* tab root.
*/
import { Show } from "solid-js";
import { Show, onMount, onCleanup } from "solid-js";
import { PlaybackControls } from "./PlaybackControls";
import { ProgressBar } from "./ProgressBar";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useVisualizer } from "@/stores/visualizer";
import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { useTerminalDimensions } from "@opentui/solid";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
@@ -27,7 +29,20 @@ export function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const viz = useVisualizer();
const dims = useTerminalDimensions();
const app = useAppStore();
const muted = () => theme.muted || theme.text;
// Settings master switch: off hides the waveform entirely (the store
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
const vizEnabled = () => app.state().settings.visualizer.enabled;
// The page is mounted exactly while the Player tab is in focus (Shell
// renders only the active tab), so mount ⇔ focused. Report it to the
// visualizer store: losing focus starts the unload grace timer instead
// of killing the pipeline with the page; regaining focus restarts it.
onMount(() => viz.setFocused(true));
onCleanup(() => viz.setFocused(false));
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
@@ -76,24 +91,20 @@ export function PlayerPage() {
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<Show
when={ep().description}
fallback={<text fg={muted()}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={muted()}>{ep().description}</text>
</scrollbox>
</Show>
<ProgressBar />
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
// bars is width-derived in RealtimeWaveform; pass only the
// audio-processing params here.
return {
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
<Show when={vizEnabled()}>
<RealtimeWaveform />
</Show>
</box>
)}
</Show>
@@ -129,6 +140,7 @@ export function PlayerPage() {
currentLabel="Player"
panes={2}
focused={isActive}
currentBorder={["left"]}
/>
);
}

View File

@@ -10,6 +10,7 @@ import { useTerminalDimensions } from "@opentui/solid";
import type { Renderable } from "@opentui/core";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ────────────────────────────────────────────────────────
@@ -17,16 +18,19 @@ export function ProgressBar() {
const audio = useAudio();
const { theme } = useTheme();
const dimensions = useTerminalDimensions();
const layout = usePaneLayout();
// The bar's renderable, captured for its absolute left edge: MouseEvent.x
// is terminal-absolute (not bar-relative), so local x needs the offset
// of the bar inside the 2-pane row (parent pane ≈ 20% of the width).
// of the bar inside the 2-pane row (parent pane = left split of the width).
let bar: Renderable | undefined;
// Full content width of the player pane: the player is a 2-pane row
// (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars
// (parent = left split, current = the rest of the terminal width). Track
// the live split so a dragged border re-sizes the bar. Subtract ~8 chars
// of border/padding chrome (same math as RealtimeWaveform's numBars).
const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8);
const width = () =>
Math.max(8, Math.floor(dimensions().width * (1 - layout.splits().left)) - 8);
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
@@ -45,6 +49,10 @@ export function ProgressBar() {
padding={0}
flexDirection="row"
gap={0}
// The bar's block-char texts are non-selectable below: a drag
// over the bar is a seek gesture, not a text selection — otherwise
// mouse-up would copy █/░ to the clipboard via the global
// selection handler.
ref={(el) => {
bar = el;
}}
@@ -58,9 +66,11 @@ export function ProgressBar() {
}}
>
{playedChars() > 0 && (
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
<text fg={theme.primary} selectable={false}>
{"\u2588".repeat(playedChars())}
</text>
)}
<text fg={remainingColor}>
<text fg={remainingColor} selectable={false}>
{"\u2591".repeat(width() - playedChars())}
</text>
</box>

View File

@@ -1,248 +1,65 @@
/**
* RealtimeWaveform — live audio frequency visualization using cavacore.
* RealtimeWaveform — renders the shared visualizer pipeline state.
*
* Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps.
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
* visualizer store (`@/stores/visualizer`), not in this component, so it
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
*
* This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames or the
* player is stalled (re-buffering), the frequency bars once frames arrive,
* and a dotted placeholder when idle.
*/
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import { createEffect, on } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
import { useAudio } from "@/hooks/useAudio";
import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
export function RealtimeWaveform() {
const { theme } = useTheme();
const audio = useAudio();
const viz = useVisualizer();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
// frame against the running peak so a loud start can't pin every bar
// at full height and quiet content still gets normalized up.
const scaler = createBarScaler();
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
// Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
// chrome (scrollbox border + box padding + waveform border + padding).
// Falls back to 64 before the renderer reports a real size.
const dimensions = useTerminalDimensions();
const layout = usePaneLayout();
const numBars = () => {
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
const width = dimensions().width;
if (!width) return 64;
// The player is a 2-pane row: the current column = whole width minus
// the parent (left split). Subtract ~8 chars of chrome.
return Math.max(
8,
Math.min(256, Math.floor((width * current) / total) - 8),
Math.min(256, Math.floor(width * (1 - layout.splits().left)) - 8),
);
};
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
return false;
}
return true;
};
// ── Smooth position clock ──────────────────────────────────────────
//
// audio.position() updates at the useAudio poll rate (~150ms). Between
// polls, interpolate the position from wall time so the FFT window
// tracks the audio continuously instead of stepping. The 0.5s cap
// prevents extrapolating far beyond reality when the player stalls
// (e.g. network re-buffering).
let lastPolledPosition = 0;
let lastPolledAt = 0;
const smoothPosition = () => {
const pos = audio.position();
const now = performance.now();
if (pos !== lastPolledPosition) {
lastPolledPosition = pos;
lastPolledAt = now;
return pos;
}
if (lastPolledAt === 0) return pos;
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides.
// bars is width-derived (see numBars); visualizerConfig supplies the
// audio-processing params (noise reduction, cutoffs, etc.).
// autosens is disabled (after the spread so it always wins): cava's
// autosens gain-ramps during silence then clips everything to 1.0
// when audio arrives — the JS peak scaler handles dynamics instead.
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
autosens: 0,
};
cava.init(config);
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it (at 44.1kHz mono the window is 8192
// samples — FFTbassbufferSize × channels; a 512-sample frame would
// leave the tail garbage).
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy();
}
sampleBuffer = null;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Sample the FFT window at the player's position, not the decode
// head — the reader decodes independently (paced at the player's
// clock rate with a LEAD_SECONDS burst head start) and only the
// position clock ties the bars to what's actually playing.
const target = smoothPosition();
const count = reader.read(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
const output = cava.execute(sampleBuffer);
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
};
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "",
audio.speed,
numBars,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(audio.position);
startVisualization(url, pos, speed);
} else {
stopVisualization();
}
},
),
);
// ── Seek detection: lightweight effect for position jumps ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0;
createEffect(
on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1);
}
}),
);
onCleanup(() => {
stopVisualization();
if (reader) {
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy();
}
});
// Keep the store's bar count in sync with the terminal width; the store
// re-inits the running pipeline when it changes (terminal resize).
createEffect(on(numBars, (n) => viz.setBarCount(n)));
// ── Rendering ──────────────────────────────────────────────────────
const renderLine = () => {
const bars = barData();
const bars = viz.barData();
const count = numBars();
// Loading state: the braille spinner shows while the pipeline is
// warming up — cold start (first play / after an unload), resume
// into undecoded audio, or a stalled position clock (mpv
// re-buffering after a long pause on a network stream). The store
// clears it the moment the first fresh frame renders, so stale
// bars never masquerade as live data while the pipeline re-arms.
if (viz.isLoading() || viz.isStalled()) {
return <LoadingIndicator />;
}
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (

View File

@@ -30,6 +30,10 @@ import {
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { DownloadStatus } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
@@ -55,6 +59,9 @@ export const SearchPaneCount = 1;
function SearchPage() {
const searchStore = useSearchStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const toast = useToast();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
@@ -70,30 +77,15 @@ function SearchPage() {
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
//
// The input's REAL focus is the source of truth for the flag:
// useInputFocusNav (the same hook the Settings forms use) flips
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// and the renderable in lockstep. That matters when the user clicks OFF the
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// event drops the flag, so the Shell router immediately resumes j/k/h
// instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
//
// The depth stack still SEEDS the flag on transitions, since the query
// depth defaults to typing: re-entering depth 0 (h back from results, or a
// fresh mount) focuses the input; mounting at depth 1 (returning to the
// tab after a search) stays list-navigation — a stuck-on flag there would
// have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
// `inputFocused` tells the Shell router to yield keys to the query input.
// The input's REAL focus is the source of truth: useInputFocusNav flips
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
// input drops it and the router resumes j/k/h — no stuck "typing" state.
// The depth stack only SEEDS it on transitions (re-entering depth 0
// focuses the input; mounting at depth 1 stays list-nav), gated on the
// depth VALUE via a memo because setDepthFocus also writes the stack
// signal — without the memo every j/k at query depth re-focuses the input
// and strands the recents list.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();
@@ -134,6 +126,35 @@ function SearchPage() {
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const runSearch = (query: string) => {
const q = query.trim();
if (!q) return;
@@ -185,6 +206,59 @@ function SearchPage() {
if (feed) searchStore.markSubscribed(result.podcast.id);
};
/** The subscribed feed backing a search result, if any (matched by
* directory id or feed URL). */
const feedForResult = (r: SearchResult) =>
feedStore.feeds().find(
(f) =>
f.podcast.id === r.podcast.id ||
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
);
/** Download the focused episode: under its subscribed feed when the show
* is subscribed, otherwise as an "unsubscribed show" download (listed
* under Unsubscribed Show Downloads in My Shows / the download manager). */
const downloadFocusedEpisode = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.kind !== "episode") return;
const feed = feedForResult(r);
if (feed) downloadStore.startDownload(r.episode, feed.id);
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
};
const playFocusedEpisode = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.kind !== "episode") return;
audio.play(r.episode).catch(() => {});
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
};
const unsubscribeFocused = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || !r.podcast.isSubscribed) return;
const feed = feedForResult(r);
if (feed) {
feedStore.removeFeed(feed.id);
downloadStore
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
.catch(() => {});
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
}
};
/** Subscribe the focused result's show in place (episode or podcast
* result). `enter` plays episodes regardless of subscription, so an
* unsubscribed show's episode needs this explicit path. */
const subscribeFocused = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.podcast.isSubscribed) return;
handleSubscribe(r);
};
// ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
@@ -205,6 +279,18 @@ function SearchPage() {
);
}
},
download: () => downloadFocusedEpisode(),
"delete-download": () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.kind !== "episode") return;
const id = r.episode.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
unsubscribe: () => unsubscribeFocused(),
subscribe: () => subscribeFocused(),
search: () => {
// `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true);
@@ -230,7 +316,15 @@ function SearchPage() {
}
if (depth() === 1) {
const r = focusedResult();
if (r) handleSubscribe(r);
if (!r) return;
if (r.kind === "episode") {
// Any episode result streams directly — subscribed or not
// (matches Feed/My Shows). `a` subscribes an unsubscribed
// show's episode in place.
playFocusedEpisode();
return;
}
handleSubscribe(r);
}
}
@@ -474,6 +568,13 @@ function SearchPage() {
{(result, index) => {
const fi = () => focusedResultIdx();
const ref = useScrollIntoView(() => index() === fi());
// Episode download status badge ("" when absent).
const dlLabel = () =>
result.kind === "episode"
? downloadLabel(result.episode.id)
: "";
const dlEpId = () =>
result.kind === "episode" ? result.episode.id : "";
return (
<box
ref={ref}
@@ -495,6 +596,11 @@ function SearchPage() {
? result.episode.title
: result.podcast.title}
</text>
<Show when={dlLabel()}>
<text fg={downloadColor(dlEpId())}>
{dlLabel()}
</text>
</Show>
<Show when={result.podcast.isSubscribed}>
<text
fg={index() === fi() ? theme.surface : theme.success}
@@ -576,9 +682,16 @@ function SearchPage() {
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<box flexDirection="row" gap={2}>
<text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<Show when={downloadLabel(r.episode.id)}>
<text fg={downloadColor(r.episode.id)}>
{downloadLabel(r.episode.id)}
</text>
</Show>
</box>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
@@ -591,15 +704,31 @@ function SearchPage() {
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
<text fg={theme.primary}>[+] Subscribe (a)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
<text fg={theme.success}>
Subscribed · x: unsubscribe
</text>
</Show>
<box height={1} />
<Show
when={r.podcast.isSubscribed}
fallback={
<text fg={muted()}>
enter: subscribe to show · h: back to query
enter: play · a: subscribe · d: download · h: back to query
</text>
}
>
<text fg={muted()}>
enter: play · d: download · x: unsubscribe
{downloadStore.getDownloadStatus(r.episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· h: back to query
</text>
</Show>
</box>
);
}
@@ -640,10 +769,16 @@ function SearchPage() {
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
<text fg={theme.success}>
Subscribed · x: unsubscribe
</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back to query</text>
<text fg={muted()}>
enter: subscribe
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
· h: back to query
</text>
</box>
);
}}

View File

@@ -2,13 +2,18 @@
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
*
* • "Delete All Downloads" — action item; Enter wipes every download.
* • one item per show — action item; Enter deletes all that show's
* downloads (file + metadata, aborts in-flight).
* • one item per subscribed show — action item; Enter deletes all that
* show's downloads (file + metadata, aborts
* in-flight).
* • "Unsubscribed Show Downloads" — downloads made from episode search for
* shows that aren't subscribed, grouped
* under their own header.
* • one item per episode — action item; Enter deletes a single download.
*
* Titles resolve from the feed store at render time (reactive), falling back
* to the episode id when the feed is no longer loaded. Movement flows through
* nav.action — no own useKeyboard (matches the other panels).
* to the persisted episode/show titles for unsubscribed-show downloads.
* Movement flows through nav.action — no own useKeyboard (matches the other
* panels).
*/
import { useFeedStore } from "@/stores/feed";
@@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string {
}
}
/** Episode title for a download, resolved from the feed store (reactive). */
/** Episode title for a download, resolved from the feed store (reactive);
* falls back to the persisted title (kept for unsubscribed-show downloads). */
function episodeTitle(
feedStore: ReturnType<typeof useFeedStore>,
d: DownloadedEpisode,
): string {
const feed = feedStore.getFeed(d.feedId);
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
return ep?.title ?? d.episodeId;
return ep?.title ?? d.episodeTitle ?? d.episodeId;
}
/** Show title for a download's feed id. */
/** Show title for a download's feed id; falls back to the persisted show
* title (unsubscribed-show downloads have no feed to resolve from). */
function feedTitle(
feedStore: ReturnType<typeof useFeedStore>,
feedId: string,
d: DownloadedEpisode,
): string {
const feed = feedStore.getFeed(feedId);
return feed ? feed.customName || feed.podcast.title : feedId;
const feed = feedStore.getFeed(d.feedId);
if (feed) return feed.customName || feed.podcast.title;
return d.podcastTitle ?? d.feedId;
}
export function useDownloadItems(): SettingItem[] {
@@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] {
},
];
// Group downloads by feed so each show gets a delete-by-show item.
// Group downloads by feed so each subscribed show gets a delete-by-show
// item. Unsubscribed-show downloads (search downloads, synthetic feed
// ids) are kept out of these groups and listed under their own section
// below.
const unsubscribed = downloadStore.getUnsubscribedDownloads();
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
const byFeed = new Map<string, DownloadedEpisode[]>();
for (const d of downloads()) {
if (unsubscribedIds.has(d.episodeId)) continue;
const arr = byFeed.get(d.feedId) ?? [];
arr.push(d);
byFeed.set(d.feedId, arr);
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
const size = eps.reduce((s, e) => s + e.fileSize, 0);
items.push({
id: `feed:${feedId}`,
label: `Show: ${feedTitle(feedStore, feedId)}`,
label: `Show: ${feedTitle(feedStore, eps[0])}`,
kind: "action",
display: () => `${eps.length} · ${fmtBytes(size)}`,
help: () =>
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
run: () => {
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
downloadStore
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
.catch(() => {});
},
});
}
// One item per individual episode download.
// Unsubscribed-show downloads: a section header + one item per episode.
if (unsubscribed.length > 0) {
items.push({
id: "unsubscribed-header",
label: "Unsubscribed Show Downloads",
kind: "info",
display: () => `${unsubscribed.length} files`,
help: () =>
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
});
}
for (const d of unsubscribed) {
items.push({
id: `unsub:${d.episodeId}`,
label: episodeTitle(feedStore, d),
kind: "action",
display: () =>
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
help: () =>
`Delete this single download (file + metadata). Enter to run.`,
run: () => {
downloadStore.removeDownload(d.episodeId).catch(() => {});
},
});
}
// One item per individual (subscribed-show) episode download.
for (const d of downloads()) {
if (unsubscribedIds.has(d.episodeId)) continue;
items.push({
id: `ep:${d.episodeId}`,
label: episodeTitle(feedStore, d),
kind: "action",
display: () =>
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
help: () =>
`Delete this single download (file + metadata). Enter to run.`,
run: () => {

View File

@@ -4,13 +4,15 @@
* driven by the Shell router via nav.action.
*
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
* • Auto Download — master toggle (default: off)
* • Auto Download Count — X most recent episodes per show (default: 2,
* any positive integer — type it in the editor)
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
* • Auto Download Whitelist — shown only when scope is "whitelist": search
* field over subscribed shows; suggestions toggle
* in/out with Space (j/k to move, Esc to browse).
* • Episode Cache Mode — date or count bound for the episode list
* (default: date)
* • Episode Cache Count — N most recent episodes when mode is count
* (default: 25)
* • Episode Cache Days — rolling N-day window when mode is date
* (default: 60)
*/
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
@@ -30,7 +32,7 @@ import {
import { on } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { TABS } from "@/utils/navigation";
import type { AutoDownloadScope, ThemeName } from "@/types/settings";
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
import type { Feed } from "@/types/feed";
import type { SettingItem } from "./types";
@@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
{ value: "none", label: "None" },
{ value: "whitelist", label: "Whitelist" },
];
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
{ value: "date", label: "Date" },
{ value: "count", label: "Count" },
];
function cacheModeLabel(mode: EpisodeCacheMode): string {
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
}
function scopeLabel(scope: AutoDownloadScope): string {
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
@@ -206,19 +216,104 @@ export function usePreferencesItems(): SettingItem[] {
}),
},
{
id: "fetchMore",
label: "Fetch More",
id: "episodeCacheMode",
label: "Episode Cache Mode",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
display: () => cacheModeLabel(prefs().episodeCacheMode),
help: () =>
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
const idx = CACHE_MODE_LABELS.findIndex(
(s) => s.value === prefs().episodeCacheMode,
);
const next =
CACHE_MODE_LABELS[
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
].value;
app.updatePreferences({ episodeCacheMode: next });
},
},
{
id: "episodeCacheCount",
label: "Episode Cache Count",
kind: "number",
display: () =>
prefs().episodeCacheMode === "count"
? `${prefs().episodeCacheCount} eps`
: "(date mode)",
help: () =>
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to /+1 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.max(1, prefs().episodeCacheCount + dir);
app.updatePreferences({ episodeCacheCount: next });
},
renderEditor: () => (
<NumberInputEditor
label="Episode Cache Count"
value={() => prefs().episodeCacheCount}
commit={(n) => {
app.updatePreferences({
episodeCacheCount: Math.max(1, n),
});
}}
/>
),
},
{
id: "episodeCacheDays",
label: "Episode Cache Days",
kind: "number",
display: () =>
prefs().episodeCacheMode === "date"
? `${prefs().episodeCacheDays} days`
: "(count mode)",
help: () =>
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to /+5 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.min(
365,
Math.max(1, prefs().episodeCacheDays + dir * 5),
);
app.updatePreferences({ episodeCacheDays: next });
},
renderEditor: () => (
<NumberInputEditor
label="Episode Cache Days"
value={() => prefs().episodeCacheDays}
commit={(n) => {
app.updatePreferences({
episodeCacheDays: Math.min(365, Math.max(1, n)),
});
}}
/>
),
},
{
id: "refreshInterval",
label: "Feed Refresh Interval",
kind: "number",
display: () => `${prefs().refreshIntervalMinutes} min`,
help: () =>
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to /+5 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.min(
120,
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
);
app.updatePreferences({ refreshIntervalMinutes: next });
},
renderEditor: () => (
<NumberInputEditor
label="Feed Refresh Interval (minutes)"
value={() => prefs().refreshIntervalMinutes}
commit={(n) => {
app.updatePreferences({
refreshIntervalMinutes: Math.min(120, n),
});
}}
/>
),
},
];
// Whitelist management only appears while scope is set to "whitelist".

View File

@@ -64,7 +64,7 @@ const SECTIONS: SettingsSectionDef[] = [
{
id: 3,
label: "Visualizer",
description: "Audio visualizer: bars, sensitivity, cutoffs.",
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
icon: NF_ICONS.visualizer,
},
{
@@ -140,7 +140,6 @@ export function SettingsPage() {
function open() {
const d = depth();
if (d === 0) {
// drill into the focused section's items
const id = focusedSection().id;
nav.pushDepth({
kind: `settings:${id}`,
@@ -206,7 +205,6 @@ export function SettingsPage() {
function step(delta: number) {
const d = depth();
if (d === 2) {
// editor: j/k nudges the value
const it = editorItem();
if (it?.kind === "number" || it?.kind === "select")
it.cycle?.(delta as -1 | 1);
@@ -220,7 +218,6 @@ export function SettingsPage() {
pane: PaneId;
mode: NavMode;
}) => {
// ignore actions meant for non-center panes
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
const handler = PAGE_ACTIONS[data.action];

View File

@@ -11,6 +11,15 @@ export function useVisualizerItems(): SettingItem[] {
const viz = () => app.state().settings.visualizer;
return [
{
id: "enabled",
label: "Waveform",
kind: "toggle",
display: () => (viz().enabled ? "On" : "Off"),
help: () =>
`Realtime waveform visualizer in the player.\nType: toggle\nDefault: on\nCurrent: ${viz().enabled ? "on" : "off"}\nSpace/Enter to toggle.`,
toggle: () => app.updateVisualizer({ enabled: !viz().enabled }),
},
{
id: "bars",
label: "Bars",

73
src/stores/activity.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* 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;
}

View File

@@ -17,6 +17,7 @@ import {
} from "../utils/app-persistence";
const defaultVisualizerSettings: VisualizerSettings = {
enabled: true,
bars: 64,
sensitivity: 1,
noiseReduction: 0.77,
@@ -28,6 +29,7 @@ const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
volume: 1,
downloadPath: "",
transparentBackground: false,
showSelectionMarker: false,
@@ -41,7 +43,11 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
paneSplit: { left: 0.2, right: 0.7 },
};
const defaultState: AppState = {
@@ -54,12 +60,14 @@ function createAppStore() {
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation
// Fire-and-forget async initialisation; the promise is exposed via
// whenReady() so boot-time consumers (audio-level restore) can await
// the config read before reading settings.
const init = async () => {
const loaded = await loadAppStateFromFile();
setState(loaded);
};
init();
const appInit = init();
const saveState = (next: AppState) => {
saveAppStateToFile(next);
@@ -118,6 +126,8 @@ function createAppStore() {
return {
state,
/** Resolves once persisted settings are loaded from disk. */
whenReady: () => appInit,
updateSettings,
updatePreferences,
updateCustomTheme,

View File

@@ -9,14 +9,12 @@ import {
saveAudioNavToFile,
} from "../utils/app-persistence";
/** Source type for audio navigation */
export enum AudioSource {
FEED = "feed",
MY_SHOWS = "my_shows",
SEARCH = "search",
}
/** Audio navigation state */
export interface AudioNavState {
/** Current source type */
source: AudioSource;
@@ -28,14 +26,12 @@ export interface AudioNavState {
lastUpdated: Date;
}
/** Default navigation state */
const defaultNavState: AudioNavState = {
source: AudioSource.FEED,
currentIndex: 0,
lastUpdated: new Date(),
};
/** Create audio navigation store */
function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
@@ -56,12 +52,10 @@ function createAudioNavStore() {
init();
return {
/** Get current navigation state */
get state(): AudioNavState {
return navState();
},
/** Update source type */
setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({
...prev,
@@ -72,7 +66,6 @@ function createAudioNavStore() {
persist();
},
/** Move to next episode */
next: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -82,7 +75,6 @@ function createAudioNavStore() {
persist();
},
/** Move to previous episode */
prev: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -92,23 +84,19 @@ function createAudioNavStore() {
persist();
},
/** Reset to default state */
reset: () => {
setNavState(defaultNavState);
persist();
},
/** Get current index */
getCurrentIndex: (): number => {
return navState().currentIndex;
},
/** Get current source */
getSource: (): AudioSource => {
return navState().source;
},
/** Get current podcast ID */
getPodcastId: (): string | undefined => {
return navState().podcastId;
},

View File

@@ -11,6 +11,7 @@
import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import { useFeedStore } from "./feed";
export interface DiscoverCategory {
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
/** Cache window for the remote featured list (24 hours) */
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** Max episodes to load when previewing an unsubscribed show's episode list
* from Discover (drill-in, no subscription). Mirrors the refresh window. */
const PREVIEW_EPISODE_LIMIT = 50;
/** Shape of a single entry in the remote JSON */
interface FeaturedEntry {
id: string;
@@ -85,12 +90,24 @@ function syncSubscriptionState(
}));
}
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
// Episodes fetched for an unsubscribed show's preview list (drill-in from
// a podcast result, no subscription). Cached per podcast id for the
// session; keyed by id so switching shows never clobbers another's list.
const [previewEpisodes, setPreviewEpisodes] = createSignal<
Record<string, Episode[]>
>({});
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
new Set(),
);
const [previewErrors, setPreviewErrors] = createSignal<
Record<string, string>
>({});
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0;
@@ -107,7 +124,6 @@ export function createDiscoverStore() {
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
@@ -131,7 +147,6 @@ export function createDiscoverStore() {
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
@@ -140,7 +155,6 @@ export function createDiscoverStore() {
}
};
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
const category = selectedCategory();
if (category === "all") {
@@ -155,7 +169,6 @@ export function createDiscoverStore() {
});
};
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -168,7 +181,6 @@ export function createDiscoverStore() {
);
};
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -180,14 +192,64 @@ export function createDiscoverStore() {
);
};
/** Toggle subscription */
const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) {
unsubscribe(podcastId);
} else {
subscribe(podcastId);
// ── episode preview (drill-in, no subscription) ──────────────────────────
/** Cached episode list for a previewed show (empty until first drill-in). */
const episodesForPodcast = (podcastId: string): Episode[] =>
previewEpisodes()[podcastId] ?? [];
const isLoadingEpisodesFor = (podcastId: string): boolean =>
previewLoading().has(podcastId);
const previewError = (podcastId: string): string | undefined =>
previewErrors()[podcastId];
/** Fetch a show's episode list WITHOUT subscribing (Discover preview).
* The list is cached per podcast id; a failed fetch records an error
* and keeps any previous cache (a retry via refreshEpisodes clears it). */
const openEpisodes = async (podcast: Podcast): Promise<void> => {
if (previewEpisodes()[podcast.id] || previewLoading().has(podcast.id))
return;
if (!podcast.feedUrl) {
setPreviewErrors((prev) => ({
...prev,
[podcast.id]: "No RSS feed listed for this show.",
}));
return;
}
setPreviewLoading((prev) => new Set(prev).add(podcast.id));
const feedStore = useFeedStore();
const { episodes } = await feedStore.fetchEpisodes(
podcast.feedUrl,
PREVIEW_EPISODE_LIMIT,
);
if (episodes) {
setPreviewEpisodes((prev) => ({ ...prev, [podcast.id]: episodes }));
} else {
setPreviewErrors((prev) => ({
...prev,
[podcast.id]: "Couldn't load episodes.",
}));
}
setPreviewLoading((prev) => {
const next = new Set(prev);
next.delete(podcast.id);
return next;
});
};
/** Re-fetch a previewed show's episode list (`r` on the episodes depth). */
const refreshEpisodes = async (podcast: Podcast): Promise<void> => {
setPreviewErrors((prev) => {
const next = { ...prev };
delete next[podcast.id];
return next;
});
setPreviewEpisodes((prev) => {
const next = { ...prev };
delete next[podcast.id];
return next;
});
await openEpisodes(podcast);
};
return {
@@ -202,12 +264,17 @@ export function createDiscoverStore() {
setSelectedCategory,
subscribe,
unsubscribe,
toggleSubscription,
refresh,
// Episode preview (drill-in, no subscription)
episodesForPodcast,
isLoadingEpisodesFor,
previewError,
openEpisodes,
refreshEpisodes,
};
}
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() {

View File

@@ -10,6 +10,7 @@ import { createSignal } from "solid-js";
import { DownloadStatus } from "../types/episode";
import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode";
import type { Podcast } from "../types/podcast";
import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
import { useFeedStore } from "./feed";
@@ -17,6 +18,24 @@ import { useFeedStore } from "./feed";
const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2;
/** Prefix for synthetic feed ids of unsubscribed-show downloads (search
* downloads). The id doubles as the file subdirectory name, so it must be
* filesystem-safe. */
const UNSUBSCRIBED_FEED_PREFIX = "unsub-";
/** Deterministic synthetic feed id for a show that isn't subscribed: groups
* its search downloads together (and names their file subdirectory) without
* colliding with real feed ids (UUIDs). */
function unsubscribedFeedId(podcast: Pick<Podcast, "feedUrl" | "title">): string {
const base = podcast.feedUrl || podcast.title;
const slug = base
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return `${UNSUBSCRIBED_FEED_PREFIX}${slug || "podcast"}`;
}
/** Serializable download record for persistence */
interface DownloadRecord {
episodeId: string;
@@ -28,6 +47,12 @@ interface DownloadRecord {
error: string | null;
audioUrl: string;
episodeTitle: string;
/** ISO publication date, for unsubscribed-show downloads. */
pubDate?: string;
/** Show title, for downloads whose show isn't subscribed. */
podcastTitle?: string;
/** The show's RSS feed URL (re-classifies the download once subscribed). */
podcastFeedUrl?: string;
}
/** Queue item for pending downloads */
@@ -38,7 +63,62 @@ interface QueueItem {
episodeTitle: string;
}
/** Create download store */
// ── post-download decoration ─────────────────────────────────────────────────
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
* picks it up for Now Playing art when the local file plays (same basename,
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
* hangs in compiled binaries, so the shipped app never wrote this file. */
function writeCoverArt(filePath: string, coverUrl: string): void {
const dot = filePath.lastIndexOf(".");
if (dot <= 0) return;
const coverPath = filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
coverPath,
coverUrl,
])
.exited.catch(() => {});
}
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
* metadata for local playback is title=episode, artist=podcast — the source
* streams carry no usable tags and macOS composes "title - artist" from
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
* into place. */
function tagLocalFile(
filePath: string,
episode: Episode,
podcastTitle: string,
): void {
const tmp = `${filePath}.tag.mp3`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
filePath,
"-c",
"copy",
"-metadata",
`title=${episode.title}`,
"-metadata",
`artist=${podcastTitle}`,
tmp,
])
.exited.then(async (code) => {
if (code !== 0) return;
const { renameSync } = await import("node:fs");
renameSync(tmp, filePath);
})
.catch(() => {});
}
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
@@ -81,6 +161,11 @@ function createDownloadStore() {
speed: 0,
fileSize: rec.fileSize,
error: rec.error,
episodeTitle: rec.episodeTitle || undefined,
audioUrl: rec.audioUrl || undefined,
pubDate: rec.pubDate || undefined,
podcastTitle: rec.podcastTitle || undefined,
podcastFeedUrl: rec.podcastFeedUrl || undefined,
});
}
return map;
@@ -106,8 +191,11 @@ function createDownloadStore() {
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
fileSize: dl.fileSize,
error: dl.error,
audioUrl: qItem?.audioUrl ?? "",
episodeTitle: qItem?.episodeTitle ?? "",
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
pubDate: dl.pubDate,
podcastTitle: dl.podcastTitle,
podcastFeedUrl: dl.podcastFeedUrl,
});
}
const filePath = getConfigFilePath(DOWNLOADS_FILE);
@@ -162,7 +250,6 @@ function createDownloadStore() {
}
}
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController();
abortControllers.set(item.episodeId, controller);
@@ -203,24 +290,23 @@ function createDownloadStore() {
error: null,
});
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art.
const coverUrl = useFeedStore()
.feeds()
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
if (coverUrl && result.filePath) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
fetch(coverUrl)
.then(async (r) => {
if (!r.ok) return;
await Bun.write(
coverPath,
new Uint8Array(await r.arrayBuffer()),
);
})
.catch(() => {});
// Decorate the local file: cover art + ID3 tags (see the
// module-level helpers above) — the source streams carry neither.
// Cover falls back to the episode's own image when the feed has
// no channel cover (URL-added feeds).
const feedStore = useFeedStore();
const episode = feedStore.findEpisode(item.episodeId);
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
if (result.filePath && coverUrl) {
writeCoverArt(result.filePath, coverUrl);
}
if (result.filePath && episode) {
const podcastTitle =
feed?.podcast.title ??
downloads().get(item.episodeId)?.podcastTitle;
if (podcastTitle) {
tagLocalFile(result.filePath, episode, podcastTitle);
}
}
} else {
@@ -236,22 +322,18 @@ function createDownloadStore() {
processQueue();
}
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
};
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0;
};
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId);
};
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
@@ -260,8 +342,19 @@ function createDownloadStore() {
return null;
};
/** Start downloading an episode */
const startDownload = (episode: Episode, feedId: string): void => {
/** Optional metadata for a download whose show isn't subscribed (search
* downloads) — without it the record cannot render a title or be
* re-classified once the show is subscribed. */
interface UnsubscribedMeta {
podcastTitle: string;
podcastFeedUrl?: string;
}
const startDownload = (
episode: Episode,
feedId: string,
meta?: UnsubscribedMeta,
): void => {
const existing = downloads().get(episode.id);
if (
existing?.status === DownloadStatus.DOWNLOADING ||
@@ -280,6 +373,11 @@ function createDownloadStore() {
speed: 0,
fileSize: episode.fileSize ?? 0,
error: null,
episodeTitle: episode.title,
audioUrl: episode.audioUrl,
pubDate: episode.pubDate.toISOString(),
podcastTitle: meta?.podcastTitle,
podcastFeedUrl: meta?.podcastFeedUrl,
};
setDownloads((prev) => {
@@ -300,7 +398,21 @@ function createDownloadStore() {
processQueue();
};
/** Cancel a download */
/** Start downloading an episode of a show that is NOT subscribed. The
* download gets a deterministic synthetic feed id (also its file
* subdirectory) plus the show's metadata so it can render under
* "Unsubscribed Show Downloads" and re-classify if the user later
* subscribes to the show. */
const startUnsubscribedDownload = (
episode: Episode,
podcast: Podcast,
): void => {
startDownload(episode, unsubscribedFeedId(podcast), {
podcastTitle: podcast.title,
podcastFeedUrl: podcast.feedUrl || undefined,
});
};
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId);
@@ -321,7 +433,6 @@ function createDownloadStore() {
saveDownloads().catch(() => {});
};
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId);
if (dl?.filePath) {
@@ -348,10 +459,18 @@ function createDownloadStore() {
};
/** Remove every download (active/queued/completed) belonging to a feed —
* abort in-flight transfers, drop queued items, delete files + metadata. */
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
* abort in-flight transfers, drop queued items, delete files + metadata.
* Also removes downloads of the same show made while it was unsubscribed
* (matched by podcastFeedUrl) so unsubscribing purges search downloads
* of that show too. */
const removeDownloadsForFeed = async (
feedId: string,
podcastFeedUrl?: string,
): Promise<void> => {
const eps = Array.from(downloads().values()).filter(
(d) => d.feedId === feedId,
(d) =>
d.feedId === feedId ||
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
);
for (const d of eps) {
cancelDownload(d.episodeId);
@@ -359,17 +478,32 @@ function createDownloadStore() {
}
};
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values());
};
/** Get the current queue */
/** Downloads whose show is not subscribed — the "Unsubscribed Show
* Downloads" list shown in My Shows and the settings download manager.
* Reads feeds() so the list re-classifies (drops out) the moment the
* user subscribes to the show. Matched by feed id, or by the show's
* feed URL (covers downloads made before the show was subscribed). */
const getUnsubscribedDownloads = (): DownloadedEpisode[] => {
const feeds = useFeedStore().feeds();
return Array.from(downloads().values()).filter((d) => {
if (feeds.some((f) => f.id === d.feedId)) return false;
if (d.podcastFeedUrl) {
return !feeds.some(
(f) => f.podcast.feedUrl === d.podcastFeedUrl,
);
}
return true;
});
};
const getQueue = (): QueueItem[] => {
return queue();
};
/** Get count of active downloads */
const getActiveCount = (): number => {
return activeCount();
};
@@ -381,18 +515,19 @@ function createDownloadStore() {
getDownload,
getDownloadedFilePath,
getAllDownloads,
getUnsubscribedDownloads,
getQueue,
getActiveCount,
// Actions
startDownload,
startUnsubscribedDownload,
cancelDownload,
removeDownload,
removeDownloadsForFeed,
};
}
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() {

View File

@@ -4,46 +4,137 @@
*/
import { createSignal } from "solid-js";
import { Effect } from "effect";
import { refreshFeedsBatch } from "../effects/feed-refresh";
import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Feed } from "../types/feed";
import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser";
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
import { FETCH_TIMEOUT_MS, fetchFeedXml } from "../utils/rss-client";
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials";
import { mergeEpisodesBounded } from "../utils/episode-merge";
import {
episodeKeepFn,
episodeTs,
dateFetchMoreCutoff,
dateBandCount,
sameRefreshWindow,
} from "../utils/episode-windows";
import { createSourceRegistry } from "../utils/source-registry";
import { createPersistScheduler } from "./persist";
import {
DEFAULT_EPISODE_WINDOW_DAYS,
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile,
loadSourcesFromFile,
} from "../utils/feeds-persistence";
import { useActivityStore } from "./activity";
import { useDownloadStore } from "./download";
import { useAppStore } from "./app";
import { DownloadStatus } from "../types/episode";
/** Max episodes to load per page/chunk */
/** Max episodes to load per page/chunk (count mode only — date mode steps
* by episode-windows' fetch-more band instead). */
const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
* batch. */
const FETCH_CONCURRENCY = 4;
/** Default minutes between automatic background feed refreshes. */
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
/** Max episodes parsed per chunk before yielding to the event loop — bounds
* the synchronous regex work per frame so one huge feed (or a batch of
* feeds) can't stall the renderer. */
const PARSE_CHUNK_SIZE = 5;
/** Hard ceiling on the in-memory full-parse cache per feed (newest first).
* The cache exists so fetch-more can page deeper without a refetch; without
* a ceiling a 5,000-episode archive pins tens of MB of Episode objects in
* RAM for the whole session (the old cache held EVERY parsed episode of
* every feed, contributing hundreds of MB for archive-heavy
* subscriptions). 1000 covers any realistic show's entire history —
* beyond it, hasMoreEpisodes flips false and the visible list is bounded
* by the user's cache preference as usual. */
const MAX_CACHED_EPISODES_PER_FEED = 1000;
/** Yield to the event loop (task queue) so the renderer can paint between
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
* bun:test fake timers trap those (feed-refresh/pagination tests run under
* vi.useFakeTimers and await refreshes, so a trapped yield would deadlock
* them); MessageChannel posts are real task-queue turns that fire in both
* environments. */
const yieldToUI = (): Promise<void> =>
new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => {
port1.close();
port2.close();
resolve();
};
port2.postMessage(null);
});
/** Parse all episodes from feed XML in bounded chunks, yielding to the event
* loop between chunks. The whole-feed sync `parseRSSFeed` would otherwise
* block the UI thread for the combined parse time of every feed in a
* refresh batch. */
const parseEpisodesIncremental = async (
xml: string,
feedUrl: string,
): Promise<Episode[]> => {
const items = getRSSItems(xml);
// Yield after the item-extraction regex (which scans the full XML
// synchronously) so the renderer paints before the first parse chunk.
await yieldToUI();
const episodes: Episode[] = new Array(items.length);
for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) {
const end = Math.min(start + PARSE_CHUNK_SIZE, items.length);
for (let i = start; i < end; i++) {
episodes[i] = parseRSSItem(items[i], feedUrl, i);
}
if (end < items.length) await yieldToUI();
}
return episodes;
};
/** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the
* full parse — the bound (count or date) is applied when reading, not when
* writing, so changing the preference takes effect without a refetch.
* Fetch-more reads beyond the bound from this cache (volatile only — the
* cache itself is never extended by fetch-more). */
const fullEpisodeCache = new Map<string, Episode[]>();
/** Track how many episodes are currently loaded per feed */
/** Track how many episodes are currently loaded (visible) per feed. The
* loaded window grows via fetch-more but never exceeds what the cache
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds);
}
/** Write closure for the persist scheduler — reads the live feed signal
* (wired by createFeedStore) so a flush always lands the latest value. */
let readFeeds: () => Feed[] = () => [];
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources);
}
/** Shared trailing-edge debouncer for config.json writes ("feeds" domain);
* sources persist immediately instead. */
const persistScheduler = createPersistScheduler(() => {
const prefs = useAppStore().state().preferences;
saveFeedsToFile(
readFeeds(),
prefs.episodeCacheMode === "date"
? Math.max(1, prefs.episodeCacheDays)
: undefined,
);
});
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
* keychain, marking the source hasCredentials and stripping the plaintext.
@@ -89,91 +180,41 @@ async function migratePlaintextCredentials(
return changed ? migrated : sources;
}
/** True when two episode lists hold the same episodes (id-set equality,
* order-insensitive). Refreshes compare fetched content against this so an
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
* "updated" sort — instead of reordering the list on every background
* refresh. */
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
if (a.length !== b.length) return false;
const ids = new Set(a.map((e) => e.id));
return b.every((e) => ids.has(e.id));
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES,
]);
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,
sortDirection: "desc",
});
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
readFeeds = () => feeds();
const registry = createSourceRegistry(DEFAULT_SOURCES);
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
* at episodeCacheCount × (presses + 1) episodes, so one press reveals
* exactly N more of the NEWEST episodes across all shows — it can never
* dump deep history (see getAllEpisodesChronological). */
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
const scheduleSaveFeeds = (): void => {
persistScheduler.schedule("feeds");
};
const flushPendingSave = (): void => {
persistScheduler.flush("feeds");
};
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility);
}
if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId);
}
if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned);
}
if (f.searchQuery) {
const query = f.searchQuery.toLowerCase();
result = result.filter(
(feed) =>
feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query),
// The filter signal is write-only (no caller mutates it), so every
// caller observes the defaults: "all" visibility and the stable
// "updated desc" sort with pinned feeds first.
const result = [...feeds()];
result.sort(
(a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime(),
);
}
const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => {
switch (f.sortBy) {
case "title":
return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title,
)
);
case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest);
case "updated":
default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
}
});
result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return 0;
});
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
@@ -191,47 +232,88 @@ function createFeedStore() {
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
);
// COUNT mode: the Feed page is a GLOBAL top-K list — the newest
// `episodeCacheCount × (fetch-more presses + 1)` episodes across ALL
// shows, not N per show. A press reveals exactly N more recent
// episodes; deep history never surfaces in one jump. The cap stays
// even once every cache is exhausted (the button hides) — lifting it
// rendered the full deep union and froze the UI.
const prefs = useAppStore().state().preferences;
if (prefs.episodeCacheMode === "count") {
const limit =
Math.max(1, prefs.episodeCacheCount ?? 25) *
(countFetchMorePresses() + 1);
return allEpisodes.slice(0, limit);
}
return allEpisodes;
};
/** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
/** Fetch latest episodes from an RSS feed URL, caching ALL parsed
* episodes in fullEpisodeCache. The visible episodes returned are
* bounded by the user's cache preference (count or date); the full
* cache survives so fetch-more can page beyond the bound without a
* refetch (volatile only — the cache is never extended by fetch-more).
* Returns NULL episodes on any failure — a failed fetch must not look
* like an empty feed, or the store would wipe a subscribed show's
* episodes. Also returns the channel-level artwork so callers can
* backfill a feed's coverUrl (subscribe + refresh). */
const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[]> => {
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
try {
const response = await fetch(feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return [];
const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
const xml = await fetchFeedXml(feedUrl);
if (xml === null) return { episodes: null, coverUrl: undefined };
// Yield after the network read so the renderer gets a turn
// before the sync regex + parse work begins.
await yieldToUI();
const allEpisodes = sortEpisodesReverseChronological(
await parseEpisodesIncremental(xml, feedUrl),
);
// Cache all parsed episodes for pagination
if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
// Cache the FULL parse — the bound is applied when reading,
// not when writing, so a preference change takes effect
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
// so an archive-heavy feed can't pin its entire history in
// RAM for the session (the visible window below is bounded
// by the user's preference regardless).
fullEpisodeCache.set(
feedId,
allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED),
);
}
return allEpisodes.slice(0, limit);
// Bound the visible window by the user's cache preference.
const prefs = useAppStore().state().preferences;
const keep = episodeKeepFn(prefs);
const bounded = allEpisodes.filter((ep, i) => keep(ep, i));
const visible = bounded.slice(0, limit);
if (feedId) {
// Track how many episodes are visible — the bounded window,
// not the full parse. hasMoreEpisodes compares this to the
// full cache length to decide if fetch-more can page deeper.
episodeLoadCount.set(feedId, visible.length);
}
return {
episodes: visible,
coverUrl: parseChannelCoverUrl(xml),
};
} catch {
return [];
return { episodes: null, coverUrl: undefined };
}
};
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
@@ -242,6 +324,11 @@ function createFeedStore() {
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
): Promise<Feed | null> => {
const activity = useActivityStore();
// The "Subscribing" label covers the directory-resolve + subscribe
// fetch stretch — the gaps no existing signal (isLoadingFeeds,
// per-pane spinners) covers.
return activity.track((async () => {
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
// feed URL; resolve the real feed from its directory page before
// subscribing. Refuse when it can't be resolved rather than adding a
@@ -259,15 +346,18 @@ function createFeedStore() {
}
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
const { episodes, coverUrl } = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
if (!podcast.coverUrl && coverUrl) {
podcast = { ...podcast, coverUrl };
}
const newFeed: Feed = {
id: feedId,
podcast,
episodes,
episodes: episodes ?? [],
visibility,
sourceId,
lastUpdated: new Date(),
@@ -275,12 +365,13 @@ function createFeedStore() {
};
setFeeds((prev) => {
const updated = [...prev, newFeed];
saveFeeds(updated);
scheduleSaveFeeds();
return updated;
});
// Global auto-download: newly subscribed shows join the next pass.
runAutoDownload();
return newFeed;
})(), "Subscribing");
};
/** Download the N most recent episodes of every in-scope show, per the
@@ -318,82 +409,123 @@ function createFeedStore() {
};
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
* only when the content actually changed (see sameEpisodes). Returns the
* ORIGINAL array reference when nothing changed so callers skip
* persistence entirely — a refresh that fetched identical episodes must
* not re-sort the "updated" view. */
* only when the content actually changed (see sameRefreshWindow). The
* fetched window is MERGED into the existing episodes (fetched copy wins
* on id collision) so a refresh never shrinks the in-memory list; the
* union is pruned by the user's cache bound (count or date) so episodes
* outside the bound fall out of the visible list on the next refresh.
* Returns the ORIGINAL array reference when nothing changed so callers
* skip persistence entirely — a refresh that fetched identical episodes
* must not re-sort the "updated" view. */
const applyRefreshedEpisodes = (
prev: Feed[],
feedId: string,
episodes: Episode[],
): Feed[] => {
let changed = false;
const prefs = useAppStore().state().preferences;
const keep = episodeKeepFn(prefs);
const updated = prev.map((f) => {
if (f.id !== feedId) return f;
if (sameEpisodes(f.episodes, episodes)) return f;
const merged = mergeEpisodesBounded(f.episodes, episodes, keep);
if (sameRefreshWindow(f.episodes, episodes)) return f;
changed = true;
return { ...f, episodes, lastUpdated: new Date() };
return { ...f, episodes: merged, lastUpdated: new Date() };
});
return changed ? updated : prev;
};
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const activity = useActivityStore();
return activity.track((async () => {
const feed = getFeed(feedId);
if (!feed) return;
const episodes = await fetchEpisodes(
const { episodes, coverUrl } = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
// Fetch failed (null): keep the currently loaded episodes untouched.
if (!episodes) return;
setFeeds((prev) => {
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (updated !== prev) saveFeeds(updated);
let updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (coverUrl) {
updated = updated.map((f) =>
f.id === feedId && !f.podcast.coverUrl && coverUrl
? { ...f, podcast: { ...f.podcast, coverUrl } }
: f,
);
}
if (updated !== prev) scheduleSaveFeeds();
return updated;
});
// Global auto-download: ensure the N most recent episodes of in-scope
// shows are available offline after every refresh (idempotent).
runAutoDownload();
})(), "Refreshing");
};
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
* atomic update. Per-feed incremental setFeeds re-sorted the list once
* per completion (each refresh bumped lastUpdated and the "updated" sort
* re-ran), which showed up as the list order flapping until the batch
* finished. */
/** Refresh all feeds via the Effect batch program (effects/feed-refresh):
* bounded concurrency (at most FETCH_CONCURRENCY in-flight requests)
* and each feed's refreshed episodes applied AS ITS OWN FETCH LANDS
* (no barrier — the apply runs inside the feed's own fiber). Per-feed
* apply is safe because applyRefreshedEpisodes keeps unchanged feeds'
* object identity and lastUpdated (union merge), so each feed's
* refreshed episodes render as its own fetch resolves — the order
* flapping the old atomic barrier existed to hide can no longer
* happen. A failed or timed-out fetch (null episodes) leaves that
* feed untouched. */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
const currentFeeds = feeds();
const results = await Promise.all(
currentFeeds.map(async (feed) => [
feed.id,
await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feed.id,
),
] as const),
);
await Effect.runPromise(
refreshFeedsBatch(
feeds(),
(feed) =>
fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id),
(feed, { episodes, coverUrl }) => {
setFeeds((prev) => {
let updated = prev;
for (const [feedId, episodes] of results) {
updated = applyRefreshedEpisodes(updated, feedId, episodes);
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
if (coverUrl) {
updated = updated.map((f) =>
f.id === feed.id && !f.podcast.coverUrl && coverUrl
? { ...f, podcast: { ...f.podcast, coverUrl } }
: f,
);
}
if (updated !== prev) saveFeeds(updated);
if (updated !== prev) scheduleSaveFeeds();
return updated;
});
},
{ concurrency: FETCH_CONCURRENCY, timeoutMs: FETCH_TIMEOUT_MS },
),
);
// Global auto-download: one idempotent pass after the batch.
runAutoDownload();
// A refresh batch always ends with a persisted write when
// anything changed — never leave the debounce's trailing edge
// pending across a process exit.
flushPendingSave();
} finally {
setIsLoadingFeeds(false);
}
};
// Resolves once the persisted feeds are loaded and visible to feeds() —
// before the background refresh so boot-time consumers (player-session
// restore) don't wait on the network.
const { promise: feedsReady, resolve: resolveFeedsReady } =
Promise.withResolvers<void>();
(async () => {
const loadedFeeds = await loadFeedsFromFile();
const loadedFeeds = await loadFeedsFromFile(
useAppStore().state().preferences.episodeCacheMode === "date"
? Math.max(1, useAppStore().state().preferences.episodeCacheDays)
: undefined,
);
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
resolveFeedsReady();
const loadedSources = await loadSourcesFromFile<PodcastSource>();
// The default "rss" placeholder source fabricated fake search results
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
@@ -416,19 +548,45 @@ function createFeedStore() {
// apiKey/apiSecret (pre-keychain builds) move into the macOS
// keychain and are stripped from config.json.
const secured = await migratePlaintextCredentials(mergedSources);
setSources(secured);
if (secured !== mergedSources) saveSources(secured);
registry.replaceAll(secured);
if (secured !== mergedSources) saveSourcesToFile(secured);
}
await refreshAllFeeds();
})();
/** Remove a feed */
// ── Background refresh ──────────────────────────────────────────────────
// New episodes only reach the app while it runs if feeds are re-fetched
// on a schedule: startup and manual `r` alone leave a subscribed show's
// latest episode invisible until the user restarts (or presses r). A
// self-rescheduling timer re-reads the interval preference on every tick
// so a settings change takes effect without a restart, and skips a tick
// that would overlap an in-flight refresh (manual or background).
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleNextRefresh = () => {
if (refreshTimer) clearTimeout(refreshTimer);
const minutes = Math.max(
1,
useAppStore().state().preferences.refreshIntervalMinutes ??
DEFAULT_REFRESH_INTERVAL_MINUTES,
);
refreshTimer = setTimeout(() => {
if (!isLoadingFeeds()) {
refreshAllFeeds().catch(() => {});
}
scheduleNextRefresh();
}, minutes * 60_000);
};
scheduleNextRefresh();
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId);
setFeeds((prev) => {
const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated);
// Unsubscribe intent must not sit in the debounce window if the
// process exits — persist the removal immediately.
scheduleSaveFeeds();
flushPendingSave();
return updated;
});
};
@@ -441,103 +599,59 @@ function createFeedStore() {
episodeLoadCount.delete(feed.id);
setFeeds((prev) => {
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
saveFeeds(updated);
// Unsubscribe intent must not sit in the debounce window if
// the process exits — persist the removal immediately.
scheduleSaveFeeds();
flushPendingSave();
return updated;
});
}
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Toggle feed pinned status */
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSources(updated);
return updated;
});
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSources(updated);
return updated;
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);
saveSources(updated);
return updated;
});
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSources(updated);
return updated;
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
/** Find an episode by ID across all loaded feeds (undefined when the
* episode isn't in any loaded window, e.g. an unsubscribed show). */
const findEpisode = (episodeId: string): Episode | undefined => {
for (const feed of feeds()) {
const ep = feed.episodes.find((e) => e.id === episodeId);
if (ep) return ep;
}
return undefined;
};
/** Check if a feed has more episodes available beyond what's currently loaded */
/** Check if a feed has more episodes available beyond what's currently
* loaded. The full parse cache holds ALL episodes (including beyond the
* cache bound), so fetch-more can page deeper — but in DATE mode only
* when the next unloaded episode falls inside the next 2-week band: a
* sparse/dormant show whose band is empty reports false, so fetch-more
* never drags in arbitrarily old episodes just because the parse cache
* holds them. When the loaded window reaches the cache length (or the
* band is empty), this flips false. */
const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId);
if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0;
return loaded < cached.length;
if (loaded >= cached.length) return false;
const prefs = useAppStore().state().preferences;
if (prefs.episodeCacheMode === "count") return true;
const cutoff = dateFetchMoreCutoff(
cached,
loaded,
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
);
return episodeTs(cached[loaded]) >= cutoff;
};
/** Load the next chunk of episodes for one feed from the cache.
/** Load the next chunk of episodes for one feed from the full parse
* cache — VOLATILE only: the episodes surfaced beyond the cache bound
* are held in the feed's in-memory episode list (so the user can browse
* them) but are NOT written back to fullEpisodeCache (the cache keeps
* its original bounded shape; these episodes vanish on the next
* refresh or restart). The cache is populated by fetchEpisodes/refresh;
* a cold cache (post-restart) triggers a refetch here.
* No global guard — callers own the `isLoadingMore` flag so batches
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
const loadMoreEpisodesForFeed = async (feedId: string) => {
@@ -546,39 +660,75 @@ function createFeedStore() {
let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed
// If no cache, re-fetch and parse the full feed (cold path after a
// restart). The cache holds the FULL parse — no bound applied here.
if (!cached) {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
try {
const xml = await fetchFeedXml(feed.podcast.feedUrl);
if (xml === null) return;
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
} catch {
// Failed/hung refetch: leave the feed's loaded episodes
// untouched rather than throwing out of loadMoreEpisodes.
return;
}
// Cold-refetch parse output is unsorted; sort it newest-first.
// Yield before the sync sort (the parse already yielded before
// this point, but the sort of potentially hundreds of episodes
// is its own sync block).
await yieldToUI();
cached = sortEpisodesReverseChronological(cached);
// Same ceiling as fetchEpisodes: the cache (and the paging
// window below) never exceeds MAX_CACHED_EPISODES_PER_FEED.
cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED);
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
const prefs = useAppStore().state().preferences;
// Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band
// past the oldest loaded episode (or the cache-window edge when
// nothing is loaded) — a daily show gains ~2 weeks of episodes, a
// weekly show gains its next 2, never a fixed count. An empty band
// is a genuine stop (hasMoreEpisodes hides the button) — no minimum,
// so a sparse/dormant show can't grab arbitrarily old episodes.
// Count mode keeps the fixed MAX_EPISODES_REFRESH chunk.
let newCount: number;
if (prefs.episodeCacheMode === "date") {
const cutoff = dateFetchMoreCutoff(
cached,
currentCount,
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
);
newCount = dateBandCount(cached, currentCount, cutoff);
} else {
newCount = currentCount + MAX_EPISODES_REFRESH;
}
newCount = Math.min(newCount, cached.length);
if (newCount <= currentCount) return; // nothing more to load
// Advance the loaded window — volatile: the episodes beyond the cache
// bound are held in feed.episodes (visible) but the cache itself is
// NOT extended. episodeLoadCount tracks the volatile window size.
episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount);
// Yield a real macrotask turn before the sync state update so the
// renderer paints the spinner and processes keyboard input before the
// (potentially large, per-feed in loadMoreAllFeeds) setFeeds + sort
// runs. Without this, the whole body executes in one microtask batch
// and the UI freezes through every feed in the batch.
await yieldToUI();
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f,
);
saveFeeds(updated);
scheduleSaveFeeds();
return updated;
});
};
@@ -595,27 +745,81 @@ function createFeedStore() {
}
};
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
* still has cached episodes — powers the Feed page's "[Fetch More]". */
/** Power the Feed page's "[Fetch More]".
* Date mode: advance each feed's window by its 2-week band (empty bands
* — sparse/dormant shows — are skipped).
* Count mode: the global list cap grows by one count (see
* getAllEpisodesChronological) and every feed's window deepens by one
* count so the growing cap has material; one press reveals exactly N
* more RECENT episodes, never a far-back dump.
* Both modes compute every feed's new window FIRST (yielding between
* feeds so the renderer keeps painting) and apply ONE setFeeds — the
* Feed list rebuilds once per press instead of once per feed (the
* per-feed storms froze the UI). */
const loadMoreAllFeeds = async () => {
if (isLoadingMore()) return;
setIsLoadingMore(true);
try {
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
for (const feed of pending) {
await loadMoreEpisodesForFeed(feed.id);
const prefs = useAppStore().state().preferences;
const count = Math.max(1, prefs.episodeCacheCount ?? 25);
if (prefs.episodeCacheMode === "count") {
setCountFetchMorePresses((p) => p + 1);
}
const windowDays =
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS;
const updates: Array<{ feedId: string; episodes: Episode[] }> = [];
for (const feed of feeds()) {
const cached = fullEpisodeCache.get(feed.id);
if (!cached) continue;
const currentCount =
episodeLoadCount.get(feed.id) ?? feed.episodes.length;
if (currentCount >= cached.length) continue;
let newCount: number;
if (prefs.episodeCacheMode === "count") {
newCount = Math.min(currentCount + count, cached.length);
} else {
// Date mode: skip feeds whose next band is empty — the
// button must not surface arbitrarily old episodes.
const cutoff = dateFetchMoreCutoff(
cached,
currentCount,
windowDays,
);
newCount = dateBandCount(cached, currentCount, cutoff);
}
if (newCount <= currentCount) continue;
episodeLoadCount.set(feed.id, newCount);
updates.push({
feedId: feed.id,
episodes: cached.slice(0, newCount),
});
// Yield so the renderer paints between feed computations.
await yieldToUI();
}
if (updates.length > 0) {
const byId = new Map(
updates.map((u) => [u.feedId, u.episodes]),
);
setFeeds((prev) =>
prev.map((f) =>
byId.has(f.id)
? { ...f, episodes: byId.get(f.id)! }
: f,
),
);
scheduleSaveFeeds();
}
} finally {
setIsLoadingMore(false);
}
};
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
@@ -623,44 +827,48 @@ function createFeedStore() {
return {
// State
feeds,
sources,
filter,
selectedFeedId,
sources: registry.sources,
isLoadingMore,
/** Resolves once persisted feeds are loaded from disk (before the
* background refresh). */
whenReady: () => feedsReady,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
getSelectedFeed,
findEpisode,
hasMoreEpisodes,
isLoadingFeeds,
// Actions
setFilter,
setSelectedFeedId,
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
* record (Discover's episode preview). Pass no feedId to skip the
* full-parse cache; the visible window is bounded by the user's
* cache preference and `limit`. */
fetchEpisodes,
addFeed,
hasFeedByUrl,
removeFeed,
removeFeedByUrl,
updateFeed,
togglePinned,
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
loadMoreAllFeeds,
hasMoreAcrossAll,
addSource,
removeSource,
toggleSource,
updateSource,
flushPendingSave,
addSource: registry.addSource,
toggleSource: registry.toggleSource,
updateSource: registry.updateSource,
runAutoDownload: runAutoDownloadNow,
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
/** Re-exported: refresh-merge tests import it from the store module. */
export { sameRefreshWindow } from "../utils/episode-windows";
export function useFeedStore() {
if (!feedStoreInstance) {
feedStoreInstance = createFeedStore();

144
src/stores/pane-layout.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* pane-layout — global split positions for the yazi-style pane rows.
*
* Every tab renders its parent|current|preview columns through `PaneRow`
* sharing one layout: the two borders of the CENTER (current) column are
* draggable, and their positions are stored here as fractions of the row
* width (so a terminal resize re-derives pixel positions proportionally).
*
* parent (15+) | current (30+) | preview (15+)
* ── left ───────── right ──────── <-- draggable borders
*
* Defaults mirror the old fixed 2:5:3 ratio (20% / 50% / 30%). Minimum
* pane widths are enforced in `splitPixels` whenever a border is dragged
* or the row is re-derived. The positions persist to the app preferences
* on `commit()` (drag end) — never per drag event, so drags don't thrash
* config.json.
*/
import type { PaneSplits } from "@/types/settings";
import { createSignal } from "solid-js";
import { useAppStore } from "./app";
// ── Types ───────────────────────────────────────────────────────────────
/** Public surface of the shared pane-layout store. */
export interface PaneLayoutStore {
/** Current split positions (fractions of the row width). */
splits(): PaneSplits;
/** Move the left border of the current pane to column `x`. */
setLeft(x: number, width: number): void;
/** Move the right border of the current pane to column `x`. */
setRight(x: number, width: number): void;
/** Persist the current splits (called on drag end, not per drag event). */
commit(): void;
}
// ── Constants ───────────────────────────────────────────────────────────
/** Default split — the historical 2:5:3 ratio (parent 20 / current 50 / preview 30). */
export const DEFAULT_PANE_SPLITS: PaneSplits = { left: 0.2, right: 0.7 };
/** Per-pane minimum widths (columns) enforced while a border is being
* dragged. Static rendering maps stored fractions 1:1 to pixels — the
* minimums never distort the user's chosen layout on narrow terminals. */
export const MIN_PANE_WIDTH = {
parent: 15,
current: 30,
preview: 15,
} as const;
/** Clamp `v` into [`lo`, `hi`] (bounds may invert on degenerate widths). */
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
/** Minimum row width that can hold all three panes at their drag minimums. */
const MIN_TOTAL_WIDTH =
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
/** Resolve stored splits into concrete pixel columns for a row `width`.
* A pure 1:1 fraction→pixel mapping (keeping the ratio exact for every
* terminal size); min-width enforcement lives in the drag setters only. */
export function splitPixels(
width: number,
splits: PaneSplits,
): { leftPx: number; rightPx: number } {
if (width <= 0) return { leftPx: 0, rightPx: 0 };
const leftPx = Math.round(width * splits.left);
const rightPx = Math.max(leftPx + 1, Math.round(width * splits.right));
return { leftPx, rightPx };
}
export function createPaneLayoutStore(): PaneLayoutStore {
const app = useAppStore();
// Seeded from persisted preferences; `saved` is always defined because
// the app store backfills the default when the config predates it.
const [splits, setSplits] = createSignal<PaneSplits>(
app.state().preferences.paneSplit ?? DEFAULT_PANE_SPLITS,
);
/** Store the normalized pixel positions as fractions of `width`. */
const applyPixels = (leftPx: number, rightPx: number, width: number) => {
if (width <= 0) return;
setSplits({ left: leftPx / width, right: rightPx / width });
};
/** Clamp a dragged border to the per-pane minimums (only on terminals
* wide enough to hold them; smaller rows just follow the cursor). */
const dragPixels = (
leftPx: number,
rightPx: number,
width: number,
): { leftPx: number; rightPx: number } => {
if (width < MIN_TOTAL_WIDTH)
return {
leftPx: clamp(leftPx, 1, width - 2),
rightPx: Math.max(rightPx, leftPx + 1),
};
const minLeft = MIN_PANE_WIDTH.parent;
const maxLeft = width - MIN_PANE_WIDTH.current - MIN_PANE_WIDTH.preview;
const maxRight = width - MIN_PANE_WIDTH.preview;
const newLeft = clamp(leftPx, minLeft, maxLeft);
// The dragged border follows the cursor; the other border is pushed
// only as far as needed to keep the current pane at its minimum.
return {
leftPx: newLeft,
rightPx: clamp(rightPx, newLeft + MIN_PANE_WIDTH.current, maxRight),
};
};
/** Move the left border to column `x` (the current pane's left edge). */
const setLeft = (x: number, width: number) => {
if (width <= 0) return;
const { rightPx: curRight } = splitPixels(width, splits());
const { leftPx, rightPx } = dragPixels(Math.round(x), curRight, width);
applyPixels(leftPx, rightPx, width);
};
/** Move the right border to column `x` (the current pane's right edge). */
const setRight = (x: number, width: number) => {
if (width <= 0) return;
const { leftPx: curLeft, rightPx: curRight } = splitPixels(width, splits());
const { leftPx, rightPx } = dragPixels(curLeft, Math.round(x), width);
applyPixels(leftPx, rightPx, width);
};
/** Persist the current splits (called on drag end, not per drag event). */
const commit = () => {
app.updatePreferences({ paneSplit: splits() });
};
return { splits, setLeft, setRight, commit };
}
// ── Singleton ───────────────────────────────────────────────────────────
let paneLayoutInstance: PaneLayoutStore | null = null;
/** Accessor for the shared pane-layout store (all tabs share one split). */
export function usePaneLayout(): PaneLayoutStore {
if (!paneLayoutInstance) paneLayoutInstance = createPaneLayoutStore();
return paneLayoutInstance;
}

57
src/stores/persist.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Persistence scheduler for PodTUI
* Per-domain trailing-edge debounced writes
*/
/** Debounced writer: rapid schedules collapse into one write per domain. */
export interface PersistScheduler {
/** Mark a domain dirty and (re)arm its trailing-edge write timer. */
schedule(domain: string): void;
/** Write the domain immediately if dirty; cancels any pending timer. */
flush(domain: string): void;
/** Write every dirty domain immediately. */
flushAll(): void;
}
/** Timer handle as returned by setTimeout in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
/** Build a per-domain trailing-edge debouncer. `write` is invoked with the
* domain name; callers read current state inside it, so a flush always
* lands the latest value. Rapid schedule() calls share one timer. */
export function createPersistScheduler(
write: (domain: string) => void,
debounceMs = 250,
): PersistScheduler {
const dirty = new Set<string>();
const timers = new Map<string, TimerHandle>();
const flush = (domain: string): void => {
const timer = timers.get(domain);
if (timer) {
clearTimeout(timer);
timers.delete(domain);
}
if (!dirty.has(domain)) return;
dirty.delete(domain);
write(domain);
};
const schedule = (domain: string): void => {
dirty.add(domain);
clearTimeout(timers.get(domain));
timers.set(
domain,
setTimeout(() => {
timers.delete(domain);
flush(domain);
}, debounceMs),
);
};
const flushAll = (): void => {
for (const domain of [...dirty]) flush(domain);
};
return { schedule, flush, flushAll };
}

View File

@@ -53,21 +53,21 @@ async function initProgress(): Promise<void> {
setProgressMap(parsed);
}
// Fire-and-forget init
initProgress();
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
// consumers (e.g. player-session restore) can await the file load.
const progressInit = initProgress();
function createProgressStore() {
return {
/**
* Get progress for a specific episode.
* Resolves once the persisted progress map has been loaded from disk.
*/
whenReady: () => progressInit,
get(episodeId: string): Progress | undefined {
return progressMap()[episodeId];
},
/**
* Get all progress entries.
*/
all(): Record<string, Progress> {
return progressMap();
},
@@ -96,18 +96,12 @@ function createProgressStore() {
persist();
},
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD;
},
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0;
@@ -145,9 +139,6 @@ function createProgressStore() {
persist();
},
/**
* Clear all progress data.
*/
clear(): void {
setProgressMap({});
persist();

View File

@@ -5,12 +5,16 @@
import { createSignal } from "solid-js";
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
import {
loadSearchHistoryFromFile,
saveSearchHistoryToFile,
} from "../utils/app-persistence";
import { useFeedStore } from "./feed";
import type { SearchResult, SearchScope } from "../types/source";
import { createPersistScheduler } from "./persist";
const STORAGE_KEY = "podtui_search_history";
const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 20;
const MAX_HISTORY = 10;
export interface SearchState {
query: string;
@@ -21,25 +25,19 @@ export interface SearchState {
const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */
function loadHistory(): string[] {
if (typeof localStorage === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/** Save search history to localStorage */
function saveHistory(history: string[]): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// Ignore errors
/** Normalize raw history: drop blanks, dedupe case-insensitively (newest
* wins), cap at MAX_HISTORY. */
function sanitizeHistory(items: string[]): string[] {
const seen = new Set<string>();
const cleaned: string[] = [];
for (const item of items) {
const trimmed = item.trim();
const key = trimmed.toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
cleaned.push(trimmed);
}
return cleaned.slice(0, MAX_HISTORY);
}
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
@@ -63,17 +61,34 @@ function saveScope(scope: SearchScope): void {
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [history, setHistory] = createSignal<string[]>([]);
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** History persistence: rapid mutations collapse into one debounced
* write; the closure reads the live signal so a flush lands the
* latest list. */
const persistHistory = createPersistScheduler((domain: string) => {
if (domain === "search-history") {
saveSearchHistoryToFile(history());
}
});
/** Load search history from file (fire-and-forget; recents appear as
* soon as the file is read). */
async function init(): Promise<void> {
const loaded = await loadSearchHistoryFromFile();
if (loaded.length > 0) setHistory(sanitizeHistory(loaded));
}
init();
/** Set the search scope (shows vs episodes) and persist it. */
const setScope = (next: SearchScope) => {
setScopeState(next);
@@ -161,39 +176,27 @@ export function createSearchStore() {
}
};
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
setHistory((prev) => sanitizeHistory([q, ...prev]));
persistHistory.schedule("search-history");
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveHistory([]);
persistHistory.schedule("search-history");
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
saveHistory(updated);
return updated;
});
setHistory((prev) => prev.filter((h) => h !== q));
persistHistory.schedule("search-history");
};
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
@@ -213,6 +216,27 @@ export function createSearchStore() {
);
};
/** Mark a podcast as unsubscribed in results (after an in-place
* unsubscribe from the results list). */
const markUnsubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId;
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: false,
},
};
}
return result;
}),
);
};
return {
// State
query,
@@ -232,10 +256,10 @@ export function createSearchStore() {
setSelectedSources,
setScope,
markSubscribed,
markUnsubscribed,
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {

556
src/stores/visualizer.ts Normal file
View File

@@ -0,0 +1,556 @@
/**
* visualizer-store — module-level singleton owning the realtime waveform
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
*
* Pipeline shape (see utils/audio-pcm-cache.ts for the rationale):
* an ffmpeg process decodes the episode at full speed into a
* position-indexed PCM cache; the render loop reads the window ending at
* the player's current position from that cache. Because reads are
* indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline
* choreography at all — and cannot desync:
*
* - Pause: stop the render loop and the decode pass; the PCM cache stays
* resident. Bars freeze on the last rendered frame.
* - Resume: re-arm the render loop — bars render instantly from the cache
* — and continue the tail decode in the background. No cold start, no
* coverage guessing, no clamped-buffer freeze (the old bug: resume
* re-armed the loop over a DEAD ffmpeg and the bars exhausted the ring
* buffer, then froze on a repeated stale window forever).
* - Seek into decoded audio: nothing to do. Seek into a hole: kick off a
* decode segment there; the last frame holds until data arrives.
* - Speed changes: nothing. The cache is position-indexed raw PCM.
*
* Focus lifecycle: Shell unmounts a tab's page when it loses focus, but the
* pipeline outlives the page so playback keeps visualizing; UNLOAD_DELAY_MS
* after the Player tab stops being focused it tears down. Reads outside
* decoded coverage return empty — the renderer simply holds the last frame
* until the decode frontier arrives.
*
* Loading semantics: `isLoading` is true from any pipeline start (cold
* start, resume into undecoded audio) until the first complete FFT frame,
* and `isStalled` while playback claims to be live but the position clock
* is frozen (player re-buffering). The component renders the spinner for
* either; bars replace it the moment fresh frames arrive.
*/
import {
createSignal,
createEffect,
createRoot,
on,
untrack,
} from "solid-js";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { EpisodePcmCache, PCM_SAMPLE_RATE } from "@/utils/audio-pcm-cache";
import { createBarScaler } from "@/utils/bar-mapping";
import { audioPlaybackSignals } from "@/utils/audio-signals";
import { useAppStore } from "@/stores/app";
// ── Constants ────────────────────────────────────────────────────────────
/** How long the pipeline keeps running after the Player tab loses focus. */
export const VISUALIZER_UNLOAD_DELAY_MS = 30_000;
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
/**
* How long the position clock may stay frozen while the UI believes
* playback is live before the waveform reports a stall (loading state).
* mpv polls time-pos every ~150ms, so a frozen clock means the player is
* re-buffering — the long-pause-then-resume case on network streams.
*/
const STALL_DETECT_MS = 2000;
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
// ── Types ────────────────────────────────────────────────────────────────
export interface VisualizerStore {
/** Frequency bar values (0.01.0 per bar), empty until the first frame. */
barData: () => number[];
/** True from pipeline start until the first complete FFT frame renders. */
isLoading: () => boolean;
/** True while playback claims to be live but the position clock has
* been frozen past STALL_DETECT_MS (player re-buffering, e.g. after a
* long pause on a network stream). */
isStalled: () => boolean;
/** True while the ~30fps render loop is armed. */
isRunning: () => boolean;
/** Report whether the Player tab is the visible tab. */
setFocused: (focused: boolean) => void;
/** Report the terminal-width-derived bar count (resize re-inits). */
setBarCount: (count: number) => void;
}
// ── Store factory ────────────────────────────────────────────────────────
function createVisualizerStore(): VisualizerStore {
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// True from pipeline start until the first complete FFT frame renders.
const [isLoading, setIsLoading] = createSignal(false);
// True while playback is live but the position clock is frozen
// (player re-buffering) — see STALL_DETECT_MS.
const [isStalled, setIsStalled] = createSignal(false);
// Whether the Player tab is the visible tab (fed by PlayerPage).
const [focused, setFocused] = createSignal(false);
// Width-derived bar count (fed by RealtimeWaveform; default before the
// renderer reports a real size).
const [barCount, setBarCount] = createSignal(64);
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
// frame against the running peak so a loud start can't pin every bar
// at full height and quiet content still gets normalized up.
const scaler = createBarScaler();
let cava: CavaCore | null = null;
// Position-indexed PCM cache for the current episode. Kept across
// pause/resume (segments survive; only the ffmpeg pass is killed) and
// dropped only on episode change, stop, disable, or unload.
let pcm: EpisodePcmCache | null = null;
let frameTimer: TimerHandle | null = null;
let sampleBuffer: Float64Array | null = null;
let unloadTimer: TimerHandle | null = null;
// Stall tracker: last observed position-signal value and when it moved.
// Any change (forward, backward, seek) re-arms the clock; a frozen
// signal while playing trips isStalled after STALL_DETECT_MS.
let lastRenderPos = -1;
let lastPosMoveAt = 0;
// Resume point: the position a paused pipeline was re-armed at. The
// loading state set by resume clears once the position clock has MOVED
// from this (either direction) — while the player is still re-buffering
// the clock is frozen, and the cache serving the same window must not
// let stale pre-pause bars masquerade as live data. -1 = cold start
// (clear on the first produced frame, regardless of the clock).
let resumePos = -1;
// What the running pipeline was started with — lets the playback effect
// tell "nothing changed, stay warm" from "must restart".
let activeUrl = "";
let activeBars = 64;
// ── Lifecycle helpers ──────────────────────────────────────────────
const clearUnloadTimer = () => {
if (unloadTimer) {
clearTimeout(unloadTimer);
unloadTimer = null;
}
};
const initCava = () => {
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
return false;
}
return true;
};
// ── Smooth position clock ──────────────────────────────────────────
//
// audio.position() updates at the useAudio poll rate (~150ms). Between
// polls, interpolate the position from wall time so the FFT window
// tracks the audio continuously instead of stepping. The 0.5s cap
// prevents extrapolating far beyond reality when the player stalls
// (e.g. network re-buffering).
let lastPolledPosition = 0;
let lastPolledAt = 0;
const smoothPosition = () => {
const pos = audioPlaybackSignals.position();
const now = performance.now();
if (pos !== lastPolledPosition) {
lastPolledPosition = pos;
lastPolledAt = now;
return pos;
}
if (lastPolledAt === 0) return pos;
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
return lastPolledPosition + elapsed * (audioPlaybackSignals.speed() ?? 1);
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + the user's
// audio-processing params (noise reduction, cutoffs, etc.).
// autosens is disabled (after the spread so it always wins): cava's
// autosens gain-ramps during silence then clips everything to 1.0
// when audio arrives — the JS peak scaler handles dynamics instead.
const viz = useAppStore().state().settings.visualizer;
const config: CavaCoreConfig = {
bars: barCount(),
sampleRate: PCM_SAMPLE_RATE,
channels: 1,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
autosens: 0,
};
cava.init(config);
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it.
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// PCM cache per episode (reuse when the episode is unchanged)
if (!pcm || pcm.url !== url) {
if (pcm) pcm.stop();
pcm = new EpisodePcmCache({ url });
}
// Decode from 1s before the position so the window ENDING at the
// position is covered as soon as the first PCM lands.
pcm.startDecode(Math.max(0, position - 1));
// Seed the smooth position clock with the start position. Without
// this, a fresh play at position 0 would sample the window ending at
// exactly 0 — a 1-sample slice — so bars would be starved until the
// first mpv poll advanced the position clock.
lastPolledPosition = position;
lastPolledAt = performance.now();
// Seed the stall tracker: a fresh pipeline should not report a
// stall just because the first position poll hasn't landed.
lastRenderPos = position;
lastPosMoveAt = performance.now();
// Cold start: the loading state clears on the first produced frame
// (see renderFrame) — no resume-position gating.
resumePos = -1;
activeUrl = url;
activeBars = barCount();
setIsLoading(true);
setIsStalled(false);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
clearUnloadTimer();
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
clearTimeout(seekDecodeTimer);
seekDecodeTimer = undefined;
if (pcm) {
pcm.stop();
// Keep the (now cache-less, url-tagged) object: a re-start of the
// same episode reuses it; segments re-decode in seconds at 80x.
}
if (cava?.isReady) {
cava.destroy();
}
sampleBuffer = null;
setIsLoading(false);
setIsStalled(false);
// Drop the last rendered frame: after a stop the bars are stale (a
// different episode, a different position) and would masquerade as
// live data while the next cold start warms up — and, because the
// component only shows the spinner while bars are empty, they'd
// also suppress the loading state. Cold restarts re-render fresh
// bars within the first frame.
setBarData([]);
};
// ── Pause: freeze the loop, keep the cache ──────────────────────────
//
// The render loop stops (bars hold their last frame) and the ffmpeg
// pass dies (no background CPU), but the decoded PCM stays: resume
// serves it instantly.
const suspendVisualization = () => {
clearUnloadTimer();
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
// Cancel any debounced seek-decode: it would restart ffmpeg while
// paused, defeating the "no background CPU while paused" contract.
clearTimeout(seekDecodeTimer);
seekDecodeTimer = undefined;
if (pcm) pcm.pauseDecode();
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
// Clear the loading spinner: if the pipeline never produced bars
// (still cold-starting when paused), the component should fall back
// to the placeholder, not freeze on a spinner.
setIsLoading(false);
setIsStalled(false);
};
// ── Resume: re-arm the render loop, top up the cache ───────────────
//
// Returns true if the pipeline resumed, false if there was nothing to
// resume (no prior pipeline).
const resumeVisualization = (): boolean => {
// Already running — nothing to do.
if (frameTimer !== null) return true;
if (!pcm || !cava?.isReady || !sampleBuffer) return false;
const pos = untrack(audioPlaybackSignals.position);
// Bars come from the cache on the next frame tick (~33ms) whenever
// the position is covered; any gap (uncached region) restarts the
// decode pass in the background with the last frame holding.
pcm.ensureDecodeAround(pos);
lastPolledPosition = pos;
lastPolledAt = performance.now();
// Re-arm the stall tracker from the resume position (a long pause
// left the old timestamps stale — they'd trip the stall detector on
// the very first frame otherwise).
lastRenderPos = pos;
lastPosMoveAt = performance.now();
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause:
// the pre-pause bars are stale until fresh frames flow, so show the
// loading state IN THEIR PLACE. It clears only once the position
// clock has advanced past the resume point (see renderFrame) — a
// player still re-buffering after a long pause keeps the spinner
// instead of serving static cached bars.
resumePos = pos;
setIsLoading(true);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
let lastBarWriteAt = 0;
const renderFrame = () => {
if (!cava?.isReady || !sampleBuffer || !pcm) return;
// Sample the FFT window at the player's position. Outside decoded
// coverage (decode cold start, seek into a hole) the read is empty
// and the LAST FRAME simply holds — never clamped/repeated junk.
const target = smoothPosition();
// Stall detection: while the UI believes playback is live, the
// position signal must keep advancing (useAudio polls it every
// ~150ms). A frozen clock with a warm pipeline means the player is
// re-buffering — the classic long-pause-then-resume on a network
// stream — and without this the waveform shows dead-looking static
// bars for the whole stall. Report it as loading; the first frame
// after the clock moves again clears it.
const rawPos = audioPlaybackSignals.position();
if (rawPos !== lastRenderPos) {
lastRenderPos = rawPos;
lastPosMoveAt = performance.now();
if (isStalled()) setIsStalled(false);
} else if (
audioPlaybackSignals.isPlaying() &&
performance.now() - lastPosMoveAt > STALL_DETECT_MS
) {
setIsStalled(true);
}
const count = pcm.readWindow(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
const output = cava.execute(sampleBuffer);
// Write the UI signal at ~10fps, not 30: cava already smooths
// (noise reduction + peak release), and each Solid write costs a
// renderer diff pass. 3 of every 4 frames update only the pipeline.
const nowMs = performance.now();
if (nowMs - lastBarWriteAt >= 95) {
lastBarWriteAt = nowMs;
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
}
// Fresh frames only count once the position clock has MOVED from
// the resume point: while the player is still re-buffering after a
// long pause, the cache serves the same window and the spinner must
// stay in place of the stale bars. Any move counts — including a
// backward seek, whose window is live data for the new position and
// would strand the spinner forever under a `>` gate. Cold starts
// (resumePos < 0) clear on the first frame as before.
if (isLoading() && (resumePos < 0 || rawPos !== resumePos)) {
setIsLoading(false);
}
};
// ── Playback subscription ──────────────────────────────────────────
//
// Keeps the pipeline matched to playback. Pause suspends (render loop +
// decode pass die, cache survives) so resume is instant. Stop/track-end/
// disable fully tears down. `focused` is a dep so focus regain
// re-evaluates; the guards make a focus flip on an already-correct warm
// pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is
// position-indexed, so playback-rate changes need no pipeline restart.
createEffect(
on(
[
audioPlaybackSignals.isPlaying,
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
barCount,
focused,
() => useAppStore().state().settings.visualizer.enabled,
],
([playing, url, , , enabled]) => {
if (!url || !enabled) {
stopVisualization();
return;
}
if (!playing) {
// Pause: freeze the loop, keep the cache. Only if the
// pipeline is actually running — otherwise no-op.
if (frameTimer !== null) suspendVisualization();
return;
}
// Playing — try a fast resume first. If it succeeds and the
// pipeline matches, done.
if (
frameTimer === null &&
pcm &&
cava?.isReady &&
url === activeUrl &&
barCount() === activeBars
) {
if (resumeVisualization()) return;
}
// Warm and already correct — nothing to do (e.g. focus
// regained within the unload delay while still playing).
if (frameTimer !== null && url === activeUrl && barCount() === activeBars) {
return;
}
if (!focused()) return; // playing away: stay warm; unload timer decides
startVisualization(url, untrack(audioPlaybackSignals.position));
},
),
);
// ── Focus subscription: unload after the grace delay ───────────────
createEffect(
on(focused, (f) => {
clearUnloadTimer();
if (f) {
// Pipeline was unloaded (or never started) but playback is
// still going — restart from the current position. When the
// pipeline is warm the playback effect above is the one that
// acts (guard: no-op for an unchanged warm pipeline).
if (
audioPlaybackSignals.isPlaying() &&
audioPlaybackSignals.currentEpisode()?.audioUrl &&
useAppStore().state().settings.visualizer.enabled &&
frameTimer === null
) {
startVisualization(
audioPlaybackSignals.currentEpisode()!.audioUrl,
untrack(audioPlaybackSignals.position),
);
}
} else if (frameTimer !== null) {
unloadTimer = setTimeout(() => {
unloadTimer = null;
stopVisualization();
}, VISUALIZER_UNLOAD_DELAY_MS);
}
}),
);
// ── Seek detection: jump coverage, not pipeline restarts ───────────
//
// Watches position for significant jumps (>2s = user seek). Decoded
// audio at the new position is served instantly with zero action; a
// jump into an undecoded hole kicks a background segment decode there
// while the last frame holds.
let lastSyncPosition = 0;
let seekDecodeTimer: TimerHandle | undefined;
createEffect(
on(audioPlaybackSignals.position, (pos) => {
if (!audioPlaybackSignals.isPlaying() || !pcm) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
// Debounce: holding the seek key fires a jump per poll tick —
// without debounce each one restarts ffmpeg, spamming network
// reconnects against the stream's server. Wait for the user to
// settle, then decode at the final position.
clearTimeout(seekDecodeTimer);
const target = pcm; // capture for the timer
seekDecodeTimer = setTimeout(() => {
seekDecodeTimer = undefined;
target.ensureDecodeAround(untrack(audioPlaybackSignals.position));
}, 400);
}
}),
);
// ── Process-exit teardown ──────────────────────────────────────────
//
// The pipeline lives in a detached createRoot that is never disposed,
// so Solid's onCleanup never runs. `q`/`:quit` call process.exit(0)
// (bypassing onCleanup); SIGINT/TERM/HUP are caught by useAudio's
// handler. This handler runs synchronously on `exit` and kills the
// ffmpeg child + destroys the cava plan so they don't outlive the host.
// Without it, a warm pipeline leaks an orphaned ffmpeg process on quit.
process.on("exit", () => {
stopVisualization();
});
return {
// state
barData,
isLoading,
isStalled,
isRunning: () => frameTimer !== null,
// inputs
setFocused,
setBarCount,
};
}
// ── Singleton ─────────────────────────────────────────────────────────────
let visualizerStoreInstance: VisualizerStore | null = null;
/**
* Accessor for the shared visualizer store. Created once inside a
* `createRoot` so its effects are owned by a detached root — not by
* whichever component happens to call first (PlayerPage unmounts would
* otherwise dispose the pipeline effects with it).
*/
export function useVisualizer(): VisualizerStore {
if (!visualizerStoreInstance) {
visualizerStoreInstance = createRoot(() => createVisualizerStore());
}
return visualizerStoreInstance;
}

View File

@@ -98,7 +98,9 @@ export enum DownloadStatus {
export interface DownloadedEpisode {
/** Episode ID */
episodeId: string
/** Feed ID the episode belongs to */
/** Feed ID the episode belongs to. For downloads of shows that aren't
* subscribed (search downloads) this is a deterministic synthetic id
* ("unsub-<slug>") that also names the file subdirectory. */
feedId: string
/** Current download status */
status: DownloadStatus
@@ -114,4 +116,16 @@ export interface DownloadedEpisode {
fileSize: number
/** Error message if failed */
error: string | null
/** Episode title, persisted so unsubscribed-show downloads render without
* a loaded feed. */
episodeTitle?: string
/** Audio URL, persisted so queued downloads survive a restart. */
audioUrl?: string
/** Publication date (ISO), for display of unsubscribed-show downloads. */
pubDate?: string
/** Show title, kept for downloads whose show isn't subscribed. */
podcastTitle?: string
/** The show's RSS feed URL, used to re-classify a download as subscribed
* once the user subscribes to its show. */
podcastFeedUrl?: string
}

View File

@@ -62,6 +62,8 @@ export type DesktopTheme = {
};
export type VisualizerSettings = {
/** Master on/off switch for the player's realtime waveform (default: on). */
enabled: boolean;
/** Number of frequency bars (8128, default: 64) */
bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
@@ -78,6 +80,8 @@ export type AppSettings = {
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
/** Playback volume 01 (default: 1 = 100%). */
volume: number;
downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
@@ -86,12 +90,23 @@ export type AppSettings = {
visualizer: VisualizerSettings;
};
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */
export type AutoDownloadScope = "all" | "none" | "whitelist";
/** How the episode cache (the Feed / My Shows list + the pagination cache)
* is bounded: by a rolling date window or by a count of most-recent
* episodes (default: date). */
export type EpisodeCacheMode = "date" | "count";
/** Left/right edges of the current (center) pane as fractions of the row
* width, shared by the draggable pane borders in every depth tab. */
export type PaneSplits = {
/** Left edge of the current pane (default 0.2 = 20% of the row). */
left: number;
/** Right edge of the current pane (default 0.7 = 70% of the row). */
right: number;
};
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
@@ -103,8 +118,16 @@ export type UserPreferences = {
autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */
refreshIntervalMinutes: number;
/** How the episode list cache is bounded — by date or by count (default: date). */
episodeCacheMode: EpisodeCacheMode;
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
episodeCacheCount: number;
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
episodeCacheDays: number;
/** Pane split positions as fractions of the row width (default 0.2 / 0.7). */
paneSplit: PaneSplits;
};
export type AppState = {

View File

@@ -156,9 +156,6 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results));
});
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions();
},
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: {
options: CommandOption[];
suggestedOptions: CommandOption[];

View File

@@ -98,9 +98,6 @@ function init() {
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
@@ -113,9 +110,6 @@ function init() {
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -130,9 +124,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -143,9 +134,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!

View File

@@ -7,7 +7,8 @@
* No backups — writes always overwrite.
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { mkdirSync, writeFileSync } from "fs";
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
import { loadConfig, updateConfig } from "./config";
import type {
AppState,
@@ -20,6 +21,7 @@ import { DEFAULT_THEME } from "../constants/themes";
// --- Defaults ---
const defaultVisualizerSettings: VisualizerSettings = {
enabled: true,
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
@@ -31,6 +33,7 @@ const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
volume: 1,
downloadPath: "",
transparentBackground: false,
showSelectionMarker: false,
@@ -44,7 +47,11 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
paneSplit: { left: 0.2, right: 0.7 },
};
const defaultState: AppState = {
@@ -55,13 +62,23 @@ const defaultState: AppState = {
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const cfg = await loadConfig();
if (!cfg || typeof cfg !== "object") return defaultState;
return {
settings: { ...defaultSettings, ...cfg.settings },
settings: {
...defaultSettings,
...cfg.settings,
// Visualizer is nested: a plain spread would let a config
// saved before a field was added (e.g. `enabled`) clobber
// the whole object and leave the new field undefined.
// Deep-merge so defaults backfill missing nested keys.
visualizer: {
...defaultVisualizerSettings,
...cfg.settings?.visualizer,
},
},
preferences: { ...defaultPreferences, ...cfg.preferences },
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
};
@@ -70,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
}
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
@@ -91,7 +107,6 @@ interface ProgressEntry {
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
@@ -123,11 +138,41 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
})();
}
// ── Search History (separate file — changes on every search) ────────────────
const SEARCH_HISTORY_FILE = "search-history.json";
export async function loadSearchHistoryFromFile(): Promise<string[]> {
try {
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
if (!(await file.exists())) return [];
const raw = await file.json();
if (!Array.isArray(raw)) return [];
return raw.filter((item): item is string => typeof item === "string");
} catch {
return [];
}
}
export function saveSearchHistoryToFile(history: string[]): void {
(async () => {
try {
await ensureConfigDir();
await Bun.write(
getConfigFilePath(SEARCH_HISTORY_FILE),
JSON.stringify(history, null, 2),
);
} catch {
// Silently ignore write errors
}
})();
}
// ── Audio Nav State (separate file — changes on every track change) ──────────
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
@@ -156,3 +201,70 @@ export function saveAudioNavToFile<T>(data: T): void {
}
})();
}
// ── Last Player State (separate file — written on every load/stop) ──────────
const LAST_PLAYER_FILE = "last-player.json";
/** Which episode is currently loaded in the player, persisted so the next
* launch can restore it paused. `episodeId: null` means the player is empty
* (e.g. after Stop). */
export interface LastPlayerState {
episodeId: string | null;
timestamp: string | Date | null;
}
/** Load the last-loaded-player marker (null when absent or unreadable) */
export async function loadLastPlayerFromFile(): Promise<LastPlayerState | null> {
try {
const file = Bun.file(getConfigFilePath(LAST_PLAYER_FILE));
if (!(await file.exists())) return null;
const raw = await file.json();
if (!raw || typeof raw !== "object") return null;
return raw as LastPlayerState;
} catch {
return null;
}
}
/** Serialized marker-write chain: concurrent writes land in submission
* order, and callers can await the last one (tests read the file back
* deterministically). Mirrors updateConfig's write serialization. */
let lastPlayerWriteChain: Promise<void> = Promise.resolve();
/** Save the last-loaded-player marker (fire-and-forget) */
export function saveLastPlayerToFile(state: LastPlayerState): void {
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
try {
await ensureConfigDir();
await Bun.write(
getConfigFilePath(LAST_PLAYER_FILE),
JSON.stringify(state, null, 2),
);
} catch {
// Silently ignore write errors
}
});
}
/** Resolves once every marker write submitted so far has landed on disk. */
export function waitForLastPlayerWrite(): Promise<void> {
return lastPlayerWriteChain;
}
/** Synchronous variant for the process-exit teardown. `q` quits through
* `process.exit(0)`, which runs exit listeners synchronously — an async
* write would never land. */
export function saveLastPlayerSync(state: LastPlayerState): void {
try {
mkdirSync(getConfigDir(), { recursive: true });
writeFileSync(
getConfigFilePath(LAST_PLAYER_FILE),
JSON.stringify(state, null, 2),
);
} catch {
// Silently ignore write errors
}
}

874
src/utils/audio-engine.ts Normal file
View File

@@ -0,0 +1,874 @@
/**
* Module-level audio engine — owns the AudioBackend lifecycle, the 150ms
* playback poll (progress save + external pause/resume reconciliation),
* cover-art resolution, session restore, and the event-bus playback
* commands.
*
* `createAudioEngine()` is the only factory. It builds a lazily-booting
* engine (the backend is created on the first play/load, not here) and
* returns the SAME instance for the life of the process, so every
* useAudio() call shares one engine. The Solid-lifecycle parts that can't
* live at module scope — the ref-counted last-owner dispose and the
* process-exit teardown — stay in hooks/useAudio, the thin wrapper.
*/
import {
cachedCoverPath,
fetchCoverArt,
} from "./cover-art";
import {
createAudioBackend,
detectPlayers,
PlayerRestartedError,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "./audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed,
setSpeed,
backendName,
setBackendName,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "./audio-signals";
import { emit, on } from "./event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "./media-registry";
import {
loadLastPlayerFromFile,
saveLastPlayerToFile,
} from "./app-persistence";
import type { Episode, Progress } from "../types/episode";
import { feedForEpisode } from "./feed-resolve";
import { useAudioNavStore } from "../stores/audio-nav";
import { useDownloadStore } from "../stores/download";
import { useFeedStore } from "../stores/feed";
import { useSearchStore } from "../stores/search";
import {
nextStep,
prevStep,
queueForSource,
} from "./audio-queue";
// Singleton state — shared by every useAudio() owner through the one engine
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollCount = 0; // Counts poll ticks for throttling progress saves
// Playback signals are declared in utils/audio-signals.ts (imported above)
// so non-component consumers (the visualizer store) can subscribe without
// mounting a useAudio() owner.
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.
* restored at boot) and the first play action must start the backend
* instead of unpausing it. */
let startedPlayback = false;
/** Completion fraction at/above which an episode is NOT restored at boot. */
const RESTORE_COMPLETION_THRESHOLD = 0.98;
/** The engine surface useAudio() wraps. Deliberately omits
* availablePlayers and switchBackend — the hook re-exposes those from
* audio-signals / this module on top of the engine. */
export interface AudioEngine {
// Signals (reactive getters)
isPlaying: () => boolean;
position: () => number;
duration: () => number;
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
// Actions
play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
}
/** True when saved progress is below the restore cutoff. Episodes with no
* progress (never reached the persist threshold) or unknown duration count
* as eligible — they restore from the start. */
function isRestoreEligible(progress: Progress | undefined): boolean {
if (!progress || progress.duration <= 0) return true;
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
}
/** Lazily create the shared backend on first use. The process-exit
* teardown lives in useAudio (it must survive last-owner dispose), so it is
* registered there, not here. */
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
}
return backend;
}
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
* UI believes playback is paused we only need to catch an external
* resume (AirPod play tap, lock-screen/media-center play); checking every
* tick would just hammer mpv IPC for nothing. */
const PAUSE_WATCH_TICKS = 7;
/** The player process died while we believed playback was live — track
* ended (mpv quits at EOF) or the process crashed. Persist the final
* position and stop polling. `autoAdvance` is true only when the track
* reached its natural end with the player still alive and no stream error
* — the signal to keep the queue going. */
function finalizeTrackEnd(autoAdvance: boolean): void {
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
if (autoAdvance) {
// The episode finished: play the next one from the source that
// started it (search results / show / feed). No-op at the end of
// the list or when the episode isn't in the source list anymore.
void next().catch(() => {});
}
}
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
* device swap, OS media keys, the Now Playing center. Bring the UI in
* sync; the poll stays armed so an external resume is caught too. */
function reconcileExternalPause(): void {
setIsPlaying(false);
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
}
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
* media-center play, OS media keys). Bring the UI back to "playing". */
function reconcileExternalResume(): void {
setIsPlaying(true);
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
useMediaRegistry().setPlaybackState(true);
}
}
function startPolling(): void {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || pollInFlight) return;
pollInFlight = true;
try {
pollCount++;
if (isPlaying()) {
// Track ended (eof-reached observed) or process died. Check
// BEFORE pause reconciliation: mpv keeps the file open at EOF
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
// Natural EOF (player alive, no stream error) auto-advances
// to the next episode; a crashed/killed daemon or a failed
// stream must not start the next episode on its own.
finalizeTrackEnd(
backend.isAlive() && !backend.getPlaybackError(),
);
return;
}
// mpv can pause itself outside PodTUI. Reconcile instead of
// staying stuck on "playing" with a frozen waveform
// (getPosition would just re-read the same frozen time-pos).
const paused = await backend.getPauseState();
if (paused === true) {
reconcileExternalPause();
return;
}
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (33 ticks * 150ms)
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
// Paused — watch for playback restarted from outside (AirPods,
// lock-screen/media-center play). Only while the player is
// still alive: a dead player while we thought we were paused
// means the track ended (mpv quits at EOF) or it crashed.
if (!backend.isAlive()) {
finalizeTrackEnd(false);
return;
}
const paused = await backend.getPauseState();
if (paused === false) {
reconcileExternalResume();
}
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 150);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ── Cover art for system Now Playing ─────────────────────────────────────────
// macOS shows the media session's albumart in the audio center; mpv reads it
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
* call site's latency budget:
* "cache" — disk cache only (sync): resume paths must never wait on the
* network, so a miss plays artless and warms for next time.
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
* at file LOAD, but a slow cover server must not stall audio.
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
* while feeds/progress load anyway, so the wait is free and the
* cover must be present when the file loads.
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
* nothing on a warm cache. */
async function resolveCoverArt(
coverUrl: string | undefined,
mode: "cache" | "bounded" | "await",
): Promise<string | null> {
if (!coverUrl) return null;
if (mode === "cache") return cachedCoverPath(coverUrl);
if (mode === "bounded") {
const cached = cachedCoverPath(coverUrl);
if (cached) return cached;
return Promise.race([
fetchCoverArt(coverUrl),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
]);
}
return fetchCoverArt(coverUrl);
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
if (!episode.audioUrl) {
setError("No audio URL for this episode");
return;
}
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const feed = feedForEpisode(useFeedStore().feeds(), episode);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Play the downloaded file when present (offline + no network stalls);
// otherwise stream. Cover resolves to the feed art, falling back to the
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
// Present the new episode in the UI IMMEDIATELY, before the backend load
// (cover fetch + loadfile can take a few hundred ms): the player tab,
// status bar, and OS Now Playing must not keep showing the previous
// episode during the swap. The previous track's poll is stopped so it
// can't attribute its position/progress to the new episode; polling
// restarts once the backend is actually playing. Mirrors load()'s
// synchronous presentation.
stopPolling();
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (startPos > 0) media.setPosition(startPos);
try {
// Cover art only applies at file LOAD (the runtime video-add fallback
// never becomes an albumart track), so a cold-cache play must wait for
// the fetch or play artless. Serve the disk cache synchronously; on a
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
// past the 1.2s cap, play bare and let the fetch warm the cache.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"bounded",
);
await b.play(url, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
setPosition(startPos);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
// Remember this episode as "loaded in the player" so the next launch
// can restore it paused (cleared by stop()).
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
// Register with platform media controls
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
/**
* Load an episode into the player WITHOUT starting playback. The player tab
* renders it paused at its saved position; the first play action starts the
* backend from there (see togglePlayback). Used to restore the last player
* session at boot.
*/
async function load(episode: Episode): Promise<void> {
ensureBackend();
setError(null);
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
// Show the saved position so the player tab reflects where playback
// will resume; episodes at/above the completion threshold start from 0.
const progressStore = useProgressStore();
const saved = progressStore.get(episode.id);
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
setPosition(pos);
if (episode.duration) setDuration(episode.duration);
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
setSpeed(storeSpeed || speed());
// Surface the loaded-but-paused track to the OS media controls.
const feed = feedForEpisode(useFeedStore().feeds(), episode);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (pos > 0) media.setPosition(pos);
// Preload the episode into the backend PAUSED: mpv opens the stream and
// fills its demuxer cache while parked, so the user's first Play flips
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
// — a failed preload just makes the first play take the cold path.
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
if (episode.audioUrl && backend) {
// The preload must carry the cover AT LOAD: cover-art-files only
// applies when the file loads, and the runtime video-add fallback
// never becomes an albumart track (verified). Restore already waits
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
// 8s worst case) is free. Falls back to the episode's own image when
// the feed has no channel cover.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"await",
);
const backendSnap = backend;
backendSnap
.preload(url, {
volume: volume(),
speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
})
.catch(() => {});
}
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
// Polling stays armed (paused-watch mode): playback can be resumed
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
// and the poll must be live to catch it.
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed");
}
}
/** mpv was killed/crashed: respawn it and restart playback from the saved
* position via the full play path (fresh loadfile, cover art, media
* registry). A bare unpause would target a dead — or freshly-idle —
* daemon and silently do nothing. */
async function recoverPlayback(): Promise<void> {
const ep = currentEpisode();
if (ep && ep.audioUrl) {
await play(ep);
} else {
setError("Player is not running");
}
}
async function resume(): Promise<void> {
if (!backend) return;
if (!backend.isAlive()) {
await recoverPlayback();
return;
}
try {
await backend.resume();
setIsPlaying(true);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(true);
}
} catch (err) {
// Race: the daemon died between the liveness check above and the
// unpause — backend.resume() respawned it and threw
// PlayerRestartedError (the fresh daemon has no file loaded).
if (err instanceof PlayerRestartedError) {
await recoverPlayback();
return;
}
setError(err instanceof Error ? err.message : "Resume failed");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
}
}
async function stop(): Promise<void> {
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
startedPlayback = false;
stopPolling();
emit("player.stop", {});
// Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped);
// Sync back to app store (persisted to config.json for the next launch).
const appStore = useAppStore();
appStore.updateSettings({ volume: clamped });
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped);
// Sync back to app store
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
/** Switch the active player backend (mpv / afplay / ...). Off the
* AudioEngine interface by contract, but kept here (module-scoped) so the
* engine owns backend teardown/creation; useAudio re-exposes it. */
export async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
const feed = feedForEpisode(useFeedStore().feeds(), ep);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? ep.imageUrl,
"cache",
);
await backend.play(url, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
/** Serialized restore chain: the boot-triggered restore and any explicit
* call run one after another, so a late-finishing earlier restore can never
* overwrite state changed by a later one (and callers can await the latest
* attempt deterministically). */
let restoreChain: Promise<void> = Promise.resolve();
/**
* Boot-time session restore: reload the episode that was loaded in the
* player when the previous run ended (persisted on play/load and at exit),
* paused at its saved position — never autostarted. Episodes at/above the
* completion threshold are skipped. Silently no-ops when there is nothing
* to restore (empty player, unsubscribed show, or completed episode).
*/
export async function restoreLastSession(): Promise<void> {
const attempt = restoreChain.then(async () => {
const marker = await loadLastPlayerFromFile();
if (!marker?.episodeId) return;
// Feeds and progress load asynchronously at boot; wait for both
// before looking the episode up.
await Promise.all([
useProgressStore().whenReady(),
useFeedStore().whenReady(),
]);
const episode = useFeedStore().findEpisode(marker.episodeId);
if (!episode) return;
// Only restore episodes below the completion threshold.
const saved = useProgressStore().get(episode.id);
if (!isRestoreEligible(saved)) return;
await load(episode);
});
// Keep the chain alive even when an attempt fails; the caller awaiting
// this attempt still observes its own outcome.
restoreChain = attempt.catch(() => {});
await attempt;
}
// ── Episode queue navigation ──────────────────────────────────────────────
// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd)
// move within the ordered list of the source that STARTED the current
// episode: the Feed's chronological list, the current show's episodes, or
// the search results (see utils/audio-queue). Module-level so
// finalizeTrackEnd can auto-advance without a mounted hook owner.
const audioNav = useAudioNavStore();
/** The ordered playable episodes for the source that started playback. */
function queueForCurrentSource(): Episode[] {
const feedStore = useFeedStore();
return queueForSource(
audioNav.getSource(),
audioNav.getPodcastId(),
feedStore.feeds(),
feedStore.getAllEpisodesChronological(),
useSearchStore().results(),
);
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const step = nextStep(queueForCurrentSource(), current.id);
// A duplicated queue entry (same episode id twice) must not make
// "next" replay the CURRENT episode — that would reload it from
// saved progress and audibly repeat already-played audio.
if (!step || step.episode.id === current.id) return;
await play(step.episode);
audioNav.next(step.index);
}
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
// Standard transport behavior: past 30s in, "prev" restarts the current
// episode; before that it steps back within the source queue.
const NAV_START_THRESHOLD = 30;
const currentPos = position();
const currentDur = duration();
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
return;
}
const step = prevStep(queueForCurrentSource(), current.id);
if (!step) return;
await play(step.episode);
audioNav.prev(step.index);
}
// ── Event bus commands ────────────────────────────────────────────────────
// Registered once per process (in createAudioEngine), not per hook owner.
// Every handler is no-op-safe when the backend is absent (e.g. after the
// last owner disposed it).
let eventListenersRegistered = false;
function registerEventListeners(): void {
if (eventListenersRegistered) return;
eventListenersRegistered = true;
on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
});
on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
});
// Global multimedia key events (from useMultimediaKeys)
on("media.toggle", async () => {
await togglePlayback();
});
on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
});
}
/** Lazily create the shared backend on first use. Called from useAudio's
* boot path (the old hook created it eagerly; tests and the mpv IPC test
* rely on the backend existing before the first play). */
export function ensureEngineBackend(): AudioBackend {
return ensureBackend();
}
/** Full engine teardown for when the last hook owner unmounts: stop the
* poll, dispose the backend, and clear the OS media session. (The
* process-exit teardown in useAudio does the same minus the media clear,
* since the process is ending.) */
export function disposeEngineBackend(): void {
stopPolling();
if (backend) {
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
useMediaRegistry().clearNowPlaying();
}
/** Stop the poll — one-line wrapper so the process-exit teardown in
* useAudio doesn't reach into engine internals. */
export function stopEnginePolling(): void {
stopPolling();
}
/** The current backend (or null), for useAudio's exit-time dispose. */
export function getEngineBackend(): AudioBackend | null {
return backend;
}
let engineInstance: AudioEngine | null = null;
/** Build (once) and return the process-wide audio engine. Side-effect-free:
* the backend is created lazily on the first play/load, and the event-bus
* listeners are registered here. */
export function createAudioEngine(): AudioEngine {
if (engineInstance) return engineInstance;
registerEventListeners();
engineInstance = {
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
play,
load,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
prev,
next,
};
return engineInstance;
}

View File

@@ -0,0 +1,471 @@
/**
* Position-indexed PCM cache for visualization.
*
* One ffmpeg process decodes the episode's audio at 4x realtime (with an
* 8s initial burst — fast enough to serve bars and seeks instantly, throttled
* enough that a remote episode isn't ripped at 84x while mpv is trying to
* start playback) into an in-memory cache indexed by ABSOLUTE playback time.
* The renderer then reads the PCM
* window ending at the player's current position with zero sync machinery:
* there is no pacing (-readrate), no lead-burst, no decode-head/player
* drift math, no ring wrap, and nothing that knows or cares about pause,
* resume, seek, or playback speed — those all collapse to "read at a
* different position in the cache".
*
* Pause/resume contract (the failure mode of the old design):
* - pauseDecode() kills ffmpeg but KEEPS the cache. Resume reads from it
* instantly and resumes the tail decode in the background.
* - Reads outside decoded coverage (startup, seek into an undecoded hole)
* return 0 — the renderer HOLDS the last rendered frame rather than
* freezing on a clamped buffer or decaying into junk bars.
*
* Seeks into undecoded territory start a fresh SEGMENT (a second decode
* pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree.
*
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
* SLIDING WINDOW around the playback position — the decode pass stops
* once it is maxAheadSec ahead of the cursor and segments entirely older
* than keepBehindSec behind it are dropped (both re-filled/restarted on
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
* config.
*
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for
* file inputs).
*/
import type { Subprocess } from "bun";
/** PCM output format constants */
export const PCM_SAMPLE_RATE = 22050;
const BYTES_PER_SAMPLE = 2; // s16le
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
/**
* Gap (seconds) a running decode pass may close on its own before a restart
* at the seek target is cheaper than waiting: at 4x pacing, 15s of undecoded
* audio closes in ~4s — about the cost of a network reconnect + range
* request for a fresh ffmpeg pass. Beyond the gap, restart at the target.
*/
const CLOSE_IN_PLACE_GAP_SEC = 15;
/**
* Default decode-head budget: the ffmpeg pass pauses once it is this far
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
* the network pull — the old cache decoded the whole episode at 4x, so a
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
* remote file even when only the first 10 minutes were listened to. At 4x
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
* ~10 min of playback.
*/
const DEFAULT_DECODE_AHEAD_SEC = 600;
/**
* Default retention behind the cursor: decoded audio entirely older than
* this is dropped. Keeps pause/resume and small backward seeks instant
* without letting the window grow with playback time.
*/
const DEFAULT_KEEP_BEHIND_SEC = 300;
/**
* Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0;
interface Segment {
/** Playback seconds where this segment's first sample sits. */
baseSec: number;
/** Sample buffer; capacity >= written, doubled on overflow. */
samples: Int16Array;
/** Samples written so far (== decoded length of the segment). */
written: number;
/** ffmpeg reached stream EOF while writing this segment — nothing more
* will ever arrive after its end. */
finished: boolean;
}
export interface EpisodePcmCacheOptions {
/** Audio URL or file path to decode */
url: string;
/** Sample rate (default: 22050) */
sampleRate?: number;
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
maxAheadSec?: number;
/** Retention in seconds behind the cursor (default: 300). */
keepBehindSec?: number;
}
export class EpisodePcmCache {
private proc: Subprocess | null = null;
private segments: Segment[] = [];
private generation = 0;
private _decoding = false;
/** The running pass's segment (base + frontier); null when idle. */
private activeSegment: Segment | null = null;
readonly url: string;
readonly sampleRate: number;
/** Sliding-window budgets (see maintainWindow). */
readonly maxAheadSec: number;
readonly keepBehindSec: number;
constructor(options: EpisodePcmCacheOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
}
/** Whether an ffmpeg decode pass is currently running. */
get decoding(): boolean {
return this._decoding;
}
/** Base (playback seconds) of the running decode pass; null when idle. */
get activeDecodeBaseSec(): number | null {
return this._decoding && this.activeSegment
? this.activeSegment.baseSec
: null;
}
/** End (playback seconds) of the furthest-decoded segment. */
get coverageEndSec(): number {
let end = 0;
for (const seg of this.segments) {
const segEnd = seg.baseSec + seg.written / this.sampleRate;
if (segEnd > end) end = segEnd;
}
return end;
}
/** Whether the furthest segment finished at stream EOF. */
get decodeFinished(): boolean {
let maxEnd = -1;
let finished = false;
for (const seg of this.segments) {
const segEnd = seg.baseSec + seg.written / this.sampleRate;
if (segEnd > maxEnd) {
maxEnd = segEnd;
finished = seg.finished;
}
}
return finished;
}
/**
* Start decoding at `fromSec` of playback time into a fresh segment.
* Kills any in-flight pass first; existing segments stay readable.
*/
startDecode(fromSec: number): void {
this.killProcess();
if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization");
}
this.generation = ++globalGeneration;
const myGeneration = this.generation;
const segment: Segment = {
baseSec: Math.max(0, fromSec),
samples: new Int16Array(INITIAL_CAPACITY_SAMPLES),
written: 0,
finished: false,
};
this.segments.push(segment);
const args = ["ffmpeg", "-loglevel", "quiet"];
// Pace the decode at 4x realtime (with an 8s initial burst) instead of
// flat-out: unthrottled decode measures ~84x realtime, which pulls the
// ENTIRE episode from the network within the first minute of playback
// (~160MB/hr) and starves mpv's own buffering right at startup. 4x
// still fills the cache 4x faster than playback consumes it, lands a
// 75-min episode in ~19 min of background work, and the burst makes
// the first bars available immediately.
args.push("-readrate", "4", "-readrate_initial_burst", "8");
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
// input-open when the input is a local file, killing the process
// before any PCM is produced. Only pass them for network URLs.
if (/^https?:\/\//i.test(this.url)) {
args.push(
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
);
}
// Seek before input for network efficiency (container-level skip is
// near-instant for mp3/aac; no pre-position decode burn).
if (fromSec > 0) {
args.push("-ss", String(Math.max(0, fromSec)));
}
args.push(
"-i",
this.url,
"-ac",
"1",
"-ar",
String(this.sampleRate),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-",
);
this.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
this._decoding = true;
this.activeSegment = segment;
this.readLoop(myGeneration, segment);
this.proc.exited
.then((code) => {
if (this.generation === myGeneration) {
this._decoding = false;
this.activeSegment = null;
// Exit 0 == decoded to stream EOF.
if (code === 0) segment.finished = true;
}
})
.catch(() => {
if (this.generation === myGeneration) {
this._decoding = false;
this.activeSegment = null;
}
});
}
/**
* Whether `sec` of playback time has decoded PCM on hand.
*/
covers(sec: number): boolean {
const idx = Math.round(sec * this.sampleRate);
for (const seg of this.segments) {
const base = Math.round(seg.baseSec * this.sampleRate);
if (idx >= base && idx < base + seg.written) return true;
}
return false;
}
/**
* Make sure decode is progressing toward `sec`: no-op while a pass is
* running or the episode is fully decoded; otherwise resumes the tail
* decode from the frontier (when `sec` is inside coverage) or starts a
* new segment at `sec` (seek into a hole / resume past cached audio).
*/
ensureDecodeAround(sec: number): void {
// Enforce the sliding-window budget first (head cap, prune, refill)
// so a resume or seek never leaves stale segments behind the cursor.
this.maintainWindow(sec);
// Data already on hand: nothing needed here; only keep the tail
// filling if the decode is idle, the episode is unfinished, AND the
// head is inside its budget. A head-capped cache ("we're maxAheadSec
// ahead, enough decoded") is NOT a stalled decode — restarting it
// here would fight maintainWindow's cap on every resume call.
if (this.covers(sec)) {
if (this._decoding || this.decodeFinished) return;
const end = this.coverageEndSec;
if (end >= sec + this.maxAheadSec) return;
this.startDecode(end > sec ? end : sec);
return;
}
if (this._decoding && this.activeSegment !== null) {
// A decode pass fills monotonically FORWARD from its base. Targets
// behind the base are unreachable — restart at the target.
if (sec < this.activeSegment.baseSec) {
this.startDecode(Math.max(0, sec));
return;
}
// Target past the pass's frontier: a SMALL gap closes on its own
// (4x pacing covers 15s in ~4s — about what a cold restart costs
// to reconnect + range-request a network stream), but a FAR-FORWARD
// seek would otherwise mean minutes of frozen bars while the pass
// chews through the skipped region. Restart at the target.
const frontier =
this.activeSegment.baseSec + this.activeSegment.written / this.sampleRate;
if (sec - frontier <= CLOSE_IN_PLACE_GAP_SEC) return;
}
this.startDecode(Math.max(0, sec));
}
/**
* Sliding-window budget for the in-memory cache, driven by the live
* playback position. Runs on every read (the render loop is the only
* consumer that knows the cursor continuously) and on resume/seek:
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
* cursor (pauseDecode keeps the decoded data — a plain startDecode
* from the frontier refills it later).
* - prune: segments entirely keepBehindSec behind the cursor are
* dropped. A backward seek past the window restarts a segment there —
* the same mechanism as a seek into an undecoded hole, so no new
* failure mode.
* - topUp: when the cursor has outrun the head, restart the tail decode
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
* regardless of episode length.
*/
private maintainWindow(atSec: number): void {
const pos = Math.max(0, atSec);
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
this.pauseDecode();
}
const keepFromSec = pos - this.keepBehindSec;
if (
this.segments.some(
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
)
) {
this.segments = this.segments.filter(
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
);
}
if (!this._decoding && !this.decodeFinished) {
const end = this.coverageEndSec;
if (end < pos + this.maxAheadSec) {
this.startDecode(Math.max(end, pos));
}
}
}
/**
* Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects).
*
* Returns the number of samples written: `out.length` on a full hit, 0
* when the window is not (fully) decoded yet — the caller HOLDS the
* last rendered frame instead of rendering partial/stale data.
*/
readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0;
this.maintainWindow(atSec);
const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) {
const base = Math.round(seg.baseSec * this.sampleRate);
if (startIdx < base || endIdx >= base + seg.written) continue;
const rel = startIdx - base;
const src = seg.samples;
for (let i = 0; i < out.length; i++) {
out[i] = src[rel + i];
}
return out.length;
}
return 0;
}
/**
* Pause contract: kill the ffmpeg pass but KEEP every decoded segment.
* Resume later serves bars from the cache instantly.
*/
pauseDecode(): void {
this.generation = ++globalGeneration;
this._decoding = false;
this.activeSegment = null;
this.killProcess();
}
/** Kill the decode pass AND drop all cached audio. */
stop(): void {
this.pauseDecode();
this.segments = [];
}
/** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void {
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
}
/** Internal: continuously reads stdout from ffmpeg and appends samples
* to the segment at their absolute playback-time offsets. */
private async readLoop(myGeneration: number, segment: Segment): Promise<void> {
const stdout = this.proc?.stdout;
if (!stdout || typeof stdout === "number") return;
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
// s16 sample pairs can straddle pipe chunk boundaries: carry a lone
// trailing byte into the next chunk (dropping it would byte-flip
// every sample that follows).
let carry: number | null = null;
try {
while (this.generation === myGeneration) {
const { done, value } = await reader.read();
if (done || this.generation !== myGeneration) break;
if (!value || value.byteLength === 0) continue;
let view: Uint8Array = value;
if (carry !== null) {
const merged = new Uint8Array(1 + value.byteLength);
merged[0] = carry;
merged.set(value, 1);
view = merged;
carry = null;
}
if (view.byteLength % BYTES_PER_SAMPLE !== 0) {
carry = view[view.byteLength - 1];
view = view.subarray(0, view.byteLength - 1);
}
const sampleCount = view.byteLength / BYTES_PER_SAMPLE;
if (sampleCount === 0) continue;
if (segment.written + sampleCount > segment.samples.length) {
const grown = new Int16Array(
Math.max(
segment.samples.length * 2,
segment.written + sampleCount,
),
);
grown.set(segment.samples.subarray(0, segment.written));
segment.samples = grown;
}
// Int16Array view over the byte buffer: s16le is the platform's
// native endianness on every supported target (arm64/x64 are LE).
const src = new Int16Array(
view.buffer,
view.byteOffset,
sampleCount,
);
segment.samples.set(src, segment.written);
segment.written += sampleCount;
}
} catch {
// Stream ended or process killed — expected during stop()
} finally {
try {
reader.releaseLock();
} catch {
/* ignore */
}
}
}
}

View File

@@ -6,12 +6,34 @@
* restart. When mpv isn't installed there is no fallback: the no-op backend
* surfaces "No audio player found" honestly rather than degrading through
* players that can't change speed/volume without restarting.
*
* The backend owns ONE RESIDENT mpv daemon (`--idle=yes --keep-open=yes`)
* for the app's lifetime instead of spawning a fresh player per episode:
*
* - Play/pause/seek are IPC commands on a persistent Unix-socket
* connection — no process spawn, no socket connect/disconnect churn per
* poll, no `waitForSocket` on the play path. Measured command latency is
* single-digit ms; a mid-episode resume after pause takes ~300ms on a
* network stream.
* - State (time-pos, pause, duration) is OBSERVED (`observe_property`):
* mpv pushes time-pos at ~20Hz while playing, so `getPosition()` /
* `getPauseState()` read a cache instead of round-tripping the socket on
* every 150ms UI tick. External pauses (AirPod removal, system sleep,
* Now Playing center) arrive as pause property events with zero polling.
* - A restored session can PRELOAD: the episode is loaded paused so mpv
* fills its demuxer cache ahead of time; the first real play just flips
* `pause` to false — the ~2s network open is paid at boot, not on the
* user's first Play.
* - Crash/kill recovery: a dead daemon (process exit or broken IPC socket)
* is detected on the next command; play() respawns a fresh daemon and
* reloads. resume() cannot unpause a freshly-idle daemon — it throws
* PlayerRestartedError so the caller reloads the episode via play().
*/
import { platform } from "os";
import { existsSync } from "fs";
import { existsSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { join } from "path";
import type { Socket, Subprocess } from "bun";
// ── Types ────────────────────────────────────────────────────────────
@@ -31,6 +53,17 @@ export interface AudioState {
export interface AudioBackend {
readonly name: BackendName;
play(url: string, opts?: PlayOptions): Promise<void>;
/**
* Load the URL paused WITHOUT starting playback, so the player buffers
* ahead of the user's first Play (used for boot session restore).
* A subsequent play() of the SAME url flips pause off — near-instant.
*/
preload(url: string, opts?: PlayOptions): Promise<void>;
/**
* Attach a cover-art image to the currently-loaded file at runtime
* (mpv `video-add`). Lets play() start without waiting on art; the
* Now Playing artwork pops in when the download lands.
*/
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
@@ -40,6 +73,20 @@ export interface AudioBackend {
getPosition(): Promise<number>;
getDuration(): Promise<number>;
isPlaying(): boolean;
/** Live pause state: `true` paused, `false` playing, `undefined` when
* unknown (player unreachable / not yet loaded). Unlike `isPlaying()` —
* which reflects only commands PodTUI sent — this reflects the player's
* real state, including pauses initiated OUTSIDE PodTUI (system
* sleep/lock, AirPod removal, device swap, OS media keys, the Now
* Playing center). */
getPauseState(): Promise<boolean | undefined>;
/** True while the player process is running (regardless of pause). */
isAlive(): boolean;
/** Last playback error (end-file reason "error"), or null when the last
* track ended cleanly (or nothing has failed yet). Lets callers
* distinguish a natural end-of-file from a stream failure — a failed
* episode must not auto-advance the queue. */
getPlaybackError(): string | null;
dispose(): void;
}
@@ -71,249 +118,595 @@ function which(cmd: string): string | null {
return null;
}
let mpvInstance = 0;
function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
// Per-instance, not just per-pid: tests (and backend switching) create
// several MpvBackend objects in ONE bun process — a pid-only path makes
// every daemon bind the same socket, so later daemons unlink the path
// out from under each other.
return join(
tmpdir(),
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
);
}
/**
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
* makes macOS attribute its Now Playing session to PodTui — source-app icon
* and name in Control Center — instead of a blank placeholder for an
* unbundled binary. Falls back to PATH so dev runs and Linux keep working.
*/
function resolveMpvBinary(): string | null {
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled)) return bundled;
} catch {
/* process.execPath unusable — fall through to PATH */
// ── mpv JSON IPC connection ─────────────────────────────────────────
//
// One persistent Unix-socket connection to the resident mpv daemon. Lines
// from mpv are either command responses (`request_id` present — correlated
// to the pending promise) or unsolicited traffic (property-change events
// from `observe_property`, end-file, ...), dispatched to the event handler.
interface MpvResponse {
error?: string;
data?: unknown;
request_id?: number;
}
interface MpvEvent {
event: string;
/** Observation id for property-change events. */
id?: number;
name?: string;
data?: unknown;
reason?: string;
error?: string;
}
type MpvEventHandler = (msg: MpvEvent) => void;
class MpvConnection {
private sock: Socket | null = null;
private buf = "";
private nextId = 1;
private pending = new Map<number, (msg: MpvResponse) => void>();
private eventWaiters = new Map<string, Array<(msg: MpvEvent) => void>>();
onEvent: MpvEventHandler = () => {};
async connect(path: string): Promise<void> {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let settled = false;
Bun.connect({
unix: path,
socket: {
open: (socket) => {
this.sock = socket;
if (!settled) {
settled = true;
resolve();
}
},
data: (_socket, data) => this.onData(data),
error: (_socket, err) => {
if (!settled) {
settled = true;
reject(err);
}
this.handleTeardown();
},
close: () => this.handleTeardown(),
},
}).catch((err) => {
if (!settled) {
settled = true;
reject(err);
}
});
await promise;
}
private onData(data: Uint8Array): void {
this.buf += Buffer.from(data).toString();
let nl = this.buf.indexOf("\n");
while (nl !== -1) {
const line = this.buf.slice(0, nl);
this.buf = this.buf.slice(nl + 1);
nl = this.buf.indexOf("\n");
if (!line.trim()) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line) as Record<string, unknown>;
} catch {
continue; // skip malformed lines
}
if (msg.request_id !== undefined) {
const resolve = this.pending.get(msg.request_id as number);
if (resolve) {
this.pending.delete(msg.request_id as number);
resolve(msg as MpvResponse);
}
} else if (typeof msg.event === "string") {
const event = msg as unknown as MpvEvent;
this.onEvent(event);
const waiters = this.eventWaiters.get(event.event);
if (waiters) {
this.eventWaiters.delete(event.event);
for (const w of waiters) w(event);
}
}
}
}
/** Socket died / daemon gone: fail all pending commands so no caller
* hangs on a dead connection. */
private handleTeardown(): void {
for (const resolve of this.pending.values()) {
resolve({ error: "connection-lost" });
}
this.pending.clear();
this.sock = null;
}
/** Send a command and await mpv's response (correlated by request_id).
* Resolves `{ error: "timeout" }` instead of hanging when mpv stalls. */
send(command: unknown[], timeoutMs = 2000): Promise<MpvResponse> {
const sock = this.sock;
if (!sock) return Promise.resolve({ error: "not-connected" });
const id = this.nextId++;
const { promise, resolve } = Promise.withResolvers<MpvResponse>();
const timeout = setTimeout(() => {
if (this.pending.delete(id)) resolve({ error: "timeout" });
}, timeoutMs);
this.pending.set(id, (msg) => {
clearTimeout(timeout);
resolve(msg);
});
sock.write(JSON.stringify({ command, request_id: id }) + "\n");
return promise;
}
/** One-shot wait for an mpv event by name. Register BEFORE the command
* that triggers it. Resolves null on timeout instead of hanging. */
waitEvent(name: string, timeoutMs = 5000): Promise<MpvEvent | null> {
const { promise, resolve } = Promise.withResolvers<MpvEvent | null>();
const list = this.eventWaiters.get(name) ?? [];
list.push(resolve);
this.eventWaiters.set(name, list);
setTimeout(() => {
const current = this.eventWaiters.get(name);
if (current) {
this.eventWaiters.set(
name,
current.filter((w) => w !== resolve),
);
}
resolve(null);
}, timeoutMs);
return promise;
}
close(): void {
try {
this.sock?.end();
} catch {
/* ignore */
}
this.handleTeardown();
}
/** True while the Unix socket is open — a live, reachable daemon. */
isConnected(): boolean {
return this.sock !== null;
}
return which("mpv");
}
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
// One resident daemon for the app's lifetime, controlled over a single
// persistent JSON IPC connection with property observation.
/** Thrown by resume() when the daemon restarted (killed/crashed) and the
* previously-loaded file is gone — the fresh daemon is idle, so the
* caller must reload the episode via the full play path instead of
* unpausing (which would silently do nothing). */
export class PlayerRestartedError extends Error {
constructor() {
super("mpv restarted; episode must be reloaded");
this.name = "PlayerRestartedError";
}
}
/** Property observation ids (correlate property-change events). */
const OBS_TIME_POS = 1;
const OBS_PAUSE = 2;
const OBS_DURATION = 3;
const OBS_EOF = 4;
export class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv";
private proc: Subprocess | null = null;
private socketPath = mpvSocketPath();
private _playing = false;
private conn: MpvConnection | null = null;
/** Guarantee daemon startup runs once (concurrent play/preload). */
private startPromise: Promise<void> | null = null;
// Command intent: what PodTUI asked the player to do.
private _intentPlaying = false;
/** The file currently loaded via loadfile (null = idle). */
private _loadedUrl: string | null = null;
/** The current file was loadfile'd paused (preload) and not yet played. */
private _loadedPaused = false;
/** Set on end-file reason "eof"/"error"; cleared by the next loadfile. */
private _ended = false;
// Observed (player-reported) state, pushed by mpv property-change events.
private _position = 0;
private _duration = 0;
/** null until the first pause observation arrives. */
private _paused: boolean | null = null;
private _volume = 100;
private _speed = 1;
private _exited = false;
/** Last playback error reported via end-file reason "error". */
private _playbackError: string | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
// ── Daemon lifecycle ─────────────────────────────────────────────
private async ensureDaemon(): Promise<void> {
// Healthy = process alive AND its IPC socket open. A socket teardown
// with a living process (rare) is just as unusable as a dead one —
// every command would fail "not-connected" forever.
if (this.proc && !this._exited && this.conn?.isConnected()) return;
if (this.startPromise) return this.startPromise;
this.startPromise = this.recoverDaemon().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
/** Bring up a usable daemon. If the old process still lives with a dead
* IPC connection, kill it so the fresh spawn owns the socket path and
* no orphan lingers — and await its exit so its exit handler can't run
* after spawnDaemon() and clobber the new daemon's `_exited` flag. */
private async recoverDaemon(): Promise<void> {
const stale = this.proc;
if (stale && !this._exited) {
try {
stale.kill();
} catch {
/* already gone */
}
}
if (stale) await stale.exited.catch(() => {});
this.conn = null;
await this.spawnDaemon();
}
private async spawnDaemon(): Promise<void> {
// Clean up stale socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs");
unlinkSync(this.socketPath);
}
} catch {
/* ignore */
}
const args = [
resolveMpvBinary() ?? "mpv",
"--no-video",
this.proc = Bun.spawn(
[
"mpv",
// --vo=null (not --no-video): the albumart track must stay the
// CURRENT video track or macOS Now Playing shows no artwork.
// --no-video drops it to unselected (albumart:true, selected:false),
// so the system media center renders no cover. --vo=null is equally
// headless — no window, no rendering — but keeps the cover current
// so Now Playing gets the art.
"--vo=null",
"--no-terminal",
"--really-quiet",
// Stay alive after finishing/unloading files; PodTUI owns one mpv
// for its whole session and switches episodes via loadfile.
"--idle=yes",
"--keep-open=yes",
// Cap the demuxer cache. mpv's defaults (150MiB) make it race
// to fill while a preload sits paused — measured 45MB pulled
// within 12s of a boot-restore preload, saturating the link
// exactly when everything else is starting up. ~90s forward
// target / 40MiB hard cap is a few MB at podcast bitrates:
// plenty for instant resume + stall resilience.
"--cache-secs=90",
"--demuxer-max-bytes=40MiB",
"--demuxer-max-back-bytes=20MiB",
`--input-ipc-server=${this.socketPath}`,
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
`--speed=${opts?.speed ?? 1}`,
];
if (opts?.mediaTitle) {
args.push(`--force-media-title=${opts.mediaTitle}`);
}
if (opts?.coverArtPath) {
// Explicit cover file → albumart track → macOS Now Playing artwork
// (works for remote streams, not just local downloads).
args.push(`--cover-art-files=${opts.coverArtPath}`);
}
if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`);
}
args.push(url);
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
this._playing = true;
this._position = opts?.startPosition ?? 0;
this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1;
// Wait for socket to appear (mpv creates it async)
await this.waitForSocket(2000);
// Position is fetched live from mpv on each getPosition() call (see
// below) — the UI polls it, so no internal poll timer is needed.
// Detect process exit
],
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
);
this._exited = false;
this.proc.exited
.then(() => {
this._playing = false;
// Daemon died (crash or external kill): every per-file state
// is gone with it. _loadedUrl null forces the next play()
// down the full reload path; _position/_volume/_speed are
// kept so a recovery reload can carry them over.
this._exited = true;
this._intentPlaying = false;
this._loadedUrl = null;
this._loadedPaused = false;
this._ended = false;
this._paused = null;
})
.catch(() => {});
}
private async waitForSocket(timeoutMs: number): Promise<void> {
// mpv creates the socket asynchronously (measured ~600ms cold spawn).
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (existsSync(this.socketPath)) return;
while (Date.now() - start < 3000) {
if (this._exited) break;
if (existsSync(this.socketPath)) break;
await new Promise((r) => setTimeout(r, 50));
}
const conn = new MpvConnection();
conn.onEvent = (msg) => this.handleEvent(msg);
await conn.connect(this.socketPath);
this.conn = conn;
// Observe the state the UI polls: mpv then pushes changes at ~20Hz
// while playing and broadcasts external changes (AirPods pull, OS
// media keys) with zero polling from our side.
await this.send(["observe_property", OBS_TIME_POS, "time-pos"]);
await this.send(["observe_property", OBS_PAUSE, "pause"]);
await this.send(["observe_property", OBS_DURATION, "duration"]);
// With --keep-open=yes mpv does NOT emit end-file at natural EOF — it
// sets eof-reached=true (and pauses at the last frame) instead. That
// property is the track-end signal; end-file only covers unload/error.
await this.send(["observe_property", OBS_EOF, "eof-reached"]);
}
/** Send a fire-and-forget command (no response needed) */
private async send(command: unknown[]): Promise<void> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
});
conn.write(JSON.stringify({ command }) + "\n");
// Don't wait, just schedule a close
setTimeout(() => {
try {
conn.end();
} catch {}
}, 50);
} catch {
/* ignore */
private async send(
command: unknown[],
): Promise<MpvResponse> {
if (!this.conn) return { error: "not-connected" };
return this.conn.send(command);
}
private handleEvent(msg: MpvEvent): void {
if (msg.event === "property-change") {
if (msg.id === OBS_TIME_POS) {
// `data` is number while playing; unavailable → undefined while
// idle. Keep last known on transient gaps, reset on idle.
if (typeof msg.data === "number") this._position = msg.data;
} else if (msg.id === OBS_PAUSE) {
if (typeof msg.data === "boolean") this._paused = msg.data;
} else if (msg.id === OBS_DURATION) {
if (typeof msg.data === "number" && msg.data > 0) {
this._duration = msg.data;
}
} else if (msg.id === OBS_EOF) {
// Natural end-of-file (or a brand-new load reporting false).
this._ended = msg.data === true;
if (this._ended) this._intentPlaying = false;
}
return;
}
if (msg.event === "end-file") {
if (msg.reason === "eof") {
this._ended = true;
this._intentPlaying = false;
} else if (msg.reason === "error") {
this._ended = true;
this._intentPlaying = false;
this._playbackError = msg.error ?? "mpv failed to play the stream";
}
return;
}
if (msg.event === "file-loaded") {
this._ended = false;
}
}
// ── File presentation options ────────────────────────────────────
//
// force-media-title and cover-art-files are set as global properties
// BEFORE loadfile (verified: runtime-settable; values containing commas
// would corrupt the per-file options string). Numbers (volume, speed,
// start, pause) ride as per-file options on loadfile itself so each
// loadfile is self-contained.
private async applyPresentation(opts?: PlayOptions): Promise<void> {
await this.send([
"set_property",
"force-media-title",
opts?.mediaTitle ?? "",
]);
await this.send([
"set_property",
"cover-art-files",
opts?.coverArtPath ?? "",
]);
}
private loadfileOptions(opts: PlayOptions | undefined, paused: boolean): string {
const parts: string[] = [`pause=${paused ? "yes" : "no"}`];
if (opts?.startPosition && opts.startPosition > 0) {
parts.push(`start=${Math.max(0, opts.startPosition)}`);
}
const vol = Math.round((opts?.volume ?? 1) * 100);
if (Number.isFinite(vol)) parts.push(`volume=${vol}`);
const speed = opts?.speed ?? 1;
if (Number.isFinite(speed) && speed > 0) parts.push(`speed=${speed}`);
return parts.join(",");
}
/**
* Get a property value from mpv via IPC.
*
* Resolves the parsed numeric value, or `undefined` when the read fails
* (socket error, timeout, unparseable response, or the property being
* unavailable — e.g. `time-pos` before playback starts). Failure is
* distinct from a legitimate `0` so callers can keep the last known
* value instead of snapping the position clock to zero on a transient
* error; the next poll retries.
*
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
* onto the same connection, so we line-buffer and only settle on the
* line that carries the command response (`request_id` set). The socket
* is closed once the response is handled — leaving it open leaks an fd
* per poll, while closing it before mpv processes the request drops the
* reply.
* Every loadfile (play, preload, replay) runs under this mutex: useAudio
* fires the boot preload unawaited, so without serialization a user
* pressing Play mid-preload would send loadfile(no-pause) followed by the
* in-flight preload's loadfile(pause=yes) — and the stale preload would
* pause the file the user just started. The mutex also prevents
* presentation options (title/cover) of one episode from interleaving
* with the loadfile of another.
*/
private async getProperty(name: string): Promise<number | undefined> {
try {
return await new Promise<number | undefined>((resolve) => {
let settled = false;
let sock: Socket | null = null;
let buf = "";
const done = (value: number | undefined) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
sock?.end();
} catch {
/* ignore */
}
resolve(value);
};
const timeout = setTimeout(() => done(undefined), 300);
private loadMutex: Promise<unknown> = Promise.resolve();
Bun.connect({
unix: this.socketPath,
socket: {
open(socket) {
sock = socket;
socket.write(
JSON.stringify({ command: ["get_property", name] }) + "\n",
);
},
data(_socket, data) {
buf += Buffer.from(data).toString();
let nl = buf.indexOf("\n");
while (nl !== -1) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
nl = buf.indexOf("\n");
try {
const parsed = JSON.parse(line);
// Events carry no request_id; only settle on
// the actual command response.
if (parsed?.request_id === undefined) continue;
if (parsed?.data !== undefined) {
done(Number(parsed.data) || 0);
} else {
done(undefined);
private runLoadExclusive<T>(fn: () => Promise<T>): Promise<T> {
const result = this.loadMutex.then(fn);
this.loadMutex = result.catch(() => {});
return result;
}
private async loadFileLocked(
url: string,
opts: PlayOptions | undefined,
paused: boolean,
): Promise<void> {
await this.applyPresentation(opts);
// Paused preload of a mid-episode restore: pass NO start= option and
// seek while paused instead. mpv defers --start stream work (open,
// header probe, demuxer seek) until playback begins — measured: the
// demuxer cache stays EMPTY during the whole preload and the eventual
// unpause pays 4.3s. A time-pos seek while paused executes at once,
// so the stream opens and buffers during the preload, and the first
// real Play is a sub-second unpause.
const pausedSeek =
paused && opts?.startPosition && opts.startPosition > 0
? opts.startPosition
: null;
const loadOpts =
pausedSeek && opts ? { ...opts, startPosition: undefined } : opts;
// Register the file-loaded waiter BEFORE loadfile: the event can
// arrive between the command response and listener setup otherwise.
const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null;
const resp = await this.send([
"loadfile",
url,
"replace",
-1,
this.loadfileOptions(loadOpts, paused),
]);
if (resp.error && resp.error !== "success") {
throw new Error(`mpv loadfile failed: ${resp.error}`);
}
if (pausedSeek) {
// time-pos sent before file-loaded is silently dropped by mpv
// (no file yet) — the preload then parked at 0 and the restore
// position was lost. Wait for the open, then seek. A dead URL
// never fires file-loaded at all (mpv keeps retrying the
// open), so end-file (the open-failure notification) races it
// and the wait folds to "not loaded" instead of stalling the
// load mutex for the full 5s timeout.
await Promise.race([fileLoaded, this.conn?.waitEvent("end-file", 5000)]);
await this.send(["set_property", "time-pos", pausedSeek]);
this._position = pausedSeek;
}
this._loadedUrl = url;
this._loadedPaused = paused;
this._ended = false;
this._playbackError = null;
this._position = opts?.startPosition ?? 0;
this._duration = 0;
this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1;
}
// ── AudioBackend ─────────────────────────────────────────────────
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.ensureDaemon();
// Mark intent before the mutex: a boot preload queued behind this
// play checks it and skips its own stale paused-load.
this._intentPlaying = true;
await this.runLoadExclusive(async () => {
// Same episode re-selected (Enter in a list, key-repeat, a
// second tap on the playing row): the file is ALREADY in the
// player. Reloading with start=<saved progress> would audibly
// skip BACK and repeat already-played audio (saved progress
// lags the live position by up to the 5s persist interval), so
// align in place instead:
// - preload park (loaded paused at boot restore): seek only
// when the caller's target moved materially since load;
// - user-paused: unpause at the CURRENT position (saved
// progress is stale and must not become a backward seek);
// - already playing: unpause is a no-op — nothing to do.
// A genuinely finished episode (_ended) still falls through to
// a fresh load, which replays from the top via isCompleted.
if (this._loadedUrl === url && !this._ended) {
const target = opts?.startPosition ?? this._position;
if (this._loadedPaused && Math.abs(target - this._position) > 2) {
await this.send(["set_property", "time-pos", target]);
this._position = target;
}
await this.send([
"set_property",
"volume",
Math.round((opts?.volume ?? 1) * 100),
]);
await this.send(["set_property", "speed", opts?.speed ?? 1]);
if (opts?.mediaTitle) {
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
}
await this.send(["set_property", "pause", false]);
this._loadedPaused = false;
return;
} catch {
/* skip malformed lines */
}
}
},
error() {
done(undefined);
},
close() {
done(undefined);
},
},
}).catch(() => done(undefined));
await this.loadFileLocked(url, opts, false);
});
} catch {
return undefined;
}
async preload(url: string, opts?: PlayOptions): Promise<void> {
await this.ensureDaemon();
await this.runLoadExclusive(async () => {
// Already loaded (paused park, or actively playing because the
// user pressed Play while this preload was queued — either way
// the file is in the player and must not be clobbered).
if (this._loadedUrl === url) return;
await this.loadFileLocked(url, opts, true);
this._intentPlaying = false;
});
}
async pause(): Promise<void> {
await this.send(["set_property", "pause", true]);
this._playing = false;
this._intentPlaying = false;
}
async resume(): Promise<void> {
await this.send(["set_property", "pause", false]);
this._playing = true;
// The daemon may have died while we were paused (crash/kill): bring
// a fresh one up. It starts idle — no file to unpause — so throw
// PlayerRestartedError and let the caller reload the episode.
await this.ensureDaemon();
if (!this._loadedUrl) {
throw new PlayerRestartedError();
}
if (this._ended && this._loadedUrl) {
// Play pressed on a finished episode: replay from the top.
this._ended = false;
const url = this._loadedUrl;
await this.runLoadExclusive(async () => {
await this.loadFileLocked(
url,
{ volume: this._volume / 100, speed: this._speed },
false,
);
});
this._intentPlaying = true;
return;
}
if (this._loadedPaused && this._loadedUrl) {
// Deferred first play of a preloaded file.
this._loadedPaused = false;
}
this._ended = false;
const resp = await this.send(["set_property", "pause", false]);
// Never claim success when the unpause didn't land: a dead/restarted
// daemon would otherwise leave the UI "playing" with no audio.
if (resp.error && resp.error !== "success") {
throw new Error(`mpv resume failed: ${resp.error}`);
}
this._intentPlaying = true;
}
async stop(): Promise<void> {
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
if (this.conn && this._loadedUrl) {
await this.send(["stop"]);
}
this.proc = null;
}
this._playing = false;
this._intentPlaying = false;
this._loadedUrl = null;
this._loadedPaused = false;
this._ended = false;
this._position = 0;
// Clean up socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs");
unlinkSync(this.socketPath);
}
} catch {
/* ignore */
}
this._duration = 0;
await this.send(["set_property", "cover-art-files", ""]);
}
async seek(seconds: number): Promise<void> {
@@ -333,30 +726,55 @@ export class MpvBackend implements AudioBackend {
}
async getPosition(): Promise<number> {
// Live-fetch `time-pos` so the position clock is as fresh as the
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
// failure, keep the last known value rather than returning 0.
if (this._playing && this.proc) {
const pos = await this.getProperty("time-pos");
if (pos !== undefined) this._position = pos;
}
// Observed at ~20Hz by mpv — no socket roundtrip on the UI poll.
return this._position;
}
async getDuration(): Promise<number> {
if (this._duration <= 0) {
const dur = await this.getProperty("duration");
if (dur !== undefined && dur > 0) this._duration = dur;
}
return this._duration;
}
isPlaying(): boolean {
return this._playing;
return this._intentPlaying && this.isAlive() && !this._ended;
}
async getPauseState(): Promise<boolean | undefined> {
if (!this.isAlive() || this._paused === null) return undefined;
return this._paused;
}
isAlive(): boolean {
return this.proc !== null && !this._exited;
}
/** Last mpv playback failure (end-file reason "error"), if any. */
getPlaybackError(): string | null {
return this._playbackError;
}
dispose(): void {
this.stop();
const conn = this.conn;
this.conn = null;
if (conn) {
// Ask nicely, then force: dispose runs inside process-exit
// handlers where awaiting is not guaranteed to complete.
conn.send(["quit"], 500).catch(() => {});
}
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
this._exited = true;
this._intentPlaying = false;
try {
unlinkSync(this.socketPath);
} catch {
/* ignore */
}
}
}
@@ -365,6 +783,7 @@ export class MpvBackend implements AudioBackend {
class NoopBackend implements AudioBackend {
readonly name: BackendName = "none";
async play(): Promise<void> {}
async preload(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async stop(): Promise<void> {}
@@ -380,6 +799,16 @@ class NoopBackend implements AudioBackend {
isPlaying(): boolean {
return false;
}
async getPauseState(): Promise<boolean | undefined> {
// Nothing plays on the no-op backend — never externally paused.
return false;
}
isAlive(): boolean {
return false;
}
getPlaybackError(): string | null {
return null;
}
dispose(): void {}
}
@@ -400,7 +829,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = [];
const mpvPath = resolveMpvBinary();
const mpvPath = which("mpv");
if (mpvPath) {
players.push({
name: "mpv",
@@ -434,13 +863,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend;
}
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
return which("mpv") ? new MpvBackend() : new NoopBackend();
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv":
return resolveMpvBinary() ? new MpvBackend() : null;
return which("mpv") ? new MpvBackend() : null;
case "none":
return new NoopBackend();
}

88
src/utils/audio-queue.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* audio-queue — ordered episode queue for "what plays next" navigation.
*
* Pure selection logic for source-based auto-advance (and manual next/prev):
* given the navigation source that STARTED the current episode, which
* episodes come after it?
*
* FEED — the global chronological Feed list (newest first), so "next"
* walks toward older episodes — further down the list.
* MY_SHOWS — the current show's episode list (newest first), scoped to the
* podcast that started playback.
* SEARCH — the current search results, in display order (episode-kind
* results only — a show result has nothing to play).
*
* Kept dependency-light (pure functions over plain data) so the ordering and
* bounds contract is unit-testable without stores or audio.
*/
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import type { SearchResult } from "../types/source";
import { AudioSource } from "../stores/audio-nav";
/** The ordered playable queue for a navigation source. Empty when the
* source's context is missing (no podcastId, no search results, no feeds). */
export function queueForSource(
source: AudioSource,
podcastId: string | undefined,
feeds: Feed[],
allEpisodes: Array<{ episode: Episode; feed: Feed }>,
searchResults: SearchResult[],
): Episode[] {
if (source === AudioSource.FEED) {
// Dedupe by episode id: the same episode can appear twice after a
// refresh merge or when two feeds list it — a duplicate would make
// next/auto-advance step onto the CURRENT episode and replay it.
const seen = new Set<string>();
const unique: Episode[] = [];
for (const e of allEpisodes) {
if (seen.has(e.episode.id)) continue;
seen.add(e.episode.id);
unique.push(e.episode);
}
return unique;
}
if (source === AudioSource.MY_SHOWS) {
const feed = feeds.find((f) => f.podcast.id === podcastId);
return feed ? feed.episodes : [];
}
if (source === AudioSource.SEARCH) {
return searchResults
.filter((r) => r.kind === "episode")
.map((r) => r.episode);
}
return [];
}
/** Index of an episode in the queue, or -1 when the episode isn't in it. */
export function queueIndex(queue: Episode[], episodeId: string): number {
return queue.findIndex((e) => e.id === episodeId);
}
export interface QueueStep {
episode: Episode;
index: number;
}
/** The episode after `episodeId` in the queue, with its index. Null when
* the episode isn't in the queue or is already the last one. */
export function nextStep(
queue: Episode[],
episodeId: string,
): QueueStep | null {
const idx = queueIndex(queue, episodeId);
if (idx < 0 || idx + 1 >= queue.length) return null;
return { episode: queue[idx + 1], index: idx + 1 };
}
/** The episode before `episodeId` in the queue, with its index. Null when
* the episode isn't in the queue or is already the first one. */
export function prevStep(
queue: Episode[],
episodeId: string,
): QueueStep | null {
const idx = queueIndex(queue, episodeId);
if (idx <= 0) return null;
return { episode: queue[idx - 1], index: idx - 1 };
}

View File

@@ -0,0 +1,42 @@
/**
* audio-signals — module-level playback state shared by useAudio and
* non-component consumers.
*
* useAudio's playback state is a module-level singleton (signals live at
* module scope, every `useAudio()` call shares them). Those signals are
* declared here so components that must react to playback WITHOUT mounting
* a `useAudio()` owner — the visualizer store — can subscribe directly via
* `audioPlaybackSignals` (or the individual accessors/setters), instead of
* going through the hook. `useAudio()` re-exports nothing from this module
* for callers; it imports the accessors and setters for its own use.
*/
import { createSignal } from "solid-js";
import type { Episode } from "../types/episode";
import type { BackendName, DetectedPlayer } from "./audio-player";
export const [isPlaying, setIsPlaying] = createSignal(false);
export const [position, setPosition] = createSignal(0);
export const [duration, setDuration] = createSignal(0);
export const [volume, setVolume] = createSignal(1);
export const [speed, setSpeed] = createSignal(1);
export const [backendName, setBackendName] = createSignal<BackendName>("none");
export const [error, setError] = createSignal<string | null>(null);
export const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(
null,
);
export const [availablePlayers, setAvailablePlayers] = createSignal<
DetectedPlayer[]
>([]);
/**
* The playback signals the visualizer pipeline reacts to. `useAudio()`
* itself remains the component-facing surface; this is for module-level
* consumers that must track playback without a component owner.
*/
export const audioPlaybackSignals = {
isPlaying,
position,
speed,
currentEpisode,
} as const;

View File

@@ -1,324 +0,0 @@
/**
* Real-time audio stream reader for visualization.
*
* Spawns a separate ffmpeg process that decodes the same audio URL
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
* and serves windows *at a requested playback position* to the caller.
*
* This is independent from the actual playback backend — it's a
* read-only "tap" on the audio for FFT analysis purposes. Sync with the
* player is maintained by pacing decode at the player's clock rate
* (`-readrate <speed>`) while front-loading a burst of LEAD_SECONDS
* (`-readrate_initial_burst`) so the decode head leads the player
* position by a stable lead — read() samples at the exact position the
* player reports, never at the decode head.
*/
/** PCM output format constants */
const SAMPLE_RATE = 44100;
const CHANNELS = 1;
const BYTES_PER_SAMPLE = 2; // s16le
/**
* How many samples to buffer (~10 seconds).
* Large enough to absorb the gap between mpv's startup latency (0.53s,
* more for network streams at speed) and the reader's decode head, plus
* short player stalls. Samples older than the ring window are never needed
* again — the renderer only samples at the current playback position.
*/
const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10;
/**
* Decode-head lead over the player position, in seconds.
*
* `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio
* immediately on start, then pace at realtime (`-readrate speed`) after.
* The decode head thus leads the player by ~LEAD_SECONDS from the very
* first frame. read() samples at the player's current position, which is
* always behind the head — so it finds freshly decoded samples there
* instead of clamping to stale data.
*
* Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open
* + first-packet latency) and, since both advance at the same rate, never
* catches up — the bars lag by ε (up to several seconds on network
* streams). The burst eliminates that constant offset.
*
* Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the
* lead audio hasn't wrapped out by the time the player reaches it.
*/
const LEAD_SECONDS = 3;
export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */
url: string;
/** Sample rate (default: 44100) */
sampleRate?: number;
}
/**
* Monotonically increasing generation counter.
* Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0;
import type { Subprocess } from "bun";
export class AudioStreamReader {
private proc: Subprocess | null = null;
private ringBuffer: Float64Array;
private writePos = 0;
private totalSamplesWritten = 0;
private startPosition = 0;
private _running = false;
private generation = 0;
readonly url: string;
private sampleRate: number;
constructor(options: AudioStreamReaderOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
}
/** Whether the reader is actively reading samples. */
get running(): boolean {
return this._running;
}
/** Total number of samples written since start(). */
get samplesWritten(): number {
return this.totalSamplesWritten;
}
/**
* Start the ffmpeg decode process and begin reading PCM data.
*
* If already running, the previous process is killed first.
* Uses a generation counter to guarantee that only one read loop
* is ever active — stale loops from killed processes bail out
* immediately.
*
* @param startPosition Seek position in seconds (default: 0).
* @param speed Playback speed multiplier (default: 1). Paces ffmpeg
* at the player's advance rate so decode tracks the
* player clock; `-readrate_initial_burst` front-loads
* a LEAD_SECONDS head start.
*/
start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running
this.killProcess();
if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization");
}
// Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit.
this.generation = ++globalGeneration;
this.startPosition = Math.max(0, startPosition);
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
const args = [
"ffmpeg",
"-loglevel",
"quiet",
// Pace input at the player's advance rate (speed× native). Combined
// with -readrate_initial_burst below, the decode head starts
// LEAD_SECONDS ahead of the player and advances at the same rate —
// read() samples at the player position and always finds fresh data.
"-readrate",
String(readRate),
// Front-load LEAD_SECONDS of audio immediately so the decode head
// leads the player from the very first frame. Without this, ffmpeg
// starts ε behind mpv (input-open + first-packet latency) and,
// pacing at the same rate, never catches up — bars lag by ε.
"-readrate_initial_burst",
String(LEAD_SECONDS),
];
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
// input-open when the input is a local file, killing the process
// before any PCM is produced. Only pass them for network URLs.
if (/^https?:\/\//i.test(this.url)) {
args.push(
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
);
}
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition));
}
args.push("-i", this.url);
// No atempo filter: the renderer samples the *source* audio at the
// player's current position, so output samples map 1:1 to input time
// (stream index = (targetSeconds - startPosition) * sampleRate).
args.push(
"-ac",
String(CHANNELS),
"-ar",
String(this.sampleRate),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-",
);
this.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
this._running = true;
this.writePos = 0;
this.totalSamplesWritten = 0;
const myGeneration = this.generation;
this.readLoop(myGeneration);
// Detect process exit
this.proc.exited
.then(() => {
// Only clear _running if this is still the current generation
if (this.generation === myGeneration) {
this._running = false;
}
})
.catch(() => {
if (this.generation === myGeneration) {
this._running = false;
}
});
}
/**
* Read the visualization window ending at `targetSeconds` of playback.
*
* The player (mpv) and this decoder are independent processes, so the
* decode head and the actual playback position drift apart (startup skew,
* stalls, speed changes). Instead of sampling the decode head, we select
* the window *at* the position the player reports, clamped to the nearest
* available samples when the target hasn't been decoded yet (decode head
* behind) or has already wrapped out of the ring (long stall).
*
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
* @param targetSeconds - Playback position (input seconds) to sample.
* @returns Number of samples written to `out`.
*/
read(out: Float64Array, targetSeconds: number): number {
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
const headSample = this.totalSamplesWritten - 1;
const coveredStart = Math.max(
0,
this.totalSamplesWritten - this.ringBuffer.length,
);
const targetSample = Math.max(
0,
Math.round((targetSeconds - this.startPosition) * this.sampleRate),
);
// Window end: the target, clamped to what's been decoded so far.
const endSample = Math.min(targetSample, headSample);
// Window start: at most out.length samples back, clamped to what the
// ring still holds (target older than the ring -> serve the oldest
// available window, which is the closest to the target).
const startSample = Math.max(
coveredStart,
Math.min(endSample, endSample - out.length + 1),
);
const available = endSample - startSample + 1;
if (available <= 0) return 0;
const ringLen = this.ringBuffer.length;
for (let i = 0; i < available; i++) {
out[i] = this.ringBuffer[(startSample + i) % ringLen];
}
return available;
}
/**
* Stop the ffmpeg process and clean up.
* Safe to call multiple times. Guarantees the read loop exits.
*/
stop(): void {
// Bump generation to invalidate any running read loop
this.generation = ++globalGeneration;
this._running = false;
this.killProcess();
this.writePos = 0;
this.totalSamplesWritten = 0;
}
/**
* Restart the reader at a new position and/or speed.
*/
restart(startPosition = 0, speed = 1): void {
this.start(startPosition, speed);
}
/** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void {
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
}
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
private async readLoop(myGeneration: number): Promise<void> {
const stdout = this.proc?.stdout;
if (!stdout || typeof stdout === "number") return;
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
try {
while (this.generation === myGeneration) {
const { done, value } = await reader.read();
if (done || this.generation !== myGeneration) break;
if (!value || value.byteLength === 0) continue;
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
if (sampleCount === 0) continue;
const int16View = new Int16Array(
value.buffer,
value.byteOffset,
sampleCount,
);
for (let i = 0; i < sampleCount; i++) {
this.ringBuffer[this.writePos] = int16View[i];
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
this.totalSamplesWritten++;
}
}
} catch {
// Stream ended or process killed — expected during stop()
} finally {
try {
reader.releaseLock();
} catch {
/* ignore */
}
}
}
}

View File

@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
@@ -96,13 +95,14 @@ export class CavaCore {
private _bars = 0;
private _channels = 1;
private _destroyed = false;
/** Serialized last init config — identical init() calls are no-ops. */
private lastConfigKey = "";
/** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}
@@ -114,15 +114,25 @@ export class CavaCore {
/**
* Initialize the cavacore engine with the given configuration.
* Must be called before execute(). Can be called again after destroy()
* to reinitialize with different parameters.
* Must be called before execute(). Identical configs are a no-op:
* cava_init/destroy churn leaks the old plan's FFTW work buffers
* (upstream frees only its own struct), so a pipeline restart with
* unchanged bars/rate/cutoffs must re-USE the live plan.
*/
init(config: CavaCoreConfig = {}): void {
const cfg = { ...DEFAULTS, ...config };
if (
this.plan !== null &&
!this._destroyed &&
this.lastConfigKey === JSON.stringify(cfg)
) {
return;
}
this.lastConfigKey = JSON.stringify(cfg);
if (this.plan) {
this.destroy();
}
const cfg = { ...DEFAULTS, ...config };
this._bars = cfg.bars;
this._channels = cfg.channels;

View File

@@ -13,6 +13,7 @@
* always overwrite — no backup files are created.
*/
import { mkdir } from "fs/promises";
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
import type {
AppSettings,
@@ -58,21 +59,47 @@ let writeChain: Promise<void> = Promise.resolve();
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
export function updateConfig(patch: Partial<PodTuiConfig>): void {
// Capture the target path AND the patch data eagerly, at call time:
// the write chain defers execution, and both the config dir (tests
// re-point XDG_CONFIG_HOME between ops) and the state object (stores
// mutate in place) move under a pending write. Without the capture, a
// queued save writes the LATEST state into whatever directory is
// current when the chain drains — a cross-directory misdelivery that
// was the source of a flaky "enabled:false survives reload" test.
const configPath = getConfigFilePath(CONFIG_FILE);
const configDir = getConfigDir();
const snapshot = JSON.parse(JSON.stringify(patch)) as Partial<PodTuiConfig>;
writeChain = writeChain.then(async () => {
try {
await ensureConfigDir();
const current = await loadConfig();
const next = { ...current, ...patch };
await Bun.write(
getConfigFilePath(CONFIG_FILE),
JSON.stringify(next, null, 2),
);
await migrateOnce();
await mkdir(configDir, { recursive: true });
let current: PodTuiConfig = {};
try {
const file = Bun.file(configPath);
if (await file.exists()) {
const raw = await file.json();
if (raw && typeof raw === "object") {
current = raw as PodTuiConfig;
}
}
} catch {
/* unreadable existing config — treat as empty */
}
const next = { ...current, ...snapshot };
await Bun.write(configPath, JSON.stringify(next, null, 2));
} catch {
// Fire-and-forget persistence — silently ignore write errors.
}
});
}
/** Resolve once every queued config write has flushed. Tests await this to
* observe the serialized result of pending saveFeedsToFile/updateConfig
* calls before asserting on config.json. */
export function whenConfigIdle(): Promise<void> {
return writeChain;
}
/** Guards so migration runs exactly once per process. */
let migrationDone = false;
let migrationPromise: Promise<void> | null = null;

View File

@@ -2,34 +2,97 @@
* Cover-art staging for the system Now Playing session.
*
* macOS shows the media session's albumart in the audio center (Control
* Center / lock screen). mpv reads it from `--cover-art-files` (loads the
* file as an albumart video track), so the podcast cover is staged to a temp
* file BEFORE playback starts and passed to mpv.
* Center / lock screen). mpv reads artwork from `--cover-art-files` (loads
* the file as an albumart video track), so the podcast cover must exist on
* disk before (cover-art-files) or right after (video-add) playback starts.
*
* Covers are cached persistently under `$XDG_CACHE_HOME/podtui/covers`
* (~/.cache/podtui/covers by default), keyed by the URL hash, so the
* download happens ONCE per feed — subsequent plays (including the
* boot-restored episode) hit the disk cache and never wait on the network.
* The play path must never block on art: `cachedCoverPath` is the sync
* fast path; `fetchCoverArt` is awaited only by flows where latency does
* not matter (CLI play) or fired in the background with the result
* applied to a live mpv via `video-add`.
*
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
* which would silently drop every cover in shipped builds. curl is present
* on macOS and Linux. Bounded: a slow cover server must never stall audio,
* so an 8s cap drops the art.
* on macOS and Linux. Bounded: a slow cover server must never stall audio.
*/
import { tmpdir } from "os";
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
import { createHash } from "crypto";
import { join } from "path";
import { unlinkSync, statSync } from "fs";
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
/** Resolved once per process; null when no home directory is detectable. */
let cacheDir: string | null | undefined;
export async function fetchCoverArt(url: string): Promise<string | null> {
const path = coverTempPath();
function coversDir(): string | null {
if (cacheDir !== undefined) return cacheDir;
let dir: string | null = null;
try {
unlinkSync(path);
} catch {
/* no stale cover */
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
if (home) {
dir = join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "podtui", "covers");
mkdirSync(dir, { recursive: true });
}
} catch {
dir = null;
}
cacheDir = dir;
return dir;
}
function cachePathFor(url: string): string | null {
const dir = coversDir();
if (!dir) return null;
return join(dir, `${createHash("sha1").update(url).digest("hex")}.jpg`);
}
/**
* Sync fast path: the cached cover file for `url`, or null when it has not
* been downloaded yet. This is what keeps cover art off the play() critical
* path — a cache hit costs one stat() and a miss simply plays without art
* (or applies it late via video-add).
*/
export function cachedCoverPath(url: string): string | null {
const path = cachePathFor(url);
if (!path) return null;
try {
return await Promise.race([
(async () => {
const proc = Bun.spawn([
return existsSync(path) && statSync(path).size > 0 ? path : null;
} catch {
return null;
}
}
/** In-flight downloads keyed by URL — a burst of plays of the same show
* shares one curl instead of racing ephemeral files. */
const inflight = new Map<string, Promise<string | null>>();
/**
* Fetch the cover for `url`, returns its cache path. Cache hits return
* immediately. Downloads are single-flight per URL and time-bounded (8s);
* failure resolves null and retries on the next call. The file is written
* to a temp name and renamed into place so a killed process can never
* poison the cache with a truncated file.
*/
export function fetchCoverArt(url: string): Promise<string | null> {
const cached = cachedCoverPath(url);
if (cached) return Promise.resolve(cached);
const dest = cachePathFor(url);
if (!dest) return Promise.resolve(null);
const pending = inflight.get(url);
if (pending) return pending;
const task = (async (): Promise<string | null> => {
const staging = `${dest}.${process.pid}.tmp`;
try {
const { promise, resolve } = Promise.withResolvers<string | null>();
const proc = Bun.spawn(
[
"curl",
"-sS",
"--fail",
@@ -38,20 +101,41 @@ export async function fetchCoverArt(url: string): Promise<string | null> {
"--max-filesize",
"2097152",
"-o",
path,
staging,
url,
]);
const code = await proc.exited;
if (code !== 0) return null;
],
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
);
proc.exited
.then((code) => {
if (code !== 0) return resolve(null);
try {
return statSync(path).size > 0 ? path : null;
if (statSync(staging).size <= 0) return resolve(null);
renameSync(staging, dest);
resolve(dest);
} catch {
return null;
resolve(null);
}
})(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
]);
})
.catch(() => resolve(null));
setTimeout(() => resolve(null), 8000);
return await promise;
} finally {
inflight.delete(url);
// Best-effort staging cleanup (no-op after a successful rename).
try {
Bun.spawn(["rm", "-f", staging], { stdout: "ignore", stderr: "ignore" });
} catch {
return null;
/* ignore */
}
}
})();
inflight.set(url, task);
return task;
}
/** Fire-and-forget warm-up used by the boot/restore path. */
export function prefetchCoverArt(url: string): void {
fetchCoverArt(url).catch(() => {});
}

View File

@@ -76,6 +76,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"sort",
"toggle-hidden",
"refresh",
"subscribe",
"unsubscribe",
"download",
"delete-download",

View File

@@ -124,17 +124,17 @@ export async function downloadEpisode(
}
}
const reader = body.getReader()
const chunks: Uint8Array[] = []
const fileWriter = Bun.file(filePath).writer()
let bytesDownloaded = 0
let lastProgressTime = Date.now()
let lastProgressBytes = 0
const reader = body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
fileWriter.write(value)
bytesDownloaded += value.length
// Report progress roughly every 250ms
@@ -152,22 +152,14 @@ export async function downloadEpisode(
}
}
// Concatenate chunks and write to file
const totalSize = bytesDownloaded
const buffer = new Uint8Array(totalSize)
let offset = 0
for (const chunk of chunks) {
buffer.set(chunk, offset)
offset += chunk.length
}
await Bun.write(filePath, buffer)
// Finalize the streamed file
await fileWriter.end()
// Final progress report
if (onProgress) {
onProgress({
bytesDownloaded: totalSize,
totalBytes: contentLength || totalSize,
bytesDownloaded,
totalBytes: contentLength || bytesDownloaded,
percent: 100,
speed: 0,
})
@@ -176,7 +168,7 @@ export async function downloadEpisode(
return {
success: true,
filePath,
fileSize: totalSize,
fileSize: bytesDownloaded,
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {

View File

@@ -0,0 +1,63 @@
import type { Episode } from "../types/episode";
/** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST
* (Infinity) so undated episodes float to the top instead of dropping into
* the oldest slot. */
const ts = (ep: Episode): number => {
const t = ep.pubDate?.getTime()
return t === undefined || Number.isNaN(t) ? Infinity : t
}
/** PubDate stamp for identity matching — undated episodes collapse to a
* single token so their twins match by title alone. */
const stamp = (ep: Episode): string => {
const t = ep.pubDate?.getTime()
return t === undefined || Number.isNaN(t) ? "undated" : String(t)
}
/**
* Content signature identifying the SAME episode across id changes. Episode
* ids are stable (guid / enclosure-URL derived), but a feed can still change
* an episode's id between refreshes: the one-time migration from the old
* positional-id scheme, or a host that rotates signed enclosure URLs. title +
* pubDate is the most stable combination that survives both — a feed
* re-issuing an episode with the same title and date IS that episode.
*/
export const episodeSignature = (ep: Episode): string =>
`${ep.title}\u0000${stamp(ep)}`
/**
* Union of two episode lists keyed by id — on collision the fetched copy
* wins (fresh metadata). An existing episode whose id differs from every
* fetched id but whose content signature matches a fetched episode is a
* stale-id twin (id migration / rotating enclosure URLs) and is dropped,
* otherwise the union would double every episode on the first refresh after
* the id scheme changed. Existing episodes with NO fetched twin survive
* (volatile in-memory window). Result is sorted newest-first by pubDate and
* pruned by the supplied `keep` predicate: episodes outside the configured
* cache bound (date window or count) are dropped. Never mutates either
* input.
*
* The caller supplies `keep` so this module stays free of the preference
* types — the feed store passes a closure bound to the user's mode/count/days.
*/
export function mergeEpisodesBounded(
existing: Episode[],
fetched: Episode[],
keep: (ep: Episode, index: number) => boolean,
): Episode[] {
const byId = new Map<string, Episode>()
const bySignature = new Map<string, Episode>()
for (const ep of fetched) {
byId.set(ep.id, ep)
bySignature.set(episodeSignature(ep), ep)
}
const merged = [...byId.values()]
for (const ep of existing) {
if (byId.has(ep.id)) continue
if (bySignature.has(episodeSignature(ep))) continue
merged.push(ep)
}
const sorted = merged.sort((a, b) => ts(b) - ts(a))
return sorted.filter((ep, i) => keep(ep, i))
}

View File

@@ -0,0 +1,111 @@
/**
* Episode cache-window math — shared by the feed store's refresh, retention,
* and fetch-more paging paths. Pure module: no Solid, no store imports.
*/
import type { Episode } from "../types/episode";
import { episodeSignature } from "./episode-merge";
import { episodeInWindow } from "./feeds-persistence";
/** Floor on the visible episode window for a subscribed show: at least this
* many most-recent episodes always load, regardless of a stricter count or
* date cache bound. */
const MIN_EPISODES_PER_SHOW = 5;
/** Fetch-more step in date mode: each press reveals the next two weeks of
* episodes past the oldest loaded one, instead of a fixed episode count. */
const FETCH_MORE_WINDOW_DAYS = 14;
/** Timestamp for window math — undated episodes sort/compare as NEWEST
* (Infinity) so they can never be excluded by a date cutoff. */
export const episodeTs = (ep: Episode): number => {
const t = ep.pubDate?.getTime();
return t === undefined || Number.isNaN(t) ? Infinity : t;
};
/** Read the episode cache bound from preferences: a closure that decides
* whether the episode at `index` (0 = newest, after sort) is kept. The five
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
* overriding a stricter count or date bound so every show surfaces at least
* five episodes. */
export function episodeKeepFn(
prefs: {
episodeCacheMode: "date" | "count";
episodeCacheCount: number;
episodeCacheDays: number;
},
now?: Date,
): (ep: Episode, index: number) => boolean {
const at = now ?? new Date();
if (prefs.episodeCacheMode === "count") {
const count = Math.max(1, prefs.episodeCacheCount);
return (_ep: Episode, index: number) =>
index < Math.max(count, MIN_EPISODES_PER_SHOW);
}
const days = Math.max(1, prefs.episodeCacheDays);
return (ep: Episode, index: number) =>
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, at, days);
}
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
* 2-week band. With nothing loaded (a show whose episodes all fall outside
* the cache window), the band anchors at the cache-window edge (now minus
* the configured days) — a dormant show can't drag in arbitrarily old
* episodes just because the button is pressed. */
export const dateFetchMoreCutoff = (
cached: Episode[],
loaded: number,
windowDays: number,
): number => {
if (loaded > 0) {
const t = episodeTs(cached[loaded - 1]);
if (Number.isFinite(t)) {
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
}
}
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
// cache-window edge (e.g. 60d → reveals the 6074d slice).
return (
Date.now() -
Math.max(1, windowDays) * 24 * 3600 * 1000 -
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
);
};
/** Episodes the date band adds past the loaded window: count forward while
* each next cached episode still falls on/after the cutoff. The single
* implementation behind both fetch-more paths (one feed / all feeds) — an
* empty band adds nothing, which doubles as the "has more" guard. */
export function dateBandCount(
cached: Episode[],
loaded: number,
cutoff: number,
): number {
let count = loaded;
while (count < cached.length && episodeTs(cached[count]) >= cutoff) {
count++;
}
return count;
}
/** True when the freshly fetched window matches the corresponding PREFIX of
* the existing episode list (id-set equality, order-insensitive). With
* union semantics the merged list legitimately contains episodes BEYOND the
* fetched window, so unchanged-detection must compare the fetched window
* against the existing list's prefix — comparing full lists would bump
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
* one-time positional-id migration, or a feed that rotates enclosure URLs)
* the id sets differ for the SAME content, so a content-signature
* comparison decides: an unchanged feed stays unchanged. */
export function sameRefreshWindow(
existing: Episode[],
fetched: Episode[],
): boolean {
if (fetched.length === 0) return true;
const prefix = existing.slice(0, fetched.length);
const ids = new Set(prefix.map((e) => e.id));
if (fetched.every((e) => ids.has(e.id))) return true;
if (prefix.length !== fetched.length) return false;
const signatures = new Set(prefix.map(episodeSignature));
return fetched.every((e) => signatures.has(episodeSignature(e)));
}

View File

@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler);
};

22
src/utils/feed-resolve.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Feed resolution for an episode. `episode.podcastId` is the RSS feed url
* (rss-parser), which differs from `podcast.id` (the iTunes directory id) for
* iTunes-added shows — so a strict `podcast.id` match fails and the feed (and
* its cover) is never found. Match by podcast id, then feed url, then episode
* membership, in that order.
*/
import type { Feed } from "../types/feed";
import type { Episode } from "../types/episode";
/** The feed backing `episode`, by podcast id, then feed url, then membership. */
export function feedForEpisode(
feeds: Feed[],
episode: Episode,
): Feed | undefined {
return (
feeds.find((f) => f.podcast.id === episode.podcastId) ??
feeds.find((f) => f.podcast.feedUrl === episode.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === episode.id))
);
}

View File

@@ -4,9 +4,70 @@
*/
import { loadConfig, updateConfig } from "./config";
import { getConfigFilePath } from "./config-dir";
import { DownloadStatus } from "../types/episode";
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import type { PodcastSource } from "../types/source";
/** Default episode lifecycle window in days — used when no preference is
* configured (legacy configs, first launch). The actual bound is the user's
* episodeCacheDays preference; this is just the fail-safe default. */
export const DEFAULT_EPISODE_WINDOW_DAYS = 60;
/** True when an episode falls inside a rolling date window of `days` days.
* A missing/invalid pubDate is ALWAYS kept (fail-safe: never drop an
* undatable episode) — the volatile cache must agree with
* episodeIsPersistable so an episode the persistence layer retains can
* never be silently pruned from the list. */
export function episodeInWindow(
ep: Episode,
now: Date,
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
): boolean {
const t = ep.pubDate?.getTime();
if (!t || Number.isNaN(t)) return true;
return t >= now.getTime() - days * 24 * 3600 * 1000;
}
/** True when an episode may be persisted: a completed download, or it falls
* inside the lifecycle window (undatable episodes always kept). */
export function episodeIsPersistable(
ep: Episode,
downloadedIds: Set<string>,
now: Date,
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
): boolean {
return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days);
}
/** Episode ids of completed downloads, read from downloads.json. In-flight
* downloads are NOT exempted from the retention window — a just-completed
* download is re-included by the next save because the in-memory
* feed.episodes still holds it. Missing/unreadable/invalid file → empty set. */
async function readDownloadedEpisodeIds(): Promise<Set<string>> {
try {
const file = Bun.file(getConfigFilePath("downloads.json"));
if (!(await file.exists())) return new Set();
const raw = await file.json();
if (!Array.isArray(raw)) return new Set();
const ids = new Set<string>();
for (const rec of raw) {
if (
rec &&
typeof rec === "object" &&
rec.status === DownloadStatus.COMPLETED &&
typeof rec.episodeId === "string"
) {
ids.add(rec.episodeId);
}
}
return ids;
} catch {
return new Set();
}
}
/** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed {
return {
@@ -23,23 +84,72 @@ function reviveDates(feed: Feed): Feed {
};
}
/** Load feeds from config.json */
export async function loadFeedsFromFile(): Promise<Feed[]> {
/** Config-legacy baggage: search-time parseRSSFeed once embedded the full
* episode history inside podcast.episodes (2,100+ stale copies, 3.8 MB of
* config). Nothing reads them — feed.episodes is the source of truth — so
* every load/save drops them. */
function stripLegacyPodcastEpisodes(feed: Feed): Feed {
if (!("episodes" in feed.podcast)) return feed;
const { episodes: _legacy, ...podcast } = feed.podcast;
void _legacy;
return { ...feed, podcast: podcast as Feed["podcast"] };
}
/** Load feeds from config.json, pruning episodes outside the retention
* window (completed downloads always kept). When anything was pruned, the
* pruned list is rewritten to config.json (startup cleanup for legacy
* configs). The read path is awaited so the returned value is deterministic. */
export async function loadFeedsFromFile(
windowDays?: number,
): Promise<Feed[]> {
try {
const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return [];
return cfg.feeds.map(reviveDates);
const feeds = cfg.feeds
.map(reviveDates)
.map(stripLegacyPodcastEpisodes);
const downloadedIds = await readDownloadedEpisodeIds();
const now = new Date();
let prunedAny = false;
const pruned = feeds.map((f) => {
const kept = f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, now, windowDays),
);
if (kept.length !== f.episodes.length) prunedAny = true;
return { ...f, episodes: kept };
});
if (prunedAny) {
// Fire-and-forget cleanup rewrite of the legacy config.
saveFeedsToFile(pruned, windowDays);
}
return pruned;
} catch {
return [];
}
}
/** Save feeds to config.json */
export function saveFeedsToFile(feeds: Feed[]): void {
updateConfig({ feeds });
/** Save feeds to config.json, pruning episodes outside the retention window
* (completed downloads always kept). Fire-and-forget: the prune reads
* downloads.json asynchronously, then enqueues the write. On any error the
* UNPRUNED feeds are saved instead, so data is never lost. */
export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
(async () => {
try {
const downloadedIds = await readDownloadedEpisodeIds();
const pruned = feeds
.map(stripLegacyPodcastEpisodes)
.map((f) => ({
...f,
episodes: f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
),
}));
updateConfig({ feeds: pruned });
} catch {
updateConfig({ feeds }); /* never lose data on an error path */
}
})().catch(() => {});
}
/** Load sources from config.json */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const cfg = await loadConfig();
@@ -49,8 +159,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
return null;
}
}
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] });
}

View File

@@ -5,9 +5,6 @@
* and multi-line comments, which is useful for configuration files.
*/
/**
* Remove JSONC comments from a string
*/
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
return result;
}
/**
* Parse JSONC string into a JavaScript object
*/
export function parseJSONC(jsonString: string): unknown {
const stripped = stripComments(jsonString);
return JSON.parse(stripped);

View File

@@ -65,6 +65,8 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
sort: [","],
"toggle-hidden": ["."],
refresh: ["r"],
// a subscribes the focused show/episode result in place (x unsubscribes)
subscribe: ["a"],
unsubscribe: ["x"],
// downloads
download: ["d"],
@@ -93,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);

View File

@@ -1,11 +1,11 @@
/**
* layer-graph — maps each TAB id to its page component + pane count.
*
* Split out of `navigation.ts` so that the nav-model primitives (TABS,
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in
* `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests
* import the pure navigation store without pulling the OpenTUI JSX runtime
* (which is only provided by the build-time @opentui/solid bun-plugin).
* Split out of `navigation.ts` so that the navigation primitives (TABS,
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount) stay free of any
* `.tsx` / JSX imports. This lets unit tests import the pure navigation
* store without pulling the OpenTUI JSX runtime (which is only provided by
* the build-time @opentui/solid bun-plugin).
*
* The page modules live alongside their pages and export `<count>PaneCount`
* constants describing how many focusable panes each fixed page owns.

View File

@@ -49,22 +49,10 @@ export function rootFrameFor(
}
}
// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`,
// split out so this module stays free of `.tsx`/JSX imports (unit-testable).
// Yazi-style pane grow ratios (parent : current : preview). Panes use
// flexGrow (Yoga) so columns always sum to the row width regardless of
// terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
//
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
// give `current` the combined 8/10 (80%).
export const PANE_RATIO = {
parent: 2,
current: 5,
preview: 3,
} as const;
// Pane sizes are now user-resizable: the split positions (fractions of the
// row width) live in the shared pane-layout store (`@/stores/pane-layout`),
// which `PaneRow` consumes. `PANE_RATIO` was removed — see DEFAULT_PANE_SPLITS
// (0.2 / 0.7) for the historical 2:5:3 start.
// Number of *focusable* content panes per tab. The three visible columns
// (parent | current | preview) are a *render* concern, NOT three panes — for

View File

@@ -0,0 +1,74 @@
/**
* Nested scroll sections favor the innermost one under the cursor.
*
* opentui bubbles a wheel event up the renderable tree, so every ancestor
* `ScrollBoxRenderable` that has room to move scrolls — nested sections (e.g.
* the episode-description scrollbox inside a page's list pane) scroll in
* lockstep. This patches the scrollbox's wheel handler so the innermost
* scrollbox under the cursor wins instead:
*
* • The first scrollbox that can move in the wheel's direction scrolls and
* stops propagation, so its ancestors don't also scroll.
* • When it is already at its boundary it lets the next outer scrollbox
* take over (wheel chaining), matching typical nested-scroll UX.
*/
import { ScrollBoxRenderable } from "@opentui/core";
import type { MouseEvent } from "@opentui/core";
type ScrollDir = "up" | "down" | "left" | "right";
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
const original = (ScrollBoxRenderable.prototype as unknown as {
onMouseEvent: (event: MouseEvent) => void;
}).onMouseEvent;
let installed = false;
/** True when `sb` has room to move in `dir` from its current position. */
function canScroll(sb: ScrollBoxRenderable, dir: ScrollDir): boolean {
const maxTop = Math.max(0, sb.scrollHeight - sb.viewport.height);
const maxLeft = Math.max(0, sb.scrollWidth - sb.viewport.width);
switch (dir) {
case "up":
return sb.scrollTop > 0;
case "down":
return sb.scrollTop < maxTop;
case "left":
return sb.scrollLeft > 0;
case "right":
return sb.scrollLeft < maxLeft;
}
}
const handleWheel = function (
this: ScrollBoxRenderable,
event: MouseEvent,
): void {
if (event.type !== "scroll" || !event.scroll?.direction) {
original.call(this, event);
return;
}
const dir = event.scroll.direction;
const effective: ScrollDir = event.modifiers.shift
? (dir === "up" ? "left" : dir === "down" ? "right" : dir === "right" ? "down" : "up")
: dir;
const moves = canScroll(this, effective);
original.call(this, event);
// Only claim the wheel when this box actually moved; otherwise let the
// next outer scrollbox (also under the cursor) take over.
if (moves) event.stopPropagation();
};
export function installNestedScrollBehavior(): void {
if (installed || typeof original !== "function") return;
installed = true;
// `onMouseEvent` is a well-known protected method; the cast only bypasses
// TypeScript's protected-access check and trusts the shipped class shape.
const scrollboxProto = ScrollBoxRenderable.prototype as unknown as {
onMouseEvent: typeof handleWheel;
};
scrollboxProto.onMouseEvent = handleWheel;
}

31
src/utils/rss-client.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* RSS feed client — single owner of feed XML fetches: headers, timeout,
* and failure folding to null.
*/
/** Default per-feed fetch timeout (ms). */
export const FETCH_TIMEOUT_MS = 20_000;
/**
* Fetch a feed's raw XML. Identity encoding keeps the response raw; the
* Accept list matches what podcast servers send. Any failure (network,
* non-ok, timeout) resolves to null — callers must leave data untouched.
*/
export const fetchFeedXml = async (
url: string,
opts?: { timeoutMs?: number },
): Promise<string | null> => {
try {
const response = await fetch(url, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
signal: AbortSignal.timeout(opts?.timeoutMs ?? FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
return await response.text();
} catch {
return null;
}
};

View File

@@ -1,5 +1,6 @@
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser";
import { fetchFeedXml } from "./rss-client";
import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source";
@@ -81,16 +82,13 @@ export const searchByFeedUrl = async (
if (!FEED_URL_RE.test(trimmed)) return [];
try {
const response = await fetch(trimmed, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return [];
const xml = await response.text();
const podcast = parseRSSFeed(xml, trimmed);
const xml = await fetchFeedXml(trimmed);
if (xml === null) return [];
// Full parse's episodes are dead weight here (2,100+ stale copies were
// previously persisted inside Feed.podcast): addFeed refetches through
// fetchEpisodes and nothing reads Podcast.episodes off a search result.
const { episodes: _episodes, ...podcast } = parseRSSFeed(xml, trimmed);
void _episodes;
return [
{

View File

@@ -0,0 +1,59 @@
/**
* Source registry — owns the podcast source list and its immediate file
* persistence. The feed store seeds it at boot and wires the loaded list in.
*/
import { createSignal } from "solid-js";
import type { PodcastSource } from "../types/source";
import { saveSourcesToFile } from "./feeds-persistence";
/** Create a source registry around the given initial list. Every mutation
* persists immediately (async, fire-and-forget) — source edits are rare
* and must not sit in a debounce window across a process exit. */
export function createSourceRegistry(initial: PodcastSource[]) {
const [sources, setSources] = createSignal<PodcastSource[]>([...initial]);
/** Swap in a fully rebuilt list WITHOUT persisting — the boot-time
* loader saves only when its migration actually changed data. */
const replaceAll = (list: PodcastSource[]): void => {
setSources(list);
};
const addSource = (source: Omit<PodcastSource, "id">): PodcastSource => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSourcesToFile(updated);
return updated;
});
return newSource;
};
const updateSource = (
sourceId: string,
updates: Partial<PodcastSource>,
): void => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSourcesToFile(updated);
return updated;
});
};
const toggleSource = (sourceId: string): void => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSourcesToFile(updated);
return updated;
});
};
return { sources, replaceAll, addSource, updateSource, toggleSource };
}

View File

@@ -0,0 +1,47 @@
/**
* Terminal recovery for suspend/resume and system sleep/wake cycles.
*
* The renderer enters the alternate screen, enables raw mode and attaches its
* stdin listener exactly once at startup. The diff renderer also keeps
* `currentRenderBuffer` as its model of what is on screen and only writes the
* cells that changed against that model.
*
* When the session is suspended (Ctrl-Z) or the system sleeps and the process
* is later resumed, the terminal screen can desync from that model: the stale
* buffer makes the diff rewrite only "changed" cells, leaving garbled or
* previous content on screen, and the raw-mode / stdin wiring can be dropped.
* The result is a frozen, non-interactive screen that shows raw markup instead
* of the UI.
*
* SIGCONT is the standard signal delivered when a stopped process resumes.
* On it we call `renderer.resume()`, the library's own recovery path, which:
* - re-enters the alternate screen (native resumeRenderer)
* - re-enables raw mode, re-attaches the stdin listener and flushes stale input
* - clears currentRenderBuffer so the next frame performs a full repaint
*/
import type { CliRenderer } from "@opentui/core";
/**
* Register a SIGCONT handler that recovers the terminal after suspend/resume.
*
* @param renderer - the active CLI renderer
* @returns cleanup function that removes the handler
*/
export function setupTerminalRecovery(renderer: CliRenderer): () => void {
const onContinue = () => {
// Best-effort: resume() re-establishes terminal state and forces a full
// repaint by clearing the render buffer. Idempotent if fired repeatedly.
try {
renderer.resume();
} catch {
// recovery is best-effort; never crash on the recovery path itself
}
};
process.on("SIGCONT", onContinue);
return () => {
process.off("SIGCONT", onContinue);
};
}

View File

@@ -9,23 +9,14 @@
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
function emitThemeReload(): void {
emit("theme.reload", {})
}
/**
* Emit a theme changed event.
*/
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
emit("theme.changed", { theme, mode })
}
/**
* Emit a theme mode changed event.
*/
export function emitThemeModeChanged(mode: "dark" | "light"): void {
emit("theme.mode.changed", { mode })
}

View File

@@ -1,28 +1,13 @@
/**
* Theme CSS Variable Manager
* Handles dynamic theme switching by updating CSS custom properties
* Terminal Theme Resolver
* Resolves the active theme (built-in, custom, or system-derived) to colors.
*/
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
/**
* Apply CSS variable data-theme attribute
*/
export function setThemeAttribute(themeName: string) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
}
export async function loadThemes() {
return await getCustomThemes();
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode);
}
const theme = themes[name] ?? themes.catppuccin;
if (!theme) {
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, mode);
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
}

View File

@@ -0,0 +1,81 @@
# 01. Persist only a 30-day episode window, keep downloaded episodes, clean up stale data
meta:
id: bounded-feed-lifecycle-01
feature: bounded-feed-lifecycle
priority: P1
depends_on: []
tags: [implementation, tests-required]
objective:
- Bound what the app writes to `config.json`: each persisted feed keeps only episodes published within the last 30 days, plus any episode whose download is completed — everything older lives in volatile memory only (wired up in task 02). Loading an over-window legacy config must prune it automatically (cleanup on first launch).
background (read this before touching code):
- Feeds persist through `src/utils/feeds-persistence.ts`. `saveFeedsToFile(feeds)` is a fire-and-forget wrapper around `updateConfig({ feeds })` in `src/utils/config.ts`, which read-modify-writes the whole `config.json` behind a serialized promise chain (`writeChain`).
- Today `saveFeedsToFile` writes every loaded episode, so `config.json` grows forever (the Feed page's "Fetch More" keeps expanding `feed.episodes` and saving).
- Downloads persist separately in `downloads.json` (same config dir, see `src/utils/config-dir.ts` `getConfigFilePath("downloads.json")`). Each record has `episodeId`, `status`, `feedId`, etc. The `DownloadStatus` enum lives in `src/types/episode.ts` — read it there for the completed member's string value; do NOT hardcode a guessed string.
- `src/stores/feed.ts` calls `saveFeedsToFile` from a module-scope `saveFeeds()` helper. Callers must not change in this task.
- Style: match the file you edit. `feeds-persistence.ts` and `config.ts` are tab-indented WITH semicolons (some other repo files aren't — don't "fix" that anywhere).
deliverables:
- `src/utils/feeds-persistence.ts`:
- New exported constant `EPISODE_WINDOW_DAYS = 30` — the lifecycle window: bounds BOTH persistence (here) and the volatile episode list/cache (task 02).
- New exported pure function `episodeInWindow(ep: Episode, now: Date): boolean` — returns `true` when `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode) OR `ep.pubDate.getTime() >= now.getTime() - EPISODE_WINDOW_DAYS * 24 * 3600 * 1000`.
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, now: Date): boolean` — returns `true` when `downloadedIds.has(ep.id)` OR `episodeInWindow(ep, now)`.
- New (module-private) async helper `readDownloadedEpisodeIds(): Promise<Set<string>>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment.
- `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path).
- `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async).
- `src/utils/config.ts`:
- New exported `whenConfigIdle(): Promise<void>` returning the module-internal `writeChain` promise. Tests need a way to await pending serialized writes; today `updateConfig` hides the chain and tests cannot observe when a write lands.
- `tests/feed-retention.test.ts` (new) — see tests section.
steps:
1. Read `src/types/episode.ts` to confirm `DownloadStatus.COMPLETED`'s runtime value and the `Episode` shape (`id`, `pubDate`).
2. Read `src/utils/feeds-persistence.ts` and `src/utils/config.ts` fully (they are short).
3. Add `whenConfigIdle()` to `config.ts` next to `updateConfig`.
4. In `feeds-persistence.ts`: add imports (`getConfigFilePath` from `./config-dir`, `DownloadStatus` and `type Episode` from `../types/episode`), the constant, `episodeIsPersistable`, `readDownloadedEpisodeIds`, then rework `saveFeedsToFile` and `loadFeedsFromFile` per deliverables. Keep `reviveDates` untouched.
5. Ensure `saveFeeds` in `src/stores/feed.ts` still compiles unchanged (signature-compatible).
6. Write `tests/feed-retention.test.ts`, run it, then run the full suite and lint.
tests:
- Conventions (copy them): `tests/feed-refresh.test.ts` shows the harness — `mkdtempSync` into `process.env.XDG_CONFIG_HOME` **before** importing anything under test (module-level init reads the config dir), `rmSync` in `afterAll`, tabs/no-semicolon style not required but match repo.
- New `tests/feed-retention.test.ts`:
- Unit (ArrangeActAssert) for `episodeIsPersistable`:
- episode 40 days old, not downloaded → `false`.
- episode 40 days old, id in `downloadedIds``true`.
- episode 5 days old → `true`.
- episode with `pubDate: new Date(NaN)``true` (fail-safe).
- Save-path integration:
- Arrange: write a `downloads.json` in the temp config dir containing one `completed` record for `old-downloaded-id` (include all fields the loader reads in `src/stores/download.ts`'s `DownloadRecord`: at minimum `episodeId`, `feedId`, `status`, `filePath: null`, `downloadedAt: null`, `fileSize: 0`, `error: null`, `audioUrl: ""`, `episodeTitle: ""`).
- Act: call `saveFeedsToFile([feed])` where the feed has three episodes — recent, old-not-downloaded (`id: "old-plain-id"`), old-downloaded (`id: "old-downloaded-id"`). Await `whenConfigIdle()` (plus one more microtask/`await Promise.resolve()` round if the async IIFE resolves after the chain call — flush both).
- Assert: parse `config.json` raw; the feed's persisted `episodes` contain the recent and `old-downloaded-id` episodes and NOT `old-plain-id`.
- Load-path cleanup:
- Arrange: seed `config.json` (write it directly with `Bun.write`) with one feed holding only over-window episodes; no `downloads.json`.
- Act: `await loadFeedsFromFile()`, then `await whenConfigIdle()`.
- Assert: returned feed has zero episodes AND re-reading `config.json` shows the episodes pruned (cleanup rewrite happened).
acceptance_criteria:
- `saveFeedsToFile` never writes an episode older than 30 days unless its id is a completed download in `downloads.json`.
- `loadFeedsFromFile` prunes over-window episodes from legacy configs and rewrites `config.json` when it pruned anything.
- Undatable episodes (`pubDate` missing/invalid) are always persisted.
- No call site of `saveFeedsToFile`/`loadFeedsFromFile` needed to change (compatible signatures).
- `bun test tests/feed-retention.test.ts` passes; the existing `bun test` suite passes; `bun run lint` is clean.
validation:
- `bun test tests/feed-retention.test.ts`
- `bun test` (full suite — watch `feed-refresh`/`feed-pagination` for regressions)
- `bun run lint`
- Manual smoke (optional): `bun start`, subscribe to any feed, quit, then `cat ~/.config/podtui/config.json | python3 -c "import sys,json; print(max(e['pubDate'] for f in json.load(sys.stdin)['feeds'] for e in f['episodes']))"` and confirm no persisted episode is older than 30 days.
notes:
- `updateConfig` captures the patched data eagerly at call time (`JSON.parse(JSON.stringify(patch))`), so pruning in `saveFeedsToFile` before the `updateConfig` call is exactly where the filter must live — filtering later would be silently ineffective for already-queued writes.
- This task intentionally does NOT change in-memory behavior, refresh merging, or cache bounds — that is task 02. If both are worked on in parallel, 02 imports nothing from 01 except the documented window semantics; the module-level contract above is the seam.
- `downloads.json` is written by `src/stores/download.ts` (`saveDownloads`); reading it directly here avoids a store→module import cycle (download.ts already imports the feed store).

View File

@@ -0,0 +1,87 @@
# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
meta:
id: bounded-feed-lifecycle-02
feature: bounded-feed-lifecycle
priority: P2
depends_on: [bounded-feed-lifecycle-01]
tags: [implementation, tests-required]
objective:
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention by the SAME date window persistence uses (`EPISODE_WINDOW_DAYS`, 30 days) instead of an episode count: the visible list and the pagination cache hold every episode from the last 30 days, and episodes older than that age out of the list on the next refresh (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
background (read this before touching code):
- All work lands in `src/stores/feed.ts` plus one new pure-utils module. Current behavior to change:
- `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`.
- `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing).
- `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`.
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: fetched refreshes merge (never replace), and the volatile list + cache are bounded by the SAME 30-day window persistence uses — what can be browsed is exactly what can be persisted, and episodes older than the window age out on refresh.
- Style: `feed.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (2-space, no semicolons).
deliverables:
- `src/utils/feeds-persistence.ts` (the canonical window owner):
- Rename the retention constant to `EPISODE_WINDOW_DAYS = 30` — it now bounds the volatile cache/list as well as persistence.
- New exported `episodeInWindow(ep: Episode, now: Date): boolean``pubDate >= now - EPISODE_WINDOW_DAYS`; missing/invalid pubDates are ALWAYS kept (fail-safe mirror of the persistence rule, so cache and disk can never disagree about an undatable episode). `episodeIsPersistable` becomes `downloadedIds.has(ep.id) || episodeInWindow(ep, now)`.
- Rework `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
- `mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; pruned to the lifecycle window via `episodeInWindow` (out-of-window episodes dropped, undated kept). No count cap — the bound is the date.
- Invariants: never mutates inputs; stable output for `existing=[]`; entries with invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` helper).
- `src/stores/feed.ts`:
- Delete `MAX_EPISODES_IN_MEMORY` — no episode-count bound anywhere.
- `fetchEpisodes`: window-filter the parsed feed (`allEpisodes.filter(ep => episodeInWindow(ep, new Date()))`) BEFORE caching and returning: `fullEpisodeCache.set(feedId, windowed)` and `episodes: windowed.slice(0, limit)`. The limit is a page size; the window is the bound.
- `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics:
- Compute `merged = mergeEpisodesInWindow(f.episodes, episodes, new Date())`.
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering). Fetch-more stepping is mode-dependent: DATE mode advances the loaded window by a `FETCH_MORE_WINDOW_DAYS` (14) band past the oldest loaded episode — a daily show gains ~2 weeks of episodes per press, not a fixed count — with a +1 minimum so a sparse band can't wedge the button into a no-op; COUNT mode keeps the fixed `MAX_EPISODES_REFRESH` (50) chunk. `hasMoreEpisodes` still compares `episodeLoadCount < cached.length`.
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
steps:
1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness).
2. Rework `src/utils/episode-merge.ts` to `mergeEpisodesInWindow`; add `episodeInWindow` (and rename `PERSISTED_WINDOW_DAYS``EPISODE_WINDOW_DAYS`) in `feeds-persistence.ts`.
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodesInWindow` in `applyRefreshedEpisodes`; window-filter `fullEpisodeCache` writes and the returned window in `fetchEpisodes` and `loadMoreEpisodesForFeed`; delete `MAX_EPISODES_IN_MEMORY`.
4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact).
5. Write the new tests, run, then full suite + lint.
tests:
- New `tests/feed-volatile-merge.test.ts`:
- Pure unit (ArrangeActAssert) for `mergeEpisodesInWindow(existing, fetched, now)`:
- dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title).
- union of disjoint lists sorted by `pubDate` desc.
- window prune drops out-of-window episodes from BOTH inputs and keeps undated (NaN pubDate) episodes.
- input arrays not mutated.
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
- Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile).
- Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk.
- Count-mode global step: two feeds with staggered dates — one Feed-page press adds the configured N most-recent UNLOADED episodes across ALL shows (N total, not N per show), via the k-way frontier merge in `loadMoreAllFeedsByCount`.
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
- Existing suites that must keep passing: `tests/feed-refresh.test.ts`, `tests/feed-pagination.test.ts`, `tests/feed-refresh-spinner.test.tsx`.
acceptance_criteria:
- A refresh never removes an episode that was visible before the refresh during the same session — except episodes that aged past the window, which drop out on refresh (the date bound).
- An unchanged refresh does not bump `lastUpdated` (object identity of the feed is preserved).
- Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last `EPISODE_WINDOW_DAYS` days is cached or loadable, and everything inside is (no count ceiling).
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and applies the same window to the cache.
- `bun test` full suite passes; `bun run lint` clean.
validation:
- `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts`
- `bun test`
- `bun run lint`
- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the in-window pages stay; quit and relaunch — the list holds only the 30-day window, and fetch-more re-parses the feed with the same window applied.
notes:
- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01.
- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the window filter belongs at the two write sites named in deliverables, not in a wrapper.
- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope `saveFeeds(updated)` helper exactly as today.

View File

@@ -0,0 +1,84 @@
# 03. Make refresh/fetch-more/persistence nonblocking — bounded fetch concurrency, incremental per-feed apply, debounced saves
meta:
id: bounded-feed-lifecycle-03
feature: bounded-feed-lifecycle
priority: P1
depends_on: [bounded-feed-lifecycle-01, bounded-feed-lifecycle-02]
tags: [implementation, tests-required]
objective:
- Feed loading must never block or stall the UI: refresh results render as each feed lands instead of after a `Promise.all` barrier, fetch concurrency is capped so 50 subscriptions don't fire 50 simultaneous requests, and `config.json` writes (full file read-modify-write on every change today) collapse into one debounced trailing write per settle window.
background (read this before touching code):
- Work lands in `src/stores/feed.ts` only (plus its tests). Current posture:
- `refreshAllFeeds()` fires `fetchEpisodes` for every feed at once via `Promise.all` and applies results in ONE `setFeeds` at the end — the user sees nothing until the slowest feed resolves or hits `FETCH_TIMEOUT_MS` (20s).
- `parseEpisodesIncremental` already chunks XML parsing and yields to the event loop via MessageChannel — keep that mechanism untouched; the blocking/stall risk today is the fetch barrier and the save path.
- `loadMoreEpisodesForFeed`'s cold-cache refetch has NO timeout (copy the `AbortSignal.timeout(FETCH_TIMEOUT_MS)` pattern from `fetchEpisodes`).
- Persistence: `saveFeeds(updated)``saveFeedsToFile``updateConfig`, a serialized full-file read-`JSON.parse`-stringify-`Bun.write` chain in `src/utils/config.ts`. Called from `refreshFeed`, `refreshAllFeeds`, `loadMoreEpisodesForFeed`, `addFeed`, `removeFeed*`, `updateFeed`, `togglePinned`.
- The boot IIFE calls `refreshAllFeeds()` right after `loadFeedsFromFile()` — this is the cold-start refresh users currently feel; first paint already happens because module init is async, but nothing renders per-feed until the barrier resolves.
- Single feed `refreshFeed` applies its own `setFeeds` immediately — reuse exactly that shape (fetch → apply-if-changed → mark save dirty) for the incremental batch path.
- Tasks 01+02 must be merged first: this task debounces the pruned save path (01) and applies per-feed results through `applyRefreshedEpisodes`/`mergeEpisodes` (02).
- Style: tab-indented WITH semicolons, JSDoc comments on non-obvious functions, section dividers `// ── Name ──…` per repo convention.
- Tests here use `vi.useFakeTimers()``setTimeout`-based debounce must therefore be advanced with `vi.advanceTimersByTime` in tests; don't use `queueMicrotask`-style scheduling for the debounce.
deliverables:
- `src/stores/feed.ts`:
- New constant `FETCH_CONCURRENCY = 4` (comment: bounds simultaneous RSS requests; a hung feed burns at most one slot for `FETCH_TIMEOUT_MS`).
- New module-level async helper `mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>` — classic worker-pool: `limit` workers pulling indexes from a shared counter, results in input order. Pure and generic enough to unit-test.
- Rewritten `refreshAllFeeds()`:
- `setIsLoadingFeeds(true)``finally setIsLoadingFeeds(false)` as today.
- Process feeds through `mapWithConcurrency(feeds(), FETCH_CONCURRENCY, async (feed) => ...)`.
- Inside the per-feed callback: `fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id)`; if non-null, immediately `setFeeds(prev => { const updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (updated !== prev) scheduleSaveFeeds(); return updated; })`. Failed feeds (null) stay untouched, as today.
- After all workers settle: ONE `runAutoDownload()` (as today), and `flushPendingSave()` (below) so a refresh batch always ends with a persisted write when anything changed.
- Debounced save plumbing (module scope, replacing direct calls):
- `let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null; const SAVE_DEBOUNCE_MS = 250;`
- `scheduleSaveFeeds()` — after a state-changing update, mark dirty: set a `savePending = true` flag and (re)arm the trailing timer to fire `flushPendingSave()`.
- `flushPendingSave()` — if `savePending`, snapshot `feeds()`, call `saveFeeds(snapshot)`, clear flag/timer. Export it on the store's returned object (tests need it; also lets task 04/a future quit hook force a write).
- Convert ALL direct `saveFeeds(updated)` / `saveFeeds(newList)` call sites inside `setFeeds` callbacks to `scheduleSaveFeeds()` EXCEPT `removeFeed`/`removeFeedByUrl`, which must call both `scheduleSaveFeeds()` AND `flushPendingSave()` (an unsubscribe intent should not sit unsaved through the debounce window if the process exits). Keep the change mechanical: same call sites, new indirection.
- `loadMoreEpisodesForFeed`: add `signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)` to the cold refetch and return early on non-OK/throw (wrap in try/catch mirroring `fetchEpisodes`).
- `tests/feed-nonblocking.test.ts` (new) — see tests section.
steps:
1. Read `src/stores/feed.ts` and confirm tasks 01/02 are merged (`episodeIsPersistable` in `src/utils/feeds-persistence.ts`, `mergeEpisodes` in `src/utils/episode-merge.ts`).
2. Add `FETCH_CONCURRENCY`, `mapWithConcurrency`, and the debounce plumbing.
3. Rewrite `refreshAllFeeds` per deliverables; convert the save call sites.
4. Add the fetch timeout to `loadMoreEpisodesForFeed`'s cold refetch.
5. Export `flushPendingSave` from the store's return object (Actions section).
6. Write `tests/feed-nonblocking.test.ts`; run new + existing feed tests; full suite; lint.
tests:
- Harness conventions: copy `tests/feed-refresh.test.ts` (temp `XDG_CONFIG_HOME` BEFORE store imports; `Bun.serve` port 0; `vi.useFakeTimers()` in `beforeEach`). Note `vi.advanceTimersByTime(...)` also drives the debounce timer and the MessageChannel yields used by the parser are real task-queue turns (safe under fake timers per the comment on `yieldToUI`).
- New `tests/feed-nonblocking.test.ts`:
- Concurrency bound: server records concurrent in-flight requests (increment on entry, `await new Promise(r => setTimeout(r, 50_000))` under fake-timer awareness: use a gate promise the test controls instead of real sleeps — release gates with `vi.advanceTimersByTime` after asserting). Register 10 feeds; start `refreshAllFeeds()` (don't await); assert the server's max-concurrent counter never exceeded 4; release all gates and await completion.
- Incremental apply: 2 feeds — one served instantly, one gated. Start refresh; resolve the fast gate only; assert the fast feed's `lastUpdated`/episodes already updated in `feeds()` BEFORE the slow feed resolves (this is the acceptance proof the `Promise.all` barrier is gone). Then release the slow gate and assert both applied.
- Debounce: mock-observe writes by seeding the temp config dir and spawning two rapid refreshes whose content changed; `await` both, then `vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)`; read raw `config.json` ONCE — assert both new episodes are present in a single coherent write. (Counting writes precisely is brittle against `updateConfig`'s chain; asserting final content + that the pre-debounce file lacks the episodes is the binary check: before advancing the debounce, `config.json` must NOT yet contain the new episodes; after, it must.)
- `flushPendingSave`: refresh with changed content, call `store.flushPendingSave()` without advancing timers, assert `config.json` already contains the new episode.
- Existing suites must pass unchanged: `feed-refresh.test.ts`, `feed-pagination.test.ts`, `feed-volatile-merge.test.ts`, `feed-refresh-spinner.test.tsx`, `restore-session.test.ts`.
acceptance_criteria:
- During a refresh batch, no more than `FETCH_CONCURRENCY` HTTP requests are ever in flight.
- Each feed's refreshed episodes are visible in `feeds()` as soon as its own fetch resolves — no waiting for the slowest feed.
- Writes to `config.json` are trailing-edge debounced: rapid successive updates produce one final write after the settle window, and `flushPendingSave()` persists immediately.
- `loadMoreEpisodesForFeed`'s refetch aborts at `FETCH_TIMEOUT_MS` instead of hanging forever.
- `bun test` full suite passes; `bun run lint` clean.
validation:
- `bun test tests/feed-nonblocking.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts tests/feed-volatile-merge.test.ts`
- `bun test`
- `bun run lint`
- Manual smoke: `bun start` with several subscriptions; hold `j` during the startup refresh — selection moves smoothly and per-feed results appear as they land; quit/relaunch and confirm the last refresh's episodes persisted.
notes:
- The background refresh timer (`scheduleNextRefresh`) already skips ticks while `isLoadingFeeds()` is true — unchanged.
- Do not introduce a real "sleep" anywhere in tests; gates + fake timers only, matching existing suites.
- `mapWithConcurrency` is generic; keep it module-private in `feed.ts` (no premature new util file).
- `updateConfig` snapshots its patch at call time (`JSON.parse(JSON.stringify(patch))`), so debouncing by delaying the `saveFeeds` CALL is correct — a pending write always serializes the latest feeds it was handed.

View File

@@ -0,0 +1,77 @@
# 04. Add a shared activity store and global top-right loading indicator
meta:
id: bounded-feed-lifecycle-04
feature: bounded-feed-lifecycle
priority: P2
depends_on: [bounded-feed-lifecycle-03]
tags: [implementation, tests-required]
objective:
- One global indicator, always in the top-right corner of the app, visible whenever ANYTHING is being loaded or downloaded: feed refreshes (all-feeds and single-feed), fetch-more, subscribe fetches, searches, and episode downloads. Per-page spinners stay as-is; this adds the global signal that activity is happening anywhere.
background (read this before touching code):
- `src/components/Shell.tsx` renders the whole chrome: one full-width content row (`LayerGraph[nav.activeTab()]()` / `PaneRow`) plus a bottom status/command bar. There is no header row — the top-right corner belongs to whatever page is active, so the indicator must be an ABSOLUTE-POSITIONED overlay drawn after the content so it paints on top (opentui `box` supports `position="absolute"`, `top`, `right`).
- Existing activity signals (read them, don't recreate per-store bookkeeping): `useFeedStore().isLoadingFeeds()` / `.isLoadingMore()`; `useSearchStore().isSearching()` (`src/stores/search.ts`); `useDownloadStore().getActiveCount()` and `.getQueue().length` (`src/stores/download.ts`). Gaps these don't cover: single `refreshFeed`, `addFeed`'s subscribe fetch, iTunes feed resolution inside `addFeed` — hence the activity store.
- `src/components/LoadingIndicator.tsx` is the braille spinner (prop `label?: string`); reuse it inside the overlay.
- Activity tracking must be leak-proof: every `begin` paired with an `end` via a token, PLUS a `track(promise, label)` helper that auto-ends on settle so callers can't strand the counter.
- Task 03 added the incremental per-feed apply inside `refreshAllFeeds`; wire activity around the whole batch ( `isLoadingFeeds` already brackets it — prefer reusing the signal, adding explicit `begin/end` ONLY where no signal exists).
- Style: Solid + `@opentui/solid` JSX (no `className`; props like `fg`, `paddingRight`, `position`); store files tab-indented with semicolons; components match `LoadingIndicator.tsx` conventions. Style imports use `@/` alias in components, relative paths in stores.
deliverables:
- New `src/stores/activity.ts`:
- Signals: `count` (number), `labels` (string[]).
- Actions: `beginActivity(label: string): () => void` (returns the matching end function; each call adds the label, ending removes that exact instance — duplicates allowed), `track<T>(p: Promise<T>, label: string): Promise<T>` (begins, ends in `finally`, re-throws).
- Computed: `isActive(): boolean` (`count() > 0`).
- Singleton + `useActivityStore()` accessor, mirroring `src/stores/download.ts`'s module pattern.
- Wire the gaps in `src/stores/feed.ts` (only where no existing signal covers the operation):
- `refreshFeed`: `await activity.track(...)` around the fetch+apply, label `"Refreshing"`.
- `addFeed`: wrap the directory-resolve + `fetchEpisodes` stretch, label `"Subscribing"`.
- Do NOT wrap `refreshAllFeeds`/`loadMoreEpisodes*``isLoadingFeeds`/`isLoadingMore` already cover them (double-counting just lengthens the spinner's on-time cosmetically; the point is no visual gap).
- New `src/components/GlobalActivityIndicator.tsx`:
- Computes active state from: `feedStore.isLoadingFeeds() || feedStore.isLoadingMore() || searchStore.isSearching() || downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive()`.
- Label selection: downloads in flight → `Downloading N` (+`M queued` when queue non-empty); else the activity store's latest label + `…` (e.g. `Refreshing…`); else `Loading…`.
- Renders `<LoadingIndicator label={…} />` inside `<box position="absolute" top={0} right={0} paddingRight={1}>`; renders nothing (returns `null`) when inactive so it never eats layout when idle.
- `src/components/Shell.tsx`: mount `<GlobalActivityIndicator />` as the LAST child of the root `<box flexDirection="column" …>` (after the content row, bottom bar, and help overlay so it paints on top).
- `tests/global-activity-indicator.test.tsx` (new) — see tests section.
steps:
1. Read `src/stores/download.ts`, `src/stores/search.ts`, `src/components/LoadingIndicator.tsx`, and the render JSX of `src/components/Shell.tsx`.
2. Write `src/stores/activity.ts` (small; ~60 lines).
3. Wire `refreshFeed`/`addFeed` in `src/stores/feed.ts` via `useActivityStore().track(...)`. Import cycle note: `activity.ts` must import NOTHING from other stores (pure counter) so `feed.ts` importing it is safe.
4. Write `src/components/GlobalActivityIndicator.tsx`; mount it in `Shell.tsx` last (paints on top).
5. Write tests; run new tests, full suite, lint; manual smoke per validation.
tests:
- New `tests/global-activity-indicator.test.tsx` (component-test conventions: copy the render harness from `tests/feed-refresh-spinner.test.tsx` — temp `XDG_CONFIG_HOME` before imports; if a jsdom-like setup is used there, reuse it as-is):
- Activity store unit asserts: two `begin`s → `isActive()` true; ending one → still true; ending both → false. `track(failingPromise)` still decrements (rejects propagate, counter returns to baseline).
- Component asserts: render `<GlobalActivityIndicator />` in isolation —
- idle → no text rendered;
- `useActivityStore().beginActivity("Refreshing")` → spinner/label present in rendered output; matching end → gone;
- with the download store: enqueue via `downloadStore.startDownload`-equivalent the way `tests/download-unsubscribed.test.ts` does (assert indicator renders while `getActiveCount() + queue > 0`); skip actual network by following that test's existing mocking pattern.
- Existing suites must pass: `feed-refresh-spinner.test.tsx` (per-page spinners unchanged), full `bun test`.
acceptance_criteria:
- Indicator visible in the top-right overlay while any of: all-feeds refresh, single-feed refresh, fetch-more, subscribe fetch, search, active/queued download — and hidden when none are active.
- Counter never strands: every completed/failed tracked operation returns `isActive()` to its prior value (proven by the `track` rejection test).
- Idle UI unchanged: when inactive the overlay renders nothing and occupies zero layout.
- `bun test` full suite passes; `bun run lint` clean.
validation:
- `bun test tests/global-activity-indicator.test.tsx tests/feed-refresh-spinner.test.tsx`
- `bun test`
- `bun run lint`
- Manual smoke: `bun start`; (a) on cold boot with subscriptions, the top-right spinner appears during startup refresh and disappears when done; (b) press `r` on Feed — spinner appears; (c) download an episode from Search — `Downloading` label shows while the transfer runs; (d) leave idle — top-right is empty.
notes:
- Depends on 03 only for ordering cleanliness — the activity wiring hooks onto the restructured refresh paths; nothing in 03's API is required beyond the store exporting the same signals.
- The overlay intentionally does NOT replace per-pane spinners (`Refreshing…` in Feed/MyShows/Discover/Search stay) — removing those is out of scope.
- If `position="absolute"` proves unavailable for text-draw ordering in `@opentui/solid`, the fallback is a dedicated 1-row header (`height={1}`) above the content row with the indicator right-aligned — only take this path with evidence (broken render), and note the tradeoff (loses one row of content height) in the commit message.

View File

@@ -0,0 +1,27 @@
# Bounded Feed Lifecycle
Objective: Bound feed episode storage to a rolling 30-day persisted window (older episodes volatile-only unless downloaded), keep feed loading nonblocking, and surface all load/download activity in a global top-right indicator.
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — persisted-retention-window → `01-persisted-retention-window.md`
- [x] 02 — volatile-episode-merge → `02-volatile-episode-merge.md`
- [x] 03 — nonblocking-feed-refresh → `03-nonblocking-feed-refresh.md`
- [x] 04 — global-activity-indicator → `04-global-activity-indicator.md`
Dependencies
- 02 depends on 01 (the volatile merge preserves exactly what 01 drops from disk)
- 03 depends on 01 (debounced persistence layers onto the pruning save path)
- 03 depends on 02 (incremental per-feed apply consumes the merge helper from 02)
- 04 depends on 03 (the indicator subscribes to the activity wiring added across refresh/load-more paths in 03)
Exit criteria
- After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch.
- The volatile episode list and pagination cache are bounded by the same 30-day window as persistence: only in-window episodes are cached/loadable, and everything in-window is (no episode-count ceiling).
- A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout.
- The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise.
- `bun test` and `bun run lint` pass.

309
tests/audio-backend.test.ts Normal file
View File

@@ -0,0 +1,309 @@
/**
* MpvBackend resident-daemon contract tests (real mpv process).
*
* Pins the IPC contract the app's playback depends on:
*
* 1. play() loads a file and position advances (observed, no polling).
* 2. pause()/resume() flip the player-reported pause state through IPC.
* 3. seek() lands where asked.
* 4. stop() unloads the file but keeps the daemon alive (isAlive stays
* true — the daemon model's whole point: no process churn per episode).
* 5. preload() parks an episode paused; play() of the SAME url then starts
* it by unpausing — the boot-restore fast path with no second load.
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
* resume() afterwards replays from the top.
* 7. Daemon death: a killed/crashed mpv is detected (isAlive drops);
* resume() refuses to unpause the fresh idle daemon (throws
* PlayerRestartedError) and play() recovers by respawning a fresh
* daemon and loading the file.
*
* All playback runs silent (volume 0). Requires a real mpv on PATH;
* tests skip where it is missing.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import {
MpvBackend,
PlayerRestartedError,
} from "../src/utils/audio-player";
const SAMPLE_RATE = 22050;
const FREQ = 440;
const AMP = 20000;
/** Write a WAV file containing `seconds` of a sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Poll a predicate until true or the deadline expires. */
async function waitFor(
label: string,
pred: () => boolean | Promise<boolean>,
timeoutMs = 8000,
): Promise<void> {
const start = Date.now();
for (;;) {
if (await pred()) return;
if (Date.now() - start > timeoutMs) {
throw new Error(`${label}: not true within ${timeoutMs}ms`);
}
await Bun.sleep(50);
}
}
const hasMpv = !!Bun.which("mpv");
const wavA = join(tmpdir(), `podtui-backend-${process.pid}-a.wav`);
const wavB = join(tmpdir(), `podtui-backend-${process.pid}-b.wav`);
function fixtureWavs(): void {
writeSineWav(wavA, 8);
writeSineWav(wavB, 8);
}
async function cleanup(backend: MpvBackend): Promise<void> {
backend.dispose();
await Bun.$`rm -f ${wavA} ${wavB}`.quiet();
}
test.skipIf(!hasMpv)(
"play / pause / resume / seek over the resident daemon",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isAlive()).toBe(true);
expect(backend.isPlaying()).toBe(true);
// Observed position advances without any polling from us.
await waitFor("position advances", async () => (await backend.getPosition()) > 1.3);
expect(await backend.getPauseState()).toBe(false);
expect(await backend.getDuration()).toBeGreaterThan(7.5);
// Pause: reported by the player's own state, position stalls.
await backend.pause();
await waitFor("paused state observed", async () => (await backend.getPauseState()) === true);
const posAtPause = await backend.getPosition();
await Bun.sleep(400);
expect(Math.abs((await backend.getPosition()) - posAtPause)).toBeLessThan(0.3);
// Resume: clock advances again.
await backend.resume();
await waitFor("resumed state observed", async () => (await backend.getPauseState()) === false);
await waitFor(
"position advances after resume",
async () => (await backend.getPosition()) > posAtPause + 0.3,
);
// Seek lands where asked.
await backend.seek(6);
await waitFor(
"seek observed",
async () => Math.abs((await backend.getPosition()) - 6) < 0.5,
);
// Stop unloads the file — but the daemon stays resident.
await backend.stop();
expect(backend.isPlaying()).toBe(false);
expect(backend.isAlive()).toBe(true);
expect(await backend.getPosition()).toBe(0);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"preload parks the episode paused; play() of the same url starts it by unpausing",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.preload(wavB, { volume: 0, speed: 1, startPosition: 2 });
// Parked: paused, at the requested offset, nothing advancing.
await waitFor(
"preload observed paused",
async () => (await backend.getPauseState()) === true,
);
const parkedPos = await backend.getPosition();
expect(parkedPos).toBeGreaterThan(1.5);
expect(backend.isPlaying()).toBe(false);
await Bun.sleep(400);
expect(Math.abs((await backend.getPosition()) - parkedPos)).toBeLessThan(0.3);
// The boot-restore fast path: play() unpauses instead of re-loading.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: parkedPos });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"preload fast path plays",
async () => (await backend.getPosition()) > parkedPos + 0.3,
);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the already-playing url does NOT reload (no audible skip-back)",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
// Start mid-episode (as a resume would) and let it advance.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances past the start offset",
async () => (await backend.getPosition()) > 1.8,
);
const before = await backend.getPosition();
// Re-selecting the SAME episode (Enter in a list, key-repeat)
// calls play() with the STALE saved progress. The file is
// already loaded — this must not reload from that earlier
// position, or the listener hears already-played audio again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
// A reload would drop the position back to ~1; a correct no-op
// keeps advancing from where it was.
await waitFor(
"playback continues past the pre-play position",
async () => (await backend.getPosition()) > before + 0.3,
);
expect(backend.isPlaying()).toBe(true);
// And the position never fell back toward the stale offset.
expect(await backend.getPosition()).toBeGreaterThan(1.8);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the same url while user-paused resumes at the current position",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 2,
);
await backend.pause();
await waitFor(
"paused observed",
async () => (await backend.getPauseState()) === true,
);
const pausedAt = await backend.getPosition();
// Re-selecting the paused episode resumes where it PAUSED — the
// stale saved progress must not become a backward seek target.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"resumed at the paused position",
async () => (await backend.getPosition()) > pausedAt + 0.3,
);
expect(await backend.getPosition()).toBeGreaterThan(1.5);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
await waitFor("playing", () => backend.isPlaying());
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 0.5,
);
// Simulate a crash: SIGKILL the daemon out from under us.
const proc = (backend as unknown as { proc: { pid: number } }).proc;
expect(proc).toBeTruthy();
process.kill(proc.pid, "SIGKILL");
await waitFor("death observed", () => !backend.isAlive());
// resume() must NOT silently no-op on the dead daemon: it
// respawns, finds the fresh daemon idle (no file loaded), and
// throws — the hook falls back to the full play path.
await expect(backend.resume()).rejects.toThrow(PlayerRestartedError);
// play() (the hook's recovery) reuses the respawned daemon and
// plays the file — audio must actually advance again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
expect(backend.isAlive()).toBe(true);
await waitFor(
"recovered playback advances",
async () =>
(await backend.getPosition()) > 0.5 && backend.isPlaying(),
);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"EOF marks playback ended; resume() then replays from the top",
async () => {
const wavShort = join(tmpdir(), `podtui-backend-${process.pid}-short.wav`);
writeSineWav(wavShort, 2);
const backend = new MpvBackend();
try {
await backend.play(wavShort, { volume: 0, speed: 2 });
// 2s at 2x ends in ~1s+startup. isPlaying() must drop on its own.
await waitFor("episode ended", async () => !backend.isPlaying());
// Play pressed on a finished episode replays from the top.
await backend.resume();
await waitFor("replay started", async () => backend.isPlaying());
await waitFor(
"replay position near start",
async () => (await backend.getPosition()) < 3 && backend.isPlaying(),
);
} finally {
backend.dispose();
await Bun.$`rm -f ${wavShort}`.quiet();
}
},
{ timeout: 20000 },
);

View File

@@ -0,0 +1,364 @@
/**
* EpisodePcmCache position-index contract tests.
*
* The visualizer's bars are served from a position-indexed PCM cache that
* ffmpeg fills at full speed. These tests pin the observable contracts the
* fragile paced-ring design kept breaking:
*
* 1. readWindow(out, at) serves the EXACT window ending at playback time
* `at` — position mapping is sample-precise, independent of how fast or
* far the decode has run.
* 2. Reads outside decoded coverage return 0 — the renderer HOLDS the last
* frame. (The old reader CLAMPED to a stale buffer; re-rendering the
* same window decayed cava into a frozen junk pattern after pause.)
* 3. pauseDecode kills ffmpeg but keeps the cache: resume serves bars
* instantly, ensureDecodeAround restarts the tail decode.
* 4. Seeking into an undecoded region starts a new segment there WITHOUT
* invalidating the previously decoded coverage.
*
* Uses a self-generated WAV (440Hz sine, mono, 22050Hz s16le — the cache's
* native rate) so expected samples are computed analytically with no
* resampler tolerance.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache";
const SAMPLE_RATE = 22050;
const FREQ = 440;
const AMP = 30000;
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Analytic sample value at a file index, matching the writer's formula. */
function expectedAt(fileIndex: number): number {
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
}
/** Block until the cache covers playback time `sec`. */
async function waitForCoverage(
cache: EpisodePcmCache,
sec: number,
timeoutMs = 10000,
): Promise<void> {
const start = Date.now();
while (!cache.covers(sec)) {
if (Date.now() - start > timeoutMs) {
throw new Error(`cache did not cover ${sec}s in time`);
}
await Bun.sleep(25);
}
}
/** Block until the furthest decode pass has hit stream EOF. */
async function waitForFinished(
cache: EpisodePcmCache,
timeoutMs = 10000,
): Promise<void> {
const start = Date.now();
while (!cache.decodeFinished) {
if (Date.now() - start > timeoutMs) {
throw new Error("decode did not finish in time");
}
await Bun.sleep(25);
}
}
function tmpWav(): string {
return join(tmpdir(), `podtui-pcm-${process.pid}-${Math.floor(Math.random() * 1e9)}.wav`);
}
const hasFfmpeg = !!Bun.which("ffmpeg");
test.skipIf(!hasFfmpeg)(
"far-forward seek into undecoded territory restarts decode AT the target (bars recover in seconds, not minutes)",
async () => {
const wav = tmpWav();
writeSineWav(wav, 60);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(0);
await waitForCoverage(cache, 1);
// Skipping 45s ahead while the pass still crawls at 4x must restart
// the segment at the target — waiting for the frontier to chew
// through the skipped region is minutes of frozen bars.
cache.ensureDecodeAround(45);
expect(cache.decoding).toBe(true);
expect(cache.activeDecodeBaseSec).toBe(45);
await waitForCoverage(cache, 45.1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"small forward gap closes in place — no needless reconnect",
async () => {
const wav = tmpWav();
writeSineWav(wav, 60);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(0);
await waitForCoverage(cache, 2);
// ~5s past the running frontier: at 4x pacing this closes in ~1.5s,
// cheaper than a reconnect — the pass must NOT restart.
const target = cache.coverageEndSec + 5;
cache.ensureDecodeAround(target);
expect(cache.activeDecodeBaseSec).toBe(0);
await waitForCoverage(cache, target);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
test.skipIf(!hasFfmpeg)(
"readWindow serves the exact window ending at the requested position",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(5);
await waitForCoverage(cache, 6.5);
const out = new Float64Array(512);
expect(cache.readWindow(out, 5.1)).toBe(512);
// Window ENDS at the target: out[i] is the sample at
// round(5.1*SR) - (len-1) + i (5s offset + 0.1s).
const endIdx = Math.round(5.1 * SAMPLE_RATE);
for (let i = 0; i < 512; i++) {
const idx = endIdx - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// A 5ms later window is the same stream shifted by exactly
// round(0.005*SR)=110 samples — pins position mapping precision.
const later = new Float64Array(512);
expect(cache.readWindow(later, 5.105)).toBe(512);
for (let i = 0; i <= 512 - 111; i++) {
expect(later[i]).toBe(out[i + 110]);
}
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"reads outside decoded coverage return 0 (renderer holds last frame, never stale junk)",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(5);
await waitForCoverage(cache, 5.5);
const out = new Float64Array(512);
out.fill(-999);
// Beyond the decode frontier.
expect(cache.readWindow(out, 999)).toBe(0);
// Before the segment base (decode started at 5s).
expect(cache.readWindow(out, 4.0)).toBe(0);
// Buffer untouched — no partial/stale samples leak through.
for (let i = 0; i < 16; i++) expect(out[i]).toBe(-999);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"pauseDecode keeps the cache: resume serves instantly, tail decode continues",
async () => {
const wav = tmpWav();
writeSineWav(wav, 12); // short: full tail decode lands well under a second
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(0);
await waitForCoverage(cache, 1.5);
// Pause: decode dies, cache must survive.
cache.pauseDecode();
expect(cache.decoding).toBe(false);
expect(cache.covers(1)).toBe(true);
// Serve from cache immediately after pause — this is the resume
// fast path: zero ffmpeg cold start.
const out = new Float64Array(512);
expect(cache.readWindow(out, 1.0)).toBe(512);
const endIdx = Math.round(1.0 * SAMPLE_RATE);
for (let i = 0; i < 512; i++) {
const idx = endIdx - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// Resume: tail decode restarts and eventually covers the file.
cache.ensureDecodeAround(1.0);
await waitForFinished(cache);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(11.9);
expect(cache.readWindow(out, 11.5)).toBe(512);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"seek into an undecoded region starts a new segment without losing earlier coverage",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({ url: wav });
try {
// Decoded the back half only...
cache.startDecode(10);
await waitForCoverage(cache, 11);
expect(cache.covers(2)).toBe(false);
// ...then the user seeks to 2s: a new segment decodes the front,
// and the back-half coverage stays valid throughout.
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(10.5)).toBe(true);
const out = new Float64Array(512);
expect(cache.readWindow(out, 10.5)).toBe(512);
const endIdx = Math.round(10.5 * SAMPLE_RATE);
for (let i = 0; i < 512; i++) {
const idx = endIdx - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
cache.startDecode(0);
// The 8s initial burst delivers the front of the file instantly.
await waitForCoverage(cache, 5);
// Park the cursor at 0 and drive the cap (the render loop reads
// every frame; the cap applies on the first read past the head).
const out = new Float64Array(512);
for (let i = 0; i < 30 && cache.decoding; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
// Paused at the head budget (4s) + one 8s burst of slack — NOT
// decoded to the 30s EOF.
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
expect(cache.decodeFinished).toBe(false);
// A parked cursor keeps the cap: more reads must not restart
// the pass or grow the cache.
const cappedAt = cache.coverageEndSec;
for (let i = 0; i < 10; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"the window prunes segments behind the cursor as playback advances",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
// Two segments: the back half [10, ~18] and, after the seek,
// the front [2, ~10].
cache.startDecode(10);
await waitForCoverage(cache, 11);
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(2.5)).toBe(true);
expect(cache.covers(10.5)).toBe(true);
// Cursor advances past the front segment's end + keepBehind:
// the front must fall out of the window, the back must survive.
const out = new Float64Array(512);
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
cache.readWindow(out, 13);
await Bun.sleep(25);
}
expect(cache.covers(2.5)).toBe(false);
expect(cache.covers(10.5)).toBe(true);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);

155
tests/audio-queue.test.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* audio-queue unit tests — pure selection logic for next/prev navigation
* and source-based auto-advance. Covers ordering, bounds, and the
* deduplication that prevents "next" from replaying the current episode.
*/
import { test, expect } from "bun:test";
import {
queueForSource,
queueIndex,
nextStep,
prevStep,
} from "../src/utils/audio-queue";
import { AudioSource } from "../src/stores/audio-nav";
import type { Episode } from "../src/types/episode";
import type { Feed } from "../src/types/feed";
import { FeedVisibility } from "../src/types/feed";
import type { SearchResult } from "../src/types/source";
function ep(id: string, n: number): Episode {
return {
id,
podcastId: "pod-" + id,
title: `Episode ${n}`,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 600,
pubDate: new Date(2026, 0, n),
};
}
function feed(id: string, episodes: Episode[]): Feed {
return {
id,
podcast: {
id,
title: "Feed " + id,
description: "",
feedUrl: `https://example.com/${id}.xml`,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "rss",
lastUpdated: new Date(),
isPinned: false,
};
}
function episodeResult(episode: Episode): SearchResult {
return {
sourceId: "itunes",
kind: "episode",
podcast: {
id: episode.podcastId,
title: "Show " + episode.podcastId,
description: "",
feedUrl: `https://example.com/${episode.podcastId}.xml`,
lastUpdated: new Date(),
isSubscribed: false,
},
episode,
};
}
const e1 = ep("e1", 1);
const e2 = ep("e2", 2);
const e3 = ep("e3", 3);
test("FEED queue is the chronological global list, newest first", () => {
const f1 = feed("f1", [e3, e2]);
const f2 = feed("f2", [e1]);
const queue = queueForSource(
AudioSource.FEED,
undefined,
[f1, f2],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e1, feed: f2 },
],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
expect(queueIndex(queue, "e2")).toBe(1);
expect(nextStep(queue, "e2")?.episode.id).toBe("e1");
expect(prevStep(queue, "e2")?.episode.id).toBe("e3");
expect(nextStep(queue, "e1")).toBeNull();
expect(prevStep(queue, "e3")).toBeNull();
});
test("FEED queue dedupes repeated episode ids (same episode listed twice)", () => {
// The same episode appears twice in the global list (e.g. a refresh
// merge duplicated a feed's entries). Without dedupe, nextStep after
// e2 would step onto e2 AGAIN — replaying the current episode.
const f1 = feed("f1", [e3, e2, e2, e1]);
const queue = queueForSource(
AudioSource.FEED,
undefined,
[f1],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e1, feed: f1 },
],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
// Distinct objects sharing an id dedupe too.
const e2clone = { ...e2 };
const queue2 = queueForSource(
AudioSource.FEED,
undefined,
[f1],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e2clone, feed: f1 },
],
[],
);
expect(queue2.map((e) => e.id)).toEqual(["e3", "e2"]);
expect(nextStep(queue2, "e2")).toBeNull(); // no self-step
});
test("MY_SHOWS queue scopes to the podcast that started playback", () => {
const fA = feed("podA", [e3, e2]);
const fB = feed("podB", [e1]);
const queue = queueForSource(
AudioSource.MY_SHOWS,
"podA",
[fA, fB],
[],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2"]);
// Unknown podcastId → empty queue (nothing to play next).
expect(
queueForSource(AudioSource.MY_SHOWS, "podX", [fA, fB], [], []),
).toEqual([]);
});
test("SEARCH queue filters to episode-kind results in display order", () => {
const queue = queueForSource(
AudioSource.SEARCH,
undefined,
[],
[],
[episodeResult(e1), episodeResult(e2)],
);
expect(queue.map((e) => e.id)).toEqual(["e1", "e2"]);
expect(queueIndex(queue, "e1")).toBe(0);
expect(queueIndex(queue, "e3")).toBe(-1);
});

View File

@@ -1,239 +0,0 @@
/**
* AudioStreamReader sync contract tests.
*
* The visualizer's bars must track the player's position in real time even
* though the reader is an independent ffmpeg process. These tests pin the
* two mechanisms that make that true:
*
* 1. `read(out, target)` serves the FFT window *at* the requested playback
* position — not at the decode head, which drifts from the player
* (startup skew, stalls).
* 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
* the decode head keeps up with the position at any playback speed —
* native-rate pacing falls behind by (speed-1)s per second.
*
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
* expected samples can be computed analytically and compared exactly.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
const SAMPLE_RATE = 44100;
const FREQ = 440;
const AMP = 30000;
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Analytic sample value at a file index, matching the writer's formula. */
function expectedAt(fileIndex: number): number {
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
}
/**
* Block until the reader's decode head has advanced past `samples` samples.
* The head advances at readrate × real time, so this bounds how long we wait.
*/
async function waitForHead(
reader: AudioStreamReader,
samples: number,
timeoutMs = 8000,
): Promise<void> {
const start = Date.now();
while (reader.samplesWritten < samples) {
if (Date.now() - start > timeoutMs) {
throw new Error("reader decode head did not advance in time");
}
await Bun.sleep(25);
}
}
const hasFfmpeg = !!Bun.which("ffmpeg");
test.skipIf(!hasFfmpeg)(
"read() serves the exact window at the requested position",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(5, 1);
// Cover targets up to ~5.6s (head must pass the read target).
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
const out = new Float64Array(512);
// Window at 5.1s: the window ENDS at the target, so out[i] is at
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
expect(reader.read(out, 5.1)).toBe(512);
for (let i = 0; i < 512; i++) {
const idx =
Math.round(5 * SAMPLE_RATE) +
Math.round((5.1 - 5) * SAMPLE_RATE) -
(out.length - 1) +
i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// Window at 5.105s is the same stream shifted by exactly
// round(0.005*SR)=221 samples — pins that the target maps to a
// precise offset, not "whatever the decode head is at".
const later = new Float64Array(512);
expect(reader.read(later, 5.105)).toBe(512);
for (let i = 0; i <= 512 - 222; i++) {
expect(later[i]).toBe(out[i + 221]);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"decode keeps up with the player clock at 2x speed",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 2);
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
// With native-rate pacing it would only be at ~1.25s, and the
// window at 2.5s would clamp to the head — content mismatch.
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
const out = new Float64Array(512);
expect(reader.read(out, 2.5)).toBe(512);
for (let i = 0; i < 512; i++) {
const idx =
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"read() clamps to the nearest samples when the target is beyond the head",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 1);
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
// Target far beyond the decode head: serve the newest available
// window (real sine samples, never zeros or garbage).
const out = new Float64Array(512);
expect(reader.read(out, 999)).toBe(512);
const maxAbs = Math.max(...Array.from(out, Math.abs));
expect(maxAbs).toBeGreaterThan(10000);
for (const v of out) {
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
async () => {
// Real wall-clock time is required here: this test validates ffmpeg's
// actual decode pacing (-readrate + -readrate_initial_burst) against
// the platform clock. Deterministic time control cannot reproduce the
// race where ffmpeg exits early and the bars freeze — that only
// surfaces when a real process writes to a real pipe.
//
// Simulates the actual render loop: for ~5s of wall time, advance a
// simulated player position at 1× realtime and call read() each frame.
// The decode head must stay ahead of the player position so read()
// always returns 512 samples, and ffmpeg must not exit early (which
// would freeze the bars). This test would have caught the
// backpressure-pacing failure where ffmpeg decoded all data into the
// pipe buffer instantly, exited, and the readLoop stopped.
const wav = join(
tmpdir(),
`podtui-reader-${process.pid}-${Date.now()}.wav`,
);
writeSineWav(wav, 30);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 1);
const FRAME_MS = 33;
const DURATION_MS = 5000;
const out = new Float64Array(512);
let successes = 0;
let failures = 0;
let minLead = Infinity;
const start = Date.now();
for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
const playerPos = (Date.now() - start) / 1000;
const count = reader.read(out, playerPos);
if (count === 512) successes++;
else failures++;
// The decode head should stay ahead of the player position.
const headPos = reader.samplesWritten / SAMPLE_RATE;
const lead = headPos - playerPos;
if (frame > 3) minLead = Math.min(minLead, lead);
await Bun.sleep(FRAME_MS);
}
// ffmpeg must still be running — it must not have exited early.
expect(reader.running).toBe(true);
// The vast majority of frames should return a full window.
// A few early failures during ffmpeg startup are acceptable.
expect(failures).toBeLessThan(5);
expect(successes).toBeGreaterThan(100);
// The decode head must maintain a positive lead over the player.
// Without -readrate_initial_burst, the head would lag behind by
// the ffmpeg startup latency and never catch up.
expect(minLead).toBeGreaterThan(0);
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 15000 },
);

197
tests/auto-advance.test.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* auto-advance.test.ts — "at the end of episodes play the next one, from
* the source that started it" feature.
*
* When a track reaches its natural end (mpv eof-reached), useAudio must
* advance to the next episode in the source queue — the current show's
* episode list (MY_SHOWS), the Feed's chronological list, or the search
* results — and must STOP at the end of the list (no wrap-around). A
* crashed/killed daemon must NOT auto-advance (that path is pinned by
* external-pause-reconcile.test.ts).
*
* Integration style (like external-pause-reconcile.test.ts): real stores,
* real persistence sandbox, and the REAL mpv backend driven by real audio
* files — two short local WAVs served over HTTP, so EOF happens on a
* deterministic timer. The show is subscribed through the real feed store's
* addFeed() API (no config seeding — works on whatever singleton state this
* worker holds), and the audio-nav source is pinned to MY_SHOWS for that
* podcast so the queue is scoped and deterministic. Skipped when mpv isn't
* installed.
*/
import { test, expect, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const hasMpv = !!Bun.which("mpv");
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-autoadv-"));
const DATA = mkdtempSync(join(tmpdir(), "podtui-autoadv-data-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; EOF is the signal under test
/** 2s mono 16-bit WAV with a sine tone — short enough to EOF fast,
* distinct per episode so playback is unambiguous. */
function makeWav(freq: number): Buffer {
const SAMPLE_RATE = 44100;
const DURATION = 2;
const dataLen = SAMPLE_RATE * DURATION;
const buf = Buffer.alloc(44 + dataLen * 2);
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataLen * 2, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16); // fmt chunk size
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(1, 22); // mono
buf.writeUInt32LE(SAMPLE_RATE, 24);
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
buf.writeUInt16LE(2, 32); // block align
buf.writeUInt16LE(16, 34); // bits per sample
buf.write("data", 36);
buf.writeUInt32LE(dataLen * 2, 40);
for (let i = 0; i < dataLen; i++) {
const sample = Math.round(
Math.sin((2 * Math.PI * freq * i) / SAMPLE_RATE) * 8000,
);
buf.writeInt16LE(sample, 44 + i * 2);
}
return buf;
}
const wav1 = makeWav(440);
const wav2 = makeWav(880);
// ── Local HTTP server: the RSS feed + both audio files ────────────────────
let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string {
// Distinct pubDates so ep1 (newest) is episodes[0], ep2 older — "next"
// must step DOWN the list toward the older episode.
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Auto Advance Show</title>
<description>auto-advance test feed</description>
<item>
<title>Episode One</title>
<pubDate>2026-08-10T00:00:00Z</pubDate>
<enclosure url="${origin}/e1.wav" length="${wav1.length}" type="audio/wav"/>
</item>
<item>
<title>Episode Two</title>
<pubDate>2026-08-01T00:00:00Z</pubDate>
<enclosure url="${origin}/e2.wav" length="${wav2.length}" type="audio/wav"/>
</item>
</channel></rss>`;
}
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
if (url.pathname.endsWith("e1.wav")) {
return new Response(wav1.buffer as ArrayBuffer, {
headers: { "Content-Type": "audio/wav" },
});
}
if (url.pathname.endsWith("e2.wav")) {
return new Response(wav2.buffer as ArrayBuffer, {
headers: { "Content-Type": "audio/wav" },
});
}
return new Response("not found", { status: 404 });
},
});
// ── Real modules (loaded after env + server are up) ───────────────────────
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?auto-advance-test");
const { useFeedStore } = await import("../src/stores/feed");
const { useAudioNavStore, AudioSource } = await import(
"../src/stores/audio-nav"
);
const feedStore = useFeedStore();
const audioNav = useAudioNavStore();
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
async function waitFor(
check: () => boolean,
timeoutMs = 15000,
): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("condition not met in time");
}
await Bun.sleep(25);
}
}
// Subscribe to the local feed through the real store API; unique podcast id
// so the MY_SHOWS queue lookup is deterministic whatever else this worker's
// shared feed store holds.
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
const PODCAST_ID = `auto-advance-pod-${process.pid}`;
const feed = await feedStore.addFeed(
{
id: PODCAST_ID,
title: "Auto Advance Show",
description: "auto-advance test feed",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
"test-source",
);
if (!feed || feed.episodes.length < 2) {
throw new Error("test feed did not load two episodes");
}
const ep1 = feed.episodes[0]; // newest — plays first
const ep2 = feed.episodes[1]; // older — must follow automatically
if (ep1.title !== "Episode One") {
throw new Error("episode order unexpected — ep1 is not the newest");
}
afterAll(() => {
audioNav.reset(); // don't leak nav state into shared-worker tests
server?.stop(true);
rmSync(CONFIG, { recursive: true, force: true });
rmSync(DATA, { recursive: true, force: true });
});
test.skipIf(!hasMpv)(
"episode ending auto-plays the next in the show; the last episode stops",
async () => {
const audio = useAudio();
audioNav.setSource(AudioSource.MY_SHOWS, PODCAST_ID);
// Start the newest episode.
await audio.play(ep1);
expect(audio.isPlaying()).toBe(true);
expect(audio.currentEpisode()?.id).toBe(ep1.id);
// EOF → the next (older) episode starts automatically, and the nav
// index moves with it.
await waitFor(
() =>
audio.currentEpisode()?.id === ep2.id && audio.isPlaying(),
);
expect(audioNav.getCurrentIndex()).toBe(1);
// The last episode ends → playback stops; no wrap-around to ep1.
await waitFor(() => !audio.isPlaying());
expect(audio.currentEpisode()?.id).toBe(ep2.id);
await Bun.sleep(600); // give any (wrong) auto-advance time to fire
expect(audio.currentEpisode()?.id).toBe(ep2.id);
expect(audio.isPlaying()).toBe(false);
await audio.stop();
},
{ timeout: 45000 },
);

View File

@@ -0,0 +1,58 @@
/**
* CavaCore.init() must be a no-op for an identical config: re-initializing
* the same plan leaks the old plan's native FFTW work buffers, so pipeline
* restarts (focus/episode churn) have to re-USE the live plan.
*/
import { test, expect } from "bun:test"
import { CavaCore } from "../src/utils/cavacore"
function stubLib() {
const calls = { init: 0, destroy: 0 }
let plans = 0
const lib = {
symbols: {
cava_init: () => {
calls.init++
return { p: ++plans }
},
cava_execute: () => {},
cava_destroy: () => {
calls.destroy++
},
},
close: () => {},
}
return { lib, calls }
}
test("identical init config reuses the plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
cava.init(cfg)
cava.init(cfg)
cava.init(cfg)
expect(calls.init).toBe(1)
expect(cava.isReady).toBe(true)
})
test("changed config re-inits, destroying the old plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
cava.init({ bars: 64, sampleRate: 22050, channels: 1, autosens: 0 })
cava.init({ bars: 32, sampleRate: 22050, channels: 1, autosens: 0 })
expect(calls.init).toBe(2)
expect(calls.destroy).toBe(1)
expect(cava.bars).toBe(32)
})
test("init after destroy creates a fresh plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
cava.init(cfg)
cava.destroy()
cava.init(cfg)
expect(calls.init).toBe(2)
expect(cava.isReady).toBe(true)
})

77
tests/cover-art.test.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Cover-art disk-cache contract tests.
*
* fetchCoverArt downloads each cover ONCE into a persistent per-URL cache;
* playback never waits on the network for art it has already fetched. Pins:
*
* 1. A fetch stores the bytes on disk and returns the cache path.
* 2. A second fetch of the same URL returns the cached path WITHOUT hitting
* the server again (request count stays 1).
* 3. Concurrent fetches of the same URL share one download (single-flight).
* 4. A failed fetch (404) resolves null instead of throwing.
*
* Served from a local Bun server — no external network dependence. Cache
* entries created here are removed afterwards.
*/
import { test, expect } from "bun:test";
import { unlinkSync } from "fs";
import { cachedCoverPath, fetchCoverArt } from "../src/utils/cover-art";
const FAKE_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(256).fill(7)]);
test("cover art is fetched once, cached on disk, and shared", async () => {
let requests = 0;
const server = Bun.serve({
port: 0,
fetch(req) {
requests++;
if (new URL(req.url).pathname === "/missing.jpg") {
return new Response("nope", { status: 404 });
}
return new Response(FAKE_JPEG, {
headers: { "content-type": "image/jpeg" },
});
},
});
const url = `http://127.0.0.1:${server.port}/cover.jpg`;
const missing = `http://127.0.0.1:${server.port}/missing.jpg`;
let cachedPath: string | null = null;
try {
expect(cachedCoverPath(url)).toBeNull();
// First fetch: downloads and caches.
cachedPath = await fetchCoverArt(url);
expect(cachedPath).not.toBeNull();
expect(requests).toBe(1);
expect(Bun.file(cachedPath!).size).toBe(FAKE_JPEG.byteLength);
// Second fetch: disk hit, server untouched.
expect(await fetchCoverArt(url)).toBe(cachedPath);
expect(requests).toBe(1);
// Single-flight: parallel misses of a fresh URL make ONE request.
const shared = `http://127.0.0.1:${server.port}/shared.jpg`;
const [a, b, c] = await Promise.all([
fetchCoverArt(shared),
fetchCoverArt(shared),
fetchCoverArt(shared),
]);
expect(a).not.toBeNull();
expect(a).toBe(b);
expect(b).toBe(c);
if (a) unlinkSync(a);
// 404 resolves null, never throws.
expect(await fetchCoverArt(missing)).toBeNull();
} finally {
server.stop(true);
if (cachedPath) {
try {
unlinkSync(cachedPath);
} catch {
/* ignore */
}
}
}
});

View File

@@ -0,0 +1,337 @@
/**
* discover-episode-preview.test.tsx — Discover: `l`/right/enter on a podcast
* result must OPEN the show's episode list, NOT subscribe.
*
* Regression: `open` on a Discover podcast result (bound to `l`/right via
* `swipe-next`, and to enter) used to toggle subscription — pressing `l` on a
* show you wanted to browse subscribed it instead. `l`/right/enter now drill
* into a fetched-on-demand episode list (depth 2, no subscription), and `a`
* (the app-wide `subscribe` action) is the dedicated subscribe key.
*
* Mounts the real app (sandboxed, silent audio, mocked discover store) and
* drives the Discover tab with the test renderer's mock keys: drill category
* → podcast, `l` opens the episode list WITHOUT subscribing (feed store
* untouched, subscribe not called); `h` pops back; `a` subscribes the
* focused show; `l` then re-opens the episodes.
*
* App modules are loaded dynamically (never statically) because the sandbox
* config/data dirs must be set BEFORE they evaluate — their module-level init
* reads those env vars at import time.
*/
import { test, expect, afterAll, beforeAll, mock } from "bun:test";
import { testRender } from "@opentui/solid";
import { createSignal } from "solid-js";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import type { AudioControls } from "../src/hooks/useAudio";
import type { Episode } from "../src/types/episode";
import type { Podcast } from "../src/types/podcast";
import type { DepthFrame, NavigationState } from "../src/context/navigation-store";
// Recording audio stub: `play` pushes what was streamed. Registered FIRST so
// a leaked partial useAudio mock from another file in this worker can't break
// the app mount (see tests/search-focus.test.tsx for the same hazard).
const played: Episode[] = [];
const stubAudio: AudioControls = {
isPlaying: () => false,
position: () => 0,
duration: () => 0,
volume: () => 1,
speed: () => 1,
backendName: () => "none",
error: () => null,
currentEpisode: () => null,
availablePlayers: () => [],
play: async (episode: Episode) => {
played.push(episode);
},
load: async () => {},
pause: async () => {},
resume: async () => {},
togglePlayback: async () => {},
stop: async () => {},
seek: async () => {},
seekRelative: async () => {},
setVolume: async () => {},
setSpeed: async () => {},
switchBackend: async () => {},
prev: async () => {},
next: async () => {},
};
mock.module("../src/hooks/useAudio", () => ({
useAudio: () => stubAudio,
}));
// Deterministic discover store: `openEpisodes` seeds the episode list
// synchronously (no network), `subscribe`/`unsubscribe` flip the show's flag
// and are recorded so the test can assert l/enter never subscribed.
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
const [preview, setPreview] = createSignal<Record<string, Episode[]>>({});
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
new Set(),
);
const [previewErrors, setPreviewErrors] = createSignal<Record<string, string>>(
{},
);
const subscribeCalls: string[] = [];
const openCalls: string[] = [];
const flip = (id: string, subscribed: boolean) =>
setPodcasts((prev) =>
prev.map((p) => (p.id === id ? { ...p, isSubscribed: subscribed } : p)),
);
const mockDiscoverStore = {
selectedCategory,
isLoading,
podcasts,
categories: [
{ id: "all", name: "All", icon: "" },
{ id: "technology", name: "Technology", icon: "" },
],
filteredPodcasts: () => {
const cat = selectedCategory();
if (cat === "all") return podcasts();
return podcasts().filter((p) =>
(p.categories ?? []).some((c) =>
c.toLowerCase().includes(cat.toLowerCase()),
),
);
},
setSelectedCategory,
subscribe: (id: string) => {
subscribeCalls.push(id);
flip(id, true);
},
unsubscribe: (id: string) => {
flip(id, false);
},
refresh: async () => {},
episodesForPodcast: (id: string) => preview()[id] ?? [],
isLoadingEpisodesFor: (id: string) => previewLoading().has(id),
previewError: (id: string) => previewErrors()[id],
openEpisodes: async (pod: Podcast) => {
openCalls.push(pod.id);
setPreview((prev) => ({
...prev,
[pod.id]: [makeEpisode(1), makeEpisode(2)],
}));
},
refreshEpisodes: async () => {},
};
mock.module("../src/stores/discover", () => ({
DISCOVER_CATEGORIES: mockDiscoverStore.categories,
useDiscoverStore: () => mockDiscoverStore,
}));
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
// env vars at import time, so the app modules are loaded dynamically.
const SANDBOX = join(process.cwd(), ".harness", "test-discover-preview");
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
process.env.PODTUI_AUDIO_BACKEND = "none";
const { App } = await import("../src/App");
const { ThemeProvider } = await import("../src/context/ThemeContext");
const toast = await import("../src/ui/toast");
const { KeybindProvider, useKeybinds } = await import(
"../src/context/KeybindContext"
);
const { NavigationProvider, useNavigation } = await import(
"../src/context/NavigationContext"
);
const { DialogProvider } = await import("../src/ui/dialog");
const { CommandProvider } = await import("../src/ui/command");
const { TABS } = await import("../src/utils/navigation");
const { useFeedStore } = await import("../src/stores/feed");
function makePodcast(): Podcast {
return {
id: "featured-show",
title: "Featured Show",
description: "A featured show.",
feedUrl: "https://example.test/featured.xml",
author: "tester",
categories: ["Technology"],
lastUpdated: new Date(),
isSubscribed: false,
};
}
function makeEpisode(n: number): Episode {
return {
id: `featured-ep-${n}`,
podcastId: "featured-show",
title: `Featured Episode ${n}`,
description: "",
audioUrl: "https://example.test/ep.mp3",
duration: 0,
pubDate: new Date(`2026-08-0${n}T00:00:00Z`),
};
}
type MockInput = { pressKey: (key: string) => void; pressEnter: () => void };
type Mounted = {
renderer: { destroy: () => void };
renderOnce: () => Promise<void>;
mockInput: MockInput;
nav: () => NavigationState;
keybindsReady: () => boolean;
};
async function mountApp(): Promise<Mounted> {
let navRef: NavigationState | null = null;
let keybindsRef: { ready: boolean } | null = null;
const StateProbe = () => {
navRef = useNavigation();
keybindsRef = useKeybinds();
return null;
};
const HarnessRoot = () => (
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<StateProbe />
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
);
const setup = await testRender(() => <HarnessRoot />, {
width: 100,
height: 30,
useThread: false,
});
// The test renderer intercepts stdout; the app is a TUI that writes frames
// asynchronously, so silence that interception (same as search-focus).
(
setup.renderer as unknown as {
disableStdoutInterception?: () => void;
}
).disableStdoutInterception?.();
await setup.renderOnce();
await sleep(60);
return {
renderer: setup.renderer,
renderOnce: setup.renderOnce,
mockInput: setup.mockInput,
nav: () => navRef!,
keybindsReady: () => keybindsRef?.ready ?? false,
};
}
function sleep(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
async function settleReady(m: Mounted): Promise<void> {
for (let i = 0; i < 80; i++) {
await m.renderOnce();
await sleep(60);
if (m.keybindsReady()) return;
}
throw new Error("keybinds never became ready");
}
async function waitFor(
m: Mounted,
cond: () => boolean,
what: string,
timeoutMs = 5000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cond()) return;
await m.renderOnce();
await sleep(25);
}
throw new Error(`timed out waiting for: ${what}`);
}
beforeAll(() => {
setPodcasts([makePodcast()]);
});
afterAll(() => {
rmSync(SANDBOX, { recursive: true, force: true });
});
test("l on a podcast opens its episode list without subscribing; a subscribes", async () => {
const m = await mountApp();
try {
await settleReady(m);
// Open the Discover tab (digit press retried until the router attaches).
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.DISCOVER; i++) {
m.mockInput.pressKey("3");
await m.renderOnce();
await sleep(40);
}
expect(m.nav().activeTab()).toBe(TABS.DISCOVER);
m.mockInput.pressEnter(); // open the tab's content (category depth)
await waitFor(
m,
() => m.nav().currentDepth() === 0 && !m.nav().atRootTab(),
"discover content mounted",
);
// l on the focused category drills to the podcast results (depth 1).
m.mockInput.pressKey("l");
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
expect(m.nav().topFrame()?.kind).toBe("results");
// l on the focused podcast opens its episode list (depth 2) — the
// show must NOT be subscribed, the feed store untouched.
m.mockInput.pressKey("l");
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes depth");
expect(m.nav().topFrame()?.kind).toBe("episodes");
expect(m.nav().topFrame()?.ctx).toBe("featured-show");
expect(openCalls).toEqual(["featured-show"]);
expect(subscribeCalls).toHaveLength(0);
expect(played).toHaveLength(0);
expect(
useFeedStore()
.feeds()
.some((f) => f.podcast.id === "featured-show"),
).toBe(false);
// The seeded episode list is what the page renders at depth 2.
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
2,
);
// h pops back to the results (depth 1).
m.mockInput.pressKey("h");
await waitFor(m, () => m.nav().currentDepth() === 1, "back to results");
// a subscribes the focused show (the dedicated subscribe key).
m.mockInput.pressKey("a");
await waitFor(
m,
() => mockDiscoverStore.podcasts()[0]?.isSubscribed === true,
"a subscribes the show",
);
expect(subscribeCalls).toEqual(["featured-show"]);
// l still opens the episode list for a subscribed show (no toggle).
m.mockInput.pressKey("l");
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes re-opened");
expect(subscribeCalls).toEqual(["featured-show"]);
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
2,
);
} finally {
m.renderer.destroy();
}
});

View File

@@ -0,0 +1,184 @@
/**
* discover-store-preview.test.ts — the Discover episode-preview store API.
*
* `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from
* a podcast result), caches it per podcast id for the session, records a
* per-show error on failure, and never refetches while cached or in flight.
* `refreshEpisodes` clears the cache/error and refetches.
*
* The REAL feed store runs against a local RSS server. No `mock.module`:
* bun test reuses workers across files and module mocks leak into the shared
* registry, so a feed-store mock here (whose stub lacks addFeed/refreshFeed/
* isLoadingFeeds) breaks every later file that shares a worker — the suite's
* documented failure mode. The repo's defense is importing the REAL modules
* via a query-suffixed specifier, which `mock.module` does not intercept.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import type { Podcast } from "../src/types/podcast";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-discprev-"));
process.env.XDG_CONFIG_HOME = configHome;
// Query-suffixed module identity: loads the REAL discover store even when a
// sibling file's `mock.module("../src/stores/discover")` leaked into this
// worker. Its internal `./feed` import resolves the real feed store, which
// no file mocks anymore.
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useDiscoverStore } = await import("../src/stores/discover?discover-store-preview");
interface ServedEpisode {
title: string;
date: string;
}
/** Pathnames the local server has served, in order (fetch tracking). */
const requests: string[] = [];
/** Per-path episode lists served by the local server. */
const served: Record<string, ServedEpisode[]> = {};
/** When set, responses for this path wait on the release callback. */
let gatePath: string | null = null;
let releaseGate: (() => void) | null = null;
/** XML for one show's episode list (ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
(ep, i) => `<item>
<title>${ep.title}</title>
<pubDate>${ep.date}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Test Show</title>
<description>Discover preview test feed</description>
${items}
</channel></rss>`;
}
let server: Bun.Server<undefined> | null = null;
let origin = "";
beforeAll(() => {
server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
requests.push(url.pathname);
if (gatePath && url.pathname === gatePath) {
await new Promise<void>((resolve) => {
releaseGate = resolve;
});
}
// A permanently-failing feed (simulates a show that went down).
if (url.pathname === "/fail.xml") {
return new Response("feed unavailable", { status: 503 });
}
const eps = served[url.pathname];
if (!eps) return new Response("not found", { status: 404 });
return new Response(feedXml(eps, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
},
});
origin = `http://127.0.0.1:${server.port}`;
});
afterAll(() => {
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
return {
id: "show-1",
title: "Show 1",
description: "",
feedUrl: "https://example.test/feed.xml",
lastUpdated: new Date(),
isSubscribed: false,
...overrides,
};
}
test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => {
const store = useDiscoverStore();
const pod = makePodcast({ feedUrl: `${origin}/show1.xml` });
served["/show1.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
expect(store.episodesForPodcast(pod.id)).toHaveLength(0);
await store.openEpisodes(pod);
expect(requests).toEqual(["/show1.xml"]);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.episodesForPodcast(pod.id)[0].title).toBe("Ep 1");
expect(store.isLoadingEpisodesFor(pod.id)).toBe(false);
expect(store.previewError(pod.id)).toBeUndefined();
// Cache hit: second open must not refetch.
await store.openEpisodes(pod);
expect(requests).toEqual(["/show1.xml"]);
// In-flight guard: a concurrent open during loading must not refetch.
// The server holds this show's response until the gate is released.
const pod2 = makePodcast({ id: "show-2", feedUrl: `${origin}/slow.xml` });
served["/slow.xml"] = [{ title: "Ep 2", date: "2026-08-09T00:00:00Z" }];
gatePath = "/slow.xml";
const pending = store.openEpisodes(pod2);
// Loading is set synchronously before the fetch resolves.
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true);
await store.openEpisodes(pod2); // must early-return, not queue a second fetch
// The request is held by the server gate; wait until it was actually
// received so the assertion isn't racing the network.
const deadline = Date.now() + 1000;
while (requests.length < 2 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5));
}
expect(requests).toEqual(["/show1.xml", "/slow.xml"]);
releaseGate?.();
gatePath = null;
await pending;
expect(store.episodesForPodcast(pod2.id)[0].title).toBe("Ep 2");
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false);
});
test("openEpisodes records an error for feedless shows and failed fetches", async () => {
const store = useDiscoverStore();
const feedless = makePodcast({ id: "show-3", feedUrl: undefined });
await store.openEpisodes(feedless);
expect(requests).not.toContain(feedless.id);
expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show.");
expect(store.episodesForPodcast(feedless.id)).toHaveLength(0);
// Failed fetch (server 503) → error recorded, nothing cached.
const failing = makePodcast({ id: "show-4", feedUrl: `${origin}/fail.xml` });
await store.openEpisodes(failing);
expect(store.previewError(failing.id)).toBe("Couldn't load episodes.");
expect(store.episodesForPodcast(failing.id)).toHaveLength(0);
expect(store.isLoadingEpisodesFor(failing.id)).toBe(false);
});
test("refreshEpisodes clears the cache and error, then refetches", async () => {
const store = useDiscoverStore();
const pod = makePodcast({ id: "show-5", feedUrl: `${origin}/show5.xml` });
served["/show5.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
await store.openEpisodes(pod);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
const callsBefore = requests.length;
await store.refreshEpisodes(pod);
expect(requests.length).toBe(callsBefore + 1);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.previewError(pod.id)).toBeUndefined();
});

View File

@@ -171,6 +171,20 @@ test("dispatch('move-up') emits nav.action on the current pane only (j/k never c
});
});
test("dispatch('subscribe') on a depth-tab current pane emits nav.action (page-local, like unsubscribe)", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH);
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
const events = captureNavActions(() => dispatch("subscribe"));
expect(events).toHaveLength(1);
expect(events[0].action).toBe("subscribe");
expect(events[0].tab).toBe(TABS.SEARCH);
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
});
});
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
withHarness(({ nav, dispatch }) => {

View File

@@ -0,0 +1,182 @@
/**
* Unsubscribed-show download tests — the download store contract behind the
* "Unsubscribed Show Downloads" list (My Shows depth 0 and the settings
* Download Manager):
*
* 1. startUnsubscribedDownload records the episode under a deterministic
* synthetic feed id with the show's metadata, and
* getUnsubscribedDownloads lists it.
* 2. A download made under a real (subscribed) feed id is NOT listed as
* unsubscribed.
* 3. Subscribing to the show re-classifies its unsubscribed download into
* the subscribed group — it drops out of getUnsubscribedDownloads.
* 4. removeDownloadsForFeed with the show's feed URL removes that show's
* unsubscribed downloads too (unsubscribing purges search downloads).
*
* Served over a real local HTTP server, mirroring how the app's other store
* tests exercise the network path. The store singleton is shared with other
* test files, so every added feed/download is removed in afterAll.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config/data dirs at throwaway directories BEFORE importing the
// stores (their module-level init reads them).
const configHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-"));
process.env.XDG_CONFIG_HOME = configHome;
const dataHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-data-"));
process.env.XDG_DATA_HOME = dataHome;
import { useDownloadStore } from "../src/stores/download";
import { useFeedStore } from "../src/stores/feed";
import type { Episode } from "../src/types/episode";
import type { Podcast } from "../src/types/podcast";
let server: ReturnType<typeof Bun.serve> | null = null;
let audioUrl = "";
const addedFeedIds: string[] = [];
const addedEpisodeIds: string[] = [];
/** Minimal RSS feed for one show. */
function feedXml(title: string, origin: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>${title}</title>
<description>Test feed</description>
<item>
<title>Ep 1</title>
<pubDate>2026-08-01T00:00:00Z</pubDate>
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
</item>
</channel></rss>`;
}
const makeEpisode = (id: string, title: string): Episode => ({
id,
podcastId: "pod",
title,
description: "",
audioUrl,
duration: 0,
pubDate: new Date("2026-08-01T00:00:00Z"),
});
const makePodcast = (feedUrl: string, title: string): Podcast => ({
id: `dir-${title}`,
title,
description: "Test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: false,
});
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml("Test Show", url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
return new Response("audio bytes", {
headers: { "Content-Type": "audio/mpeg" },
});
},
});
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
});
afterAll(() => {
for (const id of addedEpisodeIds) {
const dl = useDownloadStore();
dl.cancelDownload(id);
dl.removeDownload(id).catch(() => {});
}
for (const id of addedFeedIds) {
useFeedStore().removeFeed(id);
}
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
rmSync(dataHome, { recursive: true, force: true });
});
test("startUnsubscribedDownload records a synthetic-feed download with show metadata", () => {
const dl = useDownloadStore();
const episode = makeEpisode("unsub-ep-1", "Ep 1");
const podcast = makePodcast("https://example.com/feed.xml", "Unsub Show");
addedEpisodeIds.push(episode.id);
dl.startUnsubscribedDownload(episode, podcast);
const listed = dl.getUnsubscribedDownloads();
const mine = listed.find((d) => d.episodeId === episode.id);
expect(mine).toBeDefined();
expect(mine!.feedId).toBe("unsub-https-example-com-feed-xml");
expect(mine!.podcastTitle).toBe("Unsub Show");
expect(mine!.podcastFeedUrl).toBe("https://example.com/feed.xml");
expect(mine!.episodeTitle).toBe("Ep 1");
});
test("downloads under a real feed id are not listed as unsubscribed", async () => {
const feedStore = useFeedStore();
const dl = useDownloadStore();
const feedUrl = `http://127.0.0.1:${server!.port}/subbed.xml`;
const feed = await feedStore.addFeed(makePodcast(feedUrl, "Subbed"), "test");
expect(feed).not.toBeNull();
addedFeedIds.push(feed!.id);
const episode = makeEpisode("subbed-ep-1", "Ep 1");
addedEpisodeIds.push(episode.id);
dl.startDownload(episode, feed!.id);
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
false,
);
});
test("subscribing to the show re-classifies its unsubscribed download", async () => {
const feedStore = useFeedStore();
const dl = useDownloadStore();
const feedUrl = `http://127.0.0.1:${server!.port}/later.xml`;
const episode = makeEpisode("unsub-ep-later", "Ep 1");
const podcast = makePodcast(feedUrl, "Later Show");
addedEpisodeIds.push(episode.id);
// Downloaded while unsubscribed.
dl.startUnsubscribedDownload(episode, podcast);
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
true,
);
// Subscribing later (same feed URL) moves it into the subscribed group.
const feed = await feedStore.addFeed(podcast, "test");
expect(feed).not.toBeNull();
addedFeedIds.push(feed!.id);
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
false,
);
});
test("removeDownloadsForFeed purges the show's unsubscribed downloads by feed URL", async () => {
const feedStore = useFeedStore();
const dl = useDownloadStore();
const feedUrl = `http://127.0.0.1:${server!.port}/purge.xml`;
const episode = makeEpisode("unsub-ep-purge", "Ep 1");
addedEpisodeIds.push(episode.id);
dl.startUnsubscribedDownload(episode, makePodcast(feedUrl, "Purge Show"));
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
true,
);
// Unsubscribe the show: the feed is gone, but its URL still identifies
// the search downloads made while it was unsubscribed.
await dl.removeDownloadsForFeed("no-such-feed-id", feedUrl);
expect(dl.getAllDownloads().some((d) => d.episodeId === episode.id)).toBe(false);
});

View File

@@ -0,0 +1,326 @@
/**
* external-pause-reconcile.test.ts — "audio paused outside PodTUI must not
* freeze the player tab" regression test.
*
* The OS can pause the player without PodTUI knowing: system sleep/lock,
* AirPod removal / device swap, OS media keys, the Now Playing center. mpv
* flips its own `pause` property and keeps it there. Before the fix,
* useAudio's signals stayed on "playing" — [Pause] button shown while
* silent, a poll that only re-read the same frozen `time-pos` (stuck
* waveform), and no way to catch an external RESUME either (the poll was
* stopped whenever the UI thought it was paused).
*
* Integration style (like restore-session.test.ts): real stores and real
* persistence files in a temp XDG_CONFIG_HOME — but with the REAL mpv
* backend driven over its actual IPC socket. The test flips mpv's pause
* property the same way the OS does and asserts useAudio reconciles in
* both directions. Skipped when mpv isn't installed.
*
* Also covers daemon crash recovery: killing mpv out from under the app
* must drop the UI out of "playing" (finalizeTrackEnd), and the next Play
* press must respawn a fresh daemon and resume audio from the saved
* position — the play button may never silently no-op on a dead player.
*
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
* interval against a real mpv process, with no injectable clock — the
* deliberate-exception case from the no-real-timers rule (same as
* visualizer-store.test.ts). `waitFor` polls with Bun.sleep.
*
* Shared-worker note (same as restore-session.test.ts): the suite reuses
* bun test workers, so other files' `mock.module("../src/hooks/useAudio")`
* leaks into this file's module registry. The REAL useAudio is therefore
* imported via a `?external-pause-test` query suffix — a distinct module
* identity bun loads from disk, bypassing the leaked mock.
*/
import { test, expect, afterAll } from "bun:test";
import {
mkdirSync,
mkdtempSync,
writeFileSync,
rmSync,
readdirSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const hasMpv = !!Bun.which("mpv");
// ── Sandbox BEFORE any app module evaluates (mirrors restore-session) ────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-extpause-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-extpause-data-"));
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; the test drives mpv's IPC
const APP_CONFIG = join(CONFIG, "podtui");
mkdirSync(APP_CONFIG, { recursive: true });
// Seed one feed so the app store boots cleanly. No coverUrl — the play()
// path skips cover-art fetching. The RSS URL is unreachable so the
// background refresh fails fast and leaves the seeded data untouched.
const ISO = "2026-08-10T00:00:00.000Z";
const feed = {
id: "feed1",
podcast: {
id: "pod1",
title: "Pod One",
description: "",
feedUrl: "http://127.0.0.1:1/show.xml",
lastUpdated: ISO,
isSubscribed: true,
},
episodes: [],
visibility: "public",
sourceId: "test",
lastUpdated: ISO,
isPinned: false,
};
await Bun.write(
join(APP_CONFIG, "config.json"),
JSON.stringify({ feeds: [feed] }, null, 2),
);
// ── Local 60s WAV so playback is hermetic (no network, no early EOF) ─────
const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
{
const SAMPLE_RATE = 44100;
const DURATION = 60;
const dataLen = SAMPLE_RATE * DURATION; // mono 16-bit
const buf = Buffer.alloc(44 + dataLen * 2);
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataLen * 2, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16); // fmt chunk size
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(1, 22); // mono
buf.writeUInt32LE(SAMPLE_RATE, 24);
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
buf.writeUInt16LE(2, 32); // block align
buf.writeUInt16LE(16, 34); // bits per sample
buf.write("data", 36);
buf.writeUInt32LE(dataLen * 2, 40);
for (let i = 0; i < dataLen; i++) {
const sample = Math.round(
Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE) * 8000,
);
buf.writeInt16LE(sample, 44 + i * 2);
}
writeFileSync(wavPath, buf);
}
// ── Real modules (loaded after env + sandbox are set up) ──────────────────
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
/**
* The socket path of the LIVE backend daemon in this process. The backend
* names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
* tmpdir for this pid's sockets and take the newest (the one mpv actually
* bound — earlier instances may have been orphaned by a re-spawn).
*/
function mpvSocket(): string | null {
let newest: string | null = null;
let newestMtime = 0;
for (const name of readdirSync(tmpdir())) {
if (
!name.startsWith(`podtui-mpv-${process.pid}-`) ||
!name.endsWith(".sock")
) {
continue;
}
const candidate = join(tmpdir(), name);
const mtime = statSync(candidate).mtimeMs;
if (mtime > newestMtime) {
newest = candidate;
newestMtime = mtime;
}
}
return newest;
}
/**
* Send a raw mpv IPC command over the unix socket — exactly how the OS
* media session pauses/resumes mpv without PodTUI's involvement.
*/
async function mpvCommand(command: unknown[]): Promise<void> {
const socket = mpvSocket();
if (!socket) throw new Error("backend mpv socket not found");
const { promise, resolve, reject } = Promise.withResolvers<void>();
let settled = false;
const settle = (err: Error | null): void => {
if (settled) return;
settled = true;
if (err) reject(err);
else resolve();
};
Bun.connect({
unix: socket,
socket: {
open(s) {
s.write(JSON.stringify({ command }) + "\n");
},
data() {},
error() {
settle(new Error("mpv IPC connect failed"));
},
close() {
settle(null);
},
},
}).then((s) =>
setTimeout(() => {
try {
s.end();
} catch {}
}, 150),
);
// Never hang the test on a vanished socket.
setTimeout(() => settle(null), 1000);
await promise;
}
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
async function waitFor(
check: () => boolean,
timeoutMs = 10000,
): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("condition not met in time");
}
await Bun.sleep(25);
}
}
/** SIGKILL the backend's mpv daemon — a crash/kill out from under the app.
* The mpv command line carries the IPC socket path, so pgrep finds it by
* that (the socket name is unique to this test process). */
async function killMpvDaemon(): Promise<void> {
const socket = mpvSocket();
if (!socket) throw new Error("backend mpv socket not found");
const pids = (await Bun.$`pgrep -f ${socket}`.quiet().text())
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map(Number);
expect(pids.length).toBeGreaterThan(0);
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
}
}
const episode = {
id: "ep1",
podcastId: "pod1",
title: "Episode One",
description: "desc",
audioUrl: wavPath,
duration: 60,
pubDate: new Date(),
};
// ── Tests ────────────────────────────────────────────────────────────────
test.skipIf(!hasMpv)(
"external pause flips the UI to paused; external resume recovers",
async () => {
const audio = useAudio();
await audio.play(episode);
expect(audio.isPlaying()).toBe(true);
// Simulate the OS pausing the session (lock/sleep, AirPod removal,
// device swap, media-center pause): flip mpv's own pause property
// over IPC. PodTUI is never told.
await mpvCommand(["set_property", "pause", true]);
await waitFor(() => !audio.isPlaying());
expect(audio.isPlaying()).toBe(false);
// The episode stays loaded — nothing was torn down.
expect(audio.currentEpisode()?.id).toBe("ep1");
// Simulate an external resume (AirPod play tap, media-center play).
await mpvCommand(["set_property", "pause", false]);
await waitFor(() => audio.isPlaying());
expect(audio.isPlaying()).toBe(true);
// The TUI transport still works from the reconciled state.
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(false);
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(true);
await audio.stop();
expect(audio.isPlaying()).toBe(false);
},
{ timeout: 30000 },
);
test.skipIf(!hasMpv)(
"mpv killed mid-play: UI drops out of playing; pressing play recovers a fresh daemon",
async () => {
const audio = useAudio();
await audio.play(episode);
// Instant assertion: play() sets isPlaying synchronously when it
// succeeded. (In a shared worker that leaked a store mock from
// another test file, play() fails and this catches it at 0ms
// instead of burning the waitFor timeout below.)
expect(audio.isPlaying()).toBe(true);
// Let the clock advance past the 5s progress-save floor so recovery
// has a saved position to resume from (positions <5s are not stored).
await waitFor(() => audio.position() > 6);
const crashPos = audio.position();
// Crash the player out from under the app.
await killMpvDaemon();
await waitFor(() => !audio.isPlaying());
expect(audio.isPlaying()).toBe(false);
// The episode stays current — recovery can restart it.
expect(audio.currentEpisode()?.id).toBe("ep1");
// Press play: must respawn mpv and resume from the saved position —
// not silently flip the UI to "playing" with no process behind it.
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(true);
expect(audio.position()).toBeGreaterThanOrEqual(crashPos - 0.5);
// Audio actually advances again — proof a fresh daemon is playing.
await waitFor(() => audio.position() > crashPos + 0.5);
await audio.stop();
expect(audio.isPlaying()).toBe(false);
},
{ timeout: 30000 },
);
// ── Teardown ──────────────────────────────────────────────────────────────
afterAll(async () => {
try {
useAudio().stop();
} catch {
/* best-effort */
}
// The resident daemon survives stop() by design — quit it so test
// workers don't leak idle mpv processes.
try {
await mpvCommand(["quit"]);
} catch {
/* best-effort */
}
try {
const socket = mpvSocket();
if (socket) rmSync(socket, { force: true });
} catch {
/* best-effort */
}
try {
rmSync(wavPath, { force: true });
} catch {
/* best-effort */
}
});

View File

@@ -0,0 +1,72 @@
/**
* Unit test for feedForEpisode: resolving the feed behind an episode.
*
* The critical case is the reported regression — an iTunes show's episode has
* `podcastId` set to the RSS feed url (rss-parser:163) while the feed's
* `podcast.id` is the iTunes directory id. Those differ, so a strict
* `podcast.id` match loses the feed (and its cover, stalling Now Playing art).
*/
import { describe, expect, test } from "bun:test";
import { feedForEpisode } from "../src/utils/feed-resolve";
import { FeedVisibility } from "../src/types/feed";
import type { Episode } from "../src/types/episode";
import type { Feed } from "../src/types/feed";
function makeFeed(id: string, feedUrl: string, title = `Show ${id}`): Feed {
return {
id,
podcast: {
id,
title,
description: "",
feedUrl,
coverUrl: `https://cover/${id}.jpg`,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId: "test",
lastUpdated: new Date(),
isPinned: false,
};
}
const episode = (podcastId: string, id = "ep"): Episode => ({
id,
podcastId,
title: "Episode",
description: "",
audioUrl: "https://audio/ep.mp3",
duration: 60,
pubDate: new Date(),
});
describe("feedForEpisode", () => {
test("matches when episode.podcastId equals the feed's podcast.id", () => {
const f = makeFeed("id-a", "http://a/feed.xml");
expect(feedForEpisode([f], episode("id-a"))?.podcast.id).toBe("id-a");
});
test("matches an iTunes show by feed url (podcastId != podcast.id)", () => {
// The regression: feed.podcast.id is the directory id, podcastId the
// RSS url — a strict id match loses the feed.
const feedUrl = "http://itunes.example/feed.xml";
const f = makeFeed("itunes-1177068388", feedUrl);
const got = feedForEpisode([f], episode(feedUrl));
expect(got?.podcast.id).toBe("itunes-1177068388");
});
test("falls back to episode membership when neither id nor feedUrl match", () => {
const f = makeFeed("id-b", "http://b/feed.xml");
const ep = episode("unrelated", "ep-42");
f.episodes = [ep];
expect(feedForEpisode([f], ep)?.podcast.id).toBe("id-b");
});
test("returns undefined when no feed matches", () => {
const f = makeFeed("id-c", "http://c/feed.xml");
expect(feedForEpisode([f], episode("nowhere"))).toBeUndefined();
});
});

View File

@@ -0,0 +1,323 @@
/**
* Non-blocking feed refresh tests — task 03 of the bounded-feed-lifecycle
* feature.
*
* Pins the contracts that make a refresh batch feel non-blocking:
*
* 1. refreshAllFeeds never holds more than FETCH_CONCURRENCY (4) RSS
* requests in flight — a worker pool bounds the batch instead of
* Promise.all firing every feed at once.
* 2. Each feed's refreshed episodes are applied AS ITS OWN FETCH LANDS —
* the old Promise.all barrier is gone, so a slow feed no longer hides
* the fast feeds' fresh episodes.
* 3. config.json writes are trailing-edge debounced (rapid changes
* collapse into one final write) and flushPendingSave() persists
* immediately, without waiting out the debounce window.
*
* Polling note (why the polls below use setImmediate, not microtasks):
* vi's fake timers trap setTimeout/setInterval/Date/Bun.sleep, so the
* debounce is driven with vi.advanceTimersByTime. But a poll loop of pure
* microtask turns (`await Promise.resolve()`) can NEVER observe an
* in-flight refresh: it keeps the microtask queue non-empty, the event
* loop's poll phase is never reached, and Bun.serve never even receives
* the fetch (verified empirically). setImmediate is a real macrotask that
* fake timers do NOT trap, and it lets the socket I/O progress — each
* `tick()` below is one bounded event-loop turn. No real sleeps anywhere.
*/
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-nonblocking-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
import { whenConfigIdle } from "../src/utils/config";
interface ServedEpisode {
title: string;
date: string;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
/** Per-pathname request gates: while a path has an unresolved gate, the
* server parks that request until the test resolves it. */
let gates = new Map<string, { gate: Promise<void>; resolve: () => void }>();
/** Requests currently inside the fetch handler (entered, not yet answered). */
let inFlight = 0;
/** High-water mark of `inFlight` — the concurrency-bound assertion source. */
let maxConcurrent = 0;
// Bun runs test files in ONE process, so the store singleton is shared with
// other test files. Track the feeds we add and remove them in afterAll so
// whichever file runs next sees a pristine store.
const addedFeedIds: string[] = [];
/** Feed created by the debounce test, reused by the flushPendingSave test. */
let debounceFeedId = "";
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
(ep, i) => `<item>
<title>${ep.title}</title>
<pubDate>${ep.date}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Non-Blocking Test Show</title>
<description>Non-blocking refresh test feed</description>
${items}
</channel></rss>`;
}
const makePodcast = (feedUrl: string): Podcast => ({
id: feedUrl,
title: "Non-Blocking Test Show",
description: "Non-blocking refresh test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
});
/** Park a request path behind an unresolved gate. */
function setGate(path: string): void {
const { promise, resolve } = Promise.withResolvers<void>();
gates.set(path, { gate: promise, resolve });
}
/** Resolve every gate currently set. */
function releaseAllGates(): void {
for (const { resolve } of gates.values()) resolve();
gates.clear();
}
/** One real macrotask turn — see the polling note in the header. */
const tick = (): Promise<void> => {
const { promise, resolve } = Promise.withResolvers<void>();
setImmediate(resolve);
return promise;
};
/** Poll `cond` across up to `iterations` event-loop turns (one setImmediate
* each). Returns whether the condition held by the deadline. */
async function pollUntil(
cond: () => boolean,
iterations = 500,
): Promise<boolean> {
for (let i = 0; i < iterations; i++) {
if (cond()) return true;
await tick();
}
return cond();
}
/** Raw config.json text ("" when the file does not exist yet). */
const readConfigRaw = (): Promise<string> =>
Bun.file(join(process.env.XDG_CONFIG_HOME!, "podtui", "config.json"))
.text()
.catch(() => "");
beforeAll(() => {
server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
inFlight++;
if (inFlight > maxConcurrent) maxConcurrent = inFlight;
try {
const gate = gates.get(url.pathname);
if (gate) await gate.gate;
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
return new Response("not found", { status: 404 });
} finally {
inFlight--;
}
},
});
});
beforeEach(() => {
vi.useFakeTimers();
gates.clear();
inFlight = 0;
maxConcurrent = 0;
});
afterAll(() => {
vi.useRealTimers();
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
test("refreshAllFeeds never exceeds FETCH_CONCURRENCY in-flight requests", async () => {
const store = useFeedStore();
servedEpisodes = [{ title: "Bound Ep 0", date: "2026-08-10T00:00:00Z" }];
const urls = Array.from(
{ length: 10 },
(_, n) => `http://127.0.0.1:${server!.port}/bound-${n}.xml`,
);
const ids: string[] = [];
for (const url of urls) {
const feed = await store.addFeed(makePodcast(url), "test-source");
expect(feed).not.toBeNull();
ids.push(feed!.id);
addedFeedIds.push(feed!.id);
}
// Gate every path so the batch's requests pile up at the server. addFeed
// ran sequentially above (its own fetches never exceed 1 in flight), so
// the counter below measures the batch alone.
for (const url of urls) setGate(new URL(url).pathname);
inFlight = 0;
maxConcurrent = 0;
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
const sawBound = await pollUntil(() => maxConcurrent >= 4);
expect(sawBound).toBe(true);
// The worker pool caps the batch at 4 — exactly 4 gated requests are
// parked (nothing has been released, so nothing completed yet), and
// nothing may exceed the bound, now or as the batch drains.
expect(maxConcurrent).toBe(4);
expect(maxConcurrent).toBeLessThanOrEqual(4);
releaseAllGates();
await refreshPromise;
expect(maxConcurrent).toBeLessThanOrEqual(4);
for (const id of ids) {
expect(store.getFeed(id)!.episodes.length).toBe(1);
}
});
test("refreshAllFeeds applies each feed as its own fetch lands (no barrier)", async () => {
const store = useFeedStore();
servedEpisodes = [{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }];
const aUrl = `http://127.0.0.1:${server!.port}/incr-a.xml`;
const bUrl = `http://127.0.0.1:${server!.port}/incr-b.xml`;
const a = await store.addFeed(makePodcast(aUrl), "test-source");
const b = await store.addFeed(makePodcast(bUrl), "test-source");
expect(a).not.toBeNull();
expect(b).not.toBeNull();
const aId = a!.id;
const bId = b!.id;
addedFeedIds.push(aId, bId);
// A new episode appears for both feeds; B's fetch is parked at the
// server, A's is not.
servedEpisodes = [
{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Incr Ep 1", date: "2026-08-09T00:00:00Z" },
];
setGate(new URL(bUrl).pathname);
const beforeA = store.getFeed(aId)!.lastUpdated.getTime();
const beforeB = store.getFeed(bId)!.lastUpdated.getTime();
// Advance the (mocked) clock so the refresh's `new Date()` lastUpdated
// bump is observably greater than beforeA (the fake clock otherwise
// never moves — same pattern as feed-refresh.test.ts).
vi.advanceTimersByTime(60_000);
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
const applied = await pollUntil(
() => store.getFeed(aId)!.lastUpdated.getTime() > beforeA,
);
expect(applied).toBe(true);
// A's refreshed window is visible in feeds() while B is STILL gated —
// the proof that per-feed results apply as they land.
expect(store.getFeed(aId)!.episodes.length).toBe(2);
expect(store.getFeed(bId)!.episodes.length).toBe(1);
expect(store.getFeed(bId)!.lastUpdated.getTime()).toBe(beforeB);
releaseAllGates();
await refreshPromise;
expect(store.getFeed(aId)!.episodes.length).toBe(2);
expect(store.getFeed(bId)!.episodes.length).toBe(2);
});
test("config.json writes are trailing-edge debounced (two refreshes, one save)", async () => {
const store = useFeedStore();
servedEpisodes = [{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }];
const url = `http://127.0.0.1:${server!.port}/debounce.xml`;
const feed = await store.addFeed(makePodcast(url), "test-source");
expect(feed).not.toBeNull();
debounceFeedId = feed!.id;
addedFeedIds.push(debounceFeedId);
servedEpisodes = [
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
];
await store.refreshFeed(debounceFeedId);
servedEpisodes = [
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
];
await store.refreshFeed(debounceFeedId);
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(3);
// No timer advanced: the debounced saves have NOT fired — the refreshed
// episodes exist only in memory (await whenConfigIdle first so a
// straggler write from an earlier test cannot race this read).
await whenConfigIdle();
const before = await readConfigRaw();
expect(before).not.toContain("Deb Ep 1");
expect(before).not.toContain("Deb Ep 2");
// SAVE_DEBOUNCE_MS = 250 (module-private in feed.ts — hardcoded here).
vi.advanceTimersByTime(250);
await whenConfigIdle();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await whenConfigIdle();
// One read, both refreshed episodes: the two refreshes collapsed into a
// single trailing-edge write.
const after = await readConfigRaw();
expect(after).toContain("Deb Ep 1");
expect(after).toContain("Deb Ep 2");
});
test("flushPendingSave persists immediately, without waiting out the debounce", async () => {
const store = useFeedStore();
// Same feed as the debounce test (still in the singleton): serve a 4th
// episode and refresh — the save is scheduled, then flushed by hand.
servedEpisodes = [
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
{ title: "Deb Ep 3", date: "2026-08-07T00:00:00Z" },
];
await store.refreshFeed(debounceFeedId);
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(4);
// No advanceTimersByTime: flushPendingSave must write right now.
store.flushPendingSave();
await whenConfigIdle();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await whenConfigIdle();
const raw = await readConfigRaw();
expect(raw).toContain("Deb Ep 3");
});

View File

@@ -3,11 +3,10 @@
* row in a drilled show's episode list (My Shows depth 1) and the Feed
* page's row.
*
* addFeed caches the FULL parsed feed while exposing only the first
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
* pins:
* Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
* fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
* (Date-mode fetch-more steps by a two-week window instead — that contract
* is pinned in feed-volatile-merge.test.ts.) This pins:
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
* needed) and hasMoreEpisodes flips false once the window reaches the end.
@@ -25,8 +24,11 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import { useAppStore } from "../src/stores/app";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode {
title: string;
date: string;
@@ -40,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
// store (execution order between files is not guaranteed).
const addedFeedIds: string[] = [];
/** XML for the current served episode list (episode ids = feedUrl#index). */
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
@@ -69,7 +71,15 @@ const makePodcast = (feedUrl: string): Podcast => ({
isSubscribed: true,
});
beforeAll(() => {
beforeAll(async () => {
// The app store loads its persisted prefs asynchronously at import; wait
// for that so our count-mode override isn't clobbered by the load.
await useAppStore().whenReady();
// Chunk-based stepping is count-mode behavior (see header comment).
useAppStore().updatePreferences({
episodeCacheMode: "count",
episodeCacheCount: 25,
});
server = Bun.serve({
port: 0,
fetch(req) {
@@ -86,6 +96,7 @@ beforeAll(() => {
afterAll(() => {
// Leave the shared singleton as we found it (see addedFeedIds note).
useAppStore().updatePreferences({ episodeCacheMode: "date" });
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
@@ -94,10 +105,12 @@ afterAll(() => {
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
const store = useFeedStore();
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
// 60 episodes: 20 shown at subscribe, 40 held back in the cache. All
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
// cacheable — the cache bound is the date window, not a count.
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
title: `Ep ${60 - i}`,
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
@@ -123,9 +136,10 @@ test("loadMoreEpisodes advances one feed's window from the cache, then no-ops",
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
const store = useFeedStore();
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
// All inside the lifecycle window (5h apart = 25 days).
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
title: `Ep ${120 - i}`,
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");

View File

@@ -0,0 +1,230 @@
/**
* Feed-refresh Effect program tests (src/effects/feed-refresh.ts).
*
* These test the Effect program in isolation — no store singleton, no
* network, no fake timers. The fetch/apply closures are injected, and the
* `Clock` service comes from TestContext's TestClock, so timeouts are driven
* deterministically with TestClock.adjust instead of real 20s waits.
*
* Contracts pinned here (mirrored at the store level by
* feed-nonblocking.test.ts / feed-refresh.test.ts against a real Bun.serve):
* 1. Bounded concurrency — never more than `concurrency` fetches in
* flight, and the pool pulls the next feed as one completes.
* 2. Per-feed apply as its own fetch lands (no barrier).
* 3. A timed-out fetch leaves that feed untouched and does not stall the
* batch (TestClock.adjust fires the timeout deterministically).
* 4. A rejecting fetch leaves that feed untouched and does not fail the
* batch.
*/
import { test, expect } from "bun:test"
import { Duration, Effect, Fiber, TestClock, TestContext } from "effect"
import {
refreshFeedsBatch,
type RefreshFetchResult,
} from "../src/effects/feed-refresh"
import type { Feed } from "../src/types/feed"
import type { Podcast } from "../src/types/podcast"
import type { Episode } from "../src/types/episode"
const makePodcast = (id: string): Podcast => ({
id,
title: `Show ${id}`,
description: `Show ${id} description`,
feedUrl: `http://example.com/${id}.xml`,
lastUpdated: new Date(0),
isSubscribed: true,
})
const makeFeed = (id: string): Feed => ({
id,
podcast: makePodcast(id),
episodes: [],
visibility: "public" as Feed["visibility"],
sourceId: "test",
lastUpdated: new Date(0),
isPinned: false,
})
const makeEpisode = (id: string): Episode => ({
id,
podcastId: "pod",
title: `Ep ${id}`,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 60,
pubDate: new Date(0),
})
/** Resolve an episode result without dragging in the full RSS shape. */
const ok = (episodeIds: string[]): RefreshFetchResult => ({
episodes: episodeIds.map(makeEpisode),
coverUrl: undefined,
})
/** One macrotask turn — lets microtask-scheduled Effect fibers run. */
const tick = (): Promise<void> => {
const { promise, resolve } = Promise.withResolvers<void>()
setImmediate(resolve)
return promise
}
/** A resolvable fetch gate: the pool parks on `promise` until the test
* resolves it. (Promise.withResolvers's return type is not in tsconfig's
* ES2015.Promise lib, hence the explicit shape.) */
interface Gate {
promise: Promise<RefreshFetchResult>
resolve: (value: RefreshFetchResult) => void
}
/** Poll `cond` across up to `iterations` event-loop turns. */
async function pollUntil(
cond: () => boolean,
iterations = 500,
): Promise<boolean> {
for (let i = 0; i < iterations; i++) {
if (cond()) return true
await tick()
}
return cond()
}
test("bounds in-flight fetches to the configured concurrency", async () => {
const feeds = Array.from({ length: 10 }, (_, i) => makeFeed(`feed-${i}`))
let inFlight = 0
let maxInFlight = 0
const gates: Gate[] = []
const applied: string[] = []
const program = refreshFeedsBatch(
feeds,
(feed) => {
inFlight++
if (inFlight > maxInFlight) maxInFlight = inFlight
const gate = Promise.withResolvers<RefreshFetchResult>()
gates.push(gate)
return gate.promise.finally(() => {
inFlight--
})
},
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 60_000 },
)
// Run the batch in flight (NOT awaited) and observe the pool from
// outside via the gate side effects.
const done = Effect.runPromise(program)
// The pool starts exactly `concurrency` fetches up front.
const sawStart = await pollUntil(() => gates.length >= 4)
expect(sawStart).toBe(true)
expect(maxInFlight).toBe(4)
expect(gates.length).toBe(4)
// Resolve one gate: the pool pulls the next feed, still bounded at 4.
gates[0].resolve(ok(["a"]))
const sawPull = await pollUntil(() => gates.length >= 5)
expect(sawPull).toBe(true)
expect(maxInFlight).toBeLessThanOrEqual(4)
// Release everything, re-draining as the pool pulls new gates, until
// every feed has been fetched and applied.
while (applied.length < 10) {
for (const gate of gates.splice(0)) gate.resolve(ok(["x"]))
await tick()
}
await done
expect(maxInFlight).toBeLessThanOrEqual(4)
expect(applied).toHaveLength(10)
})
test("applies each feed as its own fetch lands (no barrier)", async () => {
const a = makeFeed("a")
const b = makeFeed("b")
const applied: string[] = []
let bCalled = false
const gateB = Promise.withResolvers<RefreshFetchResult>()
const program = refreshFeedsBatch(
[a, b],
(feed) => {
if (feed.id === "a") return Promise.resolve(ok(["a-1"]))
bCalled = true
return gateB.promise
},
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 60_000 },
)
// Run the batch in flight; A's fetch resolves and applies while B's is
// still parked at the gate.
const done = Effect.runPromise(program)
const aApplied = await pollUntil(() => applied.includes("a"))
expect(aApplied).toBe(true)
expect(bCalled).toBe(true)
expect(applied).toEqual(["a"])
expect(applied).not.toContain("b")
gateB.resolve(ok(["b-1"]))
await done
expect(applied).toEqual(["a", "b"])
})
test("a timed-out fetch leaves that feed untouched, without stalling the batch", async () => {
const fast = makeFeed("fast")
const hung = makeFeed("hung")
const applied: string[] = []
// A promise that never settles — the fetch hangs past the timeout.
const never = new Promise<RefreshFetchResult>(() => {})
const program = refreshFeedsBatch(
[fast, hung],
(feed) =>
feed.id === "fast"
? Promise.resolve(ok(["f-1"]))
: never,
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 5_000 },
)
const timed = Effect.gen(function* () {
const fiber = yield* Effect.fork(program)
// Advance the TestClock past the timeout: the hung fetch's
// Effect.timeout fires deterministically — no real 5s wait.
yield* TestClock.adjust(Duration.millis(5_000))
yield* Fiber.join(fiber)
})
await Effect.runPromise(
timed.pipe(Effect.provide(TestContext.TestContext)),
)
// The fast feed applied; the hung one was dropped, and the batch
// completed anyway.
expect(applied).toEqual(["fast"])
})
test("a rejecting fetch leaves that feed untouched and does not fail the batch", async () => {
const bad = makeFeed("bad")
const good = makeFeed("good")
const applied: string[] = []
const program = refreshFeedsBatch(
[bad, good],
(feed) =>
feed.id === "bad"
? Promise.reject(new Error("feed exploded"))
: Promise.resolve(ok(["g-1"])),
(feed) => {
applied.push(feed.id)
},
{ concurrency: 4, timeoutMs: 60_000 },
)
await Effect.runPromise(program)
expect(applied).toEqual(["good"])
})

View File

@@ -0,0 +1,162 @@
/**
* FeedPage refresh spinner — while feeds are being fetched (manual `r` and
* the background refresh timer both route through refreshAllFeeds →
* isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode
* list, horizontally centered in the current pane.
*
* The refresh is left in flight on purpose (the test server delays its
* response) so the loading state is visible in the captured frame.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import type { Server } from "bun";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it) and silence the audio backend.
const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-"));
process.env.XDG_CONFIG_HOME = configHome;
process.env.PODTUI_AUDIO_BACKEND = "none";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { NavigationProvider } from "../src/context/NavigationContext";
import { FeedPage } from "../src/pages/Feed/FeedPage";
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
// The LoadingIndicator glyph cycle.
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
const frameLines = (f: Frame): string[] =>
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
let server: Server<undefined> | null = null;
/** Response delay (ms) for the next fetch — 0 during setup, >0 while the
* refresh is in flight so the loading state is observable. */
let delayMs = 0;
let feedUrl = "";
let feedId = "";
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them.
* Dated inside the lifecycle window (13 days ago) so all three render. */
function feedXml(origin: string): string {
const items = Array.from({ length: 3 }, (_, i) => `<item>
<title>Spin Ep ${3 - i}</title>
<pubDate>${new Date(Date.now() - (3 - i) * 24 * 3600 * 1000).toISOString()}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Spinner Show</title>
<description>spinner test feed</description>
${items}
</channel></rss>`;
}
beforeAll(async () => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (!url.pathname.endsWith(".xml")) {
return new Response("not found", { status: 404 });
}
const { promise, resolve } = Promise.withResolvers<Response>();
setTimeout(
() =>
resolve(
new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
}),
),
delayMs,
);
return promise;
},
});
const podcast: Podcast = {
id: "",
title: "Spinner Show",
description: "spinner test feed",
author: "tester",
feedUrl: "",
lastUpdated: new Date(),
isSubscribed: true,
};
feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`;
const store = useFeedStore();
const feed = await store.addFeed(
{ ...podcast, feedUrl },
"test-source",
);
feedId = feed!.id;
});
afterAll(async () => {
const store = useFeedStore();
store.removeFeed(feedId);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => {
const store = useFeedStore();
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<NavigationProvider>
<FeedPage />
</NavigationProvider>
</ThemeProvider>
),
{ width: 100, height: 30, useThread: false },
);
// Settle until the episode list is mounted.
let lines: string[] | null = null;
for (let i = 0; i < 40 && !lines; i++) {
await setup.renderOnce();
const ls = frameLines(setup.captureSpans() as unknown as Frame);
if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls;
else await new Promise((r) => setTimeout(r, 50));
}
if (!lines) throw new Error("FeedPage did not render episodes before timeout");
// Kick off a refresh and leave it in flight: isLoadingFeeds flips true
// synchronously, so the very next frame shows the spinner.
delayMs = 400;
const refreshing = store.refreshAllFeeds();
await setup.renderOnce();
const loading = frameLines(setup.captureSpans() as unknown as Frame);
// Locate the spinner row and the current pane's borders ("│" columns;
// only the current pane is bordered in PaneRow).
const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l));
expect(spinnerRow).toBeGreaterThan(-1);
const spinnerCol = loading[spinnerRow].search(SPINNER_RE);
const borderCols = loading
.map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : []))
.flat()
.filter((x) => x >= 0);
const paneLeft = Math.min(...borderCols);
const paneRight = Math.max(...borderCols);
const paneCenter = (paneLeft + paneRight) / 2;
expect(paneLeft).toBeGreaterThan(0); // borders actually found
// Bottom of the list: below the last episode row.
const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep"));
expect(spinnerRow).toBeGreaterThan(lastEpRow);
// Horizontally centered in the current pane (not left-padded).
expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8);
// Let the refresh finish so teardown is clean.
delayMs = 0;
await refreshing;
setup.renderer.destroy();
});

View File

@@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode {
title: string;
date: string;
@@ -37,8 +39,10 @@ interface ServedEpisode {
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
let feedAId = "";
/** When set, the server 503s this path — simulates a feed going down. */
let failPath: string | null = null;
/** XML for the current served episode list (episode ids = feedUrl#index). */
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
@@ -72,6 +76,21 @@ beforeAll(() => {
port: 0,
fetch(req) {
const url = new URL(req.url);
if (failPath && url.pathname === failPath) {
return new Response("feed unavailable", { status: 503 });
}
// A dedicated single-episode feed for the failed-refresh test:
// it must not depend on (or shrink) the shared servedEpisodes
// list, which other tests' feeds read on refreshAllFeeds.
if (url.pathname === "/flaky.xml") {
return new Response(
feedXml(
[{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }],
url.origin,
),
{ headers: { "Content-Type": "application/rss+xml" } },
);
}
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
@@ -130,6 +149,33 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
expect(after.episodes.length).toBe(4);
});
test("a failed refresh does not wipe the feed's episodes", async () => {
const store = useFeedStore();
// /flaky.xml serves its own fixed single-episode feed (see server route)
// so the shared servedEpisodes list stays untouched for feedA, which
// refreshAllFeeds below also refreshes.
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const feedId = feed!.id;
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
// The feed now 503s. fetchEpisodes returns null, and both refresh paths
// must leave the loaded episodes untouched — a failed refresh must never
// look like an empty feed (which would wipe the show's episodes).
failPath = "/flaky.xml";
vi.advanceTimersByTime(60_000);
await store.refreshFeed(feedId);
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
vi.advanceTimersByTime(60_000);
await store.refreshAllFeeds();
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
failPath = null;
store.removeFeed(feedId);
});
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
const store = useFeedStore();
// Feed B: distinct URL, identical served content, so refreshing it is a
@@ -153,3 +199,46 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
}
});
test("refresh parses in bounded chunks, yielding to the event loop between them", async () => {
const store = useFeedStore();
// 60 episodes: a chunked parse (25/chunk) must yield between chunks; a
// monolithic parse would complete without yielding at all. All dated
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
// cacheable and the window assertions below hold.
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
title: `Ep ${60 - i}`,
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
const feedId = feed!.id;
expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window
// Count event-loop yields during the refresh: each parse-chunk boundary
// posts through a MessageChannel (the yield primitive in feed.ts — the
// one macrotask turn bun's fake timers do not trap, which also pins that
// the yield works under fake timers). This runs under fake timers like
// the other tests; a setTimeout-based yield would deadlock here.
const OriginalMessageChannel = globalThis.MessageChannel;
let posts = 0;
globalThis.MessageChannel = class extends OriginalMessageChannel {
constructor() {
super();
posts++;
}
};
try {
vi.advanceTimersByTime(60_000);
await store.refreshFeed(feedId);
} finally {
globalThis.MessageChannel = OriginalMessageChannel;
}
expect(posts).toBeGreaterThan(0);
expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window
// Leave the shared singleton as we found it (see the addedFeedIds note
// in feed-pagination.test.ts — bun runs test files in one process).
store.removeFeed(feedId);
});

Some files were not shown because too many files have changed in this diff Show More