41 Commits

Author SHA1 Message Date
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
92 changed files with 10401 additions and 2166 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

@@ -54,14 +54,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

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

@@ -74,6 +74,114 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return undefined
}
/** 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
}
/**
* 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())
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
const durationRaw = getTagValue(item, "itunes:duration")
const duration = parseDuration(durationRaw)
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
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
// Episode image (itunes:image has href attribute)
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: stableEpisodeId(feedUrl, item, audioUrl, index),
podcastId: feedUrl,
title: epTitle,
description: epDescription,
audioUrl,
duration,
pubDate,
}
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
if (episodeType) ep.episodeType = episodeType
if (explicit !== undefined) ep.explicit = explicit
if (imageUrl) ep.imageUrl = imageUrl
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
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"
@@ -81,58 +189,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, index) => {
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
// Episode image (itunes:image has href attribute)
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: `${feedUrl}#${index}`,
podcastId: feedUrl,
title: epTitle,
description: epDescription,
audioUrl,
duration,
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
if (explicit !== undefined) ep.explicit = explicit
if (imageUrl) ep.imageUrl = imageUrl
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
if (mimeType) ep.mimeType = mimeType
return ep
})
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,246 @@
/**
* 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 { 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;
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} />
<text fg={theme.textSecondary}>
{props.episode().description?.slice(0, 400) ?? "No description available."}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
// ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: {
isLoadingMore: () => boolean;
fetchMoreMode: () => string;
/** 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.fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: 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

@@ -63,6 +63,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 ─────────────────────────────────────────────────────────────────
@@ -181,6 +184,9 @@ export function PaneRow(props: PaneRowProps) {
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
);
const currentBorder = createMemo<boolean | BorderSides[]>(
() => props.currentBorder ?? ["left", "right"],
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
@@ -197,7 +203,7 @@ export function PaneRow(props: PaneRowProps) {
grow={currentGrow()}
label={() => ""}
content={currentContent}
border={["left", "right"]}
border={currentBorder()}
scrollFocused={() => focused()}
/>
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}

View File

@@ -27,6 +27,7 @@ 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 +169,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);
@@ -396,6 +396,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 {
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme();
loadThemes()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
@@ -187,7 +188,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);
}

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

@@ -12,23 +12,52 @@
* ```
*/
import { createSignal, onCleanup } from "solid-js";
import { unlinkSync } from "fs";
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
import { onCleanup } from "solid-js";
import {
cachedCoverPath,
fetchCoverArt,
} from "../utils/cover-art";
import {
createAudioBackend,
detectPlayers,
PlayerRestartedError,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed,
setSpeed,
backendName,
setBackendName,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "../utils/audio-signals";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode";
import {
loadLastPlayerFromFile,
saveLastPlayerToFile,
saveLastPlayerSync,
} from "../utils/app-persistence";
import type { Episode, Progress } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useDownloadStore } from "../stores/download";
import { useFeedStore } from "../stores/feed";
export interface AudioControls {
@@ -45,6 +74,8 @@ export interface AudioControls {
// 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>;
@@ -64,17 +95,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
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[]>(
[],
);
// 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;
/** 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;
}
function ensureBackend(): AudioBackend {
if (!backend) {
@@ -101,6 +141,17 @@ function registerExitTeardown(): void {
exitTeardownRegistered = true;
const teardown = (): void => {
stopPolling();
// 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 {
const ep = currentEpisode();
if (ep) {
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
}
} catch {
/* best-effort at exit */
}
try {
backend?.dispose();
} catch {
@@ -111,11 +162,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,6 +172,52 @@ function registerExitTeardown(): void {
}
}
/** 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. */
function finalizeTrackEnd(): void {
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
}
/** 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;
@@ -133,36 +225,57 @@ function startPolling(): void {
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying() || pollInFlight) return;
if (!backend || 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);
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()) {
finalizeTrackEnd();
return;
}
}
// 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());
// 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();
return;
}
const paused = await backend.getPauseState();
if (paused === false) {
reconcileExternalResume();
}
}
} catch {
@@ -185,6 +298,34 @@ function stopPolling(): void {
// 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);
@@ -204,9 +345,20 @@ async function play(episode: Episode): Promise<void> {
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;
// 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;
// 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",
);
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
@@ -215,11 +367,11 @@ async function play(episode: Episode): Promise<void> {
startPos = savedProgress.position;
}
await b.play(episode.audioUrl, {
await b.play(url, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
});
@@ -228,6 +380,11 @@ async function play(episode: Episode): Promise<void> {
setPosition(startPos);
setSpeed(spd);
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
const media = useMediaRegistry();
@@ -250,12 +407,85 @@ async function play(episode: Episode): Promise<void> {
}
}
/**
* 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 feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
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);
stopPolling();
// 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
@@ -273,8 +503,25 @@ async function pause(): Promise<void> {
}
}
/** 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);
@@ -286,6 +533,13 @@ async function resume(): Promise<void> {
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");
}
}
@@ -294,7 +548,15 @@ async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
await resume();
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);
}
}
}
@@ -311,9 +573,13 @@ async function stop(): Promise<void> {
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) {
@@ -346,6 +612,10 @@ async function doSetVolume(vol: number): Promise<void> {
}
}
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> {
@@ -389,19 +659,21 @@ async function switchBackend(name: BackendName): Promise<void> {
.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, {
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: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
mediaTitle: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
@@ -410,6 +682,46 @@ async function switchBackend(name: BackendName): Promise<void> {
}
}
/** 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;
}
/**
* Reactive audio controls hook.
*
@@ -420,13 +732,29 @@ export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend();
// Sync initial speed from app store
// Sync initial speed/volume from app store (reuse the previous session's
// playback levels; defaults are 1x and 100%).
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++;
@@ -574,6 +902,7 @@ export function useAudio(): AudioControls {
availablePlayers,
play,
load,
pause,
resume,
togglePlayback,

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,7 @@
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.4.0";
const VERSION = "0.6.2";
interface CliArgs {
version: boolean;
@@ -42,7 +42,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 +185,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)")

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

@@ -20,8 +20,8 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j
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 +32,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 +68,29 @@ 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";
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
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 +106,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())
@@ -112,12 +155,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 +249,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 +260,7 @@ function FeedPage() {
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<box padding={1} alignItems="center">
<Show
when={feedStore.isLoadingFeeds()}
fallback={
@@ -245,141 +269,79 @@ 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())}
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>
);
}}
{/* 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(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()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -391,46 +353,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

@@ -15,11 +15,11 @@
*/
import { createMemo, createEffect, 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 +31,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 +264,27 @@ 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();
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
/** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */
const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined;
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
};
const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub()) return undefined;
return shows()[focusedShowIdx()];
};
// depth-1 frame ctx = the drilled feed id
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
@@ -83,12 +303,11 @@ export function MyShowsPage() {
// 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";
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
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 +321,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,7 +358,10 @@ 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;
});
});
@@ -133,12 +378,6 @@ export function MyShowsPage() {
});
// ── 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 +411,31 @@ 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) {
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 +476,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 +540,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 +558,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 +595,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 +605,53 @@ 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);
}}
/>
)}
</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={() => nav.depthFocus(0)}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
downloadColor={() => downloadColor(d.episodeId)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
nav.setDepthFocus(shows().length + 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>
/>
)}
</For>
</Show>
</Show>
</Show>
{/* depth ≥1: episodes */}
@@ -389,99 +664,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())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(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>
);
}}
{/* 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(listWindow()[0] + index(), 1);
}}
/>
)}
</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, 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,63 +712,74 @@ 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 unsubscribed-show download, else the
// hovered show.
<Show
when={selectedShow()}
when={focusedUnsub()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(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>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</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()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -556,53 +790,19 @@ export function MyShowsPage() {
</box>
}
>
{(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>
)}
{(ep) => (
<EpisodePreview
episode={() => ep()}
author={() => selectedShow()?.podcast.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() => episodeHint(ep().id)}
/>
)}
</Show>
</Show>
</Show>
</>
);
</>
);
return (
<PaneRow

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,11 +10,12 @@
* 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";
@@ -27,7 +28,19 @@ export function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const viz = useVisualizer();
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;
@@ -82,18 +95,9 @@ export function PlayerPage() {
<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 +133,7 @@ export function PlayerPage() {
currentLabel="Player"
panes={2}
focused={isActive}
currentBorder={["left"]}
/>
);
}

View File

@@ -45,6 +45,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 +62,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,55 +1,30 @@
/**
* 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, 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 { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
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;
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
export function RealtimeWaveform() {
const { theme } = useTheme();
const audio = useAudio();
// 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;
const viz = useVisualizer();
// Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of
@@ -68,181 +43,25 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
);
};
// ── 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 warms
// up — but only when there are no bars to render yet (first play /
// after an unload). On resume/seek the last bars stay on screen
// until fresh frames arrive, so the waveform never blanks out for
// the (multi-second, network-bound) cold start.
if (bars.length === 0 && viz.isLoading()) {
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>
<text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<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} />
<text fg={muted()}>
enter: subscribe to show · h: back to query
</text>
<Show
when={r.podcast.isSubscribed}
fallback={
<text fg={muted()}>
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

@@ -1,14 +1,19 @@
/**
* 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 episode — action item; Enter deletes a single download.
* • "Delete All Downloads" — action item; Enter wipes every download.
* • 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;
@@ -205,20 +215,119 @@ export function usePreferencesItems(): SettingItem[] {
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
{
id: "episodeCacheMode",
label: "Episode Cache Mode",
kind: "select",
display: () => cacheModeLabel(prefs().episodeCacheMode),
help: () =>
`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 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: "fetchMore",
label: "Fetch More",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
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 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: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
{
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",
fetchMoreMode: "auto",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
};
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() {

File diff suppressed because it is too large Load Diff

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,15 @@
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";
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 +24,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 +60,25 @@ 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());
/** 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 +166,33 @@ 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);
const updated = sanitizeHistory([q, ...prev]);
saveSearchHistoryToFile(updated);
return updated;
});
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveHistory([]);
saveSearchHistoryToFile([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
saveHistory(updated);
saveSearchHistoryToFile(updated);
return updated;
});
};
/** 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 +212,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 +252,10 @@ export function createSearchStore() {
setSelectedSources,
setScope,
markSubscribed,
markUnsubscribed,
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {

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

@@ -0,0 +1,450 @@
/**
* 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.
*/
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;
/** 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 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);
// 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;
// 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();
activeUrl = url;
activeBars = barCount();
setIsLoading(true);
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);
};
// ── 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);
};
// ── 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();
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
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();
const count = pcm.readWindow(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));
if (isLoading()) 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,
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,17 @@ export type AppSettings = {
visualizer: VisualizerSettings;
};
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
/** How the Feed and per-show episode lists load older episodes (default: auto). */
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";
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
@@ -103,8 +112,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). */
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */
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;
};
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",
fetchMoreMode: "auto",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
};
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
}
}

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,15 @@ 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;
dispose(): void;
}
@@ -71,249 +113,576 @@ 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);
}
unlinkSync(this.socketPath);
} catch {
/* ignore */
}
const args = [
resolveMpvBinary() ?? "mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
`--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
this.proc = Bun.spawn(
[
"mpv",
"--no-video",
"--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}`,
],
{ 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);
}
return;
} catch {
/* skip malformed lines */
}
}
},
error() {
done(undefined);
},
close() {
done(undefined);
},
},
}).catch(() => done(undefined));
});
} catch {
return 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.
await fileLoaded;
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 () => {
// Fast path: this exact URL was PRELOADED paused (boot restore) —
// mpv has been buffering it since boot, so flipping pause off starts
// audio ~instantly. Re-acquire the start position only when it
// moved meaningfully since the preload (progress saved meanwhile).
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
const target = opts?.startPosition ?? this._position;
if (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;
}
await this.loadFileLocked(url, opts, false);
});
}
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 */
}
this.proc = null;
if (this.conn && this._loadedUrl) {
await this.send(["stop"]);
}
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 +702,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 +759,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 +775,13 @@ 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;
}
dispose(): void {}
}
@@ -400,7 +802,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 +836,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();
}

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;
@@ -102,7 +101,6 @@ export class CavaCore {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}

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);
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 {
/* no stale cover */
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;
try {
return statSync(path).size > 0 ? path : null;
} catch {
return null;
}
})(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
]);
} catch {
return null;
}
],
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
);
proc.exited
.then((code) => {
if (code !== 0) return resolve(null);
try {
if (statSync(staging).size <= 0) return resolve(null);
renameSync(staging, dest);
resolve(dest);
} catch {
resolve(null);
}
})
.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 {
/* 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

@@ -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

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

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 {
@@ -22,24 +83,57 @@ function reviveDates(feed: Feed): Feed {
})),
};
}
/** Load feeds from config.json */
export async function loadFeedsFromFile(): Promise<Feed[]> {
/** 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);
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((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 +143,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

@@ -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.

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

@@ -0,0 +1,239 @@
/**
* 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)(
"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 },
);

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

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

View File

@@ -0,0 +1,286 @@
/**
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
*
* Pins the persistence contract:
* 1. saveFeedsToFile never writes an episode older than DEFAULT_EPISODE_WINDOW_DAYS
* unless its id is a completed download in downloads.json.
* 2. loadFeedsFromFile prunes over-window episodes from legacy configs and
* rewrites config.json when it pruned anything.
* 3. Undatable episodes (missing/invalid pubDate) are always persisted.
*
* The async saveFeedsToFile IIFE reads downloads.json then enqueues an
* updateConfig write on the serialized write chain, so assertions wait via
* whenConfigIdle() + a short real-timer settle (no fake timers here — see
* settleWrites).
*/
import { test, expect, afterAll } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing anything
// under test (config-dir reads XDG_CONFIG_HOME lazily, but stay consistent
// with the store test harness). Do NOT import the feed store — its module
// boot IIFE would hit the network.
const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
process.env.XDG_CONFIG_HOME = configHome;
import {
DEFAULT_EPISODE_WINDOW_DAYS,
episodeIsPersistable,
loadFeedsFromFile,
saveFeedsToFile,
} from "../src/utils/feeds-persistence";
import { whenConfigIdle } from "../src/utils/config";
import { FeedVisibility } from "../src/types/feed";
import type { Feed } from "../src/types/feed";
import type { Episode } from "../src/types/episode";
const configJsonPath = join(configHome, "podtui", "config.json");
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
/** Milliseconds in one day — mirrors the window math in feeds-persistence. */
const DAY = 24 * 3600 * 1000;
function makeEpisode(partial: Partial<Episode> & { id: string }): Episode {
return {
podcastId: "feed-1",
title: partial.id,
description: "",
audioUrl: `https://example.com/audio/${partial.id}.mp3`,
duration: 600,
pubDate: new Date(),
...partial,
};
}
function makeFeed(episodes: Episode[]): Feed {
return {
id: "feed-1",
podcast: {
id: "feed-1",
title: "Retention Show",
description: "Retention test feed",
author: "tester",
feedUrl: "https://example.com/feed.xml",
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "source-1",
lastUpdated: new Date(),
isPinned: false,
};
}
function delay(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
/**
* Wait for the fire-and-forget save chain to drain. saveFeedsToFile's IIFE is
* not awaitable: it reads downloads.json first and only THEN enqueues its
* write on the serialized chain, so the first whenConfigIdle() may observe
* the chain BEFORE the write is queued. A real-timer settle is the only way
* to let the IIFE's async read land without fake timers (which would stall
* the Bun.file I/O and the write chain itself); the poll fallback in the
* assertions absorbs any residual scheduling skew on a loaded machine.
*/
async function settleWrites(): Promise<void> {
await whenConfigIdle();
await delay(20);
await whenConfigIdle();
}
/** Episode ids of the first feed in config.json, or null when absent. */
async function readPersistedEpisodeIds(): Promise<string[] | null> {
const raw = await Bun.file(configJsonPath).json().catch(() => null);
if (!raw || typeof raw !== "object" || !("feeds" in raw)) return null;
const feeds = raw.feeds;
if (!Array.isArray(feeds) || feeds.length === 0) return null;
const first = feeds[0];
if (!first || typeof first !== "object" || !("episodes" in first)) return null;
const episodes = first.episodes;
if (!Array.isArray(episodes)) return null;
return episodes.map((ep) => {
if (ep && typeof ep === "object" && "id" in ep) return String(ep.id);
return "";
});
}
/** Wait (up to ~1s) until config.json's first feed has exactly these ids. */
async function pollConfigFor(ids: string[]): Promise<void> {
const expected = [...ids].sort().join(",");
const deadline = Date.now() + 1000;
for (;;) {
const actual = (await readPersistedEpisodeIds())?.sort().join(",");
if (actual === expected) return;
if (Date.now() > deadline) {
throw new Error(
`config.json never reached expected episode ids [${ids.join(", ")}]`,
);
}
await delay(20);
}
}
afterAll(() => {
rmSync(configHome, { recursive: true, force: true });
});
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => {
const ep = makeEpisode({
id: "old-plain-id",
pubDate: new Date(Date.now() - 70 * DAY),
});
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
});
test("episodeIsPersistable keeps a 70-day-old episode whose id is a completed download", () => {
const ep = makeEpisode({
id: "old-downloaded-id",
pubDate: new Date(Date.now() - 70 * DAY),
});
expect(
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
).toBe(true);
});
test("episodeIsPersistable keeps a 5-day-old episode", () => {
const ep = makeEpisode({
id: "recent-id",
pubDate: new Date(Date.now() - 5 * DAY),
});
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
});
test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
const ep = makeEpisode({ id: "undatable-id", pubDate: new Date(NaN) });
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
});
test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
});
// ── Save path: retention window applied with completed-download exemption ──
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
// Arrange: downloads.json lists one completed download.
mkdirSync(join(configHome, "podtui"), { recursive: true });
await Bun.write(
downloadsJsonPath,
JSON.stringify([
{
episodeId: "old-downloaded-id",
feedId: "feed-1",
status: "completed",
filePath: null,
downloadedAt: null,
fileSize: 0,
error: null,
audioUrl: "",
episodeTitle: "",
},
]),
);
// Act: recent episode, old plain episode, old downloaded episode.
const feed = makeFeed([
makeEpisode({
id: "recent-id",
pubDate: new Date(Date.now() - 5 * DAY),
}),
makeEpisode({
id: "old-plain-id",
pubDate: new Date(Date.now() - 70 * DAY),
}),
makeEpisode({
id: "old-downloaded-id",
pubDate: new Date(Date.now() - 70 * DAY),
}),
]);
saveFeedsToFile([feed]);
await settleWrites();
// The IIFE's downloads.json read can land after the first settle; poll
// briefly in case the write chain drained before that read resolved.
await pollConfigFor(["old-downloaded-id", "recent-id"]);
// Assert: persisted episodes keep recent + downloaded, drop old-plain.
const persisted = await readPersistedEpisodeIds();
expect(persisted).toContain("recent-id");
expect(persisted).toContain("old-downloaded-id");
expect(persisted).not.toContain("old-plain-id");
});
// ── Load path: legacy config cleanup rewrite ───────────────────────────────
test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", async () => {
// Arrange: seed config.json directly with a feed whose episodes are ALL
// older than the window; no downloads.json present.
mkdirSync(join(configHome, "podtui"), { recursive: true });
await Bun.write(
configJsonPath,
JSON.stringify(
{
feeds: [
{
id: "feed-1",
podcast: {
id: "feed-1",
title: "Legacy Show",
description: "",
author: "tester",
feedUrl: "https://example.com/legacy.xml",
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
isSubscribed: true,
},
episodes: [
{
id: "old-a",
podcastId: "feed-1",
title: "Old A",
description: "",
audioUrl: "https://example.com/audio/old-a.mp3",
duration: 60,
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
},
{
id: "old-b",
podcastId: "feed-1",
title: "Old B",
description: "",
audioUrl: "https://example.com/audio/old-b.mp3",
duration: 60,
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
},
],
visibility: "public",
sourceId: "source-1",
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
isPinned: false,
},
],
},
null,
2,
),
);
// Act.
const feeds = await loadFeedsFromFile();
await settleWrites();
// Assert: returned feed has zero episodes AND config.json was rewritten
// (the cleanup save is fire-and-forget — poll for the rewritten file).
expect(feeds).toHaveLength(1);
expect(feeds[0].episodes).toHaveLength(0);
await pollConfigFor([]);
expect(await readPersistedEpisodeIds()).toEqual([]);
});

View File

@@ -0,0 +1,538 @@
/**
* Configurable episode cache + volatile merge tests.
*
* The episode list cache (what the Feed and My Shows pages show) is bounded by
* the user's preference: a date window (default 60 days) or a count (default
* 25). The full parse cache holds ALL episodes; fetch-more pages beyond the
* bound from that cache (volatile — never written back). These tests pin:
* 1. mergeEpisodesBounded unions refreshed episodes with what's in memory
* (fetched copy wins on id collision) and prunes by the supplied keep
* predicate (count or date). Undated episodes are always kept.
* 2. The store bounds the visible list by the configured mode, but the
* full parse cache survives — fetch-more pages beyond the bound.
* 3. Refresh merge never shrinks the in-memory list except via the bound.
*
* Clock constraint: these tests run under vi.useFakeTimers, and a LARGE
* vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent
* network fetch hang in Bun 1.3.8's fake-timer implementation. The date
* boundary is pinned with relative pubDates, never by moving the clock.
*/
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-volatile-"));
process.env.XDG_CONFIG_HOME = configHome;
import { sameRefreshWindow, useFeedStore } from "../src/stores/feed";
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
import { episodeInWindow } from "../src/utils/feeds-persistence";
import { useAppStore } from "../src/stores/app";
import type { Episode } from "../src/types/episode";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
const DAY = 24 * HOUR;
interface ServedEpisode {
title: string;
date: string;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
const addedFeedIds: string[] = [];
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>Volatile Show</title>
<description>Volatile merge test feed</description>
${items}
</channel></rss>`;
}
const makePodcast = (feedUrl: string): Podcast => ({
id: feedUrl,
title: "Volatile Show",
description: "Volatile merge test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
});
const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({
id,
podcastId: "pod",
title,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 100,
pubDate,
});
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
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 });
},
});
});
beforeEach(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
const NOW = new Date("2026-08-10T00:00:00Z");
test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => {
const existing = [
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
];
const fetched = [
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged).toHaveLength(2);
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
});
test("mergeEpisodesBounded drops stale-id twins (id migration / rotating enclosure URLs)", () => {
// The same two episodes with different ids on both sides — exactly what a
// refresh sees after the positional-id → stable-id migration (or a host
// that rotates signed audio URLs). Without content matching the union
// would double every episode.
const d1 = new Date("2026-08-01T00:00:00Z");
const d2 = new Date("2026-08-02T00:00:00Z");
const existing = [
makeEpisode("feed#0", "Ep 1", d1),
makeEpisode("feed#1", "Ep 2", d2),
];
const fetched = [
makeEpisode("feed#guid:g1", "Ep 1", d1),
makeEpisode("feed#guid:g2", "Ep 2", d2),
];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged.map((e) => e.id)).toEqual(["feed#guid:g2", "feed#guid:g1"]);
expect(merged).toHaveLength(2);
});
test("mergeEpisodesBounded keeps an existing episode with no fetched twin (volatile window)", () => {
// Fetched covers Ep 1 only (by content twin). Ep 2 exists only in memory
// — the volatile window — and must survive the refresh.
const d1 = new Date("2026-08-01T00:00:00Z");
const d2 = new Date("2026-08-02T00:00:00Z");
const existing = [
makeEpisode("feed#0", "Ep 1", d1),
makeEpisode("feed#1", "Ep 2", d2),
];
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d1)];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged).toHaveLength(2);
expect(merged.map((e) => e.title).sort()).toEqual(["Ep 1", "Ep 2"]);
});
test("sameRefreshWindow treats id drift with identical content as unchanged", () => {
// Same episode, id changed between refreshes (migration / URL rotation):
// the refresh must NOT bump lastUpdated or re-render.
const d = new Date("2026-08-01T00:00:00Z");
const existing = [makeEpisode("feed#0", "Ep 1", d)];
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d)];
expect(sameRefreshWindow(existing, fetched)).toBe(true);
});
test("sameRefreshWindow flags a genuinely new episode even when ids drift", () => {
const d1 = new Date("2026-08-01T00:00:00Z");
const d2 = new Date("2026-08-02T00:00:00Z");
const existing = [makeEpisode("feed#0", "Ep 1", d1)];
const fetched = [
makeEpisode("feed#guid:g2", "Ep 2", d2),
makeEpisode("feed#guid:g1", "Ep 1", d1),
];
expect(sameRefreshWindow(existing, fetched)).toBe(false);
});
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
const existing = [
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
];
const fetched = [
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
});
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
const existing = [
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
];
const fetched = [
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
];
const keepCount2 = (_ep: Episode, i: number) => i < 2;
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
});
test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => {
const existing = [
makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")),
makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")),
makeEpisode("undated", "Undated", new Date(NaN)),
];
const fetched = [
makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")),
makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")),
];
// 30-day window from NOW (2026-08-10)
const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30);
const merged = mergeEpisodesBounded(existing, fetched, keepDate);
expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]);
});
test("mergeEpisodesBounded never mutates its inputs", () => {
const existing = [
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
];
const fetched = [
makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")),
makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")),
];
const existingIds = existing.map((e) => e.id);
const existingTitles = existing.map((e) => e.title);
const keepAll = () => true;
mergeEpisodesBounded(existing, fetched, keepAll);
expect(existing.map((e) => e.id)).toEqual(existingIds);
expect(existing.map((e) => e.title)).toEqual(existingTitles);
});
// ── store integration (default date mode, 60-day window) ─────────────────
test("refresh merges new episodes without removing the volatile window", async () => {
const store = useFeedStore();
servedEpisodes = [
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
];
const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(3);
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
servedEpisodes = [
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
{ title: "Ep 5", date: "2026-08-05T00:00:00Z" },
{ title: "Ep 4", date: "2026-08-04T00:00:00Z" },
];
vi.advanceTimersByTime(60_000);
await store.refreshFeed(id);
const afterFirst = store.getFeed(id)!;
expect(afterFirst.episodes.length).toBe(5);
expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated);
// Identical second refresh: no lastUpdated bump, object identity kept.
vi.advanceTimersByTime(60_000);
await store.refreshFeed(id);
const afterSecond = store.getFeed(id)!;
expect(afterSecond).toBe(afterFirst);
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
});
test("date mode: episodes outside the 60-day window never enter the list", async () => {
const store = useFeedStore();
const now = Date.now();
// 600 episodes at 2h spacing span ~50 days — all inside the 60-day default
// window, so all 600 are cached and loadable (no count ceiling).
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
title: `Ep ${600 - i}`,
date: new Date(now - i * 2 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
// Subscribe window (20) with more cached.
expect(store.getFeed(id)!.episodes.length).toBe(20);
// Load everything — the cache holds all 600 (date mode keeps them all).
let iterations = 0;
while (store.hasMoreEpisodes(id) && iterations < 20) {
await store.loadMoreEpisodes(id);
iterations++;
}
expect(store.hasMoreEpisodes(id)).toBe(false);
expect(store.getFeed(id)!.episodes.length).toBe(600);
});
test("date mode boundary: 25 days in, 70 days out", async () => {
const store = useFeedStore();
const now = Date.now();
servedEpisodes = [
{ title: "In Window", date: new Date(now - 25 * DAY).toISOString() },
{ title: "Out Window", date: new Date(now - 70 * DAY).toISOString() },
];
const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
]);
// The 70d episode is ~45 days past the 2-week band beyond the oldest
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
]);
});
test("date mode: a dormant show (nothing in the window or next band) never fetch-mores", async () => {
const store = useFeedStore();
const now = Date.now();
// Newest episode 100 days old, next 200 days old — both far outside the
// 60-day cache window and the 14-day band past its edge.
servedEpisodes = [
{ title: "Old A", date: new Date(now - 100 * DAY).toISOString() },
{ title: "Old B", date: new Date(now - 200 * DAY).toISOString() },
];
const feedUrl = `http://127.0.0.1:${server!.port}/dormant.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
});
test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => {
const store = useFeedStore();
const now = Date.now();
// Both episodes are outside the 60-day window (61d / 65d) but inside the
// 14-day band past its edge (60d → 74d) — fetch-more reveals them.
servedEpisodes = [
{ title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() },
{ title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() },
];
const feedUrl = `http://127.0.0.1:${server!.port}/just-out.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
expect(store.hasMoreEpisodes(id)).toBe(true);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"Just Out A",
"Just Out B",
]);
});
// ── count mode ────────────────────────────────────────────────────────────
test("date mode: fetch-more steps by a two-week window, not a count", async () => {
const store = useFeedStore();
const now = Date.now();
// 30 episodes at 3-day spacing span 87 days. The 60-day cache window
// holds the first 21 (subscribe shows 20); fetch-more then reveals the
// next 2-week band per press — 3-day cadence → ~4 episodes per band —
// NOT a fixed 50-episode chunk (which would load all 30 at once).
servedEpisodes = Array.from({ length: 30 }, (_, i) => ({
title: `Ep ${30 - i}`,
date: new Date(now - i * 3 * DAY).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/date-step.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(20);
// Press 1: oldest loaded is 57d old → cutoff 71d → i=20..23 (6069d).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(24);
expect(store.hasMoreEpisodes(id)).toBe(true);
// Press 2: oldest loaded is 69d old → cutoff 83d → i=24..27 (7281d).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(28);
expect(store.hasMoreEpisodes(id)).toBe(true);
// Press 3: oldest loaded is 81d old → cutoff 95d → i=28..29 (8487d).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(30);
expect(store.hasMoreEpisodes(id)).toBe(false);
});
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
const store = useFeedStore();
const app = useAppStore();
// The app store loads persisted prefs asynchronously at import — wait so
// the override below isn't clobbered by the load.
await app.whenReady();
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
const now = Date.now();
// 50 episodes at 1h spacing — all recent, but count mode caps at 25.
servedEpisodes = Array.from({ length: 50 }, (_, i) => ({
title: `Ep ${50 - i}`,
date: new Date(now - i * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
// Subscribe window (20), but the cache holds all 50 — count mode only
// bounds the visible list (25), but the full parse cache is unbounded.
// The subscribe window returns min(20, 25) = 20.
expect(store.getFeed(id)!.episodes.length).toBe(20);
expect(store.hasMoreEpisodes(id)).toBe(true);
// Fetch more: the visible list grows beyond the count bound — these
// episodes are volatile (held in feed.episodes, not extending the cache).
while (store.hasMoreEpisodes(id)) {
await store.loadMoreEpisodes(id);
}
expect(store.getFeed(id)!.episodes.length).toBe(50);
// Reset to date mode for subsequent tests.
app.updatePreferences({ episodeCacheMode: "date" });
});
test("count mode: Feed list is a GLOBAL top-N that grows N per press, never a far-back dump", async () => {
const store = useFeedStore();
const app = useAppStore();
// Wait out the async pref load (see the single-show count test).
await app.whenReady();
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
const now = Date.now();
// Feed A: 200 episodes at 1-day spacing (ages 0199d). Feed B: 200 at
// 1-day spacing shifted 200 days older (ages 200399d) — every A episode
// is newer than every B episode, so the global top-K is deterministic.
const serve = (prefix: string, shiftDays: number) =>
Array.from({ length: 200 }, (_, i) => ({
title: `${prefix} Ep ${200 - i}`,
date: new Date(now - (shiftDays + i) * DAY).toISOString(),
}));
servedEpisodes = serve("A", 0);
const aUrl = `http://127.0.0.1:${server!.port}/global-a.xml`;
const a = await store.addFeed(makePodcast(aUrl), "test-source");
expect(a).not.toBeNull();
const aId = a!.id;
addedFeedIds.push(aId);
servedEpisodes = serve("B", 200);
const bUrl = `http://127.0.0.1:${server!.port}/global-b.xml`;
const b = await store.addFeed(makePodcast(bUrl), "test-source");
expect(b).not.toBeNull();
const bId = b!.id;
addedFeedIds.push(bId);
// The Feed page's global list is capped at the configured count (25),
// NOT 20 per show (the union would be 40).
expect(store.getAllEpisodesChronological().length).toBe(25);
// Press 1: cap grows to 50 AND every feed's window deepens by 25 — the
// list reveals exactly the next 25 most-recent episodes (A's 25 more),
// not 25 from every show.
await store.loadMoreAllFeeds();
expect(store.getAllEpisodesChronological().length).toBe(50);
expect(store.getFeed(aId)!.episodes.length).toBe(45);
expect(store.getFeed(bId)!.episodes.length).toBe(45);
expect(store.hasMoreAcrossAll()).toBe(true);
// Press 2: cap grows to 75.
await store.loadMoreAllFeeds();
expect(store.getAllEpisodesChronological().length).toBe(75);
// Keep pressing until every cache is exhausted. The global cap stays
// (never lifts — rendering the full deep union froze the UI), so the
// Feed list stays at count×(presses+1) = 25×9 = 225 while the per-show
// windows hold everything.
let guard = 0;
while (store.hasMoreAcrossAll() && guard++ < 30) {
await store.loadMoreAllFeeds();
}
expect(guard).toBeLessThan(30);
expect(store.hasMoreAcrossAll()).toBe(false);
expect(store.getFeed(aId)!.episodes.length).toBe(200);
expect(store.getFeed(bId)!.episodes.length).toBe(200);
expect(store.getAllEpisodesChronological().length).toBe(225);
// Reset to date mode for subsequent tests.
app.updatePreferences({ episodeCacheMode: "date" });
});

View File

@@ -0,0 +1,199 @@
/**
* Global activity indicator — the shared leak-proof activity store
* (begin/end counter + track helper) and the global top-right overlay that
* surfaces feed refresh, fetch-more, subscribe fetch, search, and download
* activity. The download transfer is left in flight on purpose (the test
* server delays its response) so the "Downloading" state is observable in
* the captured frame.
*/
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) and silence the audio backend.
const configHome = mkdtempSync(join(tmpdir(), "podtui-activity-"));
process.env.XDG_CONFIG_HOME = configHome;
const dataHome = mkdtempSync(join(tmpdir(), "podtui-activity-data-"));
process.env.XDG_DATA_HOME = dataHome;
process.env.PODTUI_AUDIO_BACKEND = "none";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { GlobalActivityIndicator } from "../src/components/GlobalActivityIndicator";
import { useActivityStore } from "../src/stores/activity";
import { useDownloadStore } from "../src/stores/download";
import type { Episode } from "../src/types/episode";
// 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(""));
function sleep(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
/** Render the indicator in isolation under the dark theme. The ThemeProvider
* gates children on async init (capabilities + palette detection, up to
* ~1.5s under tmux), so settle frames until the indicator is mounted. */
async function renderIndicator() {
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<GlobalActivityIndicator />
</ThemeProvider>
),
{ width: 60, height: 10, useThread: false },
);
await setup.renderOnce();
for (let i = 0; i < 40; i++) {
await setup.renderOnce();
await sleep(50);
}
return setup;
}
const frameText = (setup: { captureSpans: () => unknown }): string =>
frameLines(setup.captureSpans() as unknown as Frame).join("\n");
let server: ReturnType<typeof Bun.serve> | null = null;
let audioUrl = "";
/** Response delay (ms) for the next audio request — keeps the transfer in
* flight while the "Downloading" state is asserted. */
let audioDelayMs = 0;
/** Episode ids this file started downloads for (shared singleton cleanup). */
const downloadedEpisodeIds: string[] = [];
const makeEpisode = (id: string, title: string): Episode => ({
id,
podcastId: "pod",
title,
description: "",
audioUrl,
duration: 0,
pubDate: new Date("2026-08-01T00:00:00Z"),
});
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch() {
const { promise, resolve } = Promise.withResolvers<Response>();
setTimeout(
() =>
resolve(
new Response("audio bytes", {
headers: { "Content-Type": "audio/mpeg" },
}),
),
audioDelayMs,
);
return promise;
},
});
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
});
afterAll(async () => {
const dl = useDownloadStore();
for (const id of downloadedEpisodeIds) {
dl.cancelDownload(id);
await dl.removeDownload(id);
}
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
rmSync(dataHome, { recursive: true, force: true });
});
test("beginActivity/end pairs compose: ending one keeps the other active", () => {
const activity = useActivityStore();
expect(activity.isActive()).toBe(false);
expect(activity.labels()).toEqual([]);
const endFirst = activity.beginActivity("Refreshing");
const endSecond = activity.beginActivity("Refreshing");
expect(activity.isActive()).toBe(true);
expect(activity.labels()).toEqual(["Refreshing", "Refreshing"]);
endFirst();
expect(activity.isActive()).toBe(true);
expect(activity.labels()).toEqual(["Refreshing"]);
endSecond();
expect(activity.isActive()).toBe(false);
expect(activity.labels()).toEqual([]);
});
test("track re-throws rejection and returns isActive() to its prior value", async () => {
const activity = useActivityStore();
const prior = activity.isActive();
await expect(
activity.track(Promise.reject(new Error("boom")), "Refreshing"),
).rejects.toThrow("boom");
expect(activity.isActive()).toBe(prior);
expect(activity.labels()).toEqual([]);
});
test("idle: renders nothing, no spinner", async () => {
const setup = await renderIndicator();
const text = frameText(setup);
expect(text).not.toMatch(SPINNER_RE);
expect(text.trim()).toBe("");
setup.renderer.destroy();
});
test("tracked activity: spinner appears while active, vanishes on end", async () => {
const activity = useActivityStore();
const setup = await renderIndicator();
expect(frameText(setup)).not.toMatch(SPINNER_RE);
const end = activity.beginActivity("Refreshing");
await setup.renderOnce();
const active = frameText(setup);
expect(active).toMatch(SPINNER_RE);
end();
await setup.renderOnce();
const done = frameText(setup);
expect(done).not.toMatch(SPINNER_RE);
expect(done.trim()).toBe("");
setup.renderer.destroy();
});
test("active download: spinner appears and disappears", async () => {
const dl = useDownloadStore();
const setup = await renderIndicator();
// Keep the transfer in flight while asserting; the response only lands
// after audioDelayMs, so the download stays DOWNLOADING across renders.
audioDelayMs = 400;
const episode = makeEpisode("activity-dl-ep", "DL Ep");
downloadedEpisodeIds.push(episode.id);
dl.startDownload(episode, "activity-test-feed");
await setup.renderOnce();
const during = frameText(setup);
expect(during).toMatch(SPINNER_RE);
// Cancel: the abort settles the fetch and activeCount returns to 0.
dl.cancelDownload(episode.id);
for (let i = 0; i < 40; i++) {
if (dl.getActiveCount() + dl.getQueue().length === 0) break;
await sleep(25);
}
await dl.removeDownload(episode.id);
await setup.renderOnce();
const after = frameText(setup);
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
expect(after).not.toMatch(SPINNER_RE);
audioDelayMs = 0;
setup.renderer.destroy();
});

View File

@@ -0,0 +1,195 @@
/**
* restore-session.test.ts — "load the last player session at boot" feature.
*
* useAudio persists which episode is loaded in the player (on play/load/stop
* and synchronously at exit), and `restoreLastSession()` reloads it at boot
* PAUSED at its saved position — never autostarted — skipping episodes at or
* above 98% completion. The first play action must START the backend (a
* restored episode was never handed to it), not unpause a dead player.
*
* Strategy — the suite shares bun test workers across files, so OTHER test
* files' `mock.module("../src/hooks/useAudio")` leaks into this file's module
* registry, and store singletons may already exist. Hence:
* - the real useAudio is imported via a `?restore-test` query suffix, which
* bun treats as a distinct module identity and loads from disk, bypassing
* the suite's useAudio mock (verified: query-suffixed imports are not
* intercepted by mock.module);
* - feeds are injected through the REAL feed store's public addFeed() API
* against a local RSS server (no config seeding — works on whatever
* singleton state this worker holds), and progress through update().
* Audio is the no-op backend via PODTUI_AUDIO_BACKEND=none.
*/
import { test, expect, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-restore-"));
const DATA = mkdtempSync(join(tmpdir(), "podtui-restore-data-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "none";
// ── Local RSS feed server (episode ids derive from enclosure URLs) ─────────
let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string {
const items = ["Episode One", "Episode Two"]
.map(
(title) => `<item>
<title>${title}</title>
<pubDate>2026-08-10T00:00:00Z</pubDate>
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Restore Test Show</title>
<description>restore-session test feed</description>
${items}
</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" },
});
}
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, restoreLastSession } = await import("../src/hooks/useAudio?restore-test");
const { useFeedStore } = await import("../src/stores/feed");
const { useProgressStore } = await import("../src/stores/progress");
const { saveLastPlayerToFile, waitForLastPlayerWrite } = await import(
"../src/utils/app-persistence"
);
const feedStore = useFeedStore();
const progressStore = useProgressStore();
// Subscribe to the local feed through the real store API.
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
const feed = await feedStore.addFeed(
{
id: feedUrl,
title: "Restore Test Show",
description: "restore-session 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];
const ep2 = feed.episodes[1];
// Simulate a previous session: ep1 loaded, 20% through, marker persisted.
progressStore.update(ep1.id, 120, 600);
saveLastPlayerToFile({ episodeId: ep1.id, timestamp: new Date() });
await waitForLastPlayerWrite();
/** Rewrite the marker as if a previous session had ended this way. */
async function writeMarker(episodeId: string | null): Promise<void> {
saveLastPlayerToFile({ episodeId, timestamp: new Date() });
await waitForLastPlayerWrite();
}
/** Read the current last-player marker from disk. */
async function readMarker(): Promise<{ episodeId: string | null } | null> {
const file = Bun.file(join(CONFIG, "podtui", "last-player.json"));
if (!(await file.exists())) return null;
return (await file.json()) as { episodeId: string | null };
}
afterAll(() => {
server?.stop(true);
rmSync(CONFIG, { recursive: true, force: true });
rmSync(DATA, { recursive: true, force: true });
});
// ── Tests ─────────────────────────────────────────────────────────────────
test("restore loads the last player episode paused, without autostart", async () => {
const audio = useAudio(); // boot trigger also fires restoreLastSession()
await restoreLastSession();
expect(audio.currentEpisode()?.id).toBe(ep1.id);
expect(audio.isPlaying()).toBe(false);
// Position reflects where playback will resume — the player tab shows it.
expect(audio.position()).toBe(120);
});
test("first play on a restored episode starts playback from saved progress", async () => {
const audio = useAudio();
expect(audio.currentEpisode()?.id).toBe(ep1.id);
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(true);
expect(audio.position()).toBe(120);
// A second toggle pauses (normal pause path) — no backend restart.
await audio.togglePlayback();
expect(audio.isPlaying()).toBe(false);
});
test("restore skips episodes at or above 98% completion", async () => {
const audio = useAudio();
await audio.stop(); // clear the previously restored episode
await waitForLastPlayerWrite(); // stop()'s null marker has landed
await writeMarker(ep1.id); // stop() cleared the marker; restore needs it
// Exactly 98% — boundary: NOT restored.
progressStore.update(ep1.id, 588, 600);
await restoreLastSession();
expect(audio.currentEpisode()).toBeNull();
// Just under 98% — restored.
progressStore.update(ep1.id, 587, 600);
await restoreLastSession();
expect(audio.currentEpisode()?.id).toBe(ep1.id);
expect(audio.position()).toBe(587);
await audio.stop();
});
test("restore no-ops when the player was empty at last quit", async () => {
const audio = useAudio();
await writeMarker(null);
await restoreLastSession();
expect(audio.currentEpisode()).toBeNull();
});
test("restore no-ops when the episode is no longer in any feed", async () => {
const audio = useAudio();
await writeMarker("gone-ep");
await restoreLastSession();
expect(audio.currentEpisode()).toBeNull();
});
test("play persists the marker; stop clears it", async () => {
const audio = useAudio();
await audio.play(ep2);
await waitForLastPlayerWrite();
expect((await readMarker())?.episodeId).toBe(ep2.id);
await audio.stop();
await waitForLastPlayerWrite();
expect((await readMarker())?.episodeId).toBeNull();
});

View File

@@ -0,0 +1,76 @@
/**
* Episode id stability regression tests.
*
* The parser used to key episodes by their position in the feed
* (`feedUrl#index`). Any feed change — a new episode published, an old one
* pruned — shifted every episode's id, so saved progress/downloads attached
* to whichever episode happened to occupy that index afterward ("start a new
* episode and it resumes minutes in"). Ids must instead be stable per
* episode: <guid> when present, else the enclosure URL, else the index.
*/
import { describe, expect, test } from "bun:test"
import { getRSSItems, parseRSSItem } from "../src/api/rss-parser"
const FEED = "https://example.com/feed.xml"
const item = (
title: string,
date: string,
audioUrl: string,
guid?: string,
): string =>
`<item>
<title>${title}</title>
<pubDate>${date}</pubDate>
${guid ? `<guid>${guid}</guid>` : ""}
<enclosure url="${audioUrl}" length="12345" type="audio/mpeg"/>
</item>`
const parse = (xml: string): ReturnType<typeof parseRSSItem>[] =>
getRSSItems(xml).map((it, i) => parseRSSItem(it, FEED, i))
describe("stable episode ids", () => {
test("guid-based ids survive a new episode being prepended", () => {
// Two episodes, newest first. Positional ids would be feedUrl#0 / #1.
const before = parse(
`<rss><channel>${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}</channel></rss>`,
)
// A third, newer episode appears at the top — every index shifts.
const after = parse(
`<rss><channel>${item("Ep 3", "2026-08-03", "https://cdn.example.com/e3.mp3", "ep-3")}${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}</channel></rss>`,
)
// The known episodes keep their ids — only the newcomer differs.
expect(after[1].id).toBe(before[0].id) // Ep 2
expect(after[2].id).toBe(before[1].id) // Ep 1
expect(after[0].id).not.toBe(before[0].id) // Ep 3 is new
})
test("enclosure-URL fallback ids survive reordering (no guid)", () => {
const a = item("A", "2026-08-02", "https://cdn.example.com/a.mp3")
const b = item("B", "2026-08-01", "https://cdn.example.com/b.mp3")
const first = parse(`<rss><channel>${a}${b}</channel></rss>`)
const reordered = parse(`<rss><channel>${b}${a}</channel></rss>`)
// The same episodes at different indexes still carry their own ids.
expect(reordered[0].id).toBe(first[1].id) // B moved to index 0
expect(reordered[1].id).toBe(first[0].id) // A moved to index 1
})
test("ids are namespaced per feed", () => {
const xml = `<rss><channel>${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3", "same-guid")}</channel></rss>`
const parsed = getRSSItems(xml)
const a = parseRSSItem(parsed[0], "https://a.example/feed.xml", 0)
const b = parseRSSItem(parsed[0], "https://b.example/feed.xml", 0)
expect(a.id).not.toBe(b.id)
})
test("id is deterministic across parses of the same item", () => {
const xml = `<rss><channel>${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3")}</channel></rss>`
const one = parse(xml)
const two = parse(xml)
expect(one[0].id).toBe(two[0].id)
})
})

View File

@@ -0,0 +1,344 @@
/**
* search-episode-actions.test.tsx — episode search results must stream
* directly, subscribed or not, and `a` must subscribe an unsubscribed show
* in place.
*
* Regression: `enter` on an unsubscribed show's episode result used to
* subscribe instead of play — there was no direct "stream unsubscribed
* episode" path (subscribing first was the only way to hear it). `enter` now
* plays every episode result (matching Feed/My Shows), and the new `subscribe`
* action (`a`, sibling of `x` unsubscribe) subscribes the focused result.
*
* Mounts the real app (sandboxed, silent audio, mocked search store) and
* drives the Search tab with the test renderer's mock keys: enter on an
* unsubscribed episode plays it without subscribing; `a` subscribes (feed
* fetched from a local server); enter then plays the now-subscribed episode.
* The search store is mocked so results are deterministic (no directory
* network calls); the feed store is real and served from a local HTTP server.
*
* 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 type { Server } from "bun";
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 { SearchResult, SearchScope } from "../src/types/source";
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 search store: `search` feeds the seeded results synchronously;
// markSubscribed/markUnsubscribed flip the result's flag (SearchPage renders
// it, and the real feed store still owns the actual subscription).
const [scope, setScope] = createSignal<SearchScope>("episode");
const [results, setResults] = createSignal<SearchResult[]>([]);
const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>([]);
const flip = (id: string, feedUrl: string | undefined, subscribed: boolean) =>
setResults((prev) =>
prev.map((r) =>
r.podcast.id === id ||
(feedUrl && r.podcast.feedUrl === feedUrl)
? { ...r, podcast: { ...r.podcast, isSubscribed: subscribed } }
: r,
),
);
const mockSearchStore = {
query,
isSearching,
results,
error,
history,
selectedSources: () => [] as string[],
scope,
search: async () => {},
setQuery,
clearResults: () => setResults([]),
clearHistory: () => setHistory([]),
removeFromHistory: () => {},
setSelectedSources: () => {},
setScope,
markSubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, true),
markUnsubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, false),
};
mock.module("../src/stores/search", () => ({
useSearchStore: () => mockSearchStore,
}));
// 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-episode-actions");
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");
// Local HTTP server serving one show's RSS feed (the feed store fetches it
// when subscribing).
let server: Server<unknown> | null = null;
let feedUrl = "";
let audioUrl = "";
function feedXml(origin: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Stream Me Show</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>`;
}
function makeResult(): SearchResult {
const episode: Episode = {
id: "stream-ep-1",
podcastId: "dir-stream-me",
title: "Ep 1",
description: "",
audioUrl,
duration: 0,
pubDate: new Date("2026-08-01T00:00:00Z"),
};
const podcast: Podcast = {
id: "dir-stream-me",
title: "Stream Me Show",
description: "Test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: false,
};
return {
kind: "episode",
sourceId: "test",
sourceName: "Test",
podcast,
episode,
};
}
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(() => {
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" },
});
}
return new Response("audio bytes", {
headers: { "Content-Type": "audio/mpeg" },
});
},
});
feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
});
afterAll(() => {
server?.stop(true);
rmSync(SANDBOX, { recursive: true, force: true });
});
test("enter streams an unsubscribed show's episode; a subscribes it in place; enter then still plays", async () => {
const m = await mountApp();
try {
await settleReady(m);
// Open the Search tab (digit press retried until the router attaches).
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) {
m.mockInput.pressKey("4");
await m.renderOnce();
await sleep(40);
}
expect(m.nav().activeTab()).toBe(TABS.SEARCH);
m.mockInput.pressEnter(); // open the tab's content (query depth)
await waitFor(m, () => m.nav().inputFocused(), "search tab mounted");
// Seed one unsubscribed-show episode result and drill to results
// (mirrors SearchPage.runSearch: set results, push the frame).
setResults([makeResult()]);
const frame: DepthFrame = { kind: "search:results", ctx: "test", focus: 0 };
m.nav().pushDepth(frame);
m.nav().setActivePane(1);
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
// enter → the episode streams; the show is NOT subscribed.
m.mockInput.pressEnter();
await waitFor(m, () => played.length === 1, "play called on enter");
expect(played[0].id).toBe("stream-ep-1");
expect(
useFeedStore()
.feeds()
.some((f) => f.podcast.feedUrl === feedUrl),
).toBe(false);
expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(false);
// a → subscribes in place (real feed fetch against the local server).
m.mockInput.pressKey("a");
await waitFor(
m,
() =>
useFeedStore()
.feeds()
.some((f) => f.podcast.feedUrl === feedUrl),
"a subscribes the show",
);
expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(true);
// enter again → still streams (now under the subscribed show).
m.mockInput.pressEnter();
await waitFor(m, () => played.length === 2, "play called after subscribe");
expect(played[1].id).toBe("stream-ep-1");
} finally {
m.renderer.destroy();
}
});

View File

@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
currentEpisode: () => null,
availablePlayers: () => [],
play: async () => {},
load: async () => {},
pause: async () => {},
resume: async () => {},
togglePlayback: async () => {},

View File

@@ -0,0 +1,189 @@
/**
* Show-row height regression — My Shows depth-0 rows must stay exactly one
* line tall: marker + show title + episode count (+ watchlist dot). The
* flexible title carries `wrapMode="none"` + `truncate` (middle-ellipsis:
* head and tail of the title stay visible) and the fixed-width cells carry
* `flexShrink={0}`, so Yoga can never shrink them and wrap the row — a
* wrapped row grows to 2+ lines and the episode count + watchlist dot shift
* below the title while scrolling (the original bug). Same guard for the
* 20%-wide parent-pane shows list at depth ≥1.
*
* Rendered at 70 columns so the 35-col current pane / 14-col parent pane are
* narrow enough to force truncation on the long title; at the default
* 100-col/50-col pane the same rows show everything in full.
*/
import { describe, test, expect, afterAll } from "bun:test";
import type { JSX } from "solid-js";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
function frameText(spans: Frame): string[] {
return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
}
const LONG_TITLE =
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)";
// The exact depth-0 row shape MyShowsPage renders: marker + title + count +
// watchlist dot. Static text, no store hooks — pure layout probe.
const ShowRowFixed = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text flexShrink={0}></text>
<text wrapMode="none" truncate>
{LONG_TITLE}
</text>
<text flexShrink={0}>(123)</text>
<text flexShrink={0}></text>
</box>
);
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
// wraps at the shrunken width and pushes the count + dot onto wrapped lines.
const ShowRowNaive = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text></text>
<text>{LONG_TITLE}</text>
<text>(123)</text>
<text></text>
</box>
);
// The depth-1 parent-pane shows-list row (marker + title + count).
const ParentRowFixed = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text flexShrink={0}></text>
<text wrapMode="none" truncate>
{LONG_TITLE}
</text>
<text flexShrink={0}>(123)</text>
</box>
);
const ParentRowNaive = () => (
<box flexDirection="row" gap={1} paddingRight={1}>
<text></text>
<text>{LONG_TITLE}</text>
<text>(123)</text>
</box>
);
async function renderRow(
row: () => JSX.Element,
pane: "current" | "parent",
width = 70,
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={pane === "parent" ? row : null}
current={pane === "current" ? row : null}
preview={null}
currentLabel="List"
/>
</ThemeProvider>
),
{ width, height: 10, useThread: false },
);
// ThemeProvider mounts children only once the theme resolves; poll for
// the header row so the captured frame is a mounted PaneRow.
let lines: string[] | null = null;
for (let i = 0; i < 40 && !lines; i++) {
await setup.renderOnce();
const frame = setup.captureSpans() as unknown as Frame;
const ls = frameText(frame);
if (ls.some((l) => l.includes("List"))) lines = ls;
else await new Promise((r) => setTimeout(r, 100));
}
if (!lines) throw new Error("PaneRow did not render before timeout");
return {
lines,
destroy: async () => {
setup.renderer.destroy();
},
};
}
const cleanups: (() => void | Promise<void>)[] = [];
afterAll(async () => {
for (const c of cleanups) {
try {
await c();
} catch {
// renderer already torn down — ignore
}
}
});
describe("show row height in the current pane (70-wide → 35-col pane)", () => {
test("long title stays on one line — middle-ellipsis keeps head AND tail, count + dot stay aligned", async () => {
const { lines, destroy } = await renderRow(ShowRowFixed, "current");
cleanups.push(destroy);
// Middle-ellipsis: the title head and its tail both survive, on a
// single line (end-truncation would drop the tail).
expect(lines.filter((l) => l.includes("Out of Wh"))).toHaveLength(1);
expect(lines.filter((l) => l.includes("#338)"))).toHaveLength(1);
// The count and watchlist dot sit on that same line — nothing wrapped.
const aligned = lines.filter(
(l) =>
l.includes("Out of Wh") &&
l.includes("#338)") &&
l.includes("(123)") &&
l.includes("●"),
);
expect(aligned).toHaveLength(1);
// Row occupies exactly 1 content line below the header.
const content = lines.filter(
(l) => l.includes("Out of Wh") || l.includes("(123)"),
);
expect(content).toHaveLength(1);
});
test("naive row (pre-fix props) wraps the title — the regression the test guards", async () => {
const { lines, destroy } = await renderRow(ShowRowNaive, "current");
cleanups.push(destroy);
// The title's wrapped fragments span 3 frame lines instead of 1 —
// every row below shifts while scrolling.
const titleFragments = lines.filter(
(l) =>
l.includes("Out of Whiskey") ||
l.includes("Rotgut") ||
l.includes("#338)"),
);
expect(titleFragments).toHaveLength(3);
});
});
describe("show row in the parent pane (70-wide → 14-col pane)", () => {
test("long title stays on one line with count aligned", async () => {
const { lines, destroy } = await renderRow(ParentRowFixed, "parent");
cleanups.push(destroy);
// The 14-col slot truncates the title to a head stub (too narrow for
// head + tail), but the row stays one line and the count pins to it.
expect(lines.filter((l) => l.includes("O..."))).toHaveLength(1);
expect(
lines.filter((l) => l.includes("O...") && l.includes("(123)")),
).toHaveLength(1);
});
test("naive parent row wraps the title — the regression the test guards", async () => {
const { lines, destroy } = await renderRow(ParentRowNaive, "parent");
cleanups.push(destroy);
const titleFragments = lines.filter(
(l) =>
l.includes("Out of") ||
l.includes("Whiskey") ||
l.includes("Rotgut") ||
l.includes("#3"),
);
expect(titleFragments.length).toBeGreaterThan(1);
});
});

View File

@@ -0,0 +1,247 @@
/**
* Visualizer store lifecycle tests — pin the waveform pipeline contract:
*
* - playback starts the pipeline and exposes a loading state until the
* first FFT frame renders (the braille-spinner window);
* - losing Player-tab focus does NOT kill a warm pipeline — it keeps
* rendering for the grace period, and regaining focus within the delay
* resumes it without a restart;
* - after VISUALIZER_UNLOAD_DELAY_MS unfocused the pipeline tears down
* (ffmpeg process + cava plan released).
*
* The store subscribes to the module-level signals in utils/audio-signals.ts
* (no useAudio mock — the signals are exported and driven directly, so this
* file can never leak a module mock into another test's worker).
*
* Uses a self-generated local WAV (a frequency chirp, so different playback
* positions produce measurably different bar output) and the real ffmpeg +
* native cavacore pipeline, mirroring audio-pcm-cache.test.ts.
*
* Timing note: this is an integration test of the store's real timers — the
* unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no
* fake-timer API (no `mock.timer`, no `vi.useFakeTimers`), so the grace
* period must be exercised against the platform clock. Delays are kept to
* the minimum that observes the contract (see the 30s unload test).
*/
import { test, expect, afterAll } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { setIsPlaying, setPosition, setCurrentEpisode } from "../src/utils/audio-signals";
import { useAppStore } from "../src/stores/app";
import type { Episode } from "../src/types/episode";
// ── Sandbox (the app store reads config from XDG_CONFIG_HOME at first
// use; set before importing the store) ─────────────────────────────────
process.env.XDG_CONFIG_HOME = join(tmpdir(), `podtui-viz-test-${process.pid}`);
process.env.XDG_DATA_HOME = join(tmpdir(), `podtui-viz-data-${process.pid}`);
process.env.PODTUI_AUDIO_BACKEND = "none";
const { useVisualizer, VISUALIZER_UNLOAD_DELAY_MS } = await import(
"../src/stores/visualizer"
);
// ── Local chirp WAV (200Hz → 2kHz over 45s) ─────────────────────────────
const SAMPLE_RATE = 44100;
const F0 = 200;
const F1 = 2000;
const DURATION = 45;
const AMP = 30000;
const hasFfmpeg = !!Bun.which("ffmpeg");
const hasNativeLib = Bun.file(
join(process.cwd(), "src", "native", "libcavacore.dylib"),
).exists();
async function writeChirpWav(path: string): Promise<void> {
const total = Math.round(DURATION * 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);
// Linear chirp: instantaneous frequency sweeps F0 → F1 over DURATION.
const sweep = (F1 - F0) / DURATION;
for (let i = 0; i < total; i++) {
const t = i / SAMPLE_RATE;
const phase = 2 * Math.PI * (F0 * t + 0.5 * sweep * t * t);
dv.setInt16(44 + i * 2, Math.round(AMP * Math.sin(phase)), true);
}
await Bun.write(path, buf);
}
const wavPath = join(tmpdir(), `podtui-viz-${process.pid}-${Date.now()}.wav`);
await writeChirpWav(wavPath); // long enough to outlast the unload delay at readrate 1
// ── Helpers ─────────────────────────────────────────────────────────────
/** Poll `check` every 5ms 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(5);
}
}
/**
* Start playback against the local WAV and wait for the first frame.
*
* The reader samples the window ENDING at the playback position, so a
* frozen position clock would serve a 1-sample window at position 0 and
* never produce a full frame (in production mpv advances the clock every
* poll). Drive the clock to 2s right after play — inside the 3s decode-head
* burst — so complete windows are available immediately.
*/
async function startPlaying(): Promise<void> {
const viz = useVisualizer();
viz.setBarCount(64);
viz.setFocused(true);
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
setIsPlaying(true);
setPosition(2);
await waitFor(() => viz.isRunning(), 10000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
}
const skip = !(hasFfmpeg && hasNativeLib);
// ── Tests ────────────────────────────────────────────────────────────────
test.skipIf(skip)(
"starts on playback: loading state first, then frequency bars",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.barData().length).toBe(64);
expect(viz.isLoading()).toBe(false);
},
{ timeout: 20000 },
);
// Regression: with the position clock frozen at the start position (mpv
// still opening the stream), the reader samples a window ending at the
// start — a 1-sample slice it can never fill. The smooth clock must be
// seeded at pipeline start so the interpolated target advances and bars
// render as soon as ffmpeg has ANY audio, not after the first position poll.
test.skipIf(skip)(
"renders bars while the position clock is still frozen at 0",
async () => {
const viz = useVisualizer();
viz.setBarCount(64);
viz.setFocused(true);
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
setIsPlaying(true);
// Deliberately do NOT advance the mock position: the clock stays at 0.
await waitFor(() => viz.isRunning(), 10000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
test.skipIf(skip)(
"losing focus keeps the warm pipeline alive; refocus within the delay resumes without restart",
async () => {
const viz = useVisualizer();
await startPlaying();
viz.setFocused(false);
// Not an instant teardown: observe the pipeline well inside the 30s
// grace window.
await Bun.sleep(500);
expect(viz.isRunning()).toBe(true);
expect(viz.isLoading()).toBe(false);
// Still live: moving the position clock changes the bars (chirp →
// different spectrum at 3s than at the 2s start position).
setPosition(3);
const barsBefore = viz.barData();
await waitFor(() => viz.barData() !== barsBefore, 3000);
// Refocus within the delay: warm pipeline, no restart — a restart
// would respawn ffmpeg and flash the loading state. Watch for that
// flash over a short observation window.
viz.setFocused(true);
let sawRestartLoading = false;
const start = Date.now();
while (Date.now() - start < 250) {
if (viz.isLoading()) sawRestartLoading = true;
await Bun.sleep(5);
}
expect(sawRestartLoading).toBe(false);
expect(viz.isRunning()).toBe(true);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// The store's unload is a real `setTimeout(VISUALIZER_UNLOAD_DELAY_MS)` with
// no injectable clock (bun 1.3.8 has no fake timers), so the grace period is
// exercised against the platform clock — this is the deliberate-exception
// case from the no-real-timers rule.
test.skipIf(skip)(
"unloads the pipeline after the unfocused grace delay",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isRunning()).toBe(true);
viz.setFocused(false);
await Bun.sleep(VISUALIZER_UNLOAD_DELAY_MS + 1500);
expect(viz.isRunning()).toBe(false);
expect(viz.isLoading()).toBe(false);
},
{ timeout: 45000 },
);
// Settings master switch: turning the visualizer off must tear the running
// pipeline down (not just hide the component), and re-enabling restarts it
// from the current position.
test.skipIf(skip)(
"disabling the visualizer stops a running pipeline; re-enabling restarts it",
async () => {
const viz = useVisualizer();
const app = useAppStore();
await app.whenReady();
await startPlaying();
expect(viz.isRunning()).toBe(true);
app.updateVisualizer({ enabled: false });
await waitFor(() => !viz.isRunning(), 10000);
expect(viz.isLoading()).toBe(false);
app.updateVisualizer({ enabled: true });
await waitFor(() => viz.isRunning(), 10000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// ── Teardown ─────────────────────────────────────────────────────────────
afterAll(() => {
// Release any pipeline still running (e.g. if a test failed midway).
setIsPlaying(false);
});

View File

@@ -0,0 +1,94 @@
/**
* visualizer-toggle.test.ts — "waveform visualizer toggleable on/off in
* settings, default on".
*
* Pins three contracts:
* 1. The default is ON (fresh config, before any user change).
* 2. The Settings → Visualizer "Waveform" item flips it via updateVisualizer.
* 3. Persistence: a config saved BEFORE `enabled` existed (no key) still
* loads as ON with its other visualizer fields intact (deep-merge
* backfill), and an explicit `enabled: false` survives a reload.
*
* The config dir is derived from XDG_CONFIG_HOME at call time, so each
* persistence test points it at a fresh tmpdir and writes its own
* config.json before calling loadAppStateFromFile directly.
*/
import { test, expect } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-data-"));
process.env.PODTUI_AUDIO_BACKEND = "none";
const { useAppStore } = await import("../src/stores/app");
const { useVisualizerItems } = await import(
"../src/pages/Settings/VisualizerSettings"
);
const { loadAppStateFromFile } = await import("../src/utils/app-persistence");
/** Write a config.json into a fresh XDG_CONFIG_HOME and load app state. */
async function loadWithConfig(settings: unknown): Promise<{
state: ReturnType<typeof loadAppStateFromFile> extends Promise<infer T>
? T
: never;
}> {
const dir = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-cfg-"));
process.env.XDG_CONFIG_HOME = dir;
mkdirSync(join(dir, "podtui"), { recursive: true });
writeFileSync(
join(dir, "podtui", "config.json"),
JSON.stringify({ settings }, null, 2),
);
return { state: await loadAppStateFromFile() };
}
test("waveform visualizer defaults to ON with a fresh config", async () => {
const app = useAppStore();
await app.whenReady(); // empty sandbox config → defaults
expect(app.state().settings.visualizer.enabled).toBe(true);
});
test("Settings → Visualizer exposes a Waveform toggle that flips the setting", async () => {
const app = useAppStore();
await app.whenReady();
const items = useVisualizerItems();
const item = items.find((it) => it.id === "enabled");
expect(item).toBeDefined();
expect(item!.kind).toBe("toggle");
expect(item!.display()).toBe("On");
item!.toggle!();
expect(app.state().settings.visualizer.enabled).toBe(false);
expect(item!.display()).toBe("Off");
item!.toggle!();
expect(app.state().settings.visualizer.enabled).toBe(true);
expect(item!.display()).toBe("On");
});
test("a config saved before `enabled` existed loads as ON with other fields intact", async () => {
const { state } = await loadWithConfig({
visualizer: { bars: 16, lowCutOff: 80 },
});
expect(state.settings.visualizer.enabled).toBe(true); // backfilled
expect(state.settings.visualizer.bars).toBe(16); // preserved, not clobbered
expect(state.settings.visualizer.lowCutOff).toBe(80);
});
test("an explicit enabled:false survives a reload", async () => {
const { state } = await loadWithConfig({
visualizer: { enabled: false, bars: 128 },
});
expect(state.settings.visualizer.enabled).toBe(false);
expect(state.settings.visualizer.bars).toBe(128);
});
test("an empty visualizer object in config falls back to full defaults", async () => {
const { state } = await loadWithConfig({ visualizer: {} });
expect(state.settings.visualizer.enabled).toBe(true);
expect(state.settings.visualizer.bars).toBeGreaterThan(0);
});

View File

@@ -0,0 +1,85 @@
/**
* volume-persistence.test.ts — "store and reuse the previous session's
* audio level" feature.
*
* useAudio's volume starts at 100% (default, before any user change);
* setVolume() persists the new level to app settings (config.json); and
* at boot the volume signal re-syncs from the persisted settings — so the
* next session resumes at the previous level.
*
* Same worker-leak defenses as restore-session.test.ts: other test files
* mock.module("../src/hooks/useAudio") and bun reuses workers, so the real
* module is imported via a query-suffixed specifier (distinct module
* identity, loads from disk). A fresh module instance simulates the next
* launch: its refCount starts at 0, so its boot sync re-reads settings.
* The boot sync awaits the app store's async config load (whenReady), so
* tests await it too and flush microtasks — no wall-clock sleeps.
*/
import { test, expect } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-volume-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-volume-data-"));
process.env.PODTUI_AUDIO_BACKEND = "none";
// ── Real modules ──────────────────────────────────────────────────────────
// @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?restore-test");
const { useAppStore } = await import("../src/stores/app");
/** Flush the boot sync's promise chain (whenReady.then(...)) — microtasks
* only, no timers. */
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
test("volume defaults to 100% and is persisted and reused across sessions", async () => {
const appStore = useAppStore();
await appStore.whenReady(); // empty sandbox config → defaults
// Boot 1: no persisted volume — the default is 100% (not the old 70%).
const audio = useAudio();
await flushMicrotasks();
expect(audio.volume()).toBe(1);
expect(appStore.state().settings.volume).toBe(1);
// User change: signal updates and the level lands in app settings.
await audio.setVolume(0.35);
expect(audio.volume()).toBe(0.35);
expect(appStore.state().settings.volume).toBe(0.35);
// Boot 2 (fresh module instance — refCount starts at 0, so the boot
// sync re-reads settings): the previous session's level is restored.
// @ts-expect-error — same bun-only query-suffix mechanism as above.
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol");
const nextAudio = useAudioNext();
await flushMicrotasks();
expect(nextAudio.volume()).toBe(0.35);
});
test("setVolume clamps to the 01 range before persisting", async () => {
const audio = useAudio();
await audio.setVolume(1.7);
expect(audio.volume()).toBe(1);
expect(useAppStore().state().settings.volume).toBe(1);
await audio.setVolume(-0.3);
expect(audio.volume()).toBe(0);
expect(useAppStore().state().settings.volume).toBe(0);
});
test("a persisted volume wins over the default at boot", async () => {
// Persist a non-default level, then simulate a fresh launch that has no
// prior signal state (new module instance).
useAppStore().updateSettings({ volume: 0.6 });
// @ts-expect-error — same bun-only query-suffix mechanism as above.
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol2");
const nextAudio = useAudioNext();
await flushMicrotasks();
expect(nextAudio.volume()).toBe(0.6);
});

View File

@@ -66,6 +66,7 @@ type TestPaneProps = {
current?: (() => unknown) | unknown;
preview?: unknown;
focused?: unknown;
currentBorder?: unknown;
width?: number;
height?: number;
};
@@ -83,6 +84,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
preview={props.preview as any}
currentLabel="List"
focused={props.focused as any}
currentBorder={props.currentBorder as any}
/>
</ThemeProvider>
),
@@ -232,4 +234,17 @@ describe("PaneRow current-pane borders", () => {
expect(borderColumns(spans)).toEqual([20, 69]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
test("currentBorder=['left'] removes the right edge (left only)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
currentBorder: ["left"],
});
cleanups.push(destroy);
// Only the left border glyph at column 20 — no right edge at 69.
expect(borderColumns(spans)).toEqual([20]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
});