52 Commits

Author SHA1 Message Date
48076fcef5 bump VERSION to 0.9.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 8s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-09-06 18:50:47 -04:00
06c5cc9184 chore(scroll): typed alias for wrapped wheel handler
onMouseEvent is untyped on ScrollBoxRenderable.prototype; cast once
at capture instead of loosening call sites.
2026-09-06 18:46:42 -04:00
a3641d100e perf(download): stream response body straight to file
Bun.file().writer() per chunk replaces the in-memory chunk array +
final concat copy: no more whole-episode buffering.
2026-09-06 18:46:42 -04:00
8a173a5180 fix(feed): stop persisting legacy podcast.episodes
search-time parseRSSFeed once embedded the full episode history
inside Feed.podcast (2,100+ stale copies, 3.8 MB of config). Nothing
reads it — feed.episodes is the source of truth — so load and save
now strip podcast.episodes, and searchByFeedUrl drops them at the
parse site.
2026-09-06 18:46:42 -04:00
132d2079f7 fix(pane): 3-column grab zones with cursor offset
Splitter strips widen to border +/- 1 help-padded column so the thin
border is easy to target; mousedown records the cursor's offset from
the border column and drag subtracts it, so the border tracks the
cursor instead of jumping. Padded columns must never overlap
interactive content (rect-based hit grid). Dead one-off render
harness scripts/_hv.ts removed. Tests cover far/inner-edge grabs
and no-resize on padded-column clicks.
2026-09-06 18:46:42 -04:00
b53d4add29 test(scratch): leak-hunt probes for cava, render loop, decode stream
One-off diagnostics behind the FFTW leak fix: init/destroy x50 RSS
bound, Mach VM region walker, render-churn region growth, ffmpeg
decode-stream region/rss sampling.
2026-09-06 18:46:42 -04:00
6c3ad5d925 perf(visualizer): write bar data at ~10fps
Each Solid setBarData costs a renderer diff pass. Cava already
smooths (noise reduction + peak release), so 3 of every 4 frames now
update only the pipeline; the UI signal writes at >=95ms intervals.
2026-09-06 18:46:42 -04:00
09d5732b55 fix(cava): identical init re-uses the live plan
cava_init/destroy churn leaks the old plan's FFTW work buffers —
upstream frees only its own struct. init() now serializes the config
and no-ops when unchanged, so pipeline restarts (focus/episode churn)
keep the live plan instead of leaking a new one each cycle.
2026-09-06 18:46:42 -04:00
9e2f232d27 chore(harness): drop dead selectedFeedId read 2026-09-03 08:33:50 -04:00
9143078b12 refactor(feed): episode windows, source registry, dead interface
episode-windows.ts: keep-fn, date-band walk (was copy-pasted twice),
sameRefreshWindow — one pure module. source-registry.ts owns source
CRUD + its persistence. Dead members with zero callers deleted:
updateFeed, togglePinned, removeSource, setFilter/selectedFeedId.
Feed persistence routed through the persist scheduler.
2026-09-03 08:33:32 -04:00
ca46de4d70 refactor(audio): engine module absorbs the hook
useAudio shrinks 928→152: backend lifecycle, poll, session restore,
crash recovery, queue advance and event-bus commands live in
createAudioEngine; the hook is a thin Solid adapter. audio-player:
preload of a dead URL stalls the load mutex 5s — end-file (open
failure) now races file-loaded.
2026-09-03 08:33:24 -04:00
3e90f9e783 refactor(feed): single RSS client closes search timeout gap
fetchFeedXml owns headers + 20s timeout; both hand-rolled fetches in
feed.ts (fetchEpisodes, load-more cold path) and searchByFeedUrl route
through it — direct-URL search hung indefinitely before.
2026-09-03 08:33:19 -04:00
c2ec356a5f refactor(persist): one per-domain persist scheduler
stores/persist.ts: createPersistScheduler — trailing-edge debounce,
flush, per-domain isolation. search-history writes collapse from
per-keystroke to one debounced write; scope save stays direct.
2026-09-03 08:33:11 -04:00
6aac138629 bump VERSION to 0.8.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5s
release / Attach to GitHub Release (push) Has been cancelled
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
2026-08-28 21:58:31 -04:00
3f0001b0d5 fix inner scroll behavior 2026-08-28 21:58:11 -04:00
74158d75d4 fix border resizing ui 2026-08-28 19:45:14 -04:00
4ff8aabb51 bump VERSION to 0.7.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 3s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-27 13:47:06 -04:00
895138357f visualizer fixes 2026-08-27 13:46:41 -04:00
02c957f584 fix curl flag 2026-08-26 21:56:26 -04:00
d7ceb9d045 bump VERSION to 0.7.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m17s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-19 09:42:57 -04:00
0677f82c44 docs(readme): document auto-advance and mpv tarball install note 2026-08-17 20:50:14 -04:00
4990eae60f feat(theme): poll terminal OSC colors to track live theme changes
Terminals answer OSC 10/11/12 color queries but never push changes, so
detect theme flips with a 60 s poll (legacy-tmux fallback for servers
< 3.6). Re-queries palette + default fg/bg, updates the system palette
and re-detects dark/light mode.
2026-08-17 20:50:14 -04:00
9ddfd21685 feat(audio): auto-advance to next episode in source queue on track end
When a track reaches natural EOF (player alive, no stream error), play the
next episode from the source that started it — search results, show, or
Feed — and stop at the end of the list. A crashed/killed daemon or failed
stream never auto-advances.

- add audio-queue.ts: pure next/prev selection from the navigation source
- audio-player: expose getPlaybackError() to distinguish EOF from failure
- useAudio: finalizeTrackEnd(autoAdvance) wiring, re-selecting the current
  episode no longer reloads from stale saved progress
- tests: audio-queue units, auto-advance integration (real mpv + local
  WAVs over HTTP), backend re-select no-reload test
2026-08-17 20:50:05 -04:00
22059c24ca fix(visualizer): show loading spinner on resume until fresh bars arrive
Resume re-arms a pipeline whose ffmpeg pass was killed at pause, so the
pre-pause bars are stale until fresh frames flow. Three changes:

- resumeVisualization always sets the loading state (previously only for
  positions outside decoded coverage) and records the resume point;
  renderFrame clears it only once the position clock advances past that
  point — a player still re-buffering after a long pause keeps the
  spinner instead of serving static cached bars.
- stopVisualization clears barData so cold restarts (unload, disable,
  episode change) show the spinner rather than stale bars, and never
  suppress it.
- renderFrame detects a frozen position clock while playing (STALL_DETECT_MS)
  and surfaces it as a loading state; recovery clears it.

Tests: resume-into-undecoded-audio shows loading until bars land; frozen
position clock surfaces a stall and recovery clears it; disable/enable
pins barData cleared on stop and the restart loading flash.
2026-08-13 21:04:12 -04:00
0aac0a157f bump VERSION to 0.7.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m15s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-13 19:35:13 -04:00
df4701957b refactor(feed): port refresh batch to Effect for concurrency and timeout
Replace the hand-rolled worker pool (mapWithConcurrency) with an Effect
program (src/effects/feed-refresh.ts): Effect.forEach bounds in-flight
fetches, Effect.timeout bounds each feed via the Clock service, and
failures fold to null so a bad feed never fails the batch. Per-feed
apply-as-it-lands is preserved — the apply callback runs inside each
feed's own fiber, so there is no Promise.all barrier.

Store boundary unchanged: refreshAllFeeds runs the program through
Effect.runPromise, keeping runAutoDownload + flushPendingSave after the
batch and isLoadingFeeds around it.

Adds TestClock-driven tests (tests/feed-refresh-effect.test.ts) that pin
concurrency, per-feed apply, timeout, and failure isolation without real
20s waits. Pins effect@^3 (V4 is in beta).
2026-08-13 19:34:30 -04:00
4b44623891 fix(tests): stop feed-store mock.module leak that broke the full suite
discover-store-preview mocked src/stores/feed via mock.module, which bun
applies process-globally: with workers reused across test files, every file
that later shared a worker imported the stub (fetchEpisodes only) and failed
with 'addFeed is not a function' — ~35 tests, drifting run to run with worker
scheduling. Rewrote the test against the REAL feed store and a local gated
Bun.serve server (repo-dominant harness), importing the discover store via a
query-suffixed specifier so a sibling discover-store mock cannot leak in.
Full suite: 215 pass, 0 fail (baseline: 194).
2026-08-13 18:01:40 -04:00
4ef9ab7e59 perf(ui): render only a bounded window around the focused row 2026-08-13 17:46:30 -04:00
9df8eebf6c feat(discover): drill into show episode previews without subscribing 2026-08-13 17:46:30 -04:00
badbc6a037 fix(audio): recover playback when the mpv daemon dies or restarts 2026-08-13 17:46:30 -04:00
878d1e01ab feat(feed): signature-aware volatile merge with date-banded fetch-more 2026-08-13 17:46:27 -04:00
42c48e59fb feat(feed): stable episode ids derived from guid or enclosure URL 2026-08-13 17:46:26 -04:00
91d4acca90 fix: fetch more respects episode cache mode 2026-08-12 22:28:13 -04:00
20d5b57cb6 bump VERSION to 0.6.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m9s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 22:12:40 -04:00
d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.

- audio-pcm-cache: sliding window around the playback position — the
  decode head caps at maxAheadSec (600s) ahead of the cursor, segments
  older than keepBehindSec (300s) are pruned, and the tail refills as
  playback advances. Steady state ~40 MB regardless of episode length;
  a backward seek past the window restarts a segment there (the existing
  seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
  archive-heavy subscriptions can't pin their entire history in RAM;
  the visible list stays bounded by the user's cache preference and
  fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
  audio-pcm-cache.test.ts; full suite 193 pass).

Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
2026-08-12 21:02:19 -04:00
77531ce41d bump VERSION to 0.6.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m21s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 16:34:54 -04:00
26729fa5e6 feat(feed): make episode cache bound user-configurable (date window or count)
Add episodeCacheMode/count/days preferences (default: date, 60 days).
Apply the bound when reading instead of writing, so a preference change
takes effect without a refetch; the full parse cache stays intact so
fetch-more can page beyond the bound. Thread the window through
load/saveFeedsToFile and update tests and task docs.
2026-08-12 15:41:22 -04:00
4127fd1181 bump VERSION to 0.6.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 28s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 12:57:57 -04:00
6c99b96b12 fix(feed): eliminate event-loop blocking during feed refresh
The boot refresh blocked the UI for up to 195ms per sync block with 10
large feeds (500 episodes × 10KB descriptions), causing noticeable freezes
when navigating to Feed during startup.

Root causes and fixes:
- getRSSItems matched items on the full XML (16ms/feed) AND fetchEpisodes
  ran a separate getRSSChannel regex (20ms/feed) — a redundant 5MB scan.
  Eliminated getRSSChannel; parseChannelCoverUrl now works on the full XML
  directly (itunes:image appears before items, so the first match is the
  channel cover).
- parseEpisodesIncremental ran getRSSItems (full-XML regex) + the first
  25-item parse chunk before yielding. Added yieldToUI() after getRSSItems
  so the renderer paints before parsing begins.
- Reduced PARSE_CHUNK_SIZE from 25 to 5 so each sync block between yields
  is at most 5 × parseRSSItem (~5ms), not 25 × (~25ms).
- Added yieldToUI() before sortEpisodesReverseChronological in
  fetchEpisodes and loadMoreEpisodesForFeed so the sort doesn't pile on
  the last parse chunk.
- Added yieldToUI() after response.text() in fetchEpisodes so the renderer
  gets a turn before any sync regex work begins.

Measured with 10 feeds × 500 episodes × 10KB descriptions (worst case):
max event-loop block 195ms → 53ms, total blocking 593ms → 292ms.
2026-08-12 12:50:01 -04:00
acbaf2ed1c fix(download): keep .mp3 extension on the ffmpeg tag temp file
ffmpeg infers the muxer from the extension; '.tag' made every download's
tag step fail with 'Unable to choose an output format'.
2026-08-12 11:11:10 -04:00
e7ed89056e fix(now-playing): episode as title, podcast as artist on both paths
The card put the podcast name in the title slot ('Podcast — Episode'
prefix truncating on long names) with an empty artist slot for local
playback — the downloaded files carry no artist tag. Now:
- mediaTitle is the episode title only (UI + CLI); streams keep their
  artist from stream tags, matching Apple Podcasts' title/artist layout.
- downloads are tagged at completion (ffmpeg -c copy, atomic rename):
  title=episode, artist=podcast — verified on a real 72MB file in 1.5s.
2026-08-12 11:10:07 -04:00
b5432e3e5f fix(download): sibling cover via curl + episode image fallback
The download wrote its sibling .jpg with Bun fetch — which hangs in
compiled binaries, so shipped builds never produced offline cover art.
Now curl (same flags as the cover cache). Also falls back to the
episode's own image when the feed has no channel cover (URL-added
feeds like The Fifth Column). mpv --cover-art-auto=exact picks up the
same-basename .jpg (verified against mpv 0.41).
2026-08-12 10:59:06 -04:00
13664c3cec fix(cover+downloads): channel art parsing, episode image fallback, local playback
The Fifth Column (and any feed added by URL) had NO coverUrl — the RSS
parser never captured channel artwork, so Now Playing had nothing to show.
- rss-parser: parseChannelCoverUrl (<itunes:image href> / RSS2 <image><url>);
  parseRSSFeed sets it on the Podcast.
- feed store: fetchEpisodes returns the channel cover; subscribe + both
  refresh paths backfill coverUrl when missing (no second fetch).
- useAudio + CLI --play: cover resolves feed.podcast.coverUrl ?? episode.imageUrl,
  so episodes without channel art still get their own image.
- useAudio play/load/switchBackend: prefer the downloaded file
  (getDownloadedFilePath) over the stream URL — downloaded episodes now
  play from disk.
Verified: Fifth Column episode, cold cache -> cover-art-files set at load
-> albumart track present.
2026-08-12 10:52:54 -04:00
0b4a551744 fix(feed): unblock UI during fetch-more and drop indicator label text
loadMoreEpisodesForFeed's hot-cache path ran fully synchronously (no
await between getFeed and setFeeds), so the isLoadingMore spinner never
painted and keyboard input froze through every feed in a loadMoreAllFeeds
batch. Add await yieldToUI() before the setFeeds so the renderer gets a
macrotask turn to paint and process input between feeds.

Drop the "Loading…/Refreshing…/Downloading N" label text from the global
activity indicator — render the braille spinner only, per request.
2026-08-12 10:47:02 -04:00
ed75c2fff7 fix(cover): await bounded cover fetch on cold-cache play
Prefetch alone can't cover every first play (any surface, feeds outside
the FeedPage focus window, quick plays) — the live session showed
cover-art-files empty with zero albumart tracks on a cold-cache play.
play() now serves the disk cache synchronously and, on a miss, awaits the
single-flight fetch with a 1.2s cap (covers fetch in ~300ms typically);
past the cap it plays bare and warms the cache. Verified: cold-cache play
-> cover present at load -> albumart track.
2026-08-12 10:44:20 -04:00
bd7d988741 fix(cover): art reaches Now Playing on first play (restore + cold cache)
Two gaps left cover art missing for the most common play paths:
- Boot-restore preloaded the episode with cachedCoverPath??undefined racing
  the fire-and-forget prefetch; a cold preload + fast-path play re-applied
  art via video-add, which produces a NON-albumart track (verified) that
  mpv's Now Playing artwork logic ignores. load() now awaits the bounded
  fetch (covers ~300ms, 8s cap) so the cover is present at load-time.
- Removed the dead video-add re-apply (fast path + addCoverArt method +
  interface) and the play() late-add fallback; a cold-cache play now
  prefetches for next time instead.
- FeedPage prefetches covers for the focus window (single-flight, cached)
  so ordinary plays land on a warm cache.
Verified: preload with awaited cover -> albumart track present (2 tracks).
2026-08-12 10:22:49 -04:00
deac6081ca feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh
Persisted feeds keep only episodes from the last 30 days (plus completed
downloads); older episodes live in volatile memory and survive refreshes
via union merge, with per-feed in-memory caches capped at 500. Refresh
batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no
Promise.all barrier), config.json writes are trailing-edge debounced
(250ms, immediate flushPendingSave for unsubscribes), and cold
fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store
powers a global top-right indicator covering refresh, fetch-more,
subscribe, search, and downloads.

Also includes the in-flight incremental RSS parsing (chunked with
event-loop yields) and refresh spinner work this tree already carried.
2026-08-12 10:13:20 -04:00
e09ae15e32 bump VERSION to 0.5.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m7s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 08:10:12 -04:00
33af131b77 revert(macos): drop PodTui.app / bundled-mpv Now Playing attribution
The pinned mpv binary breaks on brew ffmpeg major drift (libavcodec.62 vs
.63 shipped a dead player); making it self-contained costs ~100MB for an
app icon. Removing the whole machinery: build.ts app-bundle assembly,
audio-player bundled-binary resolver + probe (spawns PATH mpv again),
CI mpv install + bundle smoke checks, AppIcon.icns, README note.
Cover-art staging (curl + --cover-art-files) is unrelated and stays.
2026-08-11 23:09:44 -04:00
3d6d4918bc bump VERSION to 0.5.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m14s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 21:13:01 -04:00
35ad858d0d Default fetch-more mode to auto 2026-08-11 21:12:27 -04:00
af827a9a96 fix(visualizer): bars recover after far-forward seeks into undecoded audio
ensureDecodeAround let a running decode pass close ANY forward gap in
place — at 4x pacing, skipping 30 min ahead meant ~7.5 min for the
frontier to arrive: bars held their last frame indefinitely. Now a gap
beyond 15s restarts the pass at the seek target (network coverage there
in ~1.6s, verified); smaller gaps close in place (cheaper than a
reconnect + range request). Seek-key holds are debounced (400ms) so
rapid re-seeks don't reconnect-spam the stream's server, and pending
seek-decode cancels on pause/stop so no ffmpeg restarts while paused.

Also fixes a pre-existing config-write race that intermittently failed
visualizer-toggle.test.ts: updateConfig re-resolved the config path and
re-read the patch state when the deferred write-chain drained, so a
queued save could land in a directory XDG_CONFIG_HOME had since been
pointed at (or carry state mutated after queueing). Path and patch
snapshot are now captured eagerly at call time.
2026-08-11 21:08:09 -04:00
105 changed files with 9338 additions and 2349 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"],
}

View File

@@ -50,10 +50,7 @@ jobs:
- name: Install fftw (cavacore build dependency) - name: Install fftw (cavacore build dependency)
run: | run: |
if uname -s | grep -qi darwin; then if uname -s | grep -qi darwin; then
# mpv is required for the release bundle: build.ts copies it into brew install fftw
# PodTui.app (signed with the podtui bundle identifier) so macOS
# Now Playing shows the PodTui icon instead of a blank placeholder.
brew install fftw mpv
else else
sudo apt-get update sudo apt-get update
sudo apt-get install -y libfftw3-dev sudo apt-get install -y libfftw3-dev
@@ -79,16 +76,6 @@ jobs:
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml" printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
cd "$SMOKE_DIR" cd "$SMOKE_DIR"
./podtui-*/podtui --version ./podtui-*/podtui --version
# macOS tarballs must ship PodTui.app with a working bundled mpv
# carrying the podtui bundle identifier — otherwise Now Playing
# attribution silently regresses to a blank icon.
if [ "${{ matrix.plat }}" = "darwin" ]; then
MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv
test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; }
$MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; }
codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \
|| { echo "bundled mpv lacks podtui signing identifier"; exit 1; }
fi
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.harness/ .harness/
.ralpi .ralpi
notes.md 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 - `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting ### Linting
- `bun run lint` - Run ESLint with TypeScript rules - `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
## Code Style Guidelines ## Code Style Guidelines

View File

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

Binary file not shown.

124
build.ts
View File

@@ -131,130 +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 {
// A darwin release tarball without a bundled mpv silently ships
// without Now Playing attribution (blank icon). Fail loudly so CI
// can't produce it — the runner must have mpv installed.
console.error(
"Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)",
);
process.exit(1);
}
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",
);
}
// Version for the bundle comes from src/index.tsx (single source of
// truth — release.yml requires bumping it in the tag commit).
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
const bundleVersion = versionMatch?.[1];
if (!bundleVersion) {
console.error("Error: could not read VERSION from src/index.tsx");
process.exit(1);
}
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>${bundleVersion}</string>
<key>CFBundleVersion</key>
<string>${bundleVersion}</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([ const tar = Bun.spawnSync([
"tar", "tar",
"-czf", "-czf",

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

@@ -74,6 +74,114 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return 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[] } => { export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast" 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 author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date() const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? [] const items = getRSSItems(xml)
const episodes = items.map((item, index) => { const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, 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
})
return { return {
id: feedUrl, id: feedUrl,
@@ -142,6 +200,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
feedUrl, feedUrl,
lastUpdated, lastUpdated,
isSubscribed: true, isSubscribed: true,
coverUrl: parseChannelCoverUrl(channel),
episodes, episodes,
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
/** /**
* Reactive SolidJS hook wrapping the AudioBackend. * Reactive SolidJS hook over the module-level audio engine.
* *
* Provides signals for playback state and methods for controlling * Wraps utils/audio-engine: every useAudio() call shares ONE engine (all
* audio. Integrates with the event bus and app store. * playback logic, the 150ms poll, session restore, and the event-bus
* commands live there). This hook keeps only what is tied to the Solid
* lifecycle — the ref-counted last-owner dispose and the process-exit
* teardown — and re-exposes the two controls the engine deliberately omits
* (availablePlayers, switchBackend).
* *
* Usage: * Usage:
* ```tsx * ```tsx
@@ -14,117 +18,42 @@
import { onCleanup } from "solid-js"; import { onCleanup } from "solid-js";
import { import {
cachedCoverPath, availablePlayers,
fetchCoverArt, currentEpisode,
prefetchCoverArt,
} from "../utils/cover-art";
import {
createAudioBackend,
detectPlayers,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed, speed,
setSpeed, setSpeed,
backendName, volume,
setBackendName, setVolume,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "../utils/audio-signals"; } from "../utils/audio-signals";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app"; import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry"; import { useMediaRegistry } from "../utils/media-registry";
import { saveLastPlayerSync } from "../utils/app-persistence";
import type { BackendName, DetectedPlayer } from "../utils/audio-player";
import { import {
loadLastPlayerFromFile, createAudioEngine,
saveLastPlayerToFile, ensureEngineBackend,
saveLastPlayerSync, disposeEngineBackend,
} from "../utils/app-persistence"; stopEnginePolling,
import type { Episode, Progress } from "../types/episode"; getEngineBackend,
import type { Feed } from "../types/feed"; switchBackend,
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; restoreLastSession,
import { useFeedStore } from "../stores/feed"; type AudioEngine,
} from "../utils/audio-engine";
export interface AudioControls { // Re-exported so the session-restore test can pull it from this module.
// Signals (reactive getters) export { restoreLastSession };
isPlaying: () => boolean;
position: () => number; // useAudio() surface: the engine plus the two controls it doesn't expose.
duration: () => number; export type AudioControls = AudioEngine & {
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
availablePlayers: () => DetectedPlayer[]; availablePlayers: () => DetectedPlayer[];
// Actions
play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
switchBackend: (name: BackendName) => Promise<void>; switchBackend: (name: BackendName) => Promise<void>;
prev: () => Promise<void>; };
next: () => Promise<void>;
}
// Singleton state — shared across all components that call useAudio() const engine = createAudioEngine();
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null; // Singleton ref count — how many live useAudio() owners there are. The engine
// is shared; the last owner to unmount disposes the backend.
let refCount = 0; let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
// Playback signals are declared in utils/audio-signals.ts (imported above)
// so non-component consumers (the visualizer store) can subscribe without
// mounting a useAudio() owner.
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.
* restored at boot) and the first play action must start the backend
* instead of unpausing it. */
let startedPlayback = false;
/** Completion fraction at/above which an episode is NOT restored at boot. */
const RESTORE_COMPLETION_THRESHOLD = 0.98;
/** 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) {
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
registerExitTeardown();
}
return backend;
}
// ── Process-exit teardown ───────────────────────────────────────────── // ── Process-exit teardown ─────────────────────────────────────────────
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's // `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
@@ -139,7 +68,7 @@ function registerExitTeardown(): void {
if (exitTeardownRegistered) return; if (exitTeardownRegistered) return;
exitTeardownRegistered = true; exitTeardownRegistered = true;
const teardown = (): void => { const teardown = (): void => {
stopPolling(); stopEnginePolling();
// Persist "what's loaded in the player right now" synchronously — // Persist "what's loaded in the player right now" synchronously —
// process.exit(0) runs this handler synchronously and an async write // process.exit(0) runs this handler synchronously and an async write
// would never land. The next launch restores this episode paused. // would never land. The next launch restores this episode paused.
@@ -152,7 +81,7 @@ function registerExitTeardown(): void {
/* best-effort at exit */ /* best-effort at exit */
} }
try { try {
backend?.dispose(); getEngineBackend()?.dispose();
} catch { } catch {
/* best-effort at exit */ /* best-effort at exit */
} }
@@ -171,510 +100,24 @@ 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;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || pollInFlight) return;
pollInFlight = true;
try {
pollCount++;
if (isPlaying()) {
// Track ended (eof-reached observed) or process died. Check
// BEFORE pause reconciliation: mpv keeps the file open at EOF
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
finalizeTrackEnd();
return;
}
// mpv can pause itself outside PodTUI. Reconcile instead of
// staying stuck on "playing" with a frozen waveform
// (getPosition would just re-read the same frozen time-pos).
const paused = await backend.getPauseState();
if (paused === true) {
reconcileExternalPause();
return;
}
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (33 ticks * 150ms)
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
// Paused — watch for playback restarted from outside (AirPods,
// lock-screen/media-center play). Only while the player is
// still alive: a dead player while we thought we were paused
// means the track ended (mpv quits at EOF) or it crashed.
if (!backend.isAlive()) {
finalizeTrackEnd();
return;
}
const paused = await backend.getPauseState();
if (paused === false) {
reconcileExternalResume();
}
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 150);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ── Cover art for system Now Playing ─────────────────────────────────────────
// macOS shows the media session's albumart in the audio center; mpv reads it
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
if (!episode.audioUrl) {
setError("No audio URL for this episode");
return;
}
try {
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Cover art must NEVER gate playback (it was a curl subprocess blocking
// play() by up to 8s). Serve the disk-cached file synchronously when it
// exists; on a miss, start playback bare and fetch in the background —
// the backend applies late art at runtime (mpv video-add).
const coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
await b.play(episode.audioUrl, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
coverArtPath: coverArtPath ?? undefined,
});
if (coverUrl && !coverArtPath) {
fetchCoverArt(coverUrl)
.then((path) => {
if (path && currentEpisode()?.id === episode.id) {
b.addCoverArt(path).catch(() => {});
}
})
.catch(() => {});
}
setCurrentEpisode(episode);
setIsPlaying(true);
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();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
/**
* 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.
if (episode.audioUrl && backend) {
const coverUrl = feed?.podcast.coverUrl;
if (coverUrl) prefetchCoverArt(coverUrl);
const backendSnap = backend;
backendSnap
.preload(episode.audioUrl, {
volume: volume(),
speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined,
mediaTitle: podcastTitle
? `${podcastTitle}${episode.title}`
: episode.title,
coverArtPath: coverUrl
? (cachedCoverPath(coverUrl) ?? undefined)
: undefined,
})
.catch(() => {});
}
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
// Polling stays armed (paused-watch mode): playback can be resumed
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
// and the poll must be live to catch it.
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed");
}
}
async function resume(): Promise<void> {
if (!backend) return;
try {
await backend.resume();
setIsPlaying(true);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Resume failed");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
}
}
async function stop(): Promise<void> {
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
startedPlayback = false;
stopPolling();
emit("player.stop", {});
// Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped);
// Sync back to app store (persisted to config.json for the next launch).
const appStore = useAppStore();
appStore.updateSettings({ volume: clamped });
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped);
// Sync back to app store
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
const feedStore = useFeedStore();
const feed = feedStore
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
/** Serialized restore chain: the boot-triggered restore and any explicit
* call run one after another, so a late-finishing earlier restore can never
* overwrite state changed by a later one (and callers can await the latest
* attempt deterministically). */
let restoreChain: Promise<void> = Promise.resolve();
/**
* Boot-time session restore: reload the episode that was loaded in the
* player when the previous run ended (persisted on play/load and at exit),
* paused at its saved position — never autostarted. Episodes at/above the
* completion threshold are skipped. Silently no-ops when there is nothing
* to restore (empty player, unsubscribed show, or completed episode).
*/
export async function restoreLastSession(): Promise<void> {
const attempt = restoreChain.then(async () => {
const marker = await loadLastPlayerFromFile();
if (!marker?.episodeId) return;
// Feeds and progress load asynchronously at boot; wait for both
// before looking the episode up.
await Promise.all([
useProgressStore().whenReady(),
useFeedStore().whenReady(),
]);
const episode = useFeedStore().findEpisode(marker.episodeId);
if (!episode) return;
// Only restore episodes below the completion threshold.
const saved = useProgressStore().get(episode.id);
if (!isRestoreEligible(saved)) return;
await load(episode);
});
// Keep the chain alive even when an attempt fails; the caller awaiting
// this attempt still observes its own outcome.
restoreChain = attempt.catch(() => {});
await attempt;
}
/** /**
* Reactive audio controls hook. * Reactive audio controls hook.
* *
* Returns a singleton — all components share the same playback state. * Returns the shared audio engine wrapped with the two extra controls, so
* Registers event bus listeners and cleans them up with onCleanup. * all components observe the same playback state. The first useAudio()
* owner creates the backend, runs the one-time boot (volume/speed sync +
* session restore) and registers the process-exit teardown; the last
* owner disposes the backend.
*/ */
export function useAudio(): AudioControls { export function useAudio(): AudioControls {
// Initialize backend on first use const engine = createAudioEngine();
ensureBackend(); ensureEngineBackend();
registerExitTeardown();
// Sync initial speed/volume from app store (reuse the previous session's // First owner: sync speed/volume from the persisted settings and restore
// playback levels; defaults are 1x and 100%). // the last player session once (loaded, not playing). Raw signal
// accessors are used here on purpose — this is boot-only, not a user
// volume/speed change, so it must not re-persist to the app store.
if (refCount === 0) { if (refCount === 0) {
const appStore = useAppStore(); const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed; const storeSpeed = appStore.state().settings.playbackSpeed;
@@ -700,160 +143,13 @@ export function useAudio(): AudioControls {
refCount++; refCount++;
// Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
});
const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
});
// Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback();
});
const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
});
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const currentPos = position();
const currentDur = duration();
const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
} else {
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.prev(newIndex);
}
}
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.next(newIndex);
}
}
onCleanup(() => { onCleanup(() => {
refCount--; refCount--;
unsubPlay();
unsubStop();
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSpeed();
if (refCount <= 0) { if (refCount <= 0) {
stopPolling(); disposeEngineBackend();
if (backend) {
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
const media = useMediaRegistry();
media.clearNowPlaying();
refCount = 0; refCount = 0;
} }
}); });
return { return { ...engine, availablePlayers, switchBackend };
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
availablePlayers,
play,
load,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
switchBackend,
prev,
next,
};
} }

View File

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

View File

@@ -5,18 +5,28 @@
* placeholder (1/5 slot kept). * placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent * depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list. * pane = the categories list.
* preview — detail of the hovered item (category summary, or * depth 2 (current) — episodes of the drilled show, fetched on demand
* podcast detail + subscribe action). * 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 * Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a * remains. `l`/Enter drills in (category → results → episodes); `a`
* podcast); `h` pops a depth (noop at 0). j/k move only within the current * subscribes the focused show (enter/l never subscribe — they open the
* column. Moving through categories at depth 0 updates the store's selected * episode list); `h` pops a depth (noop at 0). j/k move only within the
* category so the preview follows. * 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 { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover"; 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 { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker"; import { useSelectionMarker } from "@/hooks/useSelectionMarker";
@@ -41,11 +52,16 @@ function DiscoverPage() {
// Static: detection never changes mid-session. // Static: detection never changes mid-session.
const nerd = supportsNerdFonts(); const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore(); const discoverStore = useDiscoverStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const marker = useSelectionMarker(); const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth; const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d); 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); podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]); 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 = () => const curLen = () =>
depth() === 0 ? categories().length : podcasts().length; depth() === 0
? categories().length
: depth() === 1
? podcasts().length
: episodes().length;
const ensureFocus = () => { const ensureFocus = () => {
if (categories().length > 0 && focus(0) >= categories().length) if (categories().length > 0 && focus(0) >= categories().length)
nav.setDepthFocus(categories().length - 1, 0); nav.setDepthFocus(categories().length - 1, 0);
if (podcasts().length > 0 && focus(1) >= podcasts().length) if (podcasts().length > 0 && focus(1) >= podcasts().length)
nav.setDepthFocus(podcasts().length - 1, 1); nav.setDepthFocus(podcasts().length - 1, 1);
if (episodes().length > 0 && focus(2) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 2);
}; };
onMount(ensureFocus); onMount(ensureFocus);
@@ -80,13 +119,56 @@ function DiscoverPage() {
onMount(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id; if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id; if (depth() === 1) return podcasts()[i]?.id;
return episodes()[i]?.id;
}); });
}); });
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy"); 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 ─────────────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
function open() { function open() {
if (depth() === 0) { if (depth() === 0) {
@@ -97,9 +179,19 @@ function DiscoverPage() {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
return; return;
} }
if (depth() >= 1) { if (depth() === 1) {
const pod = focusedPodcast(); 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()), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(), open: () => open(),
"toggle-select": () => { "toggle-select": () => {
if (depth() >= 1) { if (depth() === 1) {
const pod = focusedPodcast(); const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id); 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: () => { refresh: () => {
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
return;
}
discoverStore.refresh().catch(() => {}); discoverStore.refresh().catch(() => {});
}, },
}; };
@@ -161,7 +300,9 @@ function DiscoverPage() {
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
? "Categories" ? "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) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal // Sibling <Show> blocks per depth (the known-good opentui disposal
@@ -174,7 +315,7 @@ function DiscoverPage() {
<Show when={depth() === 0}> <Show when={depth() === 0}>
<TabListPane muted /> <TabListPane muted />
</Show> </Show>
<Show when={depth() >= 1}> <Show when={depth() === 1}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => { {(cat, index) => {
const lf = () => nav.depthFocus(0); const lf = () => nav.depthFocus(0);
@@ -203,6 +344,33 @@ function DiscoverPage() {
}} }}
</For> </For>
</Show> </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> </For>
</Show> </Show>
{/* depth ≥1: results */} {/* depth ≥1: results */}
<Show when={depth() >= 1}> <Show when={depth() === 1}>
<Show <Show
when={podcasts().length > 0} when={podcasts().length > 0}
fallback={ fallback={
@@ -304,11 +472,59 @@ function DiscoverPage() {
</For> </For>
<Show when={discoverStore.isLoading()}> <Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" /> <LoadingIndicator />
</box> </box>
</Show> </Show>
</Show> </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> </box>
)} )}
</Show> </Show>
) : ( ) : depth() === 1 ? (
// depth 1 preview: hovered podcast + subscribe // depth 1 preview: hovered podcast + episode-list hint
<Show <Show
when={focusedPodcast()} when={focusedPodcast()}
fallback={ fallback={
@@ -376,10 +592,10 @@ function DiscoverPage() {
<text fg={muted()}>by {pod().author}</text> <text fg={muted()}>by {pod().author}</text>
</Show> </Show>
<Show when={pod().isSubscribed}> <Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text> <text fg={theme.success}> Subscribed · x: unsubscribe</text>
</Show> </Show>
<Show when={!pod().isSubscribed}> <Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text> <text fg={theme.primary}>a: subscribe</text>
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
@@ -398,10 +614,67 @@ function DiscoverPage() {
</Show> </Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text> <text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} /> <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> </box>
)} )}
</Show> </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 ( return (

View File

@@ -19,9 +19,8 @@
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app"; import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { import {
@@ -32,14 +31,19 @@ import {
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus"; 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 { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
} from "@/components/EpisodeList";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker"; import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
@@ -63,14 +67,26 @@ function FeedPage() {
() => feedStore.getAllEpisodesChronological() as EpItem[], () => 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 ─────────────────────────────────────────────────────────── // ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's // 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: // loaded window by 50 episodes. Enter on the row to load the next batch.
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
const showFetchMore = () => feedStore.hasMoreAcrossAll(); const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0); const focus = () => nav.depthFocus(0);
const focusedRow = () => const focusedRow = () =>
@@ -86,7 +102,30 @@ function FeedPage() {
const focusedItem = (): EpItem | undefined => const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount(); 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 = () => { const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount()) if (rowCount() > 0 && focus() >= rowCount())
@@ -94,16 +133,6 @@ function FeedPage() {
}; };
onMount(ensureFocus); onMount(ensureFocus);
// Auto mode: reaching the bottom row loads the next batch. Guarded by
// isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreAllFeeds().catch(() => {});
});
onMount(() => { onMount(() => {
nav.registerResolver( nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, `${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -112,12 +141,6 @@ function FeedPage() {
}); });
// ── helpers ──────────────────────────────────────────────────────────────── // ── 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) => { const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) { switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED: case DownloadStatus.QUEUED:
@@ -212,19 +235,6 @@ function FeedPage() {
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; 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}`; const currentLabel = () => `Feed · ${episodes().length}`;
@@ -236,7 +246,7 @@ function FeedPage() {
<Show <Show
when={episodes().length > 0} when={episodes().length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1} alignItems="center">
<Show <Show
when={feedStore.isLoadingFeeds()} when={feedStore.isLoadingFeeds()}
fallback={ fallback={
@@ -245,141 +255,78 @@ function FeedPage() {
</text> </text>
} }
> >
<LoadingIndicator label="Refreshing…" /> <LoadingIndicator />
</Show> </Show>
</box> </box>
} }
> >
<For each={episodes()}> {/* Spacers keep the scrollbox content at the FULL list height so
{(item, index) => { the scrollbar reflects the real list, not the render window. */}
const fi = () => focusedEpIdx(); <Show when={listWindow()[0] > 0}>
const ref = useScrollIntoView(() => index() === fi()); <box height={listWindow()[0] * ROW_HEIGHT} />
return ( </Show>
<box <For each={visibleEpisodes()}>
ref={ref} {(item, index) => (
flexDirection="column" <EpisodeRow
gap={0} episode={item.episode}
paddingRight={1} subtitle={() => item.feed.customName || item.feed.podcast.title}
backgroundColor={focusBg(index(), fi(), isActive())} index={() => listWindow()[0] + index()}
onMouseDown={() => { focused={focusedEpIdx}
nav.setActivePane(DEPTH_CENTER_PANE); active={isActive}
nav.setDepthFocus(index(), 0); selected={() => nav.isSelected(item.episode.id)}
}} downloadLabel={() => downloadLabel(item.episode.id)}
> downloadColor={() => downloadColor(item.episode.id)}
<box flexDirection="row" gap={1}> marker={marker}
<text onMouseDown={() => {
flexShrink={0} nav.setActivePane(DEPTH_CENTER_PANE);
fg={focusFg(index(), fi(), isActive())} nav.setDepthFocus(listWindow()[0] + index(), 0);
> }}
{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>
);
}}
</For> </For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}> <Show when={showFetchMore()}>
<box <FetchMoreRow
ref={moreRef} index={() => episodes().length}
flexDirection="row" focused={focusedRow}
gap={1} onMore={focusedOnMore}
paddingRight={1} active={isActive}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())} isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0); 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>
<Show when={feedStore.isLoadingFeeds()}> <Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}> <box alignItems="center" paddingTop={1}>
<LoadingIndicator label="Refreshing…" /> <LoadingIndicator />
</box> </box>
</Show> </Show>
</Show> </Show>
); );
// ── preview pane: hovered-episode detail (or the Fetch More row) ────────── // ── 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 = () => ( const previewContent = () => (
<> <>
<Show when={focusedOnMore()}> <Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}> <FetchMorePreview
<text fg={theme.textPrimary ?? theme.text}> isLoadingMore={() => feedStore.isLoadingMore()}
<strong>[Fetch More]</strong> manualText={() =>
</text> "Load the next batch of older episodes across all feeds (Enter)."
<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>
</Show> </Show>
<Show when={!focusedOnMore()}> <Show when={!focusedOnMore()}>
<Show <Show
@@ -391,46 +338,16 @@ function FeedPage() {
} }
> >
{(item) => ( {(item) => (
<box flexDirection="column" gap={1} padding={1}> <EpisodePreview
<text fg={theme.textPrimary ?? theme.text}> episode={() => item().episode}
<strong> subtitle={() =>
{item().episode.episodeNumber item().feed.customName || item().feed.podcast.title
? `#${item().episode.episodeNumber} ` }
: ""} author={() => item().feed.podcast.author}
{item().episode.title} downloadLabel={() => downloadLabel(item().episode.id)}
</strong> downloadColor={() => downloadColor(item().episode.id)}
</text> hint={() => episodeHint(item())}
<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>
)} )}
</Show> </Show>
</Show> </Show>

View File

@@ -6,20 +6,21 @@
* depth 1 (current) — episodes of the drilled show. Parent pane = shows. * depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column. * preview — detail of the hovered item in the current column.
* *
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior * Depth 0's shows list and depth 1's episode list both end with a
* as the Feed tab) that loads the next batch of episodes for that show. * "[Fetch More]" row that loads the next batch of episodes — depth 0 for
* every subscribed show, depth 1 for the drilled show.
* *
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX * Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column. * 0). j/k move only within the current column.
*/ */
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { import {
@@ -31,16 +32,218 @@ import {
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus"; 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 { KeybindActionName } from "@/context/KeybindContext";
import type { Episode, DownloadedEpisode } from "@/types/episode"; import type { Episode, DownloadedEpisode } from "@/types/episode";
import type { Feed } from "@/types/feed"; 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 { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker"; 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 const MyShowsPaneCount = 1;
export function MyShowsPage() { export function MyShowsPage() {
@@ -67,21 +270,35 @@ export function MyShowsPage() {
// entry drops out the moment the user subscribes to its show. // entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads(); const unsubs = () => downloadStore.getUnsubscribedDownloads();
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads. /** True while some subscribed show has more episodes to load — shows the
const depth0Count = () => shows().length + unsubs().length; * depth-0 "[Fetch More]" row. */
const showLoadMore = () => feedStore.hasMoreAcrossAll();
const depth0Count = () =>
shows().length + unsubs().length + (showLoadMore() ? 1 : 0);
const focusedRow0 = () =>
depth0Count() === 0 ? 0 : Math.min(focus(0), depth0Count() - 1);
/** True while the depth-0 cursor sits on the "[Fetch More]" row. */
const focusedOnMore0 = () =>
showLoadMore() && focusedRow0() === shows().length + unsubs().length;
const focusedShowIdx = () => const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); focusedOnMore0()
? -1
: Math.min(focusedRow0(), Math.max(shows().length - 1, 0));
/** True when the depth-0 cursor sits on an unsubscribed-show download /** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */ * row (past the shows list). */
const focusedOnUnsub = () => const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0; !focusedOnMore0() &&
depth() === 0 &&
focusedRow0() >= shows().length &&
unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => { const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined; if (!focusedOnUnsub()) return undefined;
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)]; return unsubs()[
Math.min(focusedRow0() - shows().length, unsubs().length - 1)
];
}; };
const selectedShow = (): Feed | undefined => { const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub()) return undefined; if (focusedOnUnsub() || focusedOnMore0()) return undefined;
return shows()[focusedShowIdx()]; return shows()[focusedShowIdx()];
}; };
@@ -99,15 +316,11 @@ export function MyShowsPage() {
// ── Fetch More ─────────────────────────────────────────────────────────── // ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of a drilled show's episode list // A "[Fetch More]" row at the bottom of a drilled show's episode list
// advances that show's loaded window by 50 episodes — the per-show // advances that show's loaded window by 50 episodes — the per-show
// counterpart to the Feed page's row (which loads every feed). manual // counterpart to the Feed page's row (which loads every feed).
// 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 showFetchMore = () => const showFetchMore = () =>
depth() >= 1 && depth() >= 1 &&
!!drilledShowId() && !!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId()); feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () => const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1); rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
@@ -121,7 +334,30 @@ export function MyShowsPage() {
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0)); : Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () => const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
// ── 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 curLen = () => (depth() === 0 ? depth0Count() : rowCount());
@@ -143,24 +379,7 @@ export function MyShowsPage() {
}); });
}); });
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ───────────────────────────────────────────────────────────────── // ── 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) => { const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) { switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED: case DownloadStatus.QUEUED:
@@ -214,6 +433,10 @@ export function MyShowsPage() {
// ── drill / open ─────────────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
function open() { function open() {
if (depth() === 0) { if (depth() === 0) {
if (focusedOnMore0()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
const d = focusedUnsub(); const d = focusedUnsub();
if (d) { if (d) {
playUnsubscribedDownload(d); playUnsubscribedDownload(d);
@@ -323,14 +546,6 @@ export function MyShowsPage() {
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; 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 showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () => const currentLabel = () =>
@@ -349,19 +564,30 @@ export function MyShowsPage() {
{(feed, index) => { {(feed, index) => {
const lf = () => nav.depthFocus(0); const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf()); const ref = useScrollIntoView(() => index() === lf());
const focused = () => index() === lf();
const fg = () =>
focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return ( return (
<box <box
ref={ref} ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)} backgroundColor={focused() ? theme.border : undefined}
> >
<text fg={focusFg(index(), lf(), false)}> <text flexShrink={0} fg={fg()}>
{index() === lf() ? marker() : " "} {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>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box> </box>
); );
}} }}
@@ -385,51 +611,28 @@ export function MyShowsPage() {
} }
> >
<For each={shows()}> <For each={shows()}>
{(feed, index) => { {(feed, index) => (
const lf = () => focusedShowIdx(); <ShowRow
const ref = useScrollIntoView(() => index() === lf()); feed={feed}
const wlScope = title={showTitle(feed)}
app.state().preferences.autoDownloadScope === "whitelist"; index={index}
const wlInList = ( focused={focusedShowIdx}
app.state().preferences.autoDownloadWhitelist ?? [] active={isActive}
).includes(feed.id); marker={marker}
return ( wlScope={() =>
<box app.state().preferences.autoDownloadScope === "whitelist"
ref={ref} }
flexDirection="row" wlInList={() =>
gap={1} (app.state().preferences.autoDownloadWhitelist ?? []).includes(
paddingRight={1} feed.id,
backgroundColor={focusBg(index(), lf(), isActive())} )
onMouseDown={() => { }
nav.setActivePane(DEPTH_CENTER_PANE); onMouseDown={() => {
nav.setDepthFocus(index(), 0); nav.setActivePane(DEPTH_CENTER_PANE);
}} nav.setDepthFocus(index(), 0);
> }}
<text fg={focusFg(index(), lf(), isActive())}> />
{index() === lf() ? marker() : " "} )}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
</For> </For>
<Show when={unsubs().length > 0}> <Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}> <box paddingLeft={1} paddingTop={1}>
@@ -438,66 +641,40 @@ export function MyShowsPage() {
</text> </text>
</box> </box>
<For each={unsubs()}> <For each={unsubs()}>
{(d, index) => { {(d, index) => (
// Rows continue after the shows list. <UnsubscribedRow
const rowIdx = () => shows().length + index(); d={d}
const lf = () => nav.depthFocus(0); index={() => shows().length + index()}
const ref = useScrollIntoView(() => rowIdx() === lf()); focused={focusedRow0}
return ( active={isActive}
<box marker={marker}
ref={ref} downloadLabel={() => downloadLabel(d.episodeId)}
flexDirection="column" downloadColor={() => downloadColor(d.episodeId)}
gap={0} onMouseDown={() => {
paddingRight={1} nav.setActivePane(DEPTH_CENTER_PANE);
backgroundColor={focusBg(rowIdx(), lf(), isActive())} nav.setDepthFocus(shows().length + index(), 0);
onMouseDown={() => { }}
nav.setActivePane(DEPTH_CENTER_PANE); />
nav.setDepthFocus(rowIdx(), 0); )}
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(rowIdx(), lf(), isActive())}
>
{rowIdx() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(rowIdx(), lf(), isActive())}
>
{d.episodeTitle ?? d.episodeId}
</text>
<Show when={downloadLabel(d.episodeId)}>
<text
flexShrink={0}
fg={downloadColor(d.episodeId)}
>
{downloadLabel(d.episodeId)}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={
rowIdx() === lf()
? theme.surface
: theme.textSecondary
}
>
{d.podcastTitle ?? d.feedId}
</text>
</box>
</box>
);
}}
</For> </For>
</Show> </Show>
<Show when={showLoadMore()}>
<FetchMoreRow
index={() => shows().length + unsubs().length}
focused={focusedRow0}
onMore={focusedOnMore0}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(shows().length + unsubs().length, 0);
}}
/>
</Show> </Show>
</Show> </Show>
</Show>
{/* depth ≥1: episodes */} {/* depth ≥1: episodes */}
<Show when={depth() >= 1}> <Show when={depth() >= 1}>
<Show <Show
@@ -508,99 +685,47 @@ export function MyShowsPage() {
</box> </box>
} }
> >
<For each={episodes()}> {/* Spacers keep the scrollbox content at the FULL list
{(ep, index) => { height so the scrollbar reflects the real list, not the
const lf = () => focusedEpIdx(); render window. */}
const ref = useScrollIntoView(() => index() === lf()); <Show when={listWindow()[0] > 0}>
return ( <box height={listWindow()[0] * ROW_HEIGHT} />
<box </Show>
ref={ref} <For each={visibleEpisodes()}>
flexDirection="column" {(ep, index) => (
gap={0} <EpisodeRow
paddingRight={1} episode={ep}
backgroundColor={focusBg(index(), lf(), isActive())} index={() => listWindow()[0] + index()}
onMouseDown={() => { focused={focusedEpIdx}
nav.setActivePane(DEPTH_CENTER_PANE); active={isActive}
nav.setDepthFocus(index(), 1); selected={() => nav.isSelected(ep.id)}
}} downloadLabel={() => downloadLabel(ep.id)}
> downloadColor={() => downloadColor(ep.id)}
<box flexDirection="row" gap={1}> marker={marker}
<text onMouseDown={() => {
flexShrink={0} nav.setActivePane(DEPTH_CENTER_PANE);
fg={focusFg(index(), lf(), isActive())} nav.setDepthFocus(listWindow()[0] + index(), 1);
> }}
{index() === lf() ? marker() : " "} />
</text> )}
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For> </For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}> <Show when={showFetchMore()}>
<box <FetchMoreRow
ref={moreRef} index={() => episodes().length}
flexDirection="row" focused={focusedRow}
gap={1} onMore={focusedOnMore}
paddingRight={1} active={isActive}
backgroundColor={focusBg( isLoadingMore={() => feedStore.isLoadingMore()}
episodes().length, nerd={nerd}
focusedRow(), marker={marker}
isActive(),
)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1); 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> </Show>
</Show> </Show>
@@ -608,102 +733,85 @@ export function MyShowsPage() {
); );
// ── preview pane ─────────────────────────────────────────────────────────── // ── 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 = () => const previewContent = () =>
depth() === 0 ? ( depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the // depth 0 preview: hovered "[Fetch More]" row, else the
// hovered show. // unsubscribed-show download, else the hovered show.
<Show <>
when={focusedUnsub()} <Show when={focusedOnMore0()}>
fallback={ <FetchMorePreview
<Show isLoadingMore={() => feedStore.isLoadingMore()}
when={selectedShow()} manualText={() =>
fallback={ "Load the next batch of older episodes across all subscribed shows (Enter)."
<box padding={1}> }
<text fg={muted()}>No show focused</text> />
</box>
}
>
{(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>
)}
</Show>
}
>
{(d) => (
<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={downloadLabel(d().episodeId)}>
<text fg={downloadColor(d().episodeId)}>
{downloadLabel(d().episodeId)}
</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>
)}
</Show> </Show>
) : ( <Show when={!focusedOnMore0()}>
<Show
when={focusedUnsub()}
fallback={
<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>
}
>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
</Show>
</>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row) // depth ≥1 preview: hovered episode (or the Fetch More row)
<> <>
<Show when={focusedOnMore()}> <Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}> <FetchMorePreview
<text fg={theme.textPrimary ?? theme.text}> isLoadingMore={() => feedStore.isLoadingMore()}
<strong>[Fetch More]</strong> manualText={() =>
</text> "Load the next batch of older episodes for this show (Enter)."
<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>
</Show> </Show>
<Show when={!focusedOnMore()}> <Show when={!focusedOnMore()}>
<Show <Show
@@ -714,53 +822,19 @@ export function MyShowsPage() {
</box> </box>
} }
> >
{(ep) => ( {(ep) => (
<box flexDirection="column" gap={1} padding={1}> <EpisodePreview
<text fg={theme.textPrimary ?? theme.text}> episode={() => ep()}
<strong> author={() => selectedShow()?.podcast.author}
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} downloadLabel={() => downloadLabel(ep().id)}
{ep().title} downloadColor={() => downloadColor(ep().id)}
</strong> hint={() => episodeHint(ep().id)}
</text> />
<box flexDirection="row" gap={2}> )}
<text fg={theme.info}>{formatDate(ep().pubDate)}</text> </Show>
<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>
)}
</Show> </Show>
</Show> </>
</> );
);
return ( return (
<PaneRow <PaneRow

View File

@@ -19,6 +19,7 @@ import { useVisualizer } from "@/stores/visualizer";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext"; import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { useTerminalDimensions } from "@opentui/solid";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
@@ -29,6 +30,7 @@ export function PlayerPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const nav = useNavigation();
const viz = useVisualizer(); const viz = useVisualizer();
const dims = useTerminalDimensions();
const app = useAppStore(); const app = useAppStore();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
// Settings master switch: off hides the waveform entirely (the store // Settings master switch: off hides the waveform entirely (the store
@@ -89,9 +91,14 @@ export function PlayerPage() {
<text fg={theme.text}> <text fg={theme.text}>
<strong>{ep().title}</strong> <strong>{ep().title}</strong>
</text> </text>
<text fg={muted()}> <Show
{ep().description?.slice(0, 500) ?? "No description available."} when={ep().description}
</text> fallback={<text fg={muted()}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={muted()}>{ep().description}</text>
</scrollbox>
</Show>
<ProgressBar /> <ProgressBar />
@@ -133,6 +140,7 @@ export function PlayerPage() {
currentLabel="Player" currentLabel="Player"
panes={2} panes={2}
focused={isActive} focused={isActive}
currentBorder={["left"]}
/> />
); );
} }

View File

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

View File

@@ -8,8 +8,9 @@
* *
* This component only subscribes to store state, reports the width-derived * This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders: * bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the * a braille spinner while the pipeline is loading its first frames or the
* frequency bars once frames arrive, and a dotted placeholder when idle. * player is stalled (re-buffering), the frequency bars once frames arrive,
* and a dotted placeholder when idle.
*/ */
import { createEffect, on } from "solid-js"; import { createEffect, on } from "solid-js";
@@ -18,7 +19,7 @@ import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping"; import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { PANE_RATIO } from "@/utils/navigation"; import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
@@ -26,20 +27,16 @@ export function RealtimeWaveform() {
const { theme } = useTheme(); const { theme } = useTheme();
const viz = useVisualizer(); 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
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
// chrome (scrollbox border + box padding + waveform border + padding).
// Falls back to 64 before the renderer reports a real size.
const dimensions = useTerminalDimensions(); const dimensions = useTerminalDimensions();
const layout = usePaneLayout();
const numBars = () => { const numBars = () => {
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
const width = dimensions().width; const width = dimensions().width;
if (!width) return 64; if (!width) return 64;
// The player is a 2-pane row: the current column = whole width minus
// the parent (left split). Subtract ~8 chars of chrome.
return Math.max( return Math.max(
8, 8,
Math.min(256, Math.floor((width * current) / total) - 8), Math.min(256, Math.floor(width * (1 - layout.splits().left)) - 8),
); );
}; };
@@ -53,12 +50,13 @@ export function RealtimeWaveform() {
const bars = viz.barData(); const bars = viz.barData();
const count = numBars(); const count = numBars();
// Loading state: the braille spinner shows while the pipeline warms // Loading state: the braille spinner shows while the pipeline is
// up — but only when there are no bars to render yet (first play / // warming up — cold start (first play / after an unload), resume
// after an unload). On resume/seek the last bars stay on screen // into undecoded audio, or a stalled position clock (mpv
// until fresh frames arrive, so the waveform never blanks out for // re-buffering after a long pause on a network stream). The store
// the (multi-second, network-bound) cold start. // clears it the moment the first fresh frame renders, so stale
if (bars.length === 0 && viz.isLoading()) { // bars never masquerade as live data while the pipeline re-arms.
if (viz.isLoading() || viz.isStalled()) {
return <LoadingIndicator />; return <LoadingIndicator />;
} }

View File

@@ -77,30 +77,15 @@ function SearchPage() {
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query(); const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ──────────────────────────────────────────────────────── // ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell // `inputFocused` tells the Shell router to yield keys to the query input.
// router yields keys to the <input> while this is true; Escape (in Shell) // The input's REAL focus is the source of truth: useInputFocusNav flips
// sets it false so navigation resumes; `s` (search action) sets it true. // 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 input's REAL focus is the source of truth for the flag: // The depth stack only SEEDS it on transitions (re-entering depth 0
// useInputFocusNav (the same hook the Settings forms use) flips // focuses the input; mounting at depth 1 stays list-nav), gated on the
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag // depth VALUE via a memo because setDepthFocus also writes the stack
// and the renderable in lockstep. That matters when the user clicks OFF the // signal — without the memo every j/k at query depth re-focuses the input
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest // and strands the recents list.
// 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.
onMount(() => nav.setInputFocused(depth() === 0)); onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false)); onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav(); const focusNavRef = useInputFocusNav();

View File

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

View File

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

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

@@ -43,8 +43,11 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all", autoDownloadScope: "all",
autoDownloadWhitelist: [], autoDownloadWhitelist: [],
autoJumpToPlayer: true, autoJumpToPlayer: true,
fetchMoreMode: "manual",
refreshIntervalMinutes: 30, refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
paneSplit: { left: 0.2, right: 0.7 },
}; };
const defaultState: AppState = { const defaultState: AppState = {

View File

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

View File

@@ -11,6 +11,7 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast"; import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
export interface DiscoverCategory { export interface DiscoverCategory {
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
/** Cache window for the remote featured list (24 hours) */ /** Cache window for the remote featured list (24 hours) */
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000; 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 */ /** Shape of a single entry in the remote JSON */
interface FeaturedEntry { interface FeaturedEntry {
id: string; id: string;
@@ -85,12 +90,24 @@ function syncSubscriptionState(
})); }));
} }
/** Create discover store */
export function createDiscoverStore() { export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all"); const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false); const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]); 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) // In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0; let cachedAt = 0;
@@ -107,7 +124,6 @@ export function createDiscoverStore() {
const refresh = async () => { const refresh = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// Skip if cache is still fresh
const now = Date.now(); const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) { if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions(); syncSubscriptions();
@@ -131,7 +147,6 @@ export function createDiscoverStore() {
cachedAt = now; cachedAt = now;
setPodcasts(fetched); setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions(); syncSubscriptions();
} catch { } catch {
// Network failure — keep whatever we have (stale or empty) // Network failure — keep whatever we have (stale or empty)
@@ -140,7 +155,6 @@ export function createDiscoverStore() {
} }
}; };
/** Get filtered podcasts by category */
const filteredPodcasts = () => { const filteredPodcasts = () => {
const category = selectedCategory(); const category = selectedCategory();
if (category === "all") { if (category === "all") {
@@ -155,7 +169,6 @@ export function createDiscoverStore() {
}); });
}; };
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => { const subscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId); const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) { if (podcast) {
@@ -168,7 +181,6 @@ export function createDiscoverStore() {
); );
}; };
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => { const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId); const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) { if (podcast) {
@@ -180,14 +192,64 @@ export function createDiscoverStore() {
); );
}; };
/** Toggle subscription */ // ── episode preview (drill-in, no subscription) ──────────────────────────
const toggleSubscription = (podcastId: string) => { /** Cached episode list for a previewed show (empty until first drill-in). */
const podcast = podcasts().find((p) => p.id === podcastId); const episodesForPodcast = (podcastId: string): Episode[] =>
if (podcast?.isSubscribed) { previewEpisodes()[podcastId] ?? [];
unsubscribe(podcastId);
} else { const isLoadingEpisodesFor = (podcastId: string): boolean =>
subscribe(podcastId); 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 { return {
@@ -202,12 +264,17 @@ export function createDiscoverStore() {
setSelectedCategory, setSelectedCategory,
subscribe, subscribe,
unsubscribe, unsubscribe,
toggleSubscription,
refresh, refresh,
// Episode preview (drill-in, no subscription)
episodesForPodcast,
isLoadingEpisodesFor,
previewError,
openEpisodes,
refreshEpisodes,
}; };
} }
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null; let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() { export function useDiscoverStore() {

View File

@@ -63,7 +63,62 @@ interface QueueItem {
episodeTitle: string; 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() { function createDownloadStore() {
const [downloads, setDownloads] = createSignal< const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode> Map<string, DownloadedEpisode>
@@ -195,7 +250,6 @@ function createDownloadStore() {
} }
} }
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> { async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController(); const controller = new AbortController();
abortControllers.set(item.episodeId, controller); abortControllers.set(item.episodeId, controller);
@@ -236,24 +290,23 @@ function createDownloadStore() {
error: null, error: null,
}); });
// Write the podcast cover beside the audio so mpv's // Decorate the local file: cover art + ID3 tags (see the
// --cover-art-auto=exact picks it up for Now Playing art. // module-level helpers above) — the source streams carry neither.
const coverUrl = useFeedStore() // Cover falls back to the episode's own image when the feed has
.feeds() // no channel cover (URL-added feeds).
.find((f) => f.id === item.feedId)?.podcast.coverUrl; const feedStore = useFeedStore();
if (coverUrl && result.filePath) { const episode = feedStore.findEpisode(item.episodeId);
const dot = result.filePath.lastIndexOf("."); const feed = feedStore.feeds().find((f) => f.id === item.feedId);
if (dot > 0) { const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
const coverPath = result.filePath.slice(0, dot) + ".jpg"; if (result.filePath && coverUrl) {
fetch(coverUrl) writeCoverArt(result.filePath, coverUrl);
.then(async (r) => { }
if (!r.ok) return; if (result.filePath && episode) {
await Bun.write( const podcastTitle =
coverPath, feed?.podcast.title ??
new Uint8Array(await r.arrayBuffer()), downloads().get(item.episodeId)?.podcastTitle;
); if (podcastTitle) {
}) tagLocalFile(result.filePath, episode, podcastTitle);
.catch(() => {});
} }
} }
} else { } else {
@@ -269,22 +322,18 @@ function createDownloadStore() {
processQueue(); processQueue();
} }
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => { const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE; return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
}; };
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => { const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0; return downloads().get(episodeId)?.progress ?? 0;
}; };
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => { const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId); return downloads().get(episodeId);
}; };
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => { const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId); const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) { if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
@@ -301,7 +350,6 @@ function createDownloadStore() {
podcastFeedUrl?: string; podcastFeedUrl?: string;
} }
/** Start downloading an episode */
const startDownload = ( const startDownload = (
episode: Episode, episode: Episode,
feedId: string, feedId: string,
@@ -365,7 +413,6 @@ function createDownloadStore() {
}); });
}; };
/** Cancel a download */
const cancelDownload = (episodeId: string): void => { const cancelDownload = (episodeId: string): void => {
// Abort active download // Abort active download
const controller = abortControllers.get(episodeId); const controller = abortControllers.get(episodeId);
@@ -386,7 +433,6 @@ function createDownloadStore() {
saveDownloads().catch(() => {}); saveDownloads().catch(() => {});
}; };
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => { const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId); const dl = downloads().get(episodeId);
if (dl?.filePath) { if (dl?.filePath) {
@@ -432,7 +478,6 @@ function createDownloadStore() {
} }
}; };
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => { const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values()); return Array.from(downloads().values());
}; };
@@ -455,12 +500,10 @@ function createDownloadStore() {
}); });
}; };
/** Get the current queue */
const getQueue = (): QueueItem[] => { const getQueue = (): QueueItem[] => {
return queue(); return queue();
}; };
/** Get count of active downloads */
const getActiveCount = (): number => { const getActiveCount = (): number => {
return activeCount(); return activeCount();
}; };
@@ -485,7 +528,6 @@ function createDownloadStore() {
}; };
} }
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null; let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() { export function useDownloadStore() {

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@@ -64,16 +64,10 @@ function createProgressStore() {
*/ */
whenReady: () => progressInit, whenReady: () => progressInit,
/**
* Get progress for a specific episode.
*/
get(episodeId: string): Progress | undefined { get(episodeId: string): Progress | undefined {
return progressMap()[episodeId]; return progressMap()[episodeId];
}, },
/**
* Get all progress entries.
*/
all(): Record<string, Progress> { all(): Record<string, Progress> {
return progressMap(); return progressMap();
}, },
@@ -102,18 +96,12 @@ function createProgressStore() {
persist(); persist();
}, },
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean { isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId]; const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false; if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD; return p.position / p.duration >= COMPLETION_THRESHOLD;
}, },
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number { getPercent(episodeId: string): number {
const p = progressMap()[episodeId]; const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0; if (!p || p.duration <= 0) return 0;
@@ -151,9 +139,6 @@ function createProgressStore() {
persist(); persist();
}, },
/**
* Clear all progress data.
*/
clear(): void { clear(): void {
setProgressMap({}); setProgressMap({});
persist(); persist();

View File

@@ -11,6 +11,7 @@ import {
} from "../utils/app-persistence"; } from "../utils/app-persistence";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import type { SearchResult, SearchScope } from "../types/source"; import type { SearchResult, SearchScope } from "../types/source";
import { createPersistScheduler } from "./persist";
const STORAGE_SCOPE_KEY = "podtui_search_scope"; const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 10; const MAX_HISTORY = 10;
@@ -60,7 +61,6 @@ function saveScope(scope: SearchScope): void {
} }
} }
/** Create search store */
export function createSearchStore() { export function createSearchStore() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const [query, setQuery] = createSignal(""); const [query, setQuery] = createSignal("");
@@ -71,6 +71,15 @@ export function createSearchStore() {
const [selectedSources, setSelectedSources] = createSignal<string[]>([]); const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope()); const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** History persistence: rapid mutations collapse into one debounced
* write; the closure reads the live signal so a flush lands the
* latest list. */
const persistHistory = createPersistScheduler((domain: string) => {
if (domain === "search-history") {
saveSearchHistoryToFile(history());
}
});
/** Load search history from file (fire-and-forget; recents appear as /** Load search history from file (fire-and-forget; recents appear as
* soon as the file is read). */ * soon as the file is read). */
async function init(): Promise<void> { async function init(): Promise<void> {
@@ -167,38 +176,27 @@ export function createSearchStore() {
} }
}; };
/** Add query to history */
const addToHistory = (q: string) => { const addToHistory = (q: string) => {
setHistory((prev) => { setHistory((prev) => sanitizeHistory([q, ...prev]));
const updated = sanitizeHistory([q, ...prev]); persistHistory.schedule("search-history");
saveSearchHistoryToFile(updated);
return updated;
});
}; };
/** Clear search history */
const clearHistory = () => { const clearHistory = () => {
setHistory([]); setHistory([]);
saveSearchHistoryToFile([]); persistHistory.schedule("search-history");
}; };
/** Remove single history item */
const removeFromHistory = (q: string) => { const removeFromHistory = (q: string) => {
setHistory((prev) => { setHistory((prev) => prev.filter((h) => h !== q));
const updated = prev.filter((h) => h !== q); persistHistory.schedule("search-history");
saveSearchHistoryToFile(updated);
return updated;
});
}; };
/** Clear results */
const clearResults = () => { const clearResults = () => {
setResults([]); setResults([]);
setQuery(""); setQuery("");
setError(null); setError(null);
}; };
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => { const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) => setResults((prev) =>
prev.map((result) => { prev.map((result) => {
@@ -262,7 +260,6 @@ export function createSearchStore() {
}; };
} }
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null; let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() { export function useSearchStore() {

View File

@@ -25,6 +25,12 @@
* after the Player tab stops being focused it tears down. Reads outside * after the Player tab stops being focused it tears down. Reads outside
* decoded coverage return empty — the renderer simply holds the last frame * decoded coverage return empty — the renderer simply holds the last frame
* until the decode frontier arrives. * until the decode frontier arrives.
*
* Loading semantics: `isLoading` is true from any pipeline start (cold
* start, resume into undecoded audio) until the first complete FFT frame,
* and `isStalled` while playback claims to be live but the position clock
* is frozen (player re-buffering). The component renders the spinner for
* either; bars replace it the moment fresh frames arrive.
*/ */
import { import {
@@ -55,6 +61,17 @@ const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */ /** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512; const SAMPLES_PER_FRAME = 512;
/**
* How long the position clock may stay frozen while the UI believes
* playback is live before the waveform reports a stall (loading state).
* mpv polls time-pos every ~150ms, so a frozen clock means the player is
* re-buffering — the long-pause-then-resume case on network streams.
*/
const STALL_DETECT_MS = 2000;
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
// ── Types ──────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────
export interface VisualizerStore { export interface VisualizerStore {
@@ -62,6 +79,10 @@ export interface VisualizerStore {
barData: () => number[]; barData: () => number[];
/** True from pipeline start until the first complete FFT frame renders. */ /** True from pipeline start until the first complete FFT frame renders. */
isLoading: () => boolean; isLoading: () => boolean;
/** True while playback claims to be live but the position clock has
* been frozen past STALL_DETECT_MS (player re-buffering, e.g. after a
* long pause on a network stream). */
isStalled: () => boolean;
/** True while the ~30fps render loop is armed. */ /** True while the ~30fps render loop is armed. */
isRunning: () => boolean; isRunning: () => boolean;
/** Report whether the Player tab is the visible tab. */ /** Report whether the Player tab is the visible tab. */
@@ -79,6 +100,10 @@ function createVisualizerStore(): VisualizerStore {
// True from pipeline start until the first complete FFT frame renders. // True from pipeline start until the first complete FFT frame renders.
const [isLoading, setIsLoading] = createSignal(false); const [isLoading, setIsLoading] = createSignal(false);
// True while playback is live but the position clock is frozen
// (player re-buffering) — see STALL_DETECT_MS.
const [isStalled, setIsStalled] = createSignal(false);
// Whether the Player tab is the visible tab (fed by PlayerPage). // Whether the Player tab is the visible tab (fed by PlayerPage).
const [focused, setFocused] = createSignal(false); const [focused, setFocused] = createSignal(false);
@@ -96,9 +121,23 @@ function createVisualizerStore(): VisualizerStore {
// pause/resume (segments survive; only the ffmpeg pass is killed) and // pause/resume (segments survive; only the ffmpeg pass is killed) and
// dropped only on episode change, stop, disable, or unload. // dropped only on episode change, stop, disable, or unload.
let pcm: EpisodePcmCache | null = null; let pcm: EpisodePcmCache | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null; let frameTimer: TimerHandle | null = null;
let sampleBuffer: Float64Array | null = null; let sampleBuffer: Float64Array | null = null;
let unloadTimer: ReturnType<typeof setTimeout> | null = null; let unloadTimer: TimerHandle | null = null;
// Stall tracker: last observed position-signal value and when it moved.
// Any change (forward, backward, seek) re-arms the clock; a frozen
// signal while playing trips isStalled after STALL_DETECT_MS.
let lastRenderPos = -1;
let lastPosMoveAt = 0;
// Resume point: the position a paused pipeline was re-armed at. The
// loading state set by resume clears once the position clock has MOVED
// from this (either direction) — while the player is still re-buffering
// the clock is frozen, and the cache serving the same window must not
// let stale pre-pause bars masquerade as live data. -1 = cold start
// (clear on the first produced frame, regardless of the clock).
let resumePos = -1;
// What the running pipeline was started with — lets the playback effect // What the running pipeline was started with — lets the playback effect
// tell "nothing changed, stay warm" from "must restart". // tell "nothing changed, stay warm" from "must restart".
@@ -197,9 +236,19 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = position; lastPolledPosition = position;
lastPolledAt = performance.now(); lastPolledAt = performance.now();
// Seed the stall tracker: a fresh pipeline should not report a
// stall just because the first position poll hasn't landed.
lastRenderPos = position;
lastPosMoveAt = performance.now();
// Cold start: the loading state clears on the first produced frame
// (see renderFrame) — no resume-position gating.
resumePos = -1;
activeUrl = url; activeUrl = url;
activeBars = barCount(); activeBars = barCount();
setIsLoading(true); setIsLoading(true);
setIsStalled(false);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL); frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
}; };
@@ -209,6 +258,8 @@ function createVisualizerStore(): VisualizerStore {
clearInterval(frameTimer); clearInterval(frameTimer);
frameTimer = null; frameTimer = null;
} }
clearTimeout(seekDecodeTimer);
seekDecodeTimer = undefined;
if (pcm) { if (pcm) {
pcm.stop(); pcm.stop();
// Keep the (now cache-less, url-tagged) object: a re-start of the // Keep the (now cache-less, url-tagged) object: a re-start of the
@@ -219,6 +270,14 @@ function createVisualizerStore(): VisualizerStore {
} }
sampleBuffer = null; sampleBuffer = null;
setIsLoading(false); setIsLoading(false);
setIsStalled(false);
// Drop the last rendered frame: after a stop the bars are stale (a
// different episode, a different position) and would masquerade as
// live data while the next cold start warms up — and, because the
// component only shows the spinner while bars are empty, they'd
// also suppress the loading state. Cold restarts re-render fresh
// bars within the first frame.
setBarData([]);
}; };
// ── Pause: freeze the loop, keep the cache ────────────────────────── // ── Pause: freeze the loop, keep the cache ──────────────────────────
@@ -233,12 +292,17 @@ function createVisualizerStore(): VisualizerStore {
clearInterval(frameTimer); clearInterval(frameTimer);
frameTimer = null; 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(); if (pcm) pcm.pauseDecode();
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume. // Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
// Clear the loading spinner: if the pipeline never produced bars // Clear the loading spinner: if the pipeline never produced bars
// (still cold-starting when paused), the component should fall back // (still cold-starting when paused), the component should fall back
// to the placeholder, not freeze on a spinner. // to the placeholder, not freeze on a spinner.
setIsLoading(false); setIsLoading(false);
setIsStalled(false);
}; };
// ── Resume: re-arm the render loop, top up the cache ─────────────── // ── Resume: re-arm the render loop, top up the cache ───────────────
@@ -260,12 +324,27 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = pos; lastPolledPosition = pos;
lastPolledAt = performance.now(); lastPolledAt = performance.now();
// Re-arm the stall tracker from the resume position (a long pause
// left the old timestamps stale — they'd trip the stall detector on
// the very first frame otherwise).
lastRenderPos = pos;
lastPosMoveAt = performance.now();
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause:
// the pre-pause bars are stale until fresh frames flow, so show the
// loading state IN THEIR PLACE. It clears only once the position
// clock has advanced past the resume point (see renderFrame) — a
// player still re-buffering after a long pause keeps the spinner
// instead of serving static cached bars.
resumePos = pos;
setIsLoading(true);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL); frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true; return true;
}; };
// ── Render loop (called at ~30fps) ───────────────────────────────── // ── Render loop (called at ~30fps) ─────────────────────────────────
let lastBarWriteAt = 0;
const renderFrame = () => { const renderFrame = () => {
if (!cava?.isReady || !sampleBuffer || !pcm) return; if (!cava?.isReady || !sampleBuffer || !pcm) return;
@@ -273,15 +352,50 @@ function createVisualizerStore(): VisualizerStore {
// coverage (decode cold start, seek into a hole) the read is empty // coverage (decode cold start, seek into a hole) the read is empty
// and the LAST FRAME simply holds — never clamped/repeated junk. // and the LAST FRAME simply holds — never clamped/repeated junk.
const target = smoothPosition(); const target = smoothPosition();
// Stall detection: while the UI believes playback is live, the
// position signal must keep advancing (useAudio polls it every
// ~150ms). A frozen clock with a warm pipeline means the player is
// re-buffering — the classic long-pause-then-resume on a network
// stream — and without this the waveform shows dead-looking static
// bars for the whole stall. Report it as loading; the first frame
// after the clock moves again clears it.
const rawPos = audioPlaybackSignals.position();
if (rawPos !== lastRenderPos) {
lastRenderPos = rawPos;
lastPosMoveAt = performance.now();
if (isStalled()) setIsStalled(false);
} else if (
audioPlaybackSignals.isPlaying() &&
performance.now() - lastPosMoveAt > STALL_DETECT_MS
) {
setIsStalled(true);
}
const count = pcm.readWindow(sampleBuffer, target); const count = pcm.readWindow(sampleBuffer, target);
// Never feed a partial FFT window to cava. // Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return; if (count < sampleBuffer.length) return;
const output = cava.execute(sampleBuffer); const output = cava.execute(sampleBuffer);
// Normalize against the running peak and copy to a new array // Write the UI signal at ~10fps, not 30: cava already smooths
setBarData(scaler(output)); // (noise reduction + peak release), and each Solid write costs a
if (isLoading()) setIsLoading(false); // renderer diff pass. 3 of every 4 frames update only the pipeline.
const nowMs = performance.now();
if (nowMs - lastBarWriteAt >= 95) {
lastBarWriteAt = nowMs;
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
}
// Fresh frames only count once the position clock has MOVED from
// the resume point: while the player is still re-buffering after a
// long pause, the cache serves the same window and the spinner must
// stay in place of the stale bars. Any move counts — including a
// backward seek, whose window is live data for the new position and
// would strand the spinner forever under a `>` gate. Cold starts
// (resumePos < 0) clear on the first frame as before.
if (isLoading() && (resumePos < 0 || rawPos !== resumePos)) {
setIsLoading(false);
}
}; };
// ── Playback subscription ────────────────────────────────────────── // ── Playback subscription ──────────────────────────────────────────
@@ -375,6 +489,7 @@ function createVisualizerStore(): VisualizerStore {
// while the last frame holds. // while the last frame holds.
let lastSyncPosition = 0; let lastSyncPosition = 0;
let seekDecodeTimer: TimerHandle | undefined;
createEffect( createEffect(
on(audioPlaybackSignals.position, (pos) => { on(audioPlaybackSignals.position, (pos) => {
if (!audioPlaybackSignals.isPlaying() || !pcm) { if (!audioPlaybackSignals.isPlaying() || !pcm) {
@@ -386,7 +501,16 @@ function createVisualizerStore(): VisualizerStore {
lastSyncPosition = pos; lastSyncPosition = pos;
if (delta > 2) { if (delta > 2) {
pcm.ensureDecodeAround(pos); // 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);
} }
}), }),
); );
@@ -406,6 +530,7 @@ function createVisualizerStore(): VisualizerStore {
// state // state
barData, barData,
isLoading, isLoading,
isStalled,
isRunning: () => frameTimer !== null, isRunning: () => frameTimer !== null,
// inputs // inputs
setFocused, setFocused,

View File

@@ -90,12 +90,23 @@ export type AppSettings = {
visualizer: VisualizerSettings; visualizer: VisualizerSettings;
}; };
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */ /** Which shows the auto-download setting applies to (default: all). */
export type AutoDownloadScope = "all" | "none" | "whitelist"; export type AutoDownloadScope = "all" | "none" | "whitelist";
/** How the episode cache (the Feed / My Shows list + the pagination cache)
* is bounded: by a rolling date window or by a count of most-recent
* episodes (default: date). */
export type EpisodeCacheMode = "date" | "count";
/** Left/right edges of the current (center) pane as fractions of the row
* width, shared by the draggable pane borders in every depth tab. */
export type PaneSplits = {
/** Left edge of the current pane (default 0.2 = 20% of the row). */
left: number;
/** Right edge of the current pane (default 0.7 = 70% of the row). */
right: number;
};
export type UserPreferences = { export type UserPreferences = {
showExplicit: boolean; showExplicit: boolean;
autoDownload: boolean; autoDownload: boolean;
@@ -107,10 +118,16 @@ export type UserPreferences = {
autoDownloadWhitelist: string[]; autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */ /** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean; autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */ /** Minutes between automatic background feed refreshes (default: 30). */
refreshIntervalMinutes: number; refreshIntervalMinutes: number;
/** How the episode list cache is bounded — by date or by count (default: date). */
episodeCacheMode: EpisodeCacheMode;
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
episodeCacheCount: number;
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
episodeCacheDays: number;
/** Pane split positions as fractions of the row width (default 0.2 / 0.7). */
paneSplit: PaneSplits;
}; };
export type AppState = { export type AppState = {

View File

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

View File

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

View File

@@ -47,8 +47,11 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all", autoDownloadScope: "all",
autoDownloadWhitelist: [], autoDownloadWhitelist: [],
autoJumpToPlayer: true, autoJumpToPlayer: true,
fetchMoreMode: "manual",
refreshIntervalMinutes: 30, refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
paneSplit: { left: 0.2, right: 0.7 },
}; };
const defaultState: AppState = { const defaultState: AppState = {
@@ -59,7 +62,6 @@ const defaultState: AppState = {
// ── App State (config.json) ───────────────────────────────────────────────── // ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> { export async function loadAppStateFromFile(): Promise<AppState> {
try { try {
const cfg = await loadConfig(); const cfg = await loadConfig();
@@ -85,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
} }
} }
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void { export function saveAppStateToFile(state: AppState): void {
updateConfig({ updateConfig({
settings: state.settings, settings: state.settings,
@@ -106,7 +107,6 @@ interface ProgressEntry {
playbackSpeed?: number; playbackSpeed?: number;
} }
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise< export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry> Record<string, ProgressEntry>
> { > {
@@ -142,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
const SEARCH_HISTORY_FILE = "search-history.json"; const SEARCH_HISTORY_FILE = "search-history.json";
/** Load search history from JSON file */
export async function loadSearchHistoryFromFile(): Promise<string[]> { export async function loadSearchHistoryFromFile(): Promise<string[]> {
try { try {
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE)); const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
@@ -156,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise<string[]> {
} }
} }
/** Save search history to JSON file (overwrite, no backup) */
export function saveSearchHistoryToFile(history: string[]): void { export function saveSearchHistoryToFile(history: string[]): void {
(async () => { (async () => {
try { try {
@@ -175,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
const AUDIO_NAV_FILE = "audio-nav.json"; const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> { export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try { try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE)); const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));

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

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

View File

@@ -23,9 +23,16 @@
* pass over just that region) — earlier segments stay valid, mp3 decode of * pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree. * the same file is deterministic so abutting segments agree.
* *
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min), * Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz * SLIDING WINDOW around the playback position — the decode pass stops
* high-cutoff of the visualizer's FFT config. * 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 * Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for * old reader; local files skip them (ffmpeg rejects http-only options for
@@ -41,6 +48,32 @@ const BYTES_PER_SAMPLE = 2; // s16le
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */ /** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024; 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. * Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know * Each startDecode() increments this; the read loop checks it to know
@@ -65,6 +98,10 @@ export interface EpisodePcmCacheOptions {
url: string; url: string;
/** Sample rate (default: 22050) */ /** Sample rate (default: 22050) */
sampleRate?: number; 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 { export class EpisodePcmCache {
@@ -72,14 +109,19 @@ export class EpisodePcmCache {
private segments: Segment[] = []; private segments: Segment[] = [];
private generation = 0; private generation = 0;
private _decoding = false; private _decoding = false;
/** Base offset (playback seconds) of the running decode pass; null when idle. */ /** The running pass's segment (base + frontier); null when idle. */
private activeBaseSec: number | null = null; private activeSegment: Segment | null = null;
readonly url: string; readonly url: string;
readonly sampleRate: number; readonly sampleRate: number;
/** Sliding-window budgets (see maintainWindow). */
readonly maxAheadSec: number;
readonly keepBehindSec: number;
constructor(options: EpisodePcmCacheOptions) { constructor(options: EpisodePcmCacheOptions) {
this.url = options.url; this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE; 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. */ /** Whether an ffmpeg decode pass is currently running. */
@@ -87,6 +129,13 @@ export class EpisodePcmCache {
return this._decoding; 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. */ /** End (playback seconds) of the furthest-decoded segment. */
get coverageEndSec(): number { get coverageEndSec(): number {
let end = 0; let end = 0;
@@ -184,14 +233,14 @@ export class EpisodePcmCache {
stdin: "ignore", stdin: "ignore",
}); });
this._decoding = true; this._decoding = true;
this.activeBaseSec = segment.baseSec; this.activeSegment = segment;
this.readLoop(myGeneration, segment); this.readLoop(myGeneration, segment);
this.proc.exited this.proc.exited
.then((code) => { .then((code) => {
if (this.generation === myGeneration) { if (this.generation === myGeneration) {
this._decoding = false; this._decoding = false;
this.activeBaseSec = null; this.activeSegment = null;
// Exit 0 == decoded to stream EOF. // Exit 0 == decoded to stream EOF.
if (code === 0) segment.finished = true; if (code === 0) segment.finished = true;
} }
@@ -199,7 +248,7 @@ export class EpisodePcmCache {
.catch(() => { .catch(() => {
if (this.generation === myGeneration) { if (this.generation === myGeneration) {
this._decoding = false; this._decoding = false;
this.activeBaseSec = null; this.activeSegment = null;
} }
}); });
} }
@@ -223,26 +272,84 @@ export class EpisodePcmCache {
* new segment at `sec` (seek into a hole / resume past cached audio). * new segment at `sec` (seek into a hole / resume past cached audio).
*/ */
ensureDecodeAround(sec: number): void { ensureDecodeAround(sec: number): void {
if (this._decoding) { // Enforce the sliding-window budget first (head cap, prune, refill)
// A decode pass fills monotonically FORWARD from its base. Only a // so a resume or seek never leaves stale segments behind the cursor.
// target at/after the active base is eventually covered by it — this.maintainWindow(sec);
// a target BEHIND the base (seek into an undecoded hole ahead of
// the active pass) never is: kill the pass and restart at sec. // Data already on hand: nothing needed here; only keep the tail
if (this.activeBaseSec !== null && sec >= this.activeBaseSec) return; // filling if the decode is idle, the episode is unfinished, AND the
this.startDecode(Math.max(0, sec)); // head is inside its budget. A head-capped cache ("we're maxAheadSec
return; // 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.covers(sec)) {
// Covered here: continue the tail so the cache keeps filling if (this._decoding || this.decodeFinished) return;
// past the position (unless the whole episode is decoded). const end = this.coverageEndSec;
if (this.decodeFinished) return; if (end >= sec + this.maxAheadSec) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec); this.startDecode(end > sec ? end : sec);
return; return;
} }
// Seek into an undecoded region: start a fresh segment there.
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)); 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` * Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects). * (Int16 magnitudes widened to f64, the scale cavacore expects).
@@ -253,6 +360,7 @@ export class EpisodePcmCache {
*/ */
readWindow(out: Float64Array, atSec: number): number { readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0; if (out.length === 0) return 0;
this.maintainWindow(atSec);
const endIdx = Math.round(atSec * this.sampleRate); const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1; const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) { for (const seg of this.segments) {
@@ -275,7 +383,7 @@ export class EpisodePcmCache {
pauseDecode(): void { pauseDecode(): void {
this.generation = ++globalGeneration; this.generation = ++globalGeneration;
this._decoding = false; this._decoding = false;
this.activeBaseSec = null; this.activeSegment = null;
this.killProcess(); this.killProcess();
} }

View File

@@ -24,12 +24,16 @@
* fills its demuxer cache ahead of time; the first real play just flips * 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 * `pause` to false — the ~2s network open is paid at boot, not on the
* user's first Play. * 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 { platform } from "os";
import { existsSync, unlinkSync } from "fs"; import { existsSync, unlinkSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { dirname, join } from "path"; import { join } from "path";
import type { Socket, Subprocess } from "bun"; import type { Socket, Subprocess } from "bun";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
@@ -60,7 +64,6 @@ export interface AudioBackend {
* (mpv `video-add`). Lets play() start without waiting on art; the * (mpv `video-add`). Lets play() start without waiting on art; the
* Now Playing artwork pops in when the download lands. * Now Playing artwork pops in when the download lands.
*/ */
addCoverArt(path: string): Promise<void>;
pause(): Promise<void>; pause(): Promise<void>;
resume(): Promise<void>; resume(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
@@ -79,6 +82,11 @@ export interface AudioBackend {
getPauseState(): Promise<boolean | undefined>; getPauseState(): Promise<boolean | undefined>;
/** True while the player process is running (regardless of pause). */ /** True while the player process is running (regardless of pause). */
isAlive(): boolean; isAlive(): boolean;
/** Last playback error (end-file reason "error"), or null when the last
* track ended cleanly (or nothing has failed yet). Lets callers
* distinguish a natural end-of-file from a stream failure — a failed
* episode must not auto-advance the queue. */
getPlaybackError(): string | null;
dispose(): void; dispose(): void;
} }
@@ -115,53 +123,13 @@ function mpvSocketPath(): string {
// Per-instance, not just per-pid: tests (and backend switching) create // Per-instance, not just per-pid: tests (and backend switching) create
// several MpvBackend objects in ONE bun process — a pid-only path makes // several MpvBackend objects in ONE bun process — a pid-only path makes
// every daemon bind the same socket, so later daemons unlink the path // every daemon bind the same socket, so later daemons unlink the path
// out from under earlier ones and IPC cross-talks between backends. // out from under each other.
return join( return join(
tmpdir(), tmpdir(),
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`, `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.
*
* The bundled copy is verified to actually launch: it links against brew's
* dylibs by absolute path, and a Homebrew ffmpeg major upgrade can break it
* (dylib gone → immediate non-zero exit). If the bundled binary can't run,
* fall back to PATH mpv so audio keeps working — the icon degrades to blank
* rather than playback dying. Probed once per process.
*/
let resolvedMpv: string | null | undefined; // undefined = not yet probed
function mpvLaunches(binary: string): boolean {
try {
const proc = Bun.spawnSync([binary, "--version"], { timeout: 3000 });
return proc.exitCode === 0;
} catch {
return false;
}
}
function resolveMpvBinary(): string | null {
if (resolvedMpv !== undefined) return resolvedMpv;
let resolved: string | null = null;
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled) && mpvLaunches(bundled)) {
resolved = bundled;
}
} catch {
/* process.execPath unusable — fall through to PATH */
}
if (!resolved) resolved = which("mpv");
resolvedMpv = resolved;
return resolved;
}
// ── mpv JSON IPC connection ───────────────────────────────────────── // ── mpv JSON IPC connection ─────────────────────────────────────────
// //
// One persistent Unix-socket connection to the resident mpv daemon. Lines // One persistent Unix-socket connection to the resident mpv daemon. Lines
@@ -315,12 +283,28 @@ class MpvConnection {
} }
this.handleTeardown(); this.handleTeardown();
} }
/** True while the Unix socket is open — a live, reachable daemon. */
isConnected(): boolean {
return this.sock !== null;
}
} }
// ── mpv Backend ────────────────────────────────────────────────────── // ── mpv Backend ──────────────────────────────────────────────────────
// One resident daemon for the app's lifetime, controlled over a single // One resident daemon for the app's lifetime, controlled over a single
// persistent JSON IPC connection with property observation. // 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). */ /** Property observation ids (correlate property-change events). */
const OBS_TIME_POS = 1; const OBS_TIME_POS = 1;
const OBS_PAUSE = 2; const OBS_PAUSE = 2;
@@ -359,14 +343,35 @@ export class MpvBackend implements AudioBackend {
// ── Daemon lifecycle ───────────────────────────────────────────── // ── Daemon lifecycle ─────────────────────────────────────────────
private async ensureDaemon(): Promise<void> { private async ensureDaemon(): Promise<void> {
if (this.proc && !this._exited && this.conn) return; // 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; if (this.startPromise) return this.startPromise;
this.startPromise = this.spawnDaemon().finally(() => { this.startPromise = this.recoverDaemon().finally(() => {
this.startPromise = null; this.startPromise = null;
}); });
return this.startPromise; 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> { private async spawnDaemon(): Promise<void> {
// Clean up stale socket // Clean up stale socket
try { try {
@@ -377,8 +382,14 @@ export class MpvBackend implements AudioBackend {
this.proc = Bun.spawn( this.proc = Bun.spawn(
[ [
resolveMpvBinary() ?? "mpv", "mpv",
"--no-video", // --vo=null (not --no-video): the albumart track must stay the
// CURRENT video track or macOS Now Playing shows no artwork.
// --no-video drops it to unselected (albumart:true, selected:false),
// so the system media center renders no cover. --vo=null is equally
// headless — no window, no rendering — but keeps the cover current
// so Now Playing gets the art.
"--vo=null",
"--no-terminal", "--no-terminal",
"--really-quiet", "--really-quiet",
// Stay alive after finishing/unloading files; PodTUI owns one mpv // Stay alive after finishing/unloading files; PodTUI owns one mpv
@@ -401,9 +412,15 @@ export class MpvBackend implements AudioBackend {
this._exited = false; this._exited = false;
this.proc.exited this.proc.exited
.then(() => { .then(() => {
// 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._exited = true;
this._intentPlaying = false; this._intentPlaying = false;
this._loadedUrl = null; this._loadedUrl = null;
this._loadedPaused = false;
this._ended = false;
this._paused = null; this._paused = null;
}) })
.catch(() => {}); .catch(() => {});
@@ -562,8 +579,12 @@ export class MpvBackend implements AudioBackend {
if (pausedSeek) { if (pausedSeek) {
// time-pos sent before file-loaded is silently dropped by mpv // time-pos sent before file-loaded is silently dropped by mpv
// (no file yet) — the preload then parked at 0 and the restore // (no file yet) — the preload then parked at 0 and the restore
// position was lost. Wait for the open, then seek. // position was lost. Wait for the open, then seek. A dead URL
await fileLoaded; // never fires file-loaded at all (mpv keeps retrying the
// open), so end-file (the open-failure notification) races it
// and the wait folds to "not loaded" instead of stalling the
// load mutex for the full 5s timeout.
await Promise.race([fileLoaded, this.conn?.waitEvent("end-file", 5000)]);
await this.send(["set_property", "time-pos", pausedSeek]); await this.send(["set_property", "time-pos", pausedSeek]);
this._position = pausedSeek; this._position = pausedSeek;
} }
@@ -585,13 +606,22 @@ export class MpvBackend implements AudioBackend {
// play checks it and skips its own stale paused-load. // play checks it and skips its own stale paused-load.
this._intentPlaying = true; this._intentPlaying = true;
await this.runLoadExclusive(async () => { await this.runLoadExclusive(async () => {
// Fast path: this exact URL was PRELOADED paused (boot restore) — // Same episode re-selected (Enter in a list, key-repeat, a
// mpv has been buffering it since boot, so flipping pause off starts // second tap on the playing row): the file is ALREADY in the
// audio ~instantly. Re-acquire the start position only when it // player. Reloading with start=<saved progress> would audibly
// moved meaningfully since the preload (progress saved meanwhile). // skip BACK and repeat already-played audio (saved progress
if (this._loadedUrl === url && this._loadedPaused && !this._ended) { // lags the live position by up to the 5s persist interval), so
// align in place instead:
// - preload park (loaded paused at boot restore): seek only
// when the caller's target moved materially since load;
// - user-paused: unpause at the CURRENT position (saved
// progress is stale and must not become a backward seek);
// - already playing: unpause is a no-op — nothing to do.
// A genuinely finished episode (_ended) still falls through to
// a fresh load, which replays from the top via isCompleted.
if (this._loadedUrl === url && !this._ended) {
const target = opts?.startPosition ?? this._position; const target = opts?.startPosition ?? this._position;
if (Math.abs(target - this._position) > 2) { if (this._loadedPaused && Math.abs(target - this._position) > 2) {
await this.send(["set_property", "time-pos", target]); await this.send(["set_property", "time-pos", target]);
this._position = target; this._position = target;
} }
@@ -601,12 +631,6 @@ export class MpvBackend implements AudioBackend {
Math.round((opts?.volume ?? 1) * 100), Math.round((opts?.volume ?? 1) * 100),
]); ]);
await this.send(["set_property", "speed", opts?.speed ?? 1]); await this.send(["set_property", "speed", opts?.speed ?? 1]);
if (opts?.coverArtPath) {
// File is already loaded: cover-art-files only applies at
// load, so add the art as a runtime albumart track instead.
await this.send(["set_property", "cover-art-files", opts.coverArtPath]);
await this.send(["video-add", opts.coverArtPath]);
}
if (opts?.mediaTitle) { if (opts?.mediaTitle) {
await this.send(["set_property", "force-media-title", opts.mediaTitle]); await this.send(["set_property", "force-media-title", opts.mediaTitle]);
} }
@@ -631,20 +655,19 @@ export class MpvBackend implements AudioBackend {
}); });
} }
async addCoverArt(path: string): Promise<void> {
if (!this._loadedUrl) return;
// Keep the property pointing at the latest art too, so a subsequent
// loadfile of the same episode carries it.
await this.send(["set_property", "cover-art-files", path]);
await this.send(["video-add", path]);
}
async pause(): Promise<void> { async pause(): Promise<void> {
await this.send(["set_property", "pause", true]); await this.send(["set_property", "pause", true]);
this._intentPlaying = false; this._intentPlaying = false;
} }
async resume(): Promise<void> { async resume(): Promise<void> {
// 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) { if (this._ended && this._loadedUrl) {
// Play pressed on a finished episode: replay from the top. // Play pressed on a finished episode: replay from the top.
this._ended = false; this._ended = false;
@@ -664,7 +687,12 @@ export class MpvBackend implements AudioBackend {
this._loadedPaused = false; this._loadedPaused = false;
} }
this._ended = false; this._ended = false;
await this.send(["set_property", "pause", 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; this._intentPlaying = true;
} }
@@ -756,7 +784,6 @@ class NoopBackend implements AudioBackend {
readonly name: BackendName = "none"; readonly name: BackendName = "none";
async play(): Promise<void> {} async play(): Promise<void> {}
async preload(): Promise<void> {} async preload(): Promise<void> {}
async addCoverArt(): Promise<void> {}
async pause(): Promise<void> {} async pause(): Promise<void> {}
async resume(): Promise<void> {} async resume(): Promise<void> {}
async stop(): Promise<void> {} async stop(): Promise<void> {}
@@ -779,6 +806,9 @@ class NoopBackend implements AudioBackend {
isAlive(): boolean { isAlive(): boolean {
return false; return false;
} }
getPlaybackError(): string | null {
return null;
}
dispose(): void {} dispose(): void {}
} }
@@ -799,7 +829,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] { export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = []; const players: DetectedPlayer[] = [];
const mpvPath = resolveMpvBinary(); const mpvPath = which("mpv");
if (mpvPath) { if (mpvPath) {
players.push({ players.push({
name: "mpv", name: "mpv",
@@ -833,13 +863,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend; if (backend) return backend;
} }
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend(); return which("mpv") ? new MpvBackend() : new NoopBackend();
} }
function createBackendByName(name: BackendName): AudioBackend | null { function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) { switch (name) {
case "mpv": case "mpv":
return resolveMpvBinary() ? new MpvBackend() : null; return which("mpv") ? new MpvBackend() : null;
case "none": case "none":
return new NoopBackend(); return new NoopBackend();
} }

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

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

View File

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

View File

@@ -13,6 +13,7 @@
* always overwrite — no backup files are created. * always overwrite — no backup files are created.
*/ */
import { mkdir } from "fs/promises";
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir"; import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
import type { import type {
AppSettings, AppSettings,
@@ -58,21 +59,47 @@ let writeChain: Promise<void> = Promise.resolve();
/** Update sections of config.json (read-modify-write, serialized, overwrite). */ /** Update sections of config.json (read-modify-write, serialized, overwrite). */
export function updateConfig(patch: Partial<PodTuiConfig>): void { 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 () => { writeChain = writeChain.then(async () => {
try { try {
await ensureConfigDir(); await migrateOnce();
const current = await loadConfig(); await mkdir(configDir, { recursive: true });
const next = { ...current, ...patch }; let current: PodTuiConfig = {};
await Bun.write( try {
getConfigFilePath(CONFIG_FILE), const file = Bun.file(configPath);
JSON.stringify(next, null, 2), 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 { } catch {
// Fire-and-forget persistence — silently ignore write errors. // 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. */ /** Guards so migration runs exactly once per process. */
let migrationDone = false; let migrationDone = false;
let migrationPromise: Promise<void> | null = null; let migrationPromise: Promise<void> | null = null;

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -4,9 +4,70 @@
*/ */
import { loadConfig, updateConfig } from "./config"; import { 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 { Feed } from "../types/feed";
import type { PodcastSource } from "../types/source"; 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 */ /** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed { function reviveDates(feed: Feed): Feed {
return { return {
@@ -23,23 +84,72 @@ function reviveDates(feed: Feed): Feed {
}; };
} }
/** Load feeds from config.json */ /** Config-legacy baggage: search-time parseRSSFeed once embedded the full
export async function loadFeedsFromFile(): Promise<Feed[]> { * episode history inside podcast.episodes (2,100+ stale copies, 3.8 MB of
* config). Nothing reads them — feed.episodes is the source of truth — so
* every load/save drops them. */
function stripLegacyPodcastEpisodes(feed: Feed): Feed {
if (!("episodes" in feed.podcast)) return feed;
const { episodes: _legacy, ...podcast } = feed.podcast;
void _legacy;
return { ...feed, podcast: podcast as Feed["podcast"] };
}
/** Load feeds from config.json, pruning episodes outside the retention
* window (completed downloads always kept). When anything was pruned, the
* pruned list is rewritten to config.json (startup cleanup for legacy
* configs). The read path is awaited so the returned value is deterministic. */
export async function loadFeedsFromFile(
windowDays?: number,
): Promise<Feed[]> {
try { try {
const cfg = await loadConfig(); const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return []; if (!Array.isArray(cfg.feeds)) return [];
return cfg.feeds.map(reviveDates); const feeds = cfg.feeds
.map(reviveDates)
.map(stripLegacyPodcastEpisodes);
const downloadedIds = await readDownloadedEpisodeIds();
const now = new Date();
let prunedAny = false;
const pruned = feeds.map((f) => {
const kept = f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, now, windowDays),
);
if (kept.length !== f.episodes.length) prunedAny = true;
return { ...f, episodes: kept };
});
if (prunedAny) {
// Fire-and-forget cleanup rewrite of the legacy config.
saveFeedsToFile(pruned, windowDays);
}
return pruned;
} catch { } catch {
return []; return [];
} }
} }
/** Save feeds to config.json */ /** Save feeds to config.json, pruning episodes outside the retention window
export function saveFeedsToFile(feeds: Feed[]): void { * (completed downloads always kept). Fire-and-forget: the prune reads
updateConfig({ feeds }); * downloads.json asynchronously, then enqueues the write. On any error the
* UNPRUNED feeds are saved instead, so data is never lost. */
export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
(async () => {
try {
const downloadedIds = await readDownloadedEpisodeIds();
const pruned = feeds
.map(stripLegacyPodcastEpisodes)
.map((f) => ({
...f,
episodes: f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
),
}));
updateConfig({ feeds: pruned });
} catch {
updateConfig({ feeds }); /* never lose data on an error path */
}
})().catch(() => {});
} }
/** Load sources from config.json */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> { export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try { try {
const cfg = await loadConfig(); const cfg = await loadConfig();
@@ -49,8 +159,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
return null; return null;
} }
} }
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void { export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] }); updateConfig({ sources: sources as unknown as PodcastSource[] });
} }

View File

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

View File

@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
} }
} }
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> { export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try { try {
const filePath = getConfigFilePath(KEYBINDS_FILE); const filePath = getConfigFilePath(KEYBINDS_FILE);

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,13 @@
/** /**
* Theme CSS Variable Manager * Terminal Theme Resolver
* Handles dynamic theme switching by updating CSS custom properties * Resolves the active theme (built-in, custom, or system-derived) to colors.
*/ */
import type { TerminalColors } from "@opentui/core"; import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema"; 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 { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme"; 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( export function resolveTerminalTheme(
themes: Record<string, ThemeJson>, themes: Record<string, ThemeJson>,
name: string, name: string,
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
if (name === "system" && system) { if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode); return resolveThemeJson(generateSystemTheme(system, mode), mode);
} }
const theme = themes[name] ?? themes.catppuccin; return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
if (!theme) {
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, 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.

View File

@@ -12,6 +12,10 @@
* it by unpausing — the boot-restore fast path with no second load. * it by unpausing — the boot-restore fast path with no second load.
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing * 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
* resume() afterwards replays from the top. * 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; * All playback runs silent (volume 0). Requires a real mpv on PATH;
* tests skip where it is missing. * tests skip where it is missing.
@@ -19,7 +23,10 @@
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { MpvBackend } from "../src/utils/audio-player"; import {
MpvBackend,
PlayerRestartedError,
} from "../src/utils/audio-player";
const SAMPLE_RATE = 22050; const SAMPLE_RATE = 22050;
const FREQ = 440; const FREQ = 440;
@@ -165,6 +172,116 @@ test.skipIf(!hasMpv)(
{ timeout: 20000 }, { timeout: 20000 },
); );
test.skipIf(!hasMpv)(
"play() of the already-playing url does NOT reload (no audible skip-back)",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
// Start mid-episode (as a resume would) and let it advance.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances past the start offset",
async () => (await backend.getPosition()) > 1.8,
);
const before = await backend.getPosition();
// Re-selecting the SAME episode (Enter in a list, key-repeat)
// calls play() with the STALE saved progress. The file is
// already loaded — this must not reload from that earlier
// position, or the listener hears already-played audio again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
// A reload would drop the position back to ~1; a correct no-op
// keeps advancing from where it was.
await waitFor(
"playback continues past the pre-play position",
async () => (await backend.getPosition()) > before + 0.3,
);
expect(backend.isPlaying()).toBe(true);
// And the position never fell back toward the stale offset.
expect(await backend.getPosition()).toBeGreaterThan(1.8);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the same url while user-paused resumes at the current position",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 2,
);
await backend.pause();
await waitFor(
"paused observed",
async () => (await backend.getPauseState()) === true,
);
const pausedAt = await backend.getPosition();
// Re-selecting the paused episode resumes where it PAUSED — the
// stale saved progress must not become a backward seek target.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"resumed at the paused position",
async () => (await backend.getPosition()) > pausedAt + 0.3,
);
expect(await backend.getPosition()).toBeGreaterThan(1.5);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
await waitFor("playing", () => backend.isPlaying());
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 0.5,
);
// Simulate a crash: SIGKILL the daemon out from under us.
const proc = (backend as unknown as { proc: { pid: number } }).proc;
expect(proc).toBeTruthy();
process.kill(proc.pid, "SIGKILL");
await waitFor("death observed", () => !backend.isAlive());
// resume() must NOT silently no-op on the dead daemon: it
// respawns, finds the fresh daemon idle (no file loaded), and
// throws — the hook falls back to the full play path.
await expect(backend.resume()).rejects.toThrow(PlayerRestartedError);
// play() (the hook's recovery) reuses the respawned daemon and
// plays the file — audio must actually advance again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
expect(backend.isAlive()).toBe(true);
await waitFor(
"recovered playback advances",
async () =>
(await backend.getPosition()) > 0.5 && backend.isPlaying(),
);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)( test.skipIf(!hasMpv)(
"EOF marks playback ended; resume() then replays from the top", "EOF marks playback ended; resume() then replays from the top",
async () => { async () => {

View File

@@ -97,6 +97,55 @@ function tmpWav(): string {
} }
const hasFfmpeg = !!Bun.which("ffmpeg"); 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 const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
test.skipIf(!hasFfmpeg)( test.skipIf(!hasFfmpeg)(
@@ -229,3 +278,87 @@ test.skipIf(!hasFfmpeg)(
}, },
{ timeout: 20000 }, { timeout: 20000 },
); );
test.skipIf(!hasFfmpeg)(
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
cache.startDecode(0);
// The 8s initial burst delivers the front of the file instantly.
await waitForCoverage(cache, 5);
// Park the cursor at 0 and drive the cap (the render loop reads
// every frame; the cap applies on the first read past the head).
const out = new Float64Array(512);
for (let i = 0; i < 30 && cache.decoding; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
// Paused at the head budget (4s) + one 8s burst of slack — NOT
// decoded to the 30s EOF.
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
expect(cache.decodeFinished).toBe(false);
// A parked cursor keeps the cap: more reads must not restart
// the pass or grow the cache.
const cappedAt = cache.coverageEndSec;
for (let i = 0; i < 10; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"the window prunes segments behind the cursor as playback advances",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
// Two segments: the back half [10, ~18] and, after the seek,
// the front [2, ~10].
cache.startDecode(10);
await waitForCoverage(cache, 11);
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(2.5)).toBe(true);
expect(cache.covers(10.5)).toBe(true);
// Cursor advances past the front segment's end + keepBehind:
// the front must fall out of the window, the back must survive.
const out = new Float64Array(512);
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
cache.readWindow(out, 13);
await Bun.sleep(25);
}
expect(cache.covers(2.5)).toBe(false);
expect(cache.covers(10.5)).toBe(true);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);

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

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

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

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

View File

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

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

@@ -16,6 +16,11 @@
* property the same way the OS does and asserts useAudio reconciles in * property the same way the OS does and asserts useAudio reconciles in
* both directions. Skipped when mpv isn't installed. * 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 * Real-timer note: the reconcile path runs on useAudio's real 150ms poll
* interval against a real mpv process, with no injectable clock — the * interval against a real mpv process, with no injectable clock — the
* deliberate-exception case from the no-real-timers rule (same as * deliberate-exception case from the no-real-timers rule (same as
@@ -190,6 +195,27 @@ async function waitFor(
} }
} }
/** 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 = { const episode = {
id: "ep1", id: "ep1",
podcastId: "pod1", podcastId: "pod1",
@@ -235,6 +261,42 @@ test.skipIf(!hasMpv)(
{ timeout: 30000 }, { 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 ────────────────────────────────────────────────────────────── // ── Teardown ──────────────────────────────────────────────────────────────
afterAll(async () => { afterAll(async () => {

View File

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

View File

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

View File

@@ -3,11 +3,10 @@
* row in a drilled show's episode list (My Shows depth 1) and the Feed * row in a drilled show's episode list (My Shows depth 1) and the Feed
* page's row. * page's row.
* *
* addFeed caches the FULL parsed feed while exposing only the first * Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the * fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
* cache holds more than the loaded window; `loadMoreEpisodes` advances that * (Date-mode fetch-more steps by a two-week window instead — that contract
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This * is pinned in feed-volatile-merge.test.ts.) This pins:
* pins:
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes. * 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch * 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
* needed) and hasMoreEpisodes flips false once the window reaches the end. * 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; process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed"; import { useFeedStore } from "../src/stores/feed";
import { useAppStore } from "../src/stores/app";
import type { Podcast } from "../src/types/podcast"; import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode { interface ServedEpisode {
title: string; title: string;
date: string; date: string;
@@ -40,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
// store (execution order between files is not guaranteed). // store (execution order between files is not guaranteed).
const addedFeedIds: string[] = []; 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 { function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes const items = episodes
.map( .map(
@@ -69,7 +71,15 @@ const makePodcast = (feedUrl: string): Podcast => ({
isSubscribed: true, 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({ server = Bun.serve({
port: 0, port: 0,
fetch(req) { fetch(req) {
@@ -86,6 +96,7 @@ beforeAll(() => {
afterAll(() => { afterAll(() => {
// Leave the shared singleton as we found it (see addedFeedIds note). // Leave the shared singleton as we found it (see addedFeedIds note).
useAppStore().updatePreferences({ episodeCacheMode: "date" });
const store = useFeedStore(); const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id); for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true); server?.stop(true);
@@ -94,10 +105,12 @@ afterAll(() => {
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => { test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
const store = useFeedStore(); 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) => ({ servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
title: `Ep ${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 feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); 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 () => { test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
const store = useFeedStore(); const store = useFeedStore();
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining. // 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) => ({ servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
title: `Ep ${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 feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); 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 { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast"; import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode { interface ServedEpisode {
title: string; title: string;
date: string; date: string;
@@ -40,7 +42,7 @@ let feedAId = "";
/** When set, the server 503s this path — simulates a feed going down. */ /** When set, the server 503s this path — simulates a feed going down. */
let failPath: string | null = null; 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 { function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes const items = episodes
.map( .map(
@@ -77,6 +79,18 @@ beforeAll(() => {
if (failPath && url.pathname === failPath) { if (failPath && url.pathname === failPath) {
return new Response("feed unavailable", { status: 503 }); 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")) { if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), { return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" }, headers: { "Content-Type": "application/rss+xml" },
@@ -137,7 +151,9 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
test("a failed refresh does not wipe the feed's episodes", async () => { test("a failed refresh does not wipe the feed's episodes", async () => {
const store = useFeedStore(); const store = useFeedStore();
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }]; // /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 feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull(); expect(feed).not.toBeNull();
@@ -183,3 +199,46 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]); 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,354 @@
/**
* 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";
import type { PodcastWithEpisodes } from "../src/types/podcast";
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);
});
// ── Legacy podcast.episodes baggage ────────────────────────────────────────
test("saveFeedsToFile strips legacy podcast.episodes from the persisted feed", async () => {
const feed = makeFeed([
makeEpisode({ id: "recent-id", pubDate: new Date(Date.now() - 5 * DAY) }),
]);
// Simulate the pre-fix shape: parseRSSFeed's full history embedded on
// the podcast object (841 stale copies were persisted this way).
const podcastWithLegacy = feed.podcast as PodcastWithEpisodes;
podcastWithLegacy.episodes = [
makeEpisode({ id: "stale-history-1" }),
makeEpisode({ id: "stale-history-2" }),
];
saveFeedsToFile([feed]);
await settleWrites();
const raw = await Bun.file(configJsonPath).json();
expect("episodes" in raw.feeds[0].podcast).toBe(false);
});
test("loadFeedsFromFile drops legacy podcast.episodes from a seeded config", async () => {
await Bun.write(
configJsonPath,
JSON.stringify({
feeds: [
{
id: "feed-1",
podcast: {
id: "feed-1",
title: "Baggage Show",
description: "",
author: "tester",
feedUrl: "https://example.com/baggage.xml",
lastUpdated: new Date().toISOString(),
isSubscribed: true,
episodes: [
{ id: "huge-stale-1", title: "archived copy" },
{ id: "huge-stale-2", title: "archived copy" },
],
},
episodes: [
{
id: "recent-id",
podcastId: "feed-1",
title: "Recent",
description: "",
audioUrl: "https://example.com/audio/recent.mp3",
duration: 60,
pubDate: new Date().toISOString(),
},
],
visibility: "public",
sourceId: "source-1",
lastUpdated: new Date().toISOString(),
isPinned: false,
},
],
}),
);
const feeds = await loadFeedsFromFile();
expect(feeds).toHaveLength(1);
expect(feeds[0].episodes.map((e) => e.id)).toEqual(["recent-id"]);
expect("episodes" in feeds[0].podcast).toBe(false);
});
// ── 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,543 @@
/**
* 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: the 5 newest episodes load even outside the date window", 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",
"Out Window",
]);
// The 70d episode is the second-newest available, so the min-5 floor
// pulls it in despite the 60-day cache window.
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
"Out 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);
// The min-5 floor surfaces the show's only 2 episodes; it still cannot
// fetch-more (nothing further exists to load).
expect(store.getFeed(id)!.episodes.length).toBe(2);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(2);
});
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); the min-5
// floor loads them at subscribe time regardless.
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);
// The min-5 floor loads both out-of-window episodes immediately.
expect(store.getFeed(id)!.episodes.length).toBe(2);
expect(store.hasMoreEpisodes(id)).toBe(false);
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,128 @@
/**
* Nested scroll behavior — the innermost scrollbox under the cursor wins.
*
* opentui bubbles wheel events up the renderable tree, so without a guard
* every ancestor scrollbox scrolls in lockstep. This pins the fix from
* `src/utils/nested-scroll.ts`: two nested scrollboxes (an inner one nested
* inside an outer one, as a description pane sits inside a list pane) must
* treat the wheel as owned by the innermost scrollbox under the cursor, and
* only chain out to the outer one when the inner is at its boundary.
*/
import { describe, test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { installNestedScrollBehavior } from "../src/utils/nested-scroll";
import type { ScrollBoxRenderable } from "@opentui/core";
installNestedScrollBehavior();
type TestSetup = {
renderOnce: () => Promise<void>;
mockMouse: {
scroll: (x: number, y: number, direction: "up" | "down") => Promise<void>;
};
renderer: { destroy: () => Promise<void> };
};
async function renderNested(): Promise<{
setup: TestSetup;
outer: () => ScrollBoxRenderable;
inner: () => ScrollBoxRenderable;
destroy: () => Promise<void>;
}> {
let outer: ScrollBoxRenderable | undefined;
let inner: ScrollBoxRenderable | undefined;
const setup = (await testRender(
() => (
// Outer spans the full 25-row terminal; the inner scrollbox sits at
// rows 3..12 (a top spacer above, a tall spacer below so the outer
// has room to scroll). Inner holds 30 rows -> max scroll 20.
<box flexDirection="column" width={60} height={25}>
<scrollbox ref={(el: ScrollBoxRenderable) => (outer = el)} height="100%">
<box height={3} />
<scrollbox
ref={(el: ScrollBoxRenderable) => (inner = el)}
height={10}
width="100%"
>
{Array.from({ length: 30 }, (_, i) => (
<box height={1}>
<text>row {i}</text>
</box>
))}
</scrollbox>
<box height={40} />
</scrollbox>
</box>
),
{ width: 60, height: 25, useThread: false },
)) as unknown as TestSetup;
// Give the renderer a chance to compute scrollbox layout (scrollHeight).
for (let i = 0; i < 40 && (inner?.scrollHeight ?? 0) <= 10; i++) {
await setup.renderOnce();
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, 50);
await promise;
}
if (!inner || !outer) throw new Error("scrollboxes did not render");
return {
setup,
outer: () => outer!,
inner: () => inner!,
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("nested scroll favors the innermost scrollbox under the cursor", () => {
test("wheel over the inner section scrolls only the inner scrollbox", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
expect(inner().scrollTop).toBe(0);
await setup.mockMouse.scroll(5, 5, "down"); // inside inner (rows 3..12)
expect(inner().scrollTop).toBe(1);
expect(outer().scrollTop).toBe(0);
});
test("wheel over the outer section (outside the inner) scrolls only the outer", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
await setup.mockMouse.scroll(5, 20, "down"); // below inner, still in outer
expect(outer().scrollTop).toBe(1);
expect(inner().scrollTop).toBe(0);
});
test("at the inner's bottom edge the wheel chains out to the outer scrollbox", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
for (let i = 0; i < 30; i++) await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(20); // pinned at max (30 rows - 10 viewport)
const before = outer().scrollTop;
await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(20); // inner stays pinned
expect(outer().scrollTop).toBe(before + 1); // outer took over
});
test("wheel up favors the inner again once it has room above", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(1);
await setup.mockMouse.scroll(5, 5, "up");
expect(inner().scrollTop).toBe(0); // inner wins again
expect(outer().scrollTop).toBe(0);
});
});

View File

@@ -0,0 +1,101 @@
/**
* pane-layout-store.test.ts — the shared pane-split store: splitPixels
* clamping, border moves that respect per-pane minimum widths, and
* commit() persistence into the app preferences.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (its module-level init reads it).
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-panecfg-"));
// The config dir must be set before the module is evaluated, so the app
// store is loaded dynamically here rather than statically at the top.
const { splitPixels, createPaneLayoutStore, DEFAULT_PANE_SPLITS } = await import(
"../src/stores/pane-layout"
);
const { useAppStore } = await import("../src/stores/app");
beforeAll(async () => {
await useAppStore().whenReady();
});
afterAll(() => {
// Restore pristine preferences so a later file sharing this process
// (bun test reuses the module registry) renders the default split.
useAppStore().updatePreferences({ paneSplit: DEFAULT_PANE_SPLITS });
});
test("default splits mirror the historical 2:5:3 ratio at width 100", () => {
expect(DEFAULT_PANE_SPLITS).toEqual({ left: 0.2, right: 0.7 });
const { leftPx, rightPx } = splitPixels(100, DEFAULT_PANE_SPLITS);
expect(leftPx).toBe(20);
expect(rightPx).toBe(70);
});
test("splitPixels maps splits 1:1 to pixels (ratio exact at every width)", () => {
// Pure fraction→pixel mapping — no minimum enforcement in rendering.
expect(splitPixels(100, { left: 0.6, right: 0.7 })).toEqual({
leftPx: 60,
rightPx: 70,
});
expect(splitPixels(70, { left: 0.2, right: 0.7 })).toEqual({
leftPx: 14,
rightPx: 49,
});
});
test("splitPixels handles zero-width and degenerate terminals", () => {
expect(splitPixels(0, DEFAULT_PANE_SPLITS)).toEqual({ leftPx: 0, rightPx: 0 });
const tiny = splitPixels(40, { left: 0.2, right: 0.7 });
expect(tiny.leftPx).toBe(8);
expect(tiny.rightPx).toBe(28);
});
test("setRight clamps the preview to its minimum", () => {
const store = createPaneLayoutStore();
store.setRight(97, 100); // preview can't shrink below 15
expect(store.splits().right).toBeCloseTo(0.85, 5);
// The parent minimum also holds when the left border is dragged.
store.setLeft(1, 100);
expect(store.splits().left).toBeCloseTo(0.15, 5);
});
test("setLeft moves the border and normalizes stored fractions", () => {
const store = createPaneLayoutStore();
store.setLeft(40, 100);
expect(store.splits().left).toBeCloseTo(0.4, 5);
expect(store.splits().right).toBeCloseTo(0.7, 5);
});
test("setLeft below the parent minimum clamps up", () => {
const store = createPaneLayoutStore();
store.setLeft(5, 100);
expect(store.splits().left).toBeCloseTo(0.15, 5);
});
test("setLeft beyond the current minimum forces the right border right", () => {
const store = createPaneLayoutStore();
store.setLeft(60, 100);
expect(store.splits().left).toBeCloseTo(0.55, 5);
expect(store.splits().right).toBeCloseTo(0.85, 5);
});
test("setRight respects the current and preview minimums", () => {
const store = createPaneLayoutStore();
store.setRight(80, 100);
expect(store.splits().right).toBeCloseTo(0.8, 5);
store.setRight(40, 100); // must not cross below leftPx(20) + minCurrent(30)
expect(store.splits().right).toBeCloseTo(0.5, 5);
});
test("commit persists the split into the app preferences", async () => {
const store = createPaneLayoutStore();
store.setLeft(35, 100);
store.commit();
await useAppStore().whenReady();
expect(useAppStore().state().preferences.paneSplit.left).toBeCloseTo(0.35, 5);
expect(useAppStore().state().preferences.paneSplit.right).toBeCloseTo(0.7, 5);
});

209
tests/pane-resize.test.tsx Normal file
View File

@@ -0,0 +1,209 @@
/**
* pane-resize.test.tsx — dragging the center column's borders actually
* resizes the panes in a rendered PaneRow.
*
* Each pane renders a long run of a unique character (P / C / V). A line
* where all three meet encodes the boundary columns directly: the current
* pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its
* content starts one column in — `leftPx + 1`. Hence
* `leftPx = firstC - 1`, `rightPx = firstV`.
*
* The grab zones overlay each border: 3 columns wide, the border plus one
* help-padded column each side (left zone at [left-1, left+1], right zone
* at [right-2, right)). The test presses inside a zone and drags across
* the row — the drag bubbles to the row container which moves the split,
* so the panes must re-render at the new columns.
*/
import { test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { usePaneLayout } from "../src/stores/pane-layout";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-"));
process.env.XDG_CONFIG_HOME = configHome;
type Span = { text: string };
type Frame = { cols: number; lines: { spans: Span[] }[] };
interface BoundCols {
left: number;
right: number;
}
function readBounds(frame: Frame): BoundCols {
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
if (!line) throw new Error("pane row did not render");
return { left: line.indexOf("C") - 1, right: line.indexOf("V") };
}
async function renderRow(panes: 2 | 3 = 3) {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
panes={panes}
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
mockMouse: {
drag: (a: number, b: number, c: number, d: number) => Promise<void>;
click: (a: number, b: number) => Promise<void>;
};
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
return setup;
}
/** Reset the shared store to the default split for a deterministic start. */
function resetSplits() {
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
}
const cleanups: (() => void)[] = [];
afterAll(() => {
for (const c of cleanups) c();
// Restore the default split so a later file sharing this process (bun
// test reuses the module registry) renders the default layout.
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
rmSync(configHome, { recursive: true, force: true });
});
test("panes render at the default 20/70 split", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("dragging the left border resizes parent vs current", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the left zone (border at 20 → zone covers 19-21) and drag
// toward the middle of the row.
await setup.mockMouse.drag(20, 5, 45, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBeGreaterThanOrEqual(44);
expect(after.left).toBeLessThanOrEqual(46);
// Pushing the left border to 45 would shrink the current pane below its
// 30-col minimum (45..70 = 25), so the right border is forced right to
// 75, keeping the current pane at exactly 30 and absorbing the overflow
// in the preview.
expect(after.right).toBe(75);
});
test("dragging the right border resizes current vs preview", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the right zone (border at 69 → zone covers 68-70) and drag
// toward the right edge of the row.
await setup.mockMouse.drag(69, 5, 90, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.right).toBeGreaterThanOrEqual(84);
expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15
expect(after.left).toBe(20);
});
test("grabbing the left zone from its far edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column LEFT of the border (x=19, border at 20 → offset -1)
// and drag to 37. The border must track the grab, landing at 38 (37 + 1),
// not at 37. Without the grab offset it would jump one column.
await setup.mockMouse.drag(19, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(38);
expect(after.right).toBe(70);
});
test("grabbing the left zone from its inner edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column RIGHT of the border (x=21, border at 20 → offset +1)
// and drag to 37. The border lands at 36 (37 - 1), not 37.
await setup.mockMouse.drag(21, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(36);
expect(after.right).toBe(70);
});
test("a click inside a padded grab zone (off the border) does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// A bare click (no drag) on the help-padded column beside the border
// must not move the split — only an actual drag does.
await setup.mockMouse.click(19, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("a plain click away from the borders does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
await setup.mockMouse.click(5, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("2-pane rows offer no right border (current fills the row)", async () => {
const setup = await renderRow(2);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
for (let i = 0; i < 10; i++) await setup.renderOnce();
// No 'V' pane: the line runs to the screen edge.
const frame = setup.captureSpans();
const { left } = readBounds(frame);
expect(left).toBe(20);
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
expect(line?.includes("V")).toBe(false);
});

View File

@@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA; process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "none"; process.env.PODTUI_AUDIO_BACKEND = "none";
// ── Local RSS feed server (episode ids = feedUrl#index) ──────────────────── // ── Local RSS feed server (episode ids derive from enclosure URLs) ─────────
let server: ReturnType<typeof Bun.serve> | null = null; let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string { function feedXml(origin: string): string {
const items = ["Episode One", "Episode Two"] const items = ["Episode One", "Episode Two"]

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,23 @@
/** Scratch: cava init/destroy cycles leak native fftw buffers? */
import { test, expect } from "bun:test"
import { loadCavaCore } from "../src/utils/cavacore"
const cava = loadCavaCore()
const skip = !cava
test.skipIf(skip)("init/destroy x50: RSS bounded", () => {
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
const samples = new Float64Array(8192)
Bun.gc(true)
const startRss = process.memoryUsage.rss()
for (let i = 0; i < 50; i++) {
cava!.init(cfg)
cava!.execute(samples)
cava!.destroy()
}
Bun.gc(true)
const endRss = process.memoryUsage.rss()
const grown = (endRss - startRss) / 1048576
console.log(`init/destroy x50: rss delta=${grown.toFixed(1)}MB`)
expect(grown).toBeLessThan(100)
}, 60_000)

View File

@@ -0,0 +1,59 @@
/** Scratch — verify full-height hover accent line. */
import { test, expect } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-phover3-"));
type Span = { text: string; fg?: { r: number; g: number; b: number; a: number } | null };
type Frame = { cols: number; lines: { spans: Span[] }[] };
test("hover renders accent line on every row", async () => {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
captureCharFrame: () => string;
mockMouse: { moveTo: (x: number, y: number) => Promise<void> };
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
await setup.mockMouse.moveTo(20, 5);
for (let i = 0; i < 3; i++) await setup.renderOnce();
const frame = setup.captureSpans();
let accentRows = 0;
for (let y = 0; y < frame.lines.length; y++) {
const line = frame.lines[y].spans.map((s) => s.text).join("");
// Border column = 20.
const ch = line[20];
const isAccent = frame.lines[y].spans
.filter((s) => s.text.length > 0)
.some((s) => {
const t = s.text;
let c = 0;
// recompute col: approximate by scanning previous spans
return false;
});
if (ch === "│") accentRows++;
console.log(`row ${y}: ${JSON.stringify(line.slice(16, 25))} ch20=${JSON.stringify(ch)}`);
}
console.log("accentRows:", accentRows, "of", frame.lines.length);
expect(accentRows).toBeGreaterThanOrEqual(6);
setup.renderer.destroy();
});

View File

@@ -0,0 +1,34 @@
/** Mach VM region walker via FFI (self-process). */
import { dlopen, FFIType, ptr } from "bun:ffi"
const k = dlopen("/usr/lib/system/libsystem_kernel.dylib", {
mach_task_self: { args: [], returns: FFIType.u64 },
mach_vm_region: {
args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr],
returns: FFIType.i32,
},
})
/** Walk own VM map; count total regions and ~128K ones. */
export function countRegions(): { total: number; r128k: number } {
const task = (k.symbols.mach_task_self as any)() as number
const addr = new BigUint64Array(1)
const size = new BigUint64Array(1)
const info = new Uint32Array(16)
const infoCnt = new Uint32Array(1)
const objectName = new Uint32Array(1)
let total = 0
let r128k = 0
addr[0] = 1n
const walk = k.symbols.mach_vm_region as any
for (;;) {
infoCnt[0] = 16
const kr = walk(BigInt(task), ptr(addr), ptr(size), 9, ptr(info), ptr(infoCnt), ptr(objectName))
if (kr !== 0) break
total++
if (size[0] >= 131072n && size[0] <= 139264n) r128k++
addr[0] = addr[0] + size[0]
if (addr[0] === 0n || total > 500000) break
}
return { total, r128k }
}

View File

@@ -0,0 +1,27 @@
/** Minimal opentui app: VM region growth from render loop and/or text churn. */
import { appendFileSync } from "node:fs"
import { createSignal } from "solid-js"
import { render } from "@opentui/solid"
import { countRegions } from "./scratch-region-count"
const CHURN = Bun.argv.includes("--churn")
const OUT = Bun.argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/render-leak.log"
const log = (m: string) => Bun.write(Bun.stderr, m + "\n") // stderr may be hijacked too; use fd via file:
const append = (m: string) => appendFileSync(OUT, m + "\n")
const [s, setS] = createSignal("hello")
if (CHURN) {
let i = 0
setInterval(() => setS(`hello ${++i} ${"x".repeat(i % 50)}`), 33)
}
render(() => <text>{s()}</text>)
await Bun.sleep(1000)
const c0 = countRegions()
append(`start churn=${CHURN}: total=${c0.total}`)
for (let w = 1; w <= 4; w++) {
await Bun.sleep(15_000)
const c = countRegions()
append(`t=${w * 15}s total=${c.total} (delta ${c.total - c0.total})`)
}
process.exit(0)

View File

@@ -0,0 +1,36 @@
/** Scratch: does the ffmpeg decode stream leak VM regions per chunk? */
import { test, expect } from "bun:test"
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache"
import { spawnSync } from "child_process"
const wav = "/tmp/podtui-stream.wav"
if (!(await Bun.file(wav).exists()) && (await Bun.$`which ffmpeg`.nothrow())) {
await Bun.$`ffmpeg -f lavfi -i "sine=frequency=440:duration=600" -ar 22050 -ac 1 -sample_fmt s16 ${wav}`.quiet().nothrow()
}
function regions(): number {
const out = spawnSync("vmmap", [String(process.pid)], { timeout: 20000 }).stdout?.toString() ?? ""
return out.split("\n").filter((l) => l.includes("VM_ALLOCATE")).length
}
function rss(): number {
return Number(spawnSync("ps", ["-o", "rss=", "-p", String(process.pid)]).stdout?.toString().trim() || 0)
}
test("decode stream 90s: regions and rss bounded", async () => {
Bun.gc(true)
await Bun.sleep(200)
const r0 = regions(), m0 = rss()
const pcm = new EpisodePcmCache({ url: wav })
pcm.startDecode(0)
const t0 = Date.now()
while (Date.now() - t0 < 90_000) {
await Bun.sleep(5_000)
const pos = ((Date.now() - t0) / 1000) * 4
pcm.readWindow(new Float64Array(512), pos)
}
Bun.gc(true)
const r1 = regions(), m1 = rss()
console.log(`stream 90s: regions ${r0}->${r1} (delta ${r1 - r0}), rss ${(m0 / 1048576) | 0}->${(m1 / 1048576) | 0}MB`)
pcm.stop()
expect(r1 - r0).toBeLessThan(100)
}, 140_000)

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