88 Commits

Author SHA1 Message Date
df4701957b refactor(feed): port refresh batch to Effect for concurrency and timeout
Replace the hand-rolled worker pool (mapWithConcurrency) with an Effect
program (src/effects/feed-refresh.ts): Effect.forEach bounds in-flight
fetches, Effect.timeout bounds each feed via the Clock service, and
failures fold to null so a bad feed never fails the batch. Per-feed
apply-as-it-lands is preserved — the apply callback runs inside each
feed's own fiber, so there is no Promise.all barrier.

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

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

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

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

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

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

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

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

Also fixes a pre-existing config-write race that intermittently failed
visualizer-toggle.test.ts: updateConfig re-resolved the config path and
re-read the patch state when the deferred write-chain drained, so a
queued save could land in a directory XDG_CONFIG_HOME had since been
pointed at (or carry state mutated after queueing). Path and patch
snapshot are now captured eagerly at call time.
2026-08-11 21:08:09 -04:00
2cf9559b0b bump VERSION to 0.5.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m25s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 19:54:36 -04:00
20336ea716 feat(audio): rebuild playback + visualization on resident daemon and PCM cache
Two fragility points, rebuilt at the root:

Playback: one resident mpv daemon (--idle --keep-open) with a persistent
IPC connection and observe_property state instead of spawn-per-episode and
connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at
~20Hz; external pauses arrive as events. Boot session restore preloads the
episode paused (loadfile + paused time-pos seek, since mpv defers --start
stream work until playback) so first Play is a ~400ms unpause instead of a
cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload
cannot clobber an in-flight play.

Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB)
so a paused preload no longer races to its 150MiB default (measured
45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start
isn't starved by the visualizer ripping the whole episode.

Visualization: replaced the paced-ring reader (AudioStreamReader) with a
position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed
by absolute playback time; reads at the player position are always exact.
Pause freezes the render loop, resume re-arms it — no coverage guessing,
no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks
and speed changes need no pipeline restarts; uncovered reads return empty
and the last frame holds.

Cover art: persistent per-URL disk cache under XDG cache dir; play() no
longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses
apply late via mpv video-add.

Test suite: 161 pass. New tests pin the position-index contract (sample-
exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments),
the daemon contract (play/pause/resume/seek/stop, preload fast path,
EOF->replay), and cover cache/single-flight/404.
2026-08-11 19:54:05 -04:00
8b7b38276e feat(player): toggleable waveform visualizer in settings, default on 2026-08-11 14:15:48 -04:00
8496922aaf feat(player): waveform pipeline survives tab switches — 30s unload grace + braille spinner loading
The waveform's ffmpeg decode + cavacore FFT pipeline lived inside
RealtimeWaveform, so switching away from the Player tab unmounted it and
killed the pipeline instantly — respawning ffmpeg on every return.

Move the pipeline into a module-level store (stores/visualizer.ts) that
outlives the page:
- losing Player-tab focus keeps the pipeline warm for
  VISUALIZER_UNLOAD_DELAY_MS (30s), then tears it down (kills ffmpeg,
  destroys the cava plan); regaining focus within the delay resumes the
  warm pipeline with no restart churn; after an unload it restarts from
  the current playback position.
- playback signals move to utils/audio-signals.ts (module-level, no
  useAudio() owner needed) so the store reacts to play/pause/seek/speed
  while no Player page is mounted; useAudio re-imports them.
- render a braille spinner as the loading state for the visualizer
  (first play / after unload); stale bars stay on screen during warmup
  restarts so the waveform never blanks out for the network-bound cold
  start.
- seed the smooth position clock at pipeline start: with the position
  still frozen at 0 while mpv opens the stream, the reader sampled a
  1-sample window that could never fill, starving the bars until mpv's
  first poll.

Pins the store contract in tests/visualizer-store.test.ts: loading→bars,
warm resume without restart, 30s unload, and bars while position is
frozen at 0.
2026-08-11 14:07:58 -04:00
5e3ad48a2d feat(audio): reconcile externally-initiated pause/resume via live mpv pause state
mpv can pause or resume OUTSIDE PodTUI — system sleep/lock, AirPod
removal, device swap, OS media keys, the Now Playing center. The poll
previously only reflected commands PodTUI sent, so the UI stayed stuck
on "playing" (or "paused") with a frozen position clock.

The poll now reads mpv's live pause state each tick: an external pause
reconciles the UI to paused (persisting progress, syncing media
controls) while keeping the poll armed; an external resume brings the
UI back to playing. A paused player is polled at a throttled rate
(PAUSE_WATCH_TICKS) so the watch costs ~1 IPC read per second instead
of hammering mpv; a dead process (track end / crash) finalizes the
track.
2026-08-11 14:07:43 -04:00
005ac8fde3 feat(search): stream unsubscribed episodes directly; a subscribes in place 2026-08-11 14:01:46 -04:00
1f0b9de456 fix(macos): derive bundle Info.plist version from VERSION constant
The release tag bumps VERSION in src/index.tsx, but build.ts hardcoded
0.3.1 in the app bundle's plist — shipped bundles reported a stale version
(lsappinfo/System Settings). Extract VERSION at bundle-assembly time and
interpolate into CFBundleShortVersionString/CFBundleVersion.
2026-08-11 13:48:23 -04:00
3388757185 fix(macos): hard-fail bundle without mpv, PATH fallback, CI mpv install
- build.ts: darwin compile now exits 1 when mpv is absent — CI can no
  longer ship a PodTui.app without its bundled player (0.4.0 did, killing
  Now Playing attribution).
- audio-player.ts: the bundled mpv is probed (--version) at first resolve;
  it links against brew's dylibs, and a Homebrew ffmpeg major upgrade can
  break it — fall back to PATH mpv so audio survives (icon degrades to
  blank instead of playback dying). Probed once per process.
- release.yml: brew install mpv on darwin runners; smoke test now asserts
  the tarball's PodTui.app has a launchable mpv signed with the
  com.mikefreno.podtui identifier.
2026-08-11 13:37:43 -04:00
8049d02457 feat(player): persist volume across sessions, default to 100%
Store the playback volume in app settings (config.json) whenever it
changes and re-apply the previous session's level at boot, instead of
always starting at the old 70% fallback.

- AppSettings gains volume (default 1 = 100%); both default-settings
  copies and the volume signal default are raised from 0.7 to 1.
- doSetVolume persists via the app store (mirrors playbackSpeed).
- The boot sync awaits the app store's async config load (new
  whenReady()) so a persisted level is applied even when settings load
  finishes after useAudio mounts.
- tests/volume-persistence.test.ts: default, clamp, and cross-session
  reuse (fresh module instance simulates the next launch).
2026-08-11 13:30:01 -04:00
1b55b7117c feat(player): restore the last player session at boot
Reload the episode that was loaded in the player when the previous run
ended (persisted on play/load and synchronously at exit) into the Player
tab paused at its saved position — never autostarted. Episodes at or
above 98% completion are skipped, as are empty-player and unsubscribed
episodes.

- useAudio gains load(episode) (sets currentEpisode/position/Now Playing
  without starting the backend) and restoreLastSession(), triggered once
  at boot and serialized through a chain so a late-finishing boot restore
  can't clobber later state.
- togglePlayback branches on a startedPlayback flag: a restored episode
  starts the backend from the saved position; a paused one resumes.
- stop() clears the marker; the exit teardown writes it synchronously
  (process.exit bypasses async writes).
- feed/progress stores expose whenReady() so restore waits for the async
  boot loads; feed store gains findEpisode().
- app-persistence serializes last-player marker writes and exposes
  waitForLastPlayerWrite() for deterministic tests.
- tests/restore-session.test.ts: real modules + local RSS feed server;
  the real useAudio is imported via a ?restore-test query suffix to
  bypass the suite's mock.module leak across shared bun workers.
2026-08-11 13:18:00 -04:00
15f8a098b5 feat(download): unsubscribed-show downloads from search
Episode search results gain d (download), D (delete), x (unsubscribe)
and enter (play for subscribed shows); downloads of unsubscribed shows
are recorded with the show's metadata under a deterministic synthetic
feed id and listed under an "Unsubscribed Show Downloads" section in
My Shows and the settings Download Manager. Classified at render time
by feed id or feed URL, so subscribing re-classifies the downloads and
unsubscribing purges them.
2026-08-11 13:11:32 -04:00
2d7d49b91c feat(feed): periodic background refresh with failed-fetch guard
Self-rescheduling refresh timer (default 30 min, configurable via a
Preferences item, re-read on every tick, skips in-flight refreshes).
fetchEpisodes returns null on network failure/timeout so a failed
refresh can never wipe a feed's episodes (addFeed/refreshFeed/
refreshAllFeeds all treat null as unchanged); feeds still refresh on
launch.
2026-08-11 13:11:20 -04:00
df9c519439 feat(search): persist recent searches between sessions
History used localStorage, which never exists in the Bun TUI, so nothing
survived a restart. Store the 10 most recent queries in config-dir
search-history.json (same fire-and-forget file pattern as audio-nav.json),
loaded asynchronously at store init. Dedupe case-insensitively, cap at 10.
2026-08-11 12:45:43 -04:00
2bf1c229c7 Require shift for speed cycle keybind (s -> S) 2026-08-11 12:22:44 -04:00
b2e9e5c16c bump VERSION to 0.4.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m24s
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 09:57:58 -04:00
41c0002090 test(search): query input focus follows real focus
Regression coverage for the search input's focus flag: it must track
the renderable's REAL focus (useInputFocusNav FOCUSED/BLURRED), not a
flag that outlives it. Clicking off the input drops inputFocused and
keyboard control resumes; s re-enters typing; Esc defocuses; clicking
the input refocuses.

Documents the observed full-suite CPU-contention flake in the header:
both tests occasionally time out in openSearch under suite load while
passing reliably in isolation.
2026-08-11 07:26:35 -04:00
c0252fc9b8 fix: playback controls block now wraps 2026-08-11 07:24:59 -04:00
0c3506beb5 feat(search): episode search scope with tab toggle
Search now covers individual episodes, not just shows: the iTunes
Search API (entity=podcastEpisode) matches episode titles and show
notes, so a guest or topic finds the episodes they appear in across
shows. Enter on an episode result subscribes to the parent show.

- types: SearchResult becomes a kind-discriminated union
  (podcast | episode); EpisodeSearchResult carries the parent show so
  existing consumers compile unchanged
- source-searcher: searchEpisodesByType (RSS/CUSTOM return []),
  buildItunesEpisodeUrl, cleanDescription (HTML -> text),
  mapItunesEpisodeResult (episode id/duration ms->s/audioUrl, reuses
  mapItunesResult so delisted shows keep a directoryUrl)
- search: searchEpisodes with an 'episode' cache/dedupe namespace so
  shows and episodes for the same query never mix
- stores/search: scope signal (podcast | episode) persisted to
  podtui_search_scope; search() branches on scope
- SearchPage: Shows/Episodes pills row with 'tab to toggle', scope-
  aware placeholder/empty state/result rows (episode row = title +
  Show · date) and preview; toggling re-runs the current query
- keybinds: search-scope-toggle bound to tab (keybinds.jsonc AND the
  runtime DEFAULT_KEYBINDS merge so the binding exists for users with
  a pre-existing config file); while the input is focused the Shell
  router never sees Tab, so the input handles it via onKeyDown +
  preventDefault (no double-toggle: the router path only fires when
  the input is defocused)
- Shell help overlay documents [tab] shows/episodes
2026-08-11 00:50:13 -04:00
ef9fc13aaa feat(settings): add Podcast Index fallback source with credential storage
Podcast Index (api.podcastindex.org) ships as a disabled, key-less source
and is only consulted as a fallback when primary search results are fewer
than 3 — never on the hot path, never when disabled or credential-less.
A failed fallback leaves primary results intact.

Credentials are user-supplied: enabling the source pops a dialog that
asks for the free key+secret, prefilled masked (first 3 chars + "...")
when already stored; toggling off never clears them. Secrets prefer the
macOS keychain (security CLI, encrypted at rest) with a plaintext
config.json fallback when the keychain is unavailable; sources carry only
a hasCredentials/credentialStorage marker, and legacy plaintext keys in
existing configs are migrated on load.

Auth follows the documented scheme: X-Auth-Key, X-Auth-Date (epoch) and
Authorization = sha1(key + secret + date). Dead feeds are filtered, feed
URLs are used directly, and episode-scope search is a no-op (no endpoint).
2026-08-11 00:37:56 -04:00
0b0637b9dc fix(search): surface iTunes stubs and resolve feeds for delisted shows
Shows that left Apple Podcasts (e.g. Daily Wire's in 2021) come back from
the iTunes Search API as metadata-only stub records with feedUrl null.
mapItunesResult dropped them, so The Ben Shapiro Show — the #1 hit for
'ben shapiro' — never appeared in search while sibling shows did.

- Keep feedUrl-less results (feedUrl "" + directoryUrl pointing at the
  Apple page) so delisted shows stay findable.
- Resolve the real feed from the Apple page at subscribe time
  (itunes-feed-resolver: anchor on the collection's adamId, forward-scan
  for the embedded feedUrl; Apple serves page variants where the
  showOffer block sits thousands of chars after the adamId).
- addFeed refuses feedless stubs whose feed can't be resolved instead of
  adding a broken feed; SearchPage surfaces the failure via toast.
- Tests: stub mapping, extractor variants, and an end-to-end subscribe
  over a local HTTP server.
2026-08-10 22:38:03 -04:00
e73e608b9f feat(myshows): add Fetch More row to per-show episode lists
Mirrors the Feed tab's '[Fetch More]' row inside a drilled show's episode
list (My Shows depth 1): shows only while the show's cache holds episodes
beyond its loaded window, and advances just that show's window by 50 on
Enter (or automatically at the bottom in auto mode). Same fetchMoreMode
preference drives both behaviors. Adds a store-contract test for the
per-feed pagination path.
2026-08-10 22:37:51 -04:00
ebed49237c style(tabpanel): convert tab indentation to 2 spaces 2026-08-10 20:57:16 -04:00
dc2b22eaa5 feat(settings): accent-colored input cursor in add-source form
Match the input's focusedTextColor with cursorColor=theme.accent on
both the name and URL fields.
2026-08-10 20:57:13 -04:00
2bb612ee07 fix(player): render help hint via content prop to avoid escaping
The babel-preset-solid JSX transform HTML-escapes static string
children (< > → &lt; &gt;), which opentui renders verbatim — so the
"< > seek" hint displayed its entities. Pass the string as the
content prop instead, which bypasses the transform.
2026-08-10 20:57:11 -04:00
dc855ab8a0 feat(shell): hold now-playing marquee at start between scroll passes
Previously the marquee looped continuously, cycling back to the
start the moment the text finished. Now it holds at the start for
SCROLL_HOLD_MS (10s), scrolls one pass at SCROLL_STEP_MS per char,
then holds again.
2026-08-10 20:57:08 -04:00
f976bdc2b7 fix(rows): keep episode rows exactly 3 lines — truncate, don't wrap
Feed and My Shows rows could grow to 4+ lines when a long title was
shrunk by the current pane: flexible text wrapped instead of
truncating, shifting every row below while scrolling. Add
wrapMode=none + truncate to flexible text and flexShrink=0 to
fixed-width cells so rows stay one line tall. Feed rows also move
the podcast name onto its own line. Adds a rendered-layout
regression test at 70 columns (35-col current pane).
2026-08-10 20:57:06 -04:00
1cf3361e59 fix(feed): stop refreshes from re-sorting the updated list
Two fixes to refresh order stability (My Shows / Feed sort by
lastUpdated):

- A refresh that fetches identical episodes no longer bumps
  lastUpdated (id-set comparison via sameEpisodes), so unchanged
  feeds keep their position instead of reordering every cycle.
- refreshAllFeeds now fetches in parallel and applies ONE atomic
  update instead of a per-feed setFeeds, which re-sorted the list
  once per completion and made order flap until the batch finished.

Adds feed-refresh regression tests with mocked clock.
2026-08-10 20:57:02 -04:00
ada441300a feat(layout): widen current pane to 50% — PANE_RATIO 2:5:3
Change the parent|current|preview split from 1:2:2 (20/40/40) to
2:5:3 (20/50/30) so the focused list gets more room. 2-pane tabs
now give current the combined 80%. Updates ratio comments and the
PaneRow test expectations.
2026-08-10 20:56:58 -04:00
6134dea044 feat(settings): configurable selection marker, default off
Adds a Selection Marker toggle under Settings → Preferences that
controls the ❯ cursor glyph on the focused row of every list.
Default off; rows keep a leading space for alignment either way.
Every list pane (tab strip, feed, my shows, discover, search,
settings, whitelist editor) reads the glyph through the shared
useSelectionMarker hook.

Also aligns the lead column across panes: page list rows drop
their extra box paddingLeft so labels start at the same column
as the tab strip (2 cells without nerd icons, 4 with), keeping
episode sub-rows aligned to the title.
2026-08-10 20:54:39 -04:00
93d5925dfd fix(waveform): add -readrate_initial_burst to eliminate audio lag
The decode head lagged the player by a constant ε (ffmpeg startup
latency) because ffmpeg paced at -readrate <speed> started behind mpv
and, advancing at the same rate, never caught up — bars were a few
seconds behind for the entire playback.

Add -readrate_initial_burst LEAD_SECONDS so ffmpeg emits 3s of audio
immediately on start, then paces at realtime after. The decode head
leads the player by a stable ~3s from the very first frame; read()
samples at the exact player position and always finds fresh samples.

Add a sustained render-loop test that simulates ~5s of real playback,
asserting ffmpeg stays alive, the decode head maintains a positive
lead, and read() returns full windows. Uses real wall-clock time
(documented exception) since ffmpeg's decode pacing can't be tested
deterministically.
2026-08-10 19:02:32 -04:00
de6d0ccbf6 feat(audio): podcast cover art in system Now Playing, CLI --play included
The cover was previously only wired through the UI hook; --play skipped it.
Also: Bun's fetch hangs in compiled binaries (Bun 1.3.8) — every cover
download silently failed in shipped builds, timing out against any host.
Cover staging now shells out to curl (present on macOS/Linux, 8s bound),
shared via src/utils/cover-art.ts so the UI and CLI paths stage the same
temp file and pass it to mpv as --cover-art-files (albumart track).
2026-08-10 18:18:58 -04:00
cda29bcb95 fix(macos): sign nested mpv with bundle identifier for Now Playing attribution
mediaremoted resolves the Now Playing client from the registering process's
code-signing identifier, not its bundle. codesign derives the identifier of
a plist-less executable from its basename, so the bundled mpv was stamped
'mpv' regardless of PodTui.app — the audio center kept showing a blank
placeholder. Signing mpv last with --identifier com.mikefreno.podtui (after
the bundle deep-seal, which would otherwise re-derive the basename) makes
the session register as PodTui: icon + name. Identity overridable via
PODTUI_CODESIGN_IDENTITY for Developer ID release builds.
2026-08-10 18:07:26 -04:00
3775a9801d icons 2026-08-10 18:04:09 -04:00
a9589e7686 feat(macos): ship PodTui.app bundle so Now Playing shows our icon
mpv owns the macOS Now Playing session (it plays the audio), and an
unbundled binary renders as a blank placeholder for the source-app icon.
macOS has no public API for a third party to claim session ownership
(MPNowPlayingSession is iOS-only; the private MRMediaRemoteSetNowPlayingApplication
was removed from the shared cache), so instead we make the OWNING process
carry our bundle: the darwin tarball now includes PodTui.app with mpv copied
into Contents/MacOS. AudioPlayer resolves the sibling mpv first (falling
back to PATH), LaunchServices attributes the process to com.mikefreno.podtui,
and Control Center shows the PodTui icon + name with podcast cover art.
AppIcon.icns generated from the Xcode icon-composer exports.
2026-08-10 17:56:54 -04:00
c52fa14e42 fix(player): correct click-to-seek by accounting for pane offset
MouseEvent coordinates are terminal-absolute, so the bar's seek handler
must subtract its own absolute left edge (renderable ref) instead of
treating the mouse x as bar-local. Clicking was off by the width of the
parent/Up pane (~20%) plus chrome. Also drop the empty played-text node
at position 0, which rendered a phantom space column and shifted the
drawn bar one char when playback started.
2026-08-10 17:53:46 -04:00
116d095ad5 feat(packaging): bundle app icon + Linux desktop entry; wire into AUR package
- release tarballs now ship podtui.png (assets/App Icon/App Icon.png, 512px)
  on every platform; Linux tarballs additionally carry podtui.desktop
  (Terminal=true so launchers open the TUI in a terminal)
- AUR PKGBUILD installs both: icon to hicolor 512x512, entry to applications/
- build.ts bundles the icon/desktop into dist before tarballing
2026-08-10 17:08:15 -04:00
b280af484c feat(ui): nerd font icons on hard-defined list rows, graceful degrade
Tabs, Discover categories (replacing unused placeholder glyphs), Settings
sections, and the Feed 'Fetch More' row get Nerd Font glyphs (Font Awesome PUA
codepoints). When the terminal font isn't Nerd Font capable the glyphs render
nothing at all — no tofu, no layout gaps — via supportsNerdFonts() (env
allowlist + PODTUI_NERD_FONTS=1/0 override). Documented in README (Configuration
-> Fonts).
2026-08-10 16:50:21 -04:00
5dce21c038 feat(media): expose podcast name + cover art to system Now Playing
- mpv: --force-media-title '<podcast> — <episode>' so macOS Now Playing shows
  the podcast name instead of the download-hash filename (mediaTitle PlayOption)
- media registry: artist is now the human podcast title (customName ||
  podcast.title), falling back to podcastId
- downloads: write the podcast cover as a <base>.jpg sibling so mpv's
  cover-art-auto=exact picks it up for artwork; delete it with the download
2026-08-10 16:50:08 -04:00
d2c46631ef feat(shell): now-playing marquee in status bar (podcast — episode)
The bottom-bar now-playing segment shows '<podcast> — <episode>' (custom name
when set, feed resolved like advanceEpisode), takes the full remaining status-bar
width, and marquee-scrolls on a 300ms timer when the text overflows instead of
truncating at 40 chars. Static when it fits; no interval runs on fit/narrow bars.
2026-08-10 16:50:03 -04:00
67032460ff feat(player): click-to-seek progress bar; 2-row waveform bars with peak normalization
- ProgressBar: full-width played/remaining bar in the player pane, click-to-seek;
  waveform no longer handles seeking or the played/future color split (pure visual)
- bars: 2 terminal rows per bar (16 levels) via barChars, partial block in the
  top row so the column renders continuously
- fix bars maxing at audio start: disable cava autosens (silence gain-ramp),
  pre-warm the malloc'd FFT window with zeros, skip partial FFT windows
- createBarScaler peak follower + power curve replaces cava autosens for
  level-to-height mapping (src/utils/bar-mapping.ts, unit-tested)
2026-08-10 16:49:57 -04:00
0f3ffcf934 fix: whitelist editor keyboard/mouse navigation + highlight gating, My Shows w on focused show
Whitelist editor (Preferences > Auto Download Whitelist):
- input focus driven by nav.inputFocused() (SearchPage pattern) so Esc
  deterministically defocuses and j/k/Space/Enter browse the suggestions
- suggestions react to mouse: click focuses the row and toggles membership
- no accent bg / no marker on any suggestion while the input is focused
- s re-enters typing mode; suggestion rows scroll into view

Search page: recent-searches rows get no bg and no marker while the query
input is focused (previously the focused row fell into the inactive branch
and rendered the border color as a background).

My Shows: w toggles the focused show in/out of the auto-download whitelist
at the show list (depth 0), with a whitelist marker on the row and a hint in
the preview pane (scope: whitelist).
2026-08-10 16:40:00 -04:00
c813949d48 fix: remove fake RSS placeholder source; migrate persisted configs 2026-08-10 15:53:25 -04:00
eb220386ce feat: global auto-download with count/scope/whitelist + d/D/w keybinds 2026-08-10 15:53:25 -04:00
19eae4fd5a feat: sync waveform to live player position (mpv time-pos, position-window reads, smooth clock) 2026-08-10 15:51:02 -04:00
e70469b1ec fix: theme command palette input with accent 2026-08-10 15:50:51 -04:00
30b7ff57d3 refactor: seek via keybind router (< / >), free arrows for navigation 2026-08-10 15:50:51 -04:00
fd689aba04 fix: opentui subtree disposal on depth swap; theme inputs; recent-click 2026-08-10 15:46:53 -04:00
8eaca82ce9 chore: bump CI action versions; ignore notes.md 2026-08-10 15:46:48 -04:00
8ac1ec1162 bump VERSION to 0.3.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m8s
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-10 13:45:24 -04:00
4a94ff5910 feat: labeled loading spinners + [Fetch More] pagination on the feed list
Loading indicators: the braille spinner now carries a contextual label
(Refreshing…, Fetching…, Loading more…, Discovering…, Searching…) and is
shown in every loading state that previously rendered nothing — Discover
results, Search results fallback, and the empty Feed list.

Feed pagination: a focusable "[Fetch More]" row at the bottom of the flat
feed list advances every feed's loaded window by 50 episodes via the new
loadMoreAllFeeds/hasMoreAcrossAll store API. Behavior is a setting
(Fetch More: manual|auto, default manual) persisted in config.json; auto
fetches when focus reaches the bottom row. The button row is excluded
from episode focus so no episode is double-highlighted while it is active.
2026-08-10 10:38:20 -04:00
2e69868ffc Build standalone binary with bunfig autoload disabled
Set autoloadBunfig: false in build.ts so the compiled runtime ignores any
bunfig.toml in the launching directory, preventing startup failures from a
CWD preload the standalone cannot resolve. Update release.yml, Makefile,
bunfig.toml, CONTRIBUTING.md, and README.md to match.
2026-08-10 09:00:30 -04:00
491a736c32 Restore center-pane borders, move title to top-left slot
- Current pane gets muted left/right borders only (no full box, no accent
  ring); border colors are passed only when a border is requested, since
  opentui flips borderless boxes to bordered when borderColor is supplied.
- Remove the Up / <current tab> / Detail titles above the panes; the current
  pane's title now renders once, top-left in the parent column's header slot.
- Drop the parentLabel/previewLabel props from PaneRow and all callers.
- Remove the tab/depth indicator from the bottom-left of the status bar.
- Tests measure column widths from the border glyphs and assert the
  left/right edges render muted regardless of focus.
2026-08-10 09:00:24 -04:00
12bd6be4bc Remove borders and accent ring from PaneRow panes
Make parent|current|preview fully borderless: no scrollbox borders and no
accent border highlight on the current column. focused still gates
scroll-following but never surfaces a separator. Update tests to measure
column widths from the header-label row and assert no border glyphs render.
2026-08-10 01:15:19 -04:00
d2f6c5c525 some hygiene 2026-08-09 23:39:35 -04:00
f758b53336 drop tasks dir 2026-08-09 22:29:24 -04:00
b0bfa41028 bump VERSION to 0.3.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m12s
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-09 22:24:58 -04:00
25307f83e9 fix: up highlight made legible for transparent bg, bring back mouse nav 2026-08-09 22:21:30 -04:00
db285530b6 feat: private feeds, all input fields supersede keyboard nav 2026-08-09 15:39:02 -04:00
e1cdd6b2a5 finished hygenie 2026-08-09 14:38:33 -04:00
2abdbaa4e9 cleaning up code 2026-08-09 09:54:52 -04:00
1d06156b8b docs: repoint Homebrew tap to mikefreno/tap after repo rename 2026-08-09 09:19:05 -04:00
210 changed files with 16276 additions and 5869 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

@@ -37,7 +37,7 @@ jobs:
plat: darwin plat: darwin
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -66,17 +66,19 @@ jobs:
env: env:
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
run: | run: |
# The embedded runtime reads the launching process's CWD bunfig.toml. # The binary is compiled with bunfig autoload disabled
# This repo's bunfig lists a preload the standalone can't resolve # (autoloadBunfig: false in build.ts), so it must boot even from a
# ("preload not found"), so kicking the binary from the workspace root # directory holding a bunfig.toml with a top-level preload the
# would falsely fail every build. cd into a clean dir first. # standalone can't resolve. Plant one to make this a real regression
# test for "preload not found".
SMOKE_DIR=$(mktemp -d) SMOKE_DIR=$(mktemp -d)
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR" tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
cd "$SMOKE_DIR" cd "$SMOKE_DIR"
./podtui-*/podtui --version ./podtui-*/podtui --version
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: podtui-${{ matrix.plat }}-${{ matrix.arch }} name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
path: dist/podtui-*.tar.gz path: dist/podtui-*.tar.gz
@@ -87,12 +89,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download all binaries - name: Download all binaries
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
path: artifacts path: artifacts
- name: Publish release - name: Publish release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
generate_release_notes: true generate_release_notes: true
files: | files: |

3
.gitignore vendored
View File

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

View File

@@ -15,7 +15,7 @@
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test - `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

@@ -38,6 +38,7 @@ The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
| `bun run lint` | Type-check | | `bun run lint` | Type-check |
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) | | `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
| `make dist` | Compile the standalone binary + make the current platform's tarball | | `make dist` | Compile the standalone binary + make the current platform's tarball |
| `make dist-mac` / `make dist-linux` | Aliases for `dist` on their platform (CI runs these) |
| `make clean` | Remove `dist/` | | `make clean` | Remove `dist/` |
## Repository layout ## Repository layout
@@ -90,19 +91,21 @@ Cavacore smoke test: `bun tests/cavacore-smoke.ts`
## Gotchas (read before touching anything) ## Gotchas (read before touching anything)
1. **Never add a top-level `preload` to `bunfig.toml`.** 1. **The compiled binary must keep bunfig autoload disabled.**
A compiled PodTui binary's embedded runtime reads the *launching process's* `build.ts` compiles the standalone with `autoloadBunfig: false`, so its
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone embedded runtime *never* reads the launching CWD's `bunfig.toml`. Without
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with that flag, a top-level `preload` in the CWD bunfig (common in Bun project
`preload not found`. This is why `bunfig.toml` has **no** top-level dirs) resolves against the CWD rather than the binary and kills startup
`preload`; dev-mode preloading happens via explicit `--preload` flags in with `preload not found`. Don't remove the flag. Preloads for dev/test
`package.json`. The `[test]` section *does* keep a preload — that only belong in the explicit `--preload` flags in `package.json` and the
affects `bun test`. `[test]` section of `bunfig.toml` — not as a top-level entry.
2. **Smoke-test the compiled binary from a bunfig-free dir.** 2. **Smoke-test the binary from a dir with a poisoned bunfig.**
Because of (1), `./dist/podtui --version` run from the repo root launched The CI smoke test unpacks the tarball into a `mktemp` dir, drops a
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir `bunfig.toml` containing an unresolvable top-level `preload` next to it,
before booting. Do the same when testing a release build locally. and boots the binary — proving bunfig autoload stayed disabled. `./dist/
podtui --version` must work from any directory, including the repo root;
do the same check when testing a release build locally.
3. **Homebrew's dylib-repair warning is benign.** 3. **Homebrew's dylib-repair warning is benign.**
`brew install` may print “load commands do not fit in the header … needs `brew install` may print “load commands do not fit in the header … needs
@@ -159,14 +162,14 @@ Releases are built and published from **tags**
4. A release is auto-created with all 4 tarballs attached. `brew` never 4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that `mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no `Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/podtui/podtui`. test: `brew install mikefreno/tap/podtui`.
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is 5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
staged, not yet published (AUR account registrations are closed; see the staged, not yet published (AUR account registrations are closed; see the
README note in section 3). On each release, keep the AUR sources in sync README's Installation section). On each release, keep the AUR sources in sync
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums` with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
entries, keep the `LICENSE` asset source (the workflow above uploads entries, keep the `LICENSE` asset source (the workflow above uploads
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with `LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
@@ -177,7 +180,7 @@ Releases are built and published from **tags**
If you ever need to sync the tap by hand (or before the hourly job runs): If you ever need to sync the tap by hand (or before the hourly job runs):
```bash ```bash
cd <clone of mikefreno/homebrew-podtui> cd <clone of mikefreno/homebrew-tap>
./scripts/sync-formula.sh 0.2.0 ./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push git commit -am 'podtui 0.2.0' && git push
``` ```
@@ -190,13 +193,36 @@ make dist # builds the binary + tarball for THIS machine only
Bun cannot cross-compile — the other platforms come from CI. Bun cannot cross-compile — the other platforms come from CI.
## Distribution & packaging
A release tarball is three files sitting side by side: the `podtui` binary
plus its two FFI libraries (`libopentui.<dylib|so>`,
`libcavacore.<dylib|so>`). The sibling rule above is why they ship together.
PodTui deliberately ships **no** `.deb`, `.rpm`, Flatpak, or Snap packages:
for a terminal app that's overwhelmingly installed through repositories or
archives, those formats add desktop-sandboxing overhead and a packaging tax
with little benefit. Instead:
- **GitHub Release tarballs** are the universal path — one upload per
OS/arch, works on any distro with `curl` + `tar`.
- **AUR (`podtui-bin`)** covers Arch/Manjaro with the same binary through the
native package manager.
- **Nix / cross-distro** users build from source (or a Nix flake can be added
later).
This keeps maintenance to a single build per OS/arch while still reaching the
vast majority of desktop Linux users. The AUR PKGBUILD lives in
`packaging/aur/` and can be built locally to test before publication:
```bash
cd packaging/aur && makepkg -si
```
--- ---
## Open items / things to sort out ## Open items / things to sort out
- **LICENSE**: `README.md` says "TBD — choose and document a license before
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
README footer.
- **Native libs in `dist/` still need committing?** No — they're built from - **Native libs in `dist/` still need committing?** No — they're built from
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64 `src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64

View File

@@ -19,12 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
---
This project bundles third-party components under their own licenses:
- **cava** (karlstav/cava, vendored under `cava/`) — MIT,
Copyright (c) 2015 Karl Stavestrand. See `cava/LICENSE-cava.txt`.
- **Bun runtime** (embedded in the standalone binary) — MIT.
- **OpenTUI** (`@opentui/core`) — MIT.

View File

@@ -47,9 +47,8 @@ native:
scripts/build-cavacore.sh scripts/build-cavacore.sh
## Standalone binary + native-libs tarball for the current platform. ## Standalone binary + native-libs tarball for the current platform.
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads ## Built with bunfig autoload disabled (build.ts sets autoloadBunfig: false),
## the launching process's CWD bunfig.toml, so smoke tests must run the binary ## so the embedded runtime ignores any bunfig.toml in the launching directory.
## from a bunfig-free dir (see release.yml).
dist: dist:
bun run build.ts --compile bun run build.ts --compile

236
README.md
View File

@@ -1,7 +1,6 @@
# PodTui # PodTui
A keyboard-first, yazi-style terminal podcast client written in TypeScript and A keyboard-first, terminal podcast client built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
browse episodes in a three-pane file-manager layout, and play audio through an browse episodes in a three-pane file-manager layout, and play audio through an
external player with full transport control — all from your terminal. external player with full transport control — all from your terminal.
@@ -11,8 +10,7 @@ external player with full transport control — all from your terminal.
`Enter` to open, `16` / `[` `]` to switch tabs. The tab list is the app root: `Enter` to open, `16` / `[` `]` to switch tabs. The tab list is the app root:
at launch it fills the current pane, and drilling into a tab's contents slides at launch it fills the current pane, and drilling into a tab's contents slides
it into the parent pane. it into the parent pane.
- **Three-pane view** — parent / current / preview (Up | Current | Preview), - **Three-pane view** — parent / current / preview (Up | Current | Preview).
mirroring yazi's pane model.
- **Podcast feeds** — add feeds, browse episodes, and manage your library - **Podcast feeds** — add feeds, browse episodes, and manage your library
(My Shows, Discover, Feed tabs). (My Shows, Discover, Feed tabs).
- **Search** across your subscribed shows. - **Search** across your subscribed shows.
@@ -22,22 +20,28 @@ external player with full transport control — all from your terminal.
- 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.
## Quick start
1. Install PodTui ([Installation](#installation)) and make sure **mpv** is in
your `PATH`.
2. Run `podtui` in your terminal.
3. Press `3` to open **Discover** (or `4` to open **Search**, then `s`), drill
in with `Enter`, and press `Enter` on a show to subscribe.
4. Press `1` (**Feed**) or `2` (**My Shows**), open an episode with `Enter`,
and use `P` to play/pause, `N`/`B` for next/previous, and `shift-.` /
`shift-,` to seek.
Press `~` any time for in-app help. All keys are remappable — see
[Keybindings](#keybindings).
## Requirements ## Requirements
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm, - A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
tmux, GNOME Terminal, etc.). Ghostty, tmux etc.).
- An **audio player** on `PATH`. PodTui auto-detects in priority order: - **mpv** on `PATH` for audio playback. PodTui drives mpv over JSON IPC, so
seek, speed, and position tracking all work. Without `mpv` on `PATH`,
| Player | Platforms | Seek | Speed | Position tracking | playback is a silent no-op (the `none` backend) — see
|----------|----------------|:----:|:-----:|:------------------| [Troubleshooting](#troubleshooting).
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
| `ffplay` | any | ✔ | ✘ | ✘ |
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
Install `mpv` for the best experience (`brew install mpv`,
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
## Installation ## Installation
@@ -47,13 +51,9 @@ Linux (arm64/x64). Pick whichever fits your platform.
### 1. Homebrew (macOS) ### 1. Homebrew (macOS)
```sh ```sh
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv brew install mikefreno/tap/podtui
``` ```
> The formula installs the standalone binary plus its two native libraries
> side by side (see [Packaging model](#packaging-model)). It does **not**
> depend on Bun.
### 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
@@ -71,69 +71,29 @@ sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
> The tarball contains `podtui` plus `libopentui.<ext>` and > The tarball contains `podtui` plus `libopentui.<ext>` and
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the > `libcavacore.<ext>` **beside it** — keep them together (don't move just the
> binary alone), or the native FFI libraries won't load. > binary alone), or the native FFI libraries won't load.
>
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
> you launch from. If that file has a `preload` entry (as Bun project
> directories often do), startup fails with `preload not found`. Launching
> from a normal directory (home, `~/bin`, …) works fine.
### 3. Arch Linux (AUR) ### 3. Arch Linux (AUR)
```bash ```bash
# Status: PKGBUILD ready, not yet on the AUR (see note below)
yay -S podtui-bin # once published yay -S podtui-bin # once published
``` ```
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR Requires an AUR helper ([paru](https://github.com/morgan/paru)); the package
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and pulls in `mpv` as a dependency.
its two FFI sibling libraries into `/usr/lib/podtui/` with a `/usr/bin/podtui`
symlink, and pulls in `mpv` (the sole audio backend) as a dependency.
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready > **Not yet on the AUR.** The `podtui-bin` package is staged and awaiting
> in `packaging/aur/` and can be built locally today: > publication (AUR account registrations are currently suspended). Until it
> > lands, use the standalone tarball above.
> ```bash
> cd packaging/aur && makepkg -si
> ```
>
> Publishing is on hold until [AUR account registrations](https://aur.archlinux.org)
> reopen (suspended while the AUR team works on suspicious-package
> moderation). Once a key can be registered, push `PKGBUILD` + `.SRCINFO`
> with `git push ssh://aur@aur.archlinux.org/podtui-bin` and update this note.
### 4. From source ### 4. From source
Requires [Bun](https://bun.sh) ≥ 1.2. PodTui is written in TypeScript and runs on [Bun](https://bun.sh). To build
from source (development, distro packaging, unreleased versions), see
```bash [CONTRIBUTING.md](CONTRIBUTING.md).
git clone https://github.com/mikefreno/podtui.git
cd podtui
bun install
bun run build:native # build the cavacore FFI lib from C source
bun run dev # run with hot reload, or: bun start
```
## Linux distribution notes
PodTUI deliberately does **not** ship `.deb`, `.rpm`, Flatpak, or Snap
packages. For a terminal application that's overwhelmingly installed through
repositories or archives, those formats add desktop-sandboxing overhead and a
packaging tax with little benefit. Instead:
- **GitHub Release tarballs** are the universal path — one upload, works on
any distro with `curl` + `tar`.
- **AUR (`podtui-bin`)** covers Arch. Anyone on Arch/Manjaro gets the same
binary through their native package manager.
- **Nix / cross-distro** users can build from source (or a Nix flake can be
added later).
This keeps maintenance to a single build per OS/arch and still reaches the
vast majority of desktop Linux users through their preferred path.
## Usage ## Usage
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~` Launch `podtui`. Press `~` for the in-app help.
for the in-app help.
### Command-line flags ### Command-line flags
@@ -145,30 +105,73 @@ for the in-app help.
### Keybindings ### Keybindings
All keys are remappable — edit `~/.config/podtui/keybinds.jsonc`. All keys are remappable — edit `keybinds.jsonc` in your config directory
(see [Configuration](#configuration)).
**Movement**
| Keys | Action | | Keys | Action |
|------|--------| |------|--------|
| `j` / `k` | Move cursor down / up | | `j` / `k` (or `down` / `up`) | Move down / up |
| `J` / `K` | Jump 5 lines | | `J` / `K` | Jump 5 lines down / up |
| `ctrl-d` / `ctrl-u` | Page down / up | | `ctrl-d` / `ctrl-u` | Page down / up |
| `ctrl-f` / `ctrl-b` | Full page down / up |
| `gg` / `G` | Go to top / bottom | | `gg` / `G` | Go to top / bottom |
| `h` / `l` | Swipe to parent pane / preview pane |
**Panes**
| Keys | Action |
|------|--------|
| `h` / `l` (or `left` / `right`) | Focus parent pane / preview pane |
| `Enter` | Open the item under the cursor (a tab, episode, show…) | | `Enter` | Open the item under the cursor (a tab, episode, show…) |
| `Space` | Select / toggle selection | | `shift-enter` | Open with the interactive variant |
**Selection**
| Keys | Action |
|------|--------|
| `Space` | Toggle selection |
| `v` | Visual mode (multi-select) | | `v` | Visual mode (multi-select) |
| `ctrl-a` | Select / deselect all |
| `ctrl-r` | Invert selection |
| `Esc` | Cancel / escape |
**Tabs**
| Keys | Action |
|------|--------|
| `1``6` | Jump to tab 16 (Feed, My Shows, Discover, Search, Player, Settings) | | `1``6` | Jump to tab 16 (Feed, My Shows, Discover, Search, Player, Settings) |
| `[` / `]` | Previous / next tab | | `[` / `]` | Previous / next tab |
| `P` (shift) | Play / pause |
**Commands, help, quit**
| Keys | Action |
|------|--------|
| `:` or `q` | Open the command palette (type `q` + `Enter` there to quit) |
| `Q` or `ctrl-c` | Quit |
| `~` or `f1` | In-app help |
**Lists**
| Keys | Action |
|------|--------|
| `s` | Search |
| `f` | Filter |
| `,` | Sort |
| `.` | Toggle hidden |
| `r` | Refresh |
| `x` | Unsubscribe the focused show (My Shows) |
| `d` | Download the focused episode (Feed / My Shows detail pane) |
| `D` | Delete the focused episode's download (if one exists) |
| `w` | Toggle the focused show in/out of the auto-download whitelist (My Shows, whitelist scope) |
**Audio**
| Keys | Action |
|------|--------|
| `P` | Play / pause |
| `N` / `B` | Next / previous episode | | `N` / `B` | Next / previous episode |
| `shift-.` / `shift-,` | Seek forward / backward | | `shift-.` / `shift-,` | Seek forward / backward |
| `s` | Search (in a list) |
| `f` | Filter |
| `r` | Refresh |
| `:` | Command bar |
| `~`, `f1` | Help |
| `q`, `ctrl-c` | Quit |
| `Esc` | Escape / cancel |
## Configuration ## Configuration
@@ -177,56 +180,49 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) | | `config.json` | Unified settings (theme, playback speed, download path), feeds, and custom feed sources |
| `sources.json` | Custom feed sources |
| `downloads.json` | Downloaded episode metadata | | `downloads.json` | Downloaded episode metadata |
| `keybinds.jsonc` | Keybinding remaps (see above) | | `keybinds.jsonc` | Keybinding remaps (see above) |
| `themes/` | Optional custom theme files | | `themes/` | Optional custom theme files |
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
the same OpenTUI environment variables. into `config.json` on first run.
## Development **Auto-download** — in Settings → Preferences: `Auto Download` (master
toggle) downloads the `Auto Download Count` most recent episodes (default 2,
any positive integer — type it in the editor) of every show in the `Auto
Download Scope` (all / none / whitelist, default all). With the whitelist
scope, a search field appears under the setting to pick shows (Space toggles
a suggestion in/out), and `w` in My Shows adds/removes the focused show.
```bash Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
bun install # install dependencies
bun run dev # run with hot reload
bun test # run the test suite
bun run build # bundle JS + copy native libs into dist/
make native # rebuild cavacore from C source
make lint # type-check (tsc)
```
### Releasing **Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed and per-show "Fetch More" rows). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs ## Troubleshooting
to your GitHub Release automatically:
```bash **`preload not found` at startup** — this used to happen when the binary was
make dist # build the standalone binary + tarball for THIS platform launched from a Bun project directory whose `bunfig.toml` had a `preload`
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz entry. Releases are compiled with bunfig autoload disabled
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz (`autoloadBunfig: false`), so current binaries ignore the CWD's `bunfig.toml`
``` entirely. If you still hit it, you're on an old release — upgrade.
`make dist` emits a config-independent binary: Bun does not bake bunfig **No audio — playback is a silent no-op** — PodTui needs **mpv** on your
settings into `--compile` output, and the solid JSX transform is registered in `PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
it from any normal directory.
## Packaging model **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
install completes, and the app boots normally.
A release tarball is three files sitting side by side: **The app won't start / no spectrum after moving files**`podtui` loads its
two native libraries relative to the binary, so keep `podtui`,
`libopentui.*`, and `libcavacore.*` together in the same directory (the
tarball unpacks them side by side).
``` ## Building from source / contributing
podtui # standalone compiled binary (embeds the Bun runtime)
libopentui.<dylib|so> # OpenTUI native renderer FFI library
libcavacore.<dylib|so> # cavacore spectrum FFI library (built from C)
```
PodTui loads its native libraries relative to the binary, so **keep them in Development setup, the test suite, packaging, and the release process are
the same directory**. The compiled binary embeds the Bun runtime, so it runs documented in [CONTRIBUTING.md](CONTRIBUTING.md).
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
is no cross-compilation.
## License ## License

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1 @@
<svg width="400" xmlns="http://www.w3.org/2000/svg" height="125.714" id="screenshot-993fe4cb-279d-80e0-8008-769a7b1374b8" viewBox="0 0 400 125.714" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-769a7b1374b8" rx="0" ry="0"><g id="shape-993fe4cb-279d-80e0-8008-769a7a62c77d"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a62c77d"><rect rx="6.857142857142833" ry="6.857142857142833" x="0" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a89c956"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a89c956"><rect rx="15" ry="15" x="45.71428571428572" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><rect rx="15" ry="15" x="91.42857142857144" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ab46172"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ab46172"><rect rx="15" ry="15" x="137.1428571428571" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ac82b71"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ac82b71"><rect rx="15" ry="15" x="182.85714285714283" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="125.71428571428578" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><rect rx="15" ry="15" x="228.57142857142856" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7aea70cd"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7aea70cd"><rect rx="15" ry="15" x="274.28571428571433" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7af77904"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7af77904"><rect rx="15" ry="15" x="320" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7b04705e"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7b04705e"><rect rx="6.857142857142833" ry="6.857142857142833" x="365.7142857142858" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1 @@
<svg width="80" xmlns="http://www.w3.org/2000/svg" height="256" id="screenshot-993fe4cb-279d-80e0-8008-76a1e45d35f1" viewBox="0 0 80 256" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-76a1e45d35f1"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-76a1e45d35f1"><rect rx="10" ry="10" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="80" height="256" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,51 @@
{
"fill" : {
"solid" : "display-p3:0.18481,0.20325,0.24683,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "Podcast Waveform.svg",
"name" : "Podcast Waveform",
"position" : {
"scale" : 2,
"translation-in-points" : [
0,
0
]
}
},
{
"blend-mode" : "normal",
"fill" : "automatic",
"glass" : false,
"image-name" : "Terminal Cursor.svg",
"name" : "Terminal Cursor",
"position" : {
"scale" : 2,
"translation-in-points" : [
320.00000000000006,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -82,6 +82,12 @@ if (COMPILE) {
plugins: [solidPlugin], plugins: [solidPlugin],
compile: { compile: {
outfile, outfile,
// Don't let the embedded runtime autoload the launching CWD's
// bunfig.toml. A top-level `preload` there (common in Bun project
// dirs) resolves against the CWD, not the binary, so startup dies
// with "preload not found". With autoload disabled, the binary is
// config-independent and boots from any directory.
autoloadBunfig: false,
}, },
}); });
console.log(`Compiled standalone binary: ${outfile}`); console.log(`Compiled standalone binary: ${outfile}`);
@@ -110,6 +116,21 @@ if (COMPILE) {
const s = join("dist", lib); const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib)); if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
} }
// App icon: bundled into every platform tarball; Linux also gets the
// desktop entry so the AUR package can install both system-wide
// (icon to hicolor, entry to applications/).
const iconSrc = join("assets", "App Icon", "App Icon.png");
if (existsSync(iconSrc)) {
copyFileSync(iconSrc, join(tarRoot, "podtui.png"));
}
if (platform === "linux") {
const desktopSrc = join("packaging", "podtui.desktop");
if (existsSync(desktopSrc)) {
copyFileSync(desktopSrc, join(tarRoot, "podtui.desktop"));
}
}
const tar = Bun.spawnSync([ const tar = Bun.spawnSync([
"tar", "tar",
"-czf", "-czf",

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,9 +1,8 @@
# NO top-level `preload` here — intentional. A compiled PodTUI binary's # No top-level `preload` here — dev/test get the solid JSX transform via the
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a # explicit `--preload` flags in package.json and the [test] section below.
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the # Releases don't read this file at all: build.ts compiles the standalone with
# standalone cannot resolve) makes the binary die at startup with # `autoloadBunfig: false`, so its embedded runtime ignores any bunfig.toml in
# "preload not found". Dev/test still get the solid transform via explicit # the launching directory — no more "preload not found" from CWD bunfigs.
# `--preload` flags in package.json and the [test] section below.
[test] [test]
preload = "@opentui/solid/preload" preload = "@opentui/solid/preload"

View File

@@ -18,21 +18,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/uuid": "^11.0.0",
"@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": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@opentui/core": "^0.1.77", "@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77", "@opentui/solid": "^0.1.77",
"babel-preset-solid": "1.9.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"solid-js": "^1.9.9", "effect": "^3",
"uuid": "^13.0.0", "solid-js": "^1.9.9"
"zustand": "^5.0.11"
} }
} }

View File

@@ -51,5 +51,13 @@ package() {
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so" install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so" install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui" ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
# App icon + desktop entry ship inside the release tarball; Terminal=true
# makes launchers drop the TUI into a terminal window.
install -Dm644 "${srcdir}/${libdir}/podtui.png" \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/podtui.png"
install -Dm644 "${srcdir}/${libdir}/podtui.desktop" \
"${pkgdir}/usr/share/applications/podtui.desktop"
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
} }

11
packaging/podtui.desktop Normal file
View File

@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=PodTui
GenericName=Podcast Player
Comment=Terminal podcast and audio player with waveform visualization
Exec=podtui
Icon=podtui
Terminal=true
Categories=Audio;AudioVideo;Player;
Keywords=podcast;audio;player;terminal;
StartupNotify=false

View File

@@ -132,7 +132,7 @@ for r in $REMOTES; do
done done
echo "" echo ""
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform" echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}" echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
echo "" echo ""
read -p "Proceed? (y/n) " -n 1 -r read -p "Proceed? (y/n) " -n 1 -r
echo "" echo ""
@@ -236,5 +236,5 @@ echo ""
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}" echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:" echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}" echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula" echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
echo " URLs + sha256s); brew upgrade podtui afterwards." echo " URLs + sha256s); brew upgrade podtui afterwards."

View File

@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
} else if (a === "--from") { } else if (a === "--from") {
flags.from = rest[++i]; flags.from = rest[++i];
} else { } else {
flags[a.slice(2)] = rest[++i] ?? true; throw new Error(`unknown flag: ${a}`);
} }
} else { } else {
positional.push(a); positional.push(a);
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
return mods; return mods;
} }
function buildAction(cmd: string, positional: string[]): Action | null { // Per-command builders. Leading positional tokens that name a modifier
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
const mods = parseMods(positional); const mods = parseMods(positional);
const first = positional[0]; return mods.length ? mods : undefined;
switch (cmd) { };
case "key":
if (!first) throw new Error("key requires a <key> argument"); const BUILDERS: Record<string, (positional: string[]) => Action> = {
return { t: "key", k: first, mods: mods.length ? mods : undefined }; key: (p) => {
case "arrow": if (!p[0]) throw new Error("key requires a <key> argument");
if (!first || !["up", "down", "left", "right"].includes(first)) return { t: "key", k: p[0], mods: modsOrUndefined(p) };
throw new Error("arrow requires up|down|left|right"); },
return { arrow: (p) => {
t: "arrow", if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
d: first as any, throw new Error("arrow requires up|down|left|right");
mods: mods.length ? mods : undefined, return {
}; t: "arrow",
case "enter": d: p[0] as "up" | "down" | "left" | "right",
case "escape": mods: modsOrUndefined(p),
case "tab": };
case "space": },
case "backspace": enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
return { t: cmd, mods: mods.length ? mods : undefined }; escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
case "type": tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
if (first === undefined) throw new Error("type requires <text>"); space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
// Re-join the rest in case text had spaces; positional[0] already is first token, backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
// caller should quote. We join all positional as the text. type: (p) => {
return { t: "type", s: positional.join(" ") }; if (p[0] === undefined) throw new Error("type requires <text>");
case "wait": // Re-join the rest in case text had spaces; p[0] already is first token,
if (!first) throw new Error("wait requires <ms>"); // caller should quote. We join all positional as the text.
return { t: "wait", ms: parseInt(first, 10) || 0 }; return { t: "type", s: p.join(" ") };
case "resize": },
if (!first || !positional[1]) throw new Error("resize requires <w> <h>"); wait: (p) => {
return { if (!p[0]) throw new Error("wait requires <ms>");
t: "resize", return { t: "wait", ms: parseInt(p[0], 10) || 0 };
w: parseInt(first, 10) || 100, },
h: parseInt(positional[1], 10) || 30, resize: (p) => {
}; if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
case "frame": return {
case "state": t: "resize",
case "reset": w: parseInt(p[0], 10) || 100,
case "actions": h: parseInt(p[1], 10) || 30,
case "init": };
case "seed": },
return null; };
default:
throw new Error(`unknown command: ${cmd}`); function buildAction(cmd: string, positional: string[]): Action | null {
} const builder = BUILDERS[cmd];
if (builder) return builder(positional);
// Local-only commands return early in main before this is reached; keep
// the null contract so the public behavior is unchanged.
if (
cmd === "frame" ||
cmd === "state" ||
cmd === "reset" ||
cmd === "actions" ||
cmd === "init" ||
cmd === "seed"
)
return null;
// Single table-miss error for any unknown command.
throw new Error(`unknown command: ${cmd}`);
} }
// ── Execute one action against a mounted setup ────────────────────────────── // ── Execute one action against a mounted setup ──────────────────────────────
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
await new Promise((r) => setTimeout(r, 40)); await new Promise((r) => setTimeout(r, 40));
} }
// ── Main ─────────────────────────────────────────────────────────────────── // ── Mount, snapshot & output (extracted from main) ─────────────────────────
async function main() { // A line is "visually empty" if it's either fully blank OR contains only
activateSandbox(); // box-drawing chars + whitespace (i.e. empty-pane interior padding like
captureIssues(); // "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const argv = process.argv.slice(2); function trimFrame(plainFrame: string): string {
const cmd = argv[0] ?? "frame"; const lines = plainFrame
const { flags, positional } = parseFlags(argv.slice(1)); .replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
}
// Local-only commands that don't mount. // Local-only commands that don't mount. Returns true if handled (main returns).
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
if (cmd === "reset") { if (cmd === "reset") {
saveActions([]); saveActions([]);
console.log("✔ actions log cleared."); console.log("✔ actions log cleared.");
return; return true;
} }
if (cmd === "actions") { if (cmd === "actions") {
const a = loadActions(); const a = loadActions();
console.log(`Action log (${a.length}):`); console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2)); console.log(JSON.stringify(a, null, 2));
return; return true;
} }
if (cmd === "seed") { if (cmd === "seed") {
const from = String( const from = String(
@@ -349,9 +392,29 @@ async function main() {
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui"); const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true }); cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from}${dest}`); console.log(`✔ seeded sandbox config from ${from}${dest}`);
return; return true;
} }
return false;
}
type FrameCapture = {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
async function mountApp(
flags: Record<string, string | boolean>,
cmd: string,
positional: string[],
): Promise<{
setup: any;
spans: FrameCapture;
plainFrame: string;
audioControls: any;
actions: Action[];
}> {
// Size settings. // Size settings.
let width = 100; let width = 100;
let height = 30; let height = 30;
@@ -448,12 +511,7 @@ async function main() {
// Final settle + capture. // Final settle + capture.
await setup.renderOnce(); await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60)); await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as { const spans = setup.captureSpans() as FrameCapture;
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
const plainFrame = setup.captureCharFrame(); const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame. // Dump structured spans + plain frame.
@@ -462,6 +520,10 @@ async function main() {
writeFileSync(FRAME_TXT, plainFrame); writeFileSync(FRAME_TXT, plainFrame);
} catch {} } catch {}
return { setup, spans, plainFrame, audioControls, actions };
}
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
// Store state snapshot. // Store state snapshot.
const state: Record<string, unknown> = {}; const state: Record<string, unknown> = {};
try { try {
@@ -514,54 +576,31 @@ async function main() {
try { try {
writeFileSync(STATE_JSON, JSON.stringify(state)); writeFileSync(STATE_JSON, JSON.stringify(state));
} catch {} } catch {}
return state;
}
// ── Output ────────────────────────────────────────────────────────────── function emitOutput(p: {
spans: FrameCapture;
plainFrame: string;
state: Record<string, unknown>;
actions: Action[];
cmd: string;
flags: Record<string, string | boolean>;
positional: string[];
}): void {
// Compact by default: trimmed frame, one-line state per section, no styles // Compact by default: trimmed frame, one-line state per section, no styles
// block, no boilerplate footer. Use --styles / --verbose to opt back in. // block, no boilerplate footer. Use --styles / --verbose to opt back in.
const verbose = !!flags.verbose; const verbose = !!p.flags.verbose;
const scope = cmd === "state" ? String(positional[0] || "all") : "all"; const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
// A line is "visually empty" if it's either fully blank OR contains only
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const frameTrimmed = (() => {
const lines = plainFrame
.replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
})();
console.log( console.log(
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`, `FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
); );
console.log(frameTrimmed); console.log(trimFrame(p.plainFrame));
// ── distinct styles: opt-in only (--styles OR --verbose) ── // ── distinct styles: opt-in only (--styles OR --verbose) ──
if (scope === "all" && (flags.styles || verbose)) { if (scope === "all" && (p.flags.styles || verbose)) {
const styles = distinctStyles(spans); const styles = distinctStyles(p.spans);
if (styles.length) { if (styles.length) {
console.log("-- styles (top 20) --"); console.log("-- styles (top 20) --");
for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`); for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`);
@@ -572,9 +611,9 @@ async function main() {
const want = (k: string) => scope === "all" || scope === k; const want = (k: string) => scope === "all" || scope === k;
const compact = (obj: unknown): string => const compact = (obj: unknown): string =>
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj); verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
if (want("nav")) console.log("nav " + compact(state.nav)); if (want("nav")) console.log("nav " + compact(p.state.nav));
if (want("audio")) console.log("audio " + compact(state.audio)); if (want("audio")) console.log("audio " + compact(p.state.audio));
if (want("feed")) console.log("feed " + compact(state.feed)); if (want("feed")) console.log("feed " + compact(p.state.feed));
if (want("app")) console.log("app (not dumped in v1)"); if (want("app")) console.log("app (not dumped in v1)");
// ── issues: terse ── // ── issues: terse ──
@@ -586,12 +625,14 @@ async function main() {
} }
// Footer is identical every run — only print on init or --verbose. // Footer is identical every run — only print on init or --verbose.
if (cmd === "init" || verbose) { if (p.cmd === "init" || verbose) {
console.log( console.log(
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`, `(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
); );
} }
}
async function teardown(setup: any, audioControls: any): Promise<void> {
// Tear down child processes (audio backend) before exit to avoid orphans. // Tear down child processes (audio backend) before exit to avoid orphans.
try { try {
if (audioControls?.stop) await audioControls.stop().catch(() => {}); if (audioControls?.stop) await audioControls.stop().catch(() => {});
@@ -606,6 +647,32 @@ async function main() {
process.exit(0); process.exit(0);
} }
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
activateSandbox();
captureIssues();
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "frame";
const { flags, positional } = parseFlags(argv.slice(1));
// Local-only commands that don't mount.
if (runLocal(cmd, flags)) return;
const m = await mountApp(flags, cmd, positional);
const state = await snapshotState(m.audioControls);
emitOutput({
spans: m.spans,
plainFrame: m.plainFrame,
state,
actions: m.actions,
cmd,
flags,
positional,
});
await teardown(m.setup, m.audioControls);
}
main().catch((err) => { main().catch((err) => {
console.error("HARNESS FAILED:", err?.stack || err); console.error("HARNESS FAILED:", err?.stack || err);
process.exit(1); process.exit(1);

View File

@@ -18,22 +18,21 @@ const DEBUG = import.meta.env.DEBUG;
export function App() { export function App() {
const nav = useNavigation(); const nav = useNavigation();
const audio = useAudio();
const toast = useToast(); const toast = useToast();
const renderer = useRenderer(); const renderer = useRenderer();
const themeContext = useTheme(); const themeContext = useTheme();
const theme = themeContext.theme; const theme = themeContext.theme;
const keybind = useKeybinds(); const keybind = useKeybinds();
// Multimedia keys (physical play/seek keys) still feed the audio backend // Multimedia keys (physical play/volume/speed keys) still feed the audio
// regardless of the on-screen yazi keybinds. // backend regardless of the on-screen yazi keybinds. Seek lives on the
// keybind router (< / > = shift+, / shift+.), so arrows stay on navigation.
useMultimediaKeys({ useMultimediaKeys({
playerFocused: () => playerFocused: () =>
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
? true ? true
: false, : false,
inputFocused: () => nav.inputFocused(), inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
}); });
// Mouse text-selection → clipboard (unchanged from the old shell). // Mouse text-selection → clipboard (unchanged from the old shell).
@@ -50,6 +49,7 @@ export function App() {
}); });
const backgroundColor = () => const backgroundColor = () =>
themeContext.transparentBackground() ||
themeContext.selected === "system" themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface; : themeContext.theme.surface;

View File

@@ -1,73 +0,0 @@
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import type { PodcastSource } from "../types/source"
import { parseRSSFeed } from "@/api/rss-parser"
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl)
if (!response.ok) return []
const xml = await response.text()
return parseRSSFeed(xml, feedUrl).episodes
} catch {
return []
}
}
export const fetchFeeds = async (
sourceIds: string[],
sources: PodcastSource[]
): Promise<Feed[]> => {
const active = sources.filter((source) => sourceIds.includes(source.id))
const feeds: Feed[] = []
await Promise.all(
active.map(async (source) => {
try {
if (source.type === "rss") {
const rssFeeds = await handleRSSSource(source)
feeds.push(...rssFeeds)
} else if (source.type === "api") {
const apiFeeds = await handleAPISource(source, "")
feeds.push(...apiFeeds)
} else {
const customFeeds = await handleCustomSource(source, "")
feeds.push(...customFeeds)
}
} catch {
// ignore individual source errors
}
})
)
return feeds
}
export const searchPodcasts = async (
query: string,
sources: PodcastSource[]
): Promise<Podcast[]> => {
const results: Podcast[] = []
await Promise.all(
sources.map(async (source) => {
try {
if (source.type === "rss") {
const feeds = await handleRSSSource(source)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else if (source.type === "api") {
const feeds = await handleAPISource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else {
const feeds = await handleCustomSource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
}
} catch {
// ignore errors
}
})
)
return results
}

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

@@ -1,94 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { PodcastSource } from "../types/source"
import type { Podcast } from "../types/podcast"
import { parseRSSFeed } from "./rss-parser"
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
return {
id: `${sourceId}-${podcast.id}`,
podcast,
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId,
lastUpdated: new Date(),
isPinned: false,
}
}
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
if (!source.baseUrl) return []
const response = await fetch(source.baseUrl)
if (!response.ok) return []
const xml = await response.text()
const parsed = parseRSSFeed(xml, source.baseUrl)
return [
{
id: `${source.id}-${parsed.feedUrl}`,
podcast: {
id: parsed.id,
title: parsed.title,
description: parsed.description,
feedUrl: parsed.feedUrl,
author: parsed.author,
categories: parsed.categories,
lastUpdated: parsed.lastUpdated,
isSubscribed: true,
},
episodes: parsed.episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: source.id,
lastUpdated: parsed.lastUpdated,
isPinned: false,
},
]
}
export const handleAPISource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
url.searchParams.set("term", query || "podcast")
url.searchParams.set("media", "podcast")
url.searchParams.set("entity", "podcast")
url.searchParams.set("country", source.country || "US")
url.searchParams.set("lang", source.language || "en_us")
const response = await fetch(url.toString())
if (!response.ok) return []
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
const results = data.results ?? []
return results
.filter((item) => item.collectionName && item.feedUrl)
.map((item) => {
const podcast: Podcast = {
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
title: item.collectionName || "Untitled Podcast",
description: item.collectionName || "",
feedUrl: item.feedUrl || "",
author: item.artistName,
lastUpdated: new Date(),
isSubscribed: false,
}
return buildFeedFromPodcast(podcast, source.id)
})
}
export const handleCustomSource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
if (!query) return []
const podcast: Podcast = {
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
title: `${query} Highlights`,
description: `Curated results for ${query}`,
feedUrl: source.baseUrl || "",
author: source.name,
lastUpdated: new Date(),
isSubscribed: false,
}
return [buildFeedFromPodcast(podcast, source.id)]
}

View File

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

View File

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

View File

@@ -1,24 +1,30 @@
import { createSignal, createMemo, onCleanup } from "solid-js"; import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
//TODO: Watch for actual loading state (fetching feeds) /**
export function LoadingIndicator() { * Animated braille spinner with an optional label (e.g. "Refreshing…").
const { theme } = useTheme(); * The spinner is rendered in the theme primary color; the label in muted.
const [index, setIndex] = createSignal(0); */
export function LoadingIndicator(props: { label?: string }) {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
const interval = setInterval(() => { const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length); setIndex((i) => (i + 1) % spinnerChars.length);
}, 65); }, 65);
onCleanup(() => clearInterval(interval)); onCleanup(() => clearInterval(interval));
const currentChar = createMemo(() => spinnerChars[index()]); const currentChar = createMemo(() => spinnerChars[index()]);
return ( return (
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start"> <box flexDirection="row" gap={1} alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} /> <text fg={theme.primary} content={currentChar()} />
</box> <Show when={props.label}>
); <text fg={theme.muted || theme.text} content={props.label} />
</Show>
</box>
);
} }

View File

@@ -1,39 +1,42 @@
/** /**
* 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 = [1, 3, 3]` contract: three bordered columns * Implements yazi's `mgr.ratio` contract: three columns grow at
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list * 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}` * so every list tab renders an identical, layout-stable shell. Columns use
* so the ratio is exact regardless of content width — a column's content can * `flexBasis={0}` so the ratio is exact regardless of content width — a
* never stretch its slot. * column's content can never stretch its slot.
* *
* 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 1/7 slot when blank (never collapses to width 0). * KEEPS its 20% slot when blank (never collapses to width 0).
* Borderless (no left/right/top/bottom edge). Carries the single
* header row: the CURRENT column's title renders top-left in the
* parent's slot (the panes above current/preview were removed).
* current — the current-depth list. The only focusable content column; it * current — the current-depth list. The only focusable content column; it
* carries the active-border focus ring when `focused` is truthy. * is the ONLY bordered column — left/right edges only, always
* preview — detail of the hovered item in `current`; always muted border. * 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 header labels. Theme colors are resolved * (static elements or accessors) plus the current-column title. Theme colors
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives * are resolved internally via `useTheme()`. Only the current column's
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane). * `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
* stay in the current pane).
* *
* Example: * Example:
* <PaneRow * <PaneRow
* parent={parentList} * parent={parentList}
* current={currentList} * current={currentList}
* preview={detail} * preview={detail}
* parentLabel="Up"
* currentLabel="List · 42" * currentLabel="List · 42"
* previewLabel="Detail"
* focused={isActive} * focused={isActive}
* /> * />
*/ */
import { createMemo, Show } from "solid-js"; import { createMemo, Show } from "solid-js";
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import type { RGBA } 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 { PANE_RATIO } from "@/utils/navigation";
@@ -43,23 +46,26 @@ 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/7 slot is always preserved). */ * placeholder — the 1/5 slot is always preserved). */
parent?: PaneContent; parent?: PaneContent;
/** Current column content (the focused list). */ /** Current column content (the focused list). */
current?: PaneContent; current?: PaneContent;
/** Preview column content (detail of the hovered item). Omit/undefined /** Preview column content (detail of the hovered item). Omit/undefined
* together with `panes={2}` to render a 2-pane parent|current row. */ * together with `panes={2}` to render a 2-pane parent|current row. */
preview?: PaneContent; preview?: PaneContent;
parentLabel?: PaneLabel; /** Title of the current column — rendered once, top-left in the parent
* pane's header slot (the per-pane Up/Detail headers are gone). */
currentLabel?: PaneLabel; currentLabel?: PaneLabel;
previewLabel?: PaneLabel; /** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
/** Whether the current column carries the active-border focus ring. Defaults to * true; pass `false` (or a signal) when the row is inactive. Does NOT change
* true; pass `false` (or a signal) when the row is inactive. Parent and * border colors — the current column's border is always muted. */
* preview columns always render muted borders. */
focused?: boolean | (() => boolean); focused?: boolean | (() => boolean);
/** 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 ─────────────────────────────────────────────────────────────────
@@ -69,16 +75,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
} }
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor. /** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately do NOT use Solid's `children()` helper here: that helper * We deliberately avoid Solid's `children()` helper: it flattens accessor
* flattens accessor children into a stable resolved-nodes array and is the * children into a stable resolved-nodes array and won't re-resolve on a
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane * truthy→truthy root swap (e.g. the current pane switching between a
* switching between a depth-1 list fragment and a depth-2 editor — both * depth-1 list fragment and a depth-2 editor), freezing the previous
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy * subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
* root swap, freezing the previous subtree in place. Instead we hand the * expression — a tracked `insert` effect that disposes the old subtree and
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below, * mounts the new whenever the accessor returns a different element identity. */
* which Solid compiles into a tracked `insert` effect that disposes the old
* subtree and mounts the new whenever the accessor returns a different
* element identity. */
function normalizeContent( function normalizeContent(
v: PaneContent | undefined, v: PaneContent | undefined,
): () => JSX.Element | undefined { ): () => JSX.Element | undefined {
@@ -99,15 +102,15 @@ function Pane(props: {
grow: number; grow: number;
label: () => string; label: () => string;
content: () => JSX.Element | undefined; content: () => JSX.Element | undefined;
borderColor: () => RGBA; border: boolean | BorderSides[];
scrollFocused: () => boolean; scrollFocused: () => boolean;
}) { }) {
const { theme } = useTheme(); const themeContext = useTheme();
const theme = themeContext.theme;
const muted = () => theme.muted ?? theme.textMuted ?? theme.text; const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// Memoize accessor results so the prop expressions below stay reactive // Memoize the scroll-focus accessor result so the prop expression below
// when the underlying signals (e.g. `focused`) change. // stays reactive when the underlying signal (e.g. `focused`) changes.
const borderColor = createMemo(() => props.borderColor());
const scrollFocused = createMemo(() => props.scrollFocused()); const scrollFocused = createMemo(() => props.scrollFocused());
return ( return (
@@ -117,32 +120,39 @@ function Pane(props: {
flexBasis={0} flexBasis={0}
height="100%" height="100%"
> >
{/* ── slim header label row ─────────────────────────────────────────── */} {/* ── title row: rendered only when the pane carries a label ────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}> <Show when={props.label() !== ""}>
<text fg={theme.textSecondary}>{props.label()}</text> <box
</box> height={1}
{/* ── bordered scrollbox ────────────────────────────────────────────── */} paddingLeft={1}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
>
<text fg={theme.textSecondary}>{props.label()}</text>
</box>
</Show>
{/* ── scrollbox; border always muted (focused or not) ──────────────── */}
<scrollbox <scrollbox
height="100%" height="100%"
focused={scrollFocused()} focused={scrollFocused()}
border border={props.border}
borderColor={borderColor()} // Only supply colors when a border is requested — opentui flips a
backgroundColor={theme.background} // borderless box to bordered when borderColor/focusedBorderColor
// are passed, which would frame the parent/preview panes too.
borderColor={props.border === false ? undefined : theme.border}
focusedBorderColor={
props.border === false ? undefined : theme.border
}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
> >
{/* {props.content() ?? <Placeholder color={muted} />}
* Render the content accessor directly via a reactive expression.
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
* effect that re-runs whenever the accessor's tracked signals
* change (e.g. `depth()` swapping the root from a list fragment to
* an editor). Solid disposes the previously-rendered subtree and
* mounts the new element identity. `null`/`undefined` falls back
* to the muted placeholder so the parent pane keeps its 1/7 slot
* visibly blank at depth 0. This is the correct tool for root
* swapping — unlike Solid's `children()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox> </scrollbox>
</box> </box>
); );
@@ -150,9 +160,7 @@ function Pane(props: {
// ── Row primitive ─────────────────────────────────────────────────────────── // ── Row primitive ───────────────────────────────────────────────────────────
export function PaneRow(props: PaneRowProps) { export function PaneRow(props: PaneRowProps) {
const { theme } = useTheme(); /** true → the current column's scrollbox is focused (scroll follows cursor). */
/** true → the current column gets the active-border focus ring. */
const focused = createMemo(() => { const focused = createMemo(() => {
const f = props.focused; const f = props.focused;
return typeof f === "function" ? f() : (f ?? true); return typeof f === "function" ? f() : (f ?? true);
@@ -164,9 +172,9 @@ export function PaneRow(props: PaneRowProps) {
const currentContent = normalizeContent(props.current); const currentContent = normalizeContent(props.current);
const previewContent = normalizeContent(props.preview); const previewContent = normalizeContent(props.preview);
const parentLabel = createMemo(() => resolveLabel(props.parentLabel)); // The single title: the CURRENT column's label, rendered in the parent
// pane's header slot (top-left). Current/preview panes have no headers.
const currentLabel = createMemo(() => resolveLabel(props.currentLabel)); const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
// 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).
@@ -176,32 +184,35 @@ export function PaneRow(props: PaneRowProps) {
? PANE_RATIO.current + PANE_RATIO.preview ? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current, : PANE_RATIO.current,
); );
const currentBorder = createMemo<boolean | BorderSides[]>(
() => props.currentBorder ?? ["left", "right"],
);
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} {/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
<Pane <Pane
grow={PANE_RATIO.parent} grow={PANE_RATIO.parent}
label={parentLabel} label={currentLabel}
content={parentContent} content={parentContent}
borderColor={() => theme.border} border={false}
scrollFocused={() => false} scrollFocused={() => false}
/> />
{/* ── current — the focused list; active-border ring when focused ──────────── */} {/* ── current — the focused list; left/right borders only ─────────── */}
<Pane <Pane
grow={currentGrow()} grow={currentGrow()}
label={currentLabel} label={() => ""}
content={currentContent} content={currentContent}
borderColor={() => (focused() ? theme.borderActive : theme.border)} border={currentBorder()}
scrollFocused={() => focused()} scrollFocused={() => focused()}
/> />
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} {/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
<Show when={panes() === 3}> <Show when={panes() === 3}>
<Pane <Pane
grow={PANE_RATIO.preview} grow={PANE_RATIO.preview}
label={previewLabel} label={() => ""}
content={previewContent} content={previewContent}
borderColor={() => theme.border} border={false}
scrollFocused={() => false} scrollFocused={() => false}
/> />
</Show> </Show>

View File

@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
backgroundColor={ backgroundColor={
props.selected() props.selected()
? theme.primary ? theme.primary
: themeContext.selected === "system" : themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface : themeContext.theme.surface
} }

View File

@@ -11,30 +11,23 @@
* event bus. There is no sidebar pane. * event bus. There is no sidebar pane.
*/ */
import { createSignal, Show, For } from "solid-js"; import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext"; import { useNavigation, NavMode } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus"; import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph"; import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } 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";
const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed",
[TABS.MYSHOWS]: "My Shows",
[TABS.DISCOVER]: "Discover",
[TABS.SEARCH]: "Search",
[TABS.PLAYER]: "Player",
[TABS.SETTINGS]: "Settings",
};
export function Shell() { export function Shell() {
const theme = useTheme(); const theme = useTheme();
@@ -42,12 +35,25 @@ export function Shell() {
const nav = useNavigation(); const nav = useNavigation();
const k = useKeybinds(); const k = useKeybinds();
const audio = useAudio(); const audio = useAudio();
const renderer = useRenderer();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const toast = useToast(); const toast = useToast();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false); const [showHelp, setShowHelp] = createSignal(false);
// ── Auto jump to Player on podcast start ───────────────────────────────────
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
// "player.started" — distinct from "player.play", which also fires on
// resume), switch to the Player tab and drop into its content pane.
on("player.started", () => {
const app = useAppStore();
if (app.state().preferences.autoJumpToPlayer) {
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
}
});
/** Play the episode adjacent (offset ±1) to the currently-playing one, /** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */ * within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) { function advanceEpisode(offset: number) {
@@ -83,74 +89,60 @@ export function Shell() {
} }
// ── Command bar dispatch ──────────────────────────────────────────────────── // ── Command bar dispatch ────────────────────────────────────────────────────
const COMMANDS: Record<string, (arg: string) => void> = {
quit: () => process.exit(0),
exit: () => process.exit(0),
q: () => process.exit(0),
refresh: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
r: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
play: () => audio.togglePlayback().catch(() => {}),
pause: () => audio.togglePlayback().catch(() => {}),
p: () => audio.togglePlayback().catch(() => {}),
next: () => advanceEpisode(1),
n: () => advanceEpisode(1),
prev: () => advanceEpisode(-1),
seek: (arg) => {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
},
feed: () => nav.setActiveTab(TABS.FEED),
f: () => nav.setActiveTab(TABS.FEED),
shows: () => nav.setActiveTab(TABS.MYSHOWS),
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
discover: () => nav.setActiveTab(TABS.DISCOVER),
d: () => nav.setActiveTab(TABS.DISCOVER),
search: () => nav.setActiveTab(TABS.SEARCH),
player: () => nav.setActiveTab(TABS.PLAYER),
settings: () => nav.setActiveTab(TABS.SETTINGS),
set: () => nav.setActiveTab(TABS.SETTINGS),
help: () => setShowHelp((v) => !v),
h: () => setShowHelp((v) => !v),
};
function runCommand(raw: string) { function runCommand(raw: string) {
const cmd = raw.trim(); const cmd = raw.trim();
if (!cmd) return; if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase(); const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim(); const arg = cmd.slice(name.length).trim();
switch (name) { const unknownCommand = () => {
case "q": nav.setCommandError(`unknown command: ${name}`);
case "quit": // re-enter command mode so the user sees the error + can correct
case "exit": nav.enterCommand();
return process.exit(0); nav.setCommandBuffer(cmd);
case "refresh": };
case "r": (COMMANDS[name] ?? unknownCommand)(arg);
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
});
break;
case "play":
case "pause":
case "p":
audio.togglePlayback().catch(() => {});
break;
case "next":
case "n":
advanceEpisode(1);
break;
case "prev":
advanceEpisode(-1);
break;
case "seek": {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
break;
}
case "feed":
case "f":
nav.setActiveTab(TABS.FEED);
break;
case "shows":
case "myshows":
nav.setActiveTab(TABS.MYSHOWS);
break;
case "discover":
case "d":
nav.setActiveTab(TABS.DISCOVER);
break;
case "search":
nav.setActiveTab(TABS.SEARCH);
break;
case "player":
nav.setActiveTab(TABS.PLAYER);
break;
case "settings":
case "set":
nav.setActiveTab(TABS.SETTINGS);
break;
case "help":
case "h":
setShowHelp((v) => !v);
break;
default:
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
}
} }
// ── Command-mode key handling ─────────────────────────────────────────────── // ── Command-mode key handling ───────────────────────────────────────────────
@@ -177,7 +169,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);
@@ -206,6 +197,11 @@ export function Shell() {
if (evt.name === "escape") { if (evt.name === "escape") {
evt.preventDefault(); evt.preventDefault();
nav.setInputFocused(false); nav.setInputFocused(false);
// Actually blur the focused renderable too — setting the flag alone
// leaves the opentui input owning keys, so nav keys would still be
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
// (and re-blurs the SearchPage input via its `focused` prop).
renderer.currentFocusedRenderable?.blur();
} }
return; return;
} }
@@ -220,11 +216,18 @@ export function Shell() {
); );
// ── Status bar fragments ────────────────────────────────────────────────── // ── Status bar fragments ──────────────────────────────────────────────────
const nowPlaying = () => { // Now-playing text carries the podcast name (custom name when set) when
// the episode's feed is resolvable, mirroring advanceEpisode's lookup.
const nowPlayingText = () => {
const ep = audio.currentEpisode(); const ep = audio.currentEpisode();
if (!ep) return null; if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title; const feeds = feedStore.getFilteredFeeds();
return `${title}`; const feed =
feeds.find((f) => f.podcast.id === ep.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
return feed
? `${feed.customName || feed.podcast.title}${ep.title}`
: `${ep.title}`;
}; };
const modeLabel = () => const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`; nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
@@ -234,13 +237,77 @@ export function Shell() {
.map((s) => s.key) .map((s) => s.key)
.join(" "); .join(" ");
// ── Now-playing marquee ────────────────────────────────────────────────────
// The now-playing segment takes the full remaining status-bar width and
// marquee-scrolls when its text overflows; when it fits (or the bar is too
// narrow to show anything) it renders statically. Each pass scrolls at
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
// before scrolling again.
const dims = useTerminalDimensions();
const GAP = 3;
const SCROLL_STEP_MS = 150;
const SCROLL_HOLD_MS = 10_000;
const [scrollOffset, setScrollOffset] = createSignal(0);
const leftFixed = () =>
modeLabel().length +
(nav.selectedIds().length > 0
? 4 + String(nav.selectedIds().length).length
: 0);
const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3;
const availableWidth = () =>
Math.max(0, dims().width - leftFixed() - rightFixed() - 2);
const visible = () => {
const text = nowPlayingText();
const avail = availableWidth();
if (!text || avail <= 0) return "";
if (text.length <= avail) return text;
// Double the text with a gap so the wrap is seamless: the window
// slides over text + gap + text without ever hitting the tail.
return (text + " ".repeat(GAP) + text).slice(
scrollOffset(),
scrollOffset() + avail,
);
};
createEffect(() => {
const text = nowPlayingText();
const avail = availableWidth();
setScrollOffset(0);
if (!text || avail <= 0 || text.length <= avail) return;
const cycle = text.length + GAP - avail;
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
// then hold again before the next pass.
let holdId: ReturnType<typeof setTimeout> | null = null;
let scrollId: ReturnType<typeof setInterval> | null = null;
const startHold = () => {
setScrollOffset(0);
holdId = setTimeout(() => {
scrollId = setInterval(() => {
const next = scrollOffset() + 1;
if (next >= cycle) {
clearInterval(scrollId!);
startHold();
} else {
setScrollOffset(next);
}
}, SCROLL_STEP_MS);
}, SCROLL_HOLD_MS);
};
startHold();
onCleanup(() => {
if (holdId) clearTimeout(holdId);
if (scrollId) clearInterval(scrollId);
});
});
return ( return (
<box <box
flexDirection="column" flexDirection="column"
width="100%" width="100%"
height="100%" height="100%"
backgroundColor={t.surface} backgroundColor={
> theme.transparentBackground() ? "transparent" : t.surface
}
>
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%"> <box flexDirection="row" flexGrow={1} width="100%">
<Show <Show
@@ -264,9 +331,7 @@ export function Shell() {
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text> <text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
</box> </box>
} }
parentLabel="Up"
currentLabel="Tabs" currentLabel="Tabs"
previewLabel=""
focused focused
/> />
</Show> </Show>
@@ -276,7 +341,11 @@ export function Shell() {
flexDirection="row" flexDirection="row"
height={1} height={1}
width="100%" width="100%"
backgroundColor={t.backgroundPanel ?? t.background} backgroundColor={
theme.transparentBackground()
? "transparent"
: (t.backgroundPanel ?? t.background)
}
> >
<Show <Show
when={nav.mode() === NavMode.COMMAND} when={nav.mode() === NavMode.COMMAND}
@@ -285,26 +354,19 @@ export function Shell() {
<text fg={t.accent} paddingLeft={1}> <text fg={t.accent} paddingLeft={1}>
{modeLabel()} {modeLabel()}
</text> </text>
<text fg={t.textMuted} paddingLeft={1}>
{nav.atRootTab()
? "Tabs · root"
: `${TAB_LABEL[nav.activeTab()]} · ${
nav.isDepthTab()
? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
}`}
</text>
<Show when={nav.selectedIds().length > 0}> <Show when={nav.selectedIds().length > 0}>
<text fg={t.warning} paddingLeft={1}> <text fg={t.warning} paddingLeft={1}>
{nav.selectedIds().length} {nav.selectedIds().length}
</text> </text>
</Show> </Show>
<Show when={nowPlaying()}> <Show when={nowPlayingText()}>
<text fg={t.primary} paddingLeft={1}> <box flexGrow={1} paddingLeft={1}>
{nowPlaying()} {/* content prop (not a text child): the babel-preset-solid JSX
</text> * transform HTML-escapes static string children (`<` → `&lt;`),
* which opentui renders verbatim; content bypasses that. */}
<text fg={t.primary} content={visible()} />
</box>
</Show> </Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}> <text fg={t.textMuted} paddingRight={1}>
{pendingLabel()} {pendingLabel()}
</text> </text>
@@ -334,6 +396,8 @@ export function Shell() {
theme={t as any} theme={t as any}
/> />
</Show> </Show>
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
<GlobalActivityIndicator />
</box> </box>
); );
} }
@@ -385,6 +449,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
["enter", "open"], ["enter", "open"],
["r", "refresh"], ["r", "refresh"],
["s", "search"], ["s", "search"],
[p("search-scope-toggle"), "shows/episodes"],
["f", "filter"], ["f", "filter"],
[",", "sort"], [",", "sort"],
[".", "hidden"], [".", "hidden"],
@@ -458,18 +523,5 @@ function k_match_escape(evt: any): boolean {
); );
} }
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
* search) into the player tab. */
export function playEpisodeAndSwitch(
nav: ReturnType<typeof useNavigation>,
audio: ReturnType<typeof useAudio>,
episode: import("@/types/episode").Episode,
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees. // Re-export Episode type for callers building pane trees.
export type { Episode } from "@/types/episode"; export type { Episode } from "@/types/episode";

View File

@@ -1,27 +0,0 @@
import { For } from "solid-js";
import { shortcuts } from "@/config/shortcuts";
import { useTheme } from "@/context/ThemeContext";
/** Yazi-style keybind reference. The Shell has its own overlay; this component
* is kept for embedding inside Settings or other surfaces. */
export function ShortcutHelp() {
const { theme } = useTheme();
return (
<box
border
title="Shortcuts"
style={{ flexDirection: "column", padding: 1 }}
>
<box style={{ flexDirection: "column" }}>
<For each={shortcuts}>
{(s) => (
<box style={{ flexDirection: "row" }} gap={2}>
<text fg={theme.accent}>{s.keys}</text>
<text fg={theme.text}>{s.action}</text>
</box>
)}
</For>
</box>
</box>
);
}

View File

@@ -1,55 +0,0 @@
import { useTheme } from "@/context/ThemeContext";
import { TABS, TabsCount } from "@/utils/navigation";
import { For } from "solid-js";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
export const tabs: TabDefinition[] = [
{ id: TABS.FEED, label: "Feed" },
{ id: TABS.MYSHOWS, label: "My Shows" },
{ id: TABS.DISCOVER, label: "Discover" },
{ id: TABS.SEARCH, label: "Search" },
{ id: TABS.PLAYER, label: "Player" },
{ id: TABS.SETTINGS, label: "Settings" },
];
export function TabNavigation() {
const { theme } = useTheme();
const { activeTab, setActiveTab, activeDepth } = useNavigation();
return (
<box
border
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
backgroundColor={"transparent"}
style={{
flexDirection: "column",
width: 12,
height: TabsCount * 3 + 2,
}}
>
<For each={tabs}>
{(tab) => (
<SelectableBox
border
height={3}
selected={() => tab.id == activeTab()}
onMouseDown={() => setActiveTab(tab.id)}
>
<SelectableText
selected={() => tab.id == activeTab()}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
)}
</For>
</box>
);
}
export type TabDefinition = {
id: TABS;
label: string;
};

View File

@@ -19,74 +19,105 @@ import { For } from "solid-js";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import { useNavigation } from "@/context/NavigationContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import { TABS } from "@/utils/navigation"; import { TABS } from "@/utils/navigation";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
const TAB_LABEL: Record<TABS, string> = { const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed", [TABS.FEED]: "Feed",
[TABS.MYSHOWS]: "My Shows", [TABS.MYSHOWS]: "My Shows",
[TABS.DISCOVER]: "Discover", [TABS.DISCOVER]: "Discover",
[TABS.SEARCH]: "Search", [TABS.SEARCH]: "Search",
[TABS.PLAYER]: "Player", [TABS.PLAYER]: "Player",
[TABS.SETTINGS]: "Settings", [TABS.SETTINGS]: "Settings",
};
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
const TAB_ICON: Record<TABS, string> = {
[TABS.FEED]: NF_ICONS.feed,
[TABS.MYSHOWS]: NF_ICONS.shows,
[TABS.DISCOVER]: NF_ICONS.discover,
[TABS.SEARCH]: NF_ICONS.search,
[TABS.PLAYER]: NF_ICONS.player,
[TABS.SETTINGS]: NF_ICONS.settings,
}; };
/** Numeric TABS values, in declaration order (1..TabsCount). */ /** Numeric TABS values, in declaration order (1..TabsCount). */
const TAB_ORDER = Object.values(TABS).filter( const TAB_ORDER = Object.values(TABS).filter(
(v): v is TABS => typeof v === "number", (v): v is TABS => typeof v === "number",
) as TABS[]; ) as TABS[];
export function TabListPane(props: { muted?: boolean }) { export function TabListPane(props: { muted?: boolean }) {
const { theme } = useTheme(); // Static: detection never changes mid-session.
const nav = useNavigation(); const nerd = supportsNerdFonts();
const { theme } = useTheme();
const nav = useNavigation();
const marker = useSelectionMarker();
const cursor = () => nav.tabCursor(); const cursor = () => nav.tabCursor();
const activeTab = () => nav.activeTab(); const activeTab = () => nav.activeTab();
/** `active=true` when this pane is the CURRENT column (Shell root); /** `active=true` when this pane is the CURRENT column (Shell root);
* `false` when it is the muted UP/parent column (pages' parent pane). */ * `false` when it is the muted UP/parent column (pages' parent pane). */
const active = () => !props.muted; const active = () => !props.muted;
// Same focus-bg / focus-fg contract every other pane uses. // Same focus-bg / focus-fg contract every other pane uses.
const focusBg = (t: TABS) => const focusBg = (t: TABS) =>
t === cursor() && active() t === cursor() && active()
? theme.primary ? theme.primary
: t === cursor() : t === cursor()
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (t: TABS) => const focusFg = (t: TABS) =>
t === cursor() && active() ? theme.surface : theme.text; t === cursor() && active()
? theme.surface
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
return ( return (
<For each={TAB_ORDER}> <For each={TAB_ORDER}>
{(tab) => { {(tab) => {
const isCursor = () => cursor() === tab; const isCursor = () => cursor() === tab;
const isActive = () => activeTab() === tab; const isActive = () => activeTab() === tab;
// The active tab is only accented in the Up/parent position — when this // The active tab is only accented in the Up/parent position — when this
// pane is CURRENT, the cursor highlight is the only highlight. // pane is CURRENT, the cursor highlight is the only highlight.
const labelFg = () => const labelFg = () =>
isCursor() isCursor()
? focusFg(tab) ? focusFg(tab)
: isActive() && !active() : isActive() && !active()
? theme.accent ? theme.accent
: theme.text; : theme.text;
const ref = useScrollIntoView(isCursor); const ref = useScrollIntoView(isCursor);
return ( return (
<box <box
ref={ref} ref={ref}
width="100%" width="100%"
height={1} height={1}
flexDirection="row" flexDirection="row"
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(tab)} backgroundColor={focusBg(tab)}
> onMouseDown={() => {
{/* ── selection marker (j/k cursor) ─────────────────────────── */} // Click = hover + open, the yazi "open" of the row
<text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text> // (switches to the tab and enters its content), the same
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text> // as l/Enter. Restores mouse support the tab-strip
<text fg={labelFg()} paddingLeft={1}> // refactor dropped.
{TAB_LABEL[tab]} nav.setTabCursor(tab);
</text> nav.activateTabCursor();
</box> }}
); >
}} {/* ── selection marker (j/k cursor) ─────────────────────────── */}
</For> <text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
); {nerd && (
<text fg={focusFg(tab)} paddingRight={1}>
{TAB_ICON[tab]}
</text>
)}
<text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
);
} }

View File

@@ -11,7 +11,8 @@
// //
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select, // Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab, // v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl. // : / q command palette (q + Enter quits there), Q quick quit, ~ help.
// Audio transport kept on shifted keys / ctrl.
// ── Movement (within a pane) ───────────────────────────────────────────── // ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"], "move-down": ["j", "down"],
@@ -50,25 +51,36 @@
"tab-goto-5": ["5"], "tab-goto-5": ["5"],
"tab-goto-6": ["6"], "tab-goto-6": ["6"],
// ── Command bar & help & quit ──────────────────────────────────────────── // ── Command palette & help & quit ────────────────────────────────────────
"command": [":"], // q opens the command palette (neovim-style: type q + Enter to quit there).
"quit": ["q", "ctrl-c"], // Q (shift+q) is the instant quick quit. ctrl-c also quits.
"command": [":", "q"],
"quit": ["Q", "ctrl-c"],
"help": ["~", "f1"], "help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
"search": ["s"], "search": ["s"],
"filter": ["f"], // tab toggles the Search page between show and episode scope (re-runs the
// current query when viewing results)
"search-scope-toggle": ["tab"],
"filter": ["f"],
"sort": [","], "sort": [","],
"toggle-hidden": ["."], "toggle-hidden": ["."],
"refresh": ["r"], "refresh": ["r"],
"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 ───────────────────────────────────
"download": ["d"], // download the focused episode (detail pane)
"delete-download": ["D"], // delete the focused episode's download (if any)
"whitelist-toggle": ["w"], // add/remove the focused show from the auto-download whitelist (My Shows)
// ── Audio transport (preserved) ────────────────────────────────────────── // ── Audio transport (preserved) ──────────────────────────────────────────
// Kept on shifted single keys so they never collide with the yazi core // Kept on shifted single keys so they never collide with the yazi core
// (space=select, s=search, f=filter, etc.). Edit freely in this file. // (space=select, s=search, f=filter, etc.). Edit freely in this file.
"audio-toggle": ["P"], // play / pause (shift+p) "audio-toggle": ["P"], // play / pause (shift+p)
"audio-next": ["N"], // next episode (shift+n) "audio-next": ["N"], // next episode (shift+n)
"audio-prev": ["B"], // prev episode (shift+b) "audio-prev": ["B"], // prev episode (shift+b)
"audio-seek-forward": ["shift-."], // seek forward (shift+.) "audio-seek-forward": ["shift-."], // seek forward (> = shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (shift+,) "audio-seek-backward": ["shift-,"] // seek backward (< = shift+,)
} }

View File

@@ -1,27 +0,0 @@
/**
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
* Shown in help overlays; the canonical source remains keybinds.jsonc.
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
*/
export const shortcuts = [
{ keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "ctrl-d / u", action: "Half page down / up" },
{ keys: "g g / G", action: "Go to top / bottom of list" },
{ keys: "1-6", action: "Go to tab 1-6" },
{ keys: "[ / ]", action: "Previous / next tab" },
{ keys: "Enter", action: "Open / activate focused item" },
{ keys: "Space", action: "Toggle selection on item" },
{ keys: "v", action: "Enter visual (range) select mode" },
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
{ keys: "r / s / f", action: "Refresh / search / filter" },
{ keys: "x", action: "Unsubscribe focused show (My Shows)" },
{ keys: ", / .", action: "Sort / toggle hidden" },
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
{ keys: "< / >", action: "Seek backward / forward 10s" },
{ keys: "~ / F1", action: "Help" },
{ keys: "q", action: "Quit" },
] as const;

View File

@@ -1,12 +0,0 @@
export const syncFormats = {
json: {
version: "1.0",
extension: ".json",
},
xml: {
version: "1.0",
extension: ".xml",
},
}
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]

View File

@@ -63,29 +63,21 @@ export type KeybindActionName =
| "quit" | "quit"
| "help" | "help"
| "search" | "search"
| "search-scope-toggle"
| "filter" | "filter"
| "sort" | "sort"
| "toggle-hidden" | "toggle-hidden"
| "refresh" | "refresh"
| "subscribe"
| "unsubscribe" | "unsubscribe"
| "download"
| "delete-download"
| "whitelist-toggle"
| "audio-toggle" | "audio-toggle"
| "audio-next" | "audio-next"
| "audio-prev" | "audio-prev"
| "audio-seek-forward" | "audio-seek-forward"
| "audio-seek-backward" | "audio-seek-backward";
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
/** Resolved config: action -> list of alternative stroke-sequences. */ /** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>; export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
@@ -146,7 +138,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
} }
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */ /** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: { function strokeFromEvent(evt: {
name: string; name: string;
ctrl?: boolean; ctrl?: boolean;
meta?: boolean; meta?: boolean;
@@ -154,7 +146,7 @@ export function strokeFromEvent(evt: {
}): Stroke { }): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize. // Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return { return {
key: (evt.name ?? "").toLowerCase(), key: evt.name.toLowerCase(),
ctrl: !!evt.ctrl, ctrl: !!evt.ctrl,
shift: !!evt.shift, shift: !!evt.shift,
meta: !!evt.meta, meta: !!evt.meta,
@@ -171,7 +163,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
} }
/** A human label for a stroke, for the status bar / help. */ /** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string { function strokeLabel(s: Stroke): string {
let out = ""; let out = "";
if (s.ctrl) out += "C-"; if (s.ctrl) out += "C-";
if (s.meta) out += "M-"; if (s.meta) out += "M-";
@@ -180,7 +172,7 @@ export function strokeLabel(s: Stroke): string {
return out; return out;
} }
export function sequenceLabel(seq: Stroke[]): string { function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" "); return seq.map(strokeLabel).join(" ");
} }
@@ -338,17 +330,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
return best; return best;
} }
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => { onMount(() => {
load().catch(() => {}); load().catch(() => {});
}); });
@@ -366,7 +347,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
pending, pending,
match, match,
tryMatch, tryMatch,
isInverting,
print, print,
save, save,
load, load,

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { useRenderer } from "@opentui/solid"; import { useRenderer } from "@opentui/solid";
@@ -9,7 +10,9 @@ 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 { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { import {
setupThemeSignalHandler, setupThemeSignalHandler,
@@ -84,6 +87,8 @@ export type ThemeResolved = {
muted?: RGBA; muted?: RGBA;
surface?: RGBA; surface?: RGBA;
selectedListItemText?: RGBA; selectedListItemText?: RGBA;
/** Theme declares a transparent (terminal-bg-visible) background. */
transparent?: boolean;
layerBackgrounds?: { layerBackgrounds?: {
layer0: RGBA; layer0: RGBA;
layer1: RGBA; layer1: RGBA;
@@ -94,6 +99,61 @@ export type ThemeResolved = {
thinkingOpacity?: number; thinkingOpacity?: number;
}; };
/**
* A TerminalColors with no values — used to keep the "system" theme rendering
* with default ANSI colors + the detected dark/light mode when the terminal
* cannot answer OSC queries (e.g. inside tmux without OSC forwarding).
*/
const EMPTY_TERMINAL_COLORS: TerminalColors = {
palette: Array.from({ length: 16 }, () => null),
defaultForeground: null,
defaultBackground: null,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
};
/** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null;
/**
* Detect the terminal's dark/light mode.
*
* Priority:
* 1. The terminal's real background color (OSC 11 response) — terminal-specific.
* 2. The macOS appearance via `defaults read -g AppleInterfaceStyle` — works
* even inside tmux, where OSC queries are usually not forwarded.
* An unset value means light mode (macOS defaults to light).
* 3. null → keep whatever mode is currently active.
*/
function detectSystemMode(
colors: TerminalColors | null,
): "dark" | "light" | null {
const fromBg = detectModeFromBackground(colors?.defaultBackground);
if (fromBg) return fromBg;
if (process.platform === "darwin" && cachedOsMode === null) {
let style: string | null = null;
try {
style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
encoding: "utf8",
timeout: 2000,
})
.trim()
.toLowerCase();
} catch {
// Unset → light appearance (macOS default).
}
cachedOsMode = style?.includes("dark") ? "dark" : "light";
}
return cachedOsMode;
}
/** /**
* Theme context using the createSimpleContext pattern. * Theme context using the createSimpleContext pattern.
* *
@@ -116,7 +176,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) => {
@@ -128,7 +188,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);
} }
@@ -195,6 +254,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
} }
} }
// ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
// real one from the terminal's background color (OSC 11) or, when that
// is unavailable (tmux without OSC forwarding), the OS appearance.
const detectedMode = detectSystemMode(colors);
if (detectedMode && detectedMode !== store.mode) {
setStore("mode", detectedMode);
emitThemeModeChanged(detectedMode);
}
const hasPalette = Boolean( const hasPalette = Boolean(
colors?.palette?.some((value) => Boolean(value)), colors?.palette?.some((value) => Boolean(value)),
); );
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
); );
if (!hasPalette && !hasDefaultColors) { if (!hasPalette && !hasDefaultColors) {
// No system colors available, fall back to default // No system colors available — the terminal can't answer OSC queries
// This happens when the terminal doesn't support OSC palette queries // (e.g. inside tmux, or unsupported terminals). Keep the "system"
// (e.g., running inside tmux, or on unsupported terminals) // theme anyway: the detected dark/light mode plus default ANSI colors
// still produce a usable, mode-correct palette.
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.active = "catppuccin"; draft.system = colors ?? EMPTY_TERMINAL_COLORS;
draft.ready = true; draft.ready = true;
}), }),
); );
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() { mode() {
return store.mode; return store.mode;
}, },
/** Whether the app background should be transparent (no solid fill):
* either the global preference is on, or the selected theme declares
* transparency (e.g. the system theme). */
transparentBackground() {
return (
appStore.state().settings.transparentBackground ||
values().transparent === true
);
},
setMode(mode: "dark" | "light") { setMode(mode: "dark" | "light") {
setStore("mode", mode); setStore("mode", mode);
emitThemeModeChanged(mode); emitThemeModeChanged(mode);

View File

@@ -13,7 +13,7 @@
* *
* parent | current | preview * parent | current | preview
* *
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in * Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable* * `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The * nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused. * parent/preview columns are always derived, never focused.
@@ -267,6 +267,9 @@ export function createNavigation() {
/** The tab the root's cursor is hovering (independent of activeTab). */ /** The tab the root's cursor is hovering (independent of activeTab). */
const tabCursor = (): TABS => tabCursorSignal(); const tabCursor = (): TABS => tabCursorSignal();
/** Directly set the root's tab cursor (e.g. a mouse click on a tab row). */
const setTabCursorTo = (tab: TABS) => setTabCursor(tab);
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */ /** Move the root's cursor to the adjacent tab (clamped, no wrap). */
const moveTabCursor = (dir: -1 | 1) => { const moveTabCursor = (dir: -1 | 1) => {
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS); setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
@@ -469,6 +472,7 @@ export function createNavigation() {
enterTabContent, enterTabContent,
backToTabRoot, backToTabRoot,
tabCursor, tabCursor,
setTabCursor: setTabCursorTo,
moveTabCursor, moveTabCursor,
activateTabCursor, activateTabCursor,
// pane focus // pane focus
@@ -488,11 +492,7 @@ export function createNavigation() {
exitVisual, exitVisual,
// modes // modes
setActiveTabSignal: setActiveTab, setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused, setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode, setMode,
enterCommand, enterCommand,
enterInput, enterInput,

View File

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

View File

@@ -12,21 +12,52 @@
* ``` * ```
*/ */
import { createSignal, onCleanup } from "solid-js"; import { onCleanup } from "solid-js";
import {
cachedCoverPath,
fetchCoverArt,
} from "../utils/cover-art";
import { import {
createAudioBackend, createAudioBackend,
detectPlayers, detectPlayers,
PlayerRestartedError,
type AudioBackend, type AudioBackend,
type BackendName, type BackendName,
type DetectedPlayer, type DetectedPlayer,
} from "../utils/audio-player"; } from "../utils/audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed,
setSpeed,
backendName,
setBackendName,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "../utils/audio-signals";
import { emit, on } from "../utils/event-bus"; import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app"; import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress"; import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry"; import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode"; import {
loadLastPlayerFromFile,
saveLastPlayerToFile,
saveLastPlayerSync,
} from "../utils/app-persistence";
import type { Episode, Progress } from "../types/episode";
import type { Feed } from "../types/feed"; import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useDownloadStore } from "../stores/download";
import { useFeedStore } from "../stores/feed"; import { useFeedStore } from "../stores/feed";
export interface AudioControls { export interface AudioControls {
@@ -43,6 +74,8 @@ export interface AudioControls {
// Actions // Actions
play: (episode: Episode) => Promise<void>; play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
resume: () => Promise<void>; resume: () => Promise<void>;
togglePlayback: () => Promise<void>; togglePlayback: () => Promise<void>;
@@ -62,17 +95,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
let refCount = 0; let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false); // Playback signals are declared in utils/audio-signals.ts (imported above)
const [position, setPosition] = createSignal(0); // so non-component consumers (the visualizer store) can subscribe without
const [duration, setDuration] = createSignal(0); // mounting a useAudio() owner.
const [volume, setVolume] = createSignal(0.7);
const [speed, setSpeed] = createSignal(1); /** True once the current episode has been handed to the backend (play
const [backendName, setBackendName] = createSignal<BackendName>("none"); * started). `false` means the episode is only LOADED in the player (e.g.
const [error, setError] = createSignal<string | null>(null); * restored at boot) and the first play action must start the backend
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null); * instead of unpausing it. */
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>( 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 { function ensureBackend(): AudioBackend {
if (!backend) { if (!backend) {
@@ -99,6 +141,17 @@ function registerExitTeardown(): void {
exitTeardownRegistered = true; exitTeardownRegistered = true;
const teardown = (): void => { const teardown = (): void => {
stopPolling(); stopPolling();
// Persist "what's loaded in the player right now" synchronously —
// process.exit(0) runs this handler synchronously and an async write
// would never land. The next launch restores this episode paused.
try {
const ep = currentEpisode();
if (ep) {
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
}
} catch {
/* best-effort at exit */
}
try { try {
backend?.dispose(); backend?.dispose();
} catch { } catch {
@@ -119,46 +172,118 @@ 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 { function startPolling(): void {
stopPolling(); stopPolling();
pollCount = 0; 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 () => { pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return; if (!backend || pollInFlight) return;
pollInFlight = true;
try { try {
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms)
pollCount++; pollCount++;
if (pollCount % 10 === 0) { if (isPlaying()) {
const ep = currentEpisode(); // Track ended (eof-reached observed) or process died. Check
if (ep) { // BEFORE pause reconciliation: mpv keeps the file open at EOF
const progressStore = useProgressStore(); // and reports pause=true there, which would otherwise be
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); // mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
// Update platform media position finalizeTrackEnd();
const media = useMediaRegistry(); return;
media.setPosition(pos);
} }
}
// Check if backend stopped playing (track ended) // mpv can pause itself outside PodTUI. Reconcile instead of
if (!backend.isPlaying() && isPlaying()) { // staying stuck on "playing" with a frozen waveform
setIsPlaying(false); // (getPosition would just re-read the same frozen time-pos).
stopPolling(); const paused = await backend.getPauseState();
// Save final position on track end if (paused === true) {
const ep = currentEpisode(); reconcileExternalPause();
if (ep) { return;
const progressStore = useProgressStore(); }
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
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 { } catch {
// Backend may have been disposed // Backend may have been disposed
} finally {
pollInFlight = false;
} }
}, 500); }, 150);
} }
function stopPolling(): void { function stopPolling(): void {
@@ -168,6 +293,39 @@ function stopPolling(): void {
} }
} }
// ── 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> { async function play(episode: Episode): Promise<void> {
const b = ensureBackend(); const b = ensureBackend();
setError(null); setError(null);
@@ -184,6 +342,24 @@ async function play(episode: Episode): Promise<void> {
const vol = volume(); const vol = volume();
const spd = storeSpeed || speed(); 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 || "";
// Play the downloaded file when present (offline + no network stalls);
// otherwise stream. Cover resolves to the feed art, falling back to the
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
// Cover art only applies at file LOAD (the runtime video-add fallback
// never becomes an albumart track), so a cold-cache play must wait for
// the fetch or play artless. Serve the disk cache synchronously; on a
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
// past the 1.2s cap, play bare and let the fetch warm the cache.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"bounded",
);
// Resume from saved progress if available and not completed // Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id); const savedProgress = progressStore.get(episode.id);
let startPos = 0; let startPos = 0;
@@ -191,10 +367,12 @@ async function play(episode: Episode): Promise<void> {
startPos = savedProgress.position; startPos = savedProgress.position;
} }
await b.play(episode.audioUrl, { await b.play(url, {
volume: vol, volume: vol,
speed: spd, speed: spd,
startPosition: startPos > 0 ? startPos : undefined, startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
}); });
setCurrentEpisode(episode); setCurrentEpisode(episode);
@@ -202,12 +380,17 @@ async function play(episode: Episode): Promise<void> {
setPosition(startPos); setPosition(startPos);
setSpeed(spd); setSpeed(spd);
if (episode.duration) setDuration(episode.duration); 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 // Register with platform media controls
const media = useMediaRegistry(); const media = useMediaRegistry();
media.setNowPlaying({ media.setNowPlaying({
title: episode.title, title: episode.title,
artist: episode.podcastId, artist: podcastTitle || episode.podcastId,
duration: episode.duration, duration: episode.duration,
}); });
media.setPlaybackState(true); media.setPlaybackState(true);
@@ -215,18 +398,94 @@ async function play(episode: Episode): Promise<void> {
startPolling(); startPolling();
emit("player.play", { episodeId: episode.id }); 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) { } catch (err) {
setError(err instanceof Error ? err.message : "Playback failed"); setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false); 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.
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> { async function pause(): Promise<void> {
if (!backend) return; if (!backend) return;
try { try {
await backend.pause(); await backend.pause();
setIsPlaying(false); setIsPlaying(false);
stopPolling(); // Polling stays armed (paused-watch mode): playback can be resumed
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
// and the poll must be live to catch it.
const ep = currentEpisode(); const ep = currentEpisode();
if (ep) { if (ep) {
// Save progress on pause // Save progress on pause
@@ -244,8 +503,25 @@ async function pause(): Promise<void> {
} }
} }
/** mpv was killed/crashed: respawn it and restart playback from the saved
* position via the full play path (fresh loadfile, cover art, media
* registry). A bare unpause would target a dead — or freshly-idle —
* daemon and silently do nothing. */
async function recoverPlayback(): Promise<void> {
const ep = currentEpisode();
if (ep && ep.audioUrl) {
await play(ep);
} else {
setError("Player is not running");
}
}
async function resume(): Promise<void> { async function resume(): Promise<void> {
if (!backend) return; if (!backend) return;
if (!backend.isAlive()) {
await recoverPlayback();
return;
}
try { try {
await backend.resume(); await backend.resume();
setIsPlaying(true); setIsPlaying(true);
@@ -257,6 +533,13 @@ async function resume(): Promise<void> {
media.setPlaybackState(true); media.setPlaybackState(true);
} }
} catch (err) { } 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"); setError(err instanceof Error ? err.message : "Resume failed");
} }
} }
@@ -265,7 +548,15 @@ async function togglePlayback(): Promise<void> {
if (isPlaying()) { if (isPlaying()) {
await pause(); await pause();
} else if (currentEpisode()) { } else if (currentEpisode()) {
await resume(); if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
} }
} }
@@ -282,10 +573,13 @@ async function stop(): Promise<void> {
setIsPlaying(false); setIsPlaying(false);
setPosition(0); setPosition(0);
setCurrentEpisode(null); setCurrentEpisode(null);
startedPlayback = false;
stopPolling(); stopPolling();
emit("player.stop", {}); emit("player.stop", {});
// Clear platform media controls // Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry(); const media = useMediaRegistry();
media.clearNowPlaying(); media.clearNowPlaying();
} catch (err) { } catch (err) {
@@ -318,6 +612,10 @@ async function doSetVolume(vol: number): Promise<void> {
} }
} }
setVolume(clamped); 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> { async function doSetSpeed(spd: number): Promise<void> {
@@ -332,12 +630,8 @@ async function doSetSpeed(spd: number): Promise<void> {
setSpeed(clamped); setSpeed(clamped);
// Sync back to app store // Sync back to app store
try { const appStore = useAppStore();
const appStore = useAppStore(); appStore.updateSettings({ playbackSpeed: clamped });
appStore.updateSettings({ playbackSpeed: clamped });
} catch {
// Store may not be available
}
} }
async function switchBackend(name: BackendName): Promise<void> { async function switchBackend(name: BackendName): Promise<void> {
@@ -347,14 +641,12 @@ async function switchBackend(name: BackendName): Promise<void> {
const vol = volume(); const vol = volume();
const spd = speed(); const spd = speed();
// Stop current backend
if (backend) { if (backend) {
stopPolling(); stopPolling();
backend.dispose(); backend.dispose();
backend = null; backend = null;
} }
// Create new backend
backend = createAudioBackend(name); backend = createAudioBackend(name);
setBackendName(backend.name); setBackendName(backend.name);
setAvailablePlayers(detectPlayers()); setAvailablePlayers(detectPlayers());
@@ -362,12 +654,26 @@ async function switchBackend(name: BackendName): Promise<void> {
// Resume playback if we were playing // Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) { if (wasPlaying && ep && ep.audioUrl) {
try { try {
await backend.play(ep.audioUrl, { const feedStore = useFeedStore();
const feed = feedStore
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
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, startPosition: pos,
volume: vol, volume: vol,
speed: spd, speed: spd,
mediaTitle: ep.title,
coverArtPath: coverArtPath ?? undefined,
}); });
setIsPlaying(true); setIsPlaying(true);
startedPlayback = true;
startPolling(); startPolling();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed"); setError(err instanceof Error ? err.message : "Backend switch failed");
@@ -376,6 +682,46 @@ async function switchBackend(name: BackendName): Promise<void> {
} }
} }
/** Serialized restore chain: the boot-triggered restore and any explicit
* call run one after another, so a late-finishing earlier restore can never
* overwrite state changed by a later one (and callers can await the latest
* attempt deterministically). */
let restoreChain: Promise<void> = Promise.resolve();
/**
* Boot-time session restore: reload the episode that was loaded in the
* player when the previous run ended (persisted on play/load and at exit),
* paused at its saved position — never autostarted. Episodes at/above the
* completion threshold are skipped. Silently no-ops when there is nothing
* to restore (empty player, unsubscribed show, or completed episode).
*/
export async function restoreLastSession(): Promise<void> {
const attempt = restoreChain.then(async () => {
const marker = await loadLastPlayerFromFile();
if (!marker?.episodeId) return;
// Feeds and progress load asynchronously at boot; wait for both
// before looking the episode up.
await Promise.all([
useProgressStore().whenReady(),
useFeedStore().whenReady(),
]);
const episode = useFeedStore().findEpisode(marker.episodeId);
if (!episode) return;
// Only restore episodes below the completion threshold.
const saved = useProgressStore().get(episode.id);
if (!isRestoreEligible(saved)) return;
await load(episode);
});
// Keep the chain alive even when an attempt fails; the caller awaiting
// this attempt still observes its own outcome.
restoreChain = attempt.catch(() => {});
await attempt;
}
/** /**
* Reactive audio controls hook. * Reactive audio controls hook.
* *
@@ -386,17 +732,29 @@ export function useAudio(): AudioControls {
// Initialize backend on first use // Initialize backend on first use
ensureBackend(); ensureBackend();
// Sync initial speed from app store // Sync initial speed/volume from app store (reuse the previous session's
// playback levels; defaults are 1x and 100%).
if (refCount === 0) { if (refCount === 0) {
try { const appStore = useAppStore();
const appStore = useAppStore(); const storeSpeed = appStore.state().settings.playbackSpeed;
const storeSpeed = appStore.state().settings.playbackSpeed; if (storeSpeed && storeSpeed !== speed()) {
if (storeSpeed && storeSpeed !== speed()) { setSpeed(storeSpeed);
setSpeed(storeSpeed);
}
} catch {
// Store may not be available yet
} }
// Volume re-syncs once settings finish loading (async config read)
// so a level persisted last session is applied at boot.
appStore
.whenReady()
.then(() => {
const storeVolume = appStore.state().settings.volume;
if (storeVolume !== undefined && storeVolume !== volume()) {
setVolume(storeVolume);
}
})
.catch(() => {});
// Restore the last player session once at boot (loaded, not playing).
restoreLastSession().catch(() => {});
} }
refCount++; refCount++;
@@ -430,14 +788,6 @@ export function useAudio(): AudioControls {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2)))); await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
}); });
const unsubMediaSeekFwd = on("media.seekForward", async () => {
await seekRelative(10);
});
const unsubMediaSeekBack = on("media.seekBackward", async () => {
await seekRelative(-10);
});
const unsubMediaSpeed = on("media.speedCycle", async () => { const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2)); const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next); await doSetSpeed(next);
@@ -524,8 +874,6 @@ export function useAudio(): AudioControls {
unsubMediaToggle(); unsubMediaToggle();
unsubMediaVolUp(); unsubMediaVolUp();
unsubMediaVolDown(); unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed(); unsubMediaSpeed();
if (refCount <= 0) { if (refCount <= 0) {
@@ -554,6 +902,7 @@ export function useAudio(): AudioControls {
availablePlayers, availablePlayers,
play, play,
load,
pause, pause,
resume, resume,
togglePlayback, togglePlayback,

View File

@@ -1,34 +0,0 @@
import { createSignal, onCleanup } from "solid-js"
type CacheOptions<T> = {
fetcher: () => Promise<T>
intervalMs?: number
}
export const useCachedData = <T,>(options: CacheOptions<T>) => {
const [data, setData] = createSignal<T | null>(null)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const refresh = async () => {
setLoading(true)
setError(null)
try {
const value = await options.fetcher()
setData(() => value)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data")
} finally {
setLoading(false)
}
}
refresh()
if (options.intervalMs) {
const interval = setInterval(refresh, options.intervalMs)
onCleanup(() => clearInterval(interval))
}
return { data, loading, error, refresh }
}

View File

@@ -0,0 +1,65 @@
/**
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
* focusable renderable) that holds the navigation store's `inputFocused`
* flag true while the renderable has focus.
*
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
* flag to each input's real focus/blur state fixes that.
*
* A module-level counter guards the blur→focus ordering gap that occurs when
* tabbing between two inputs in the same form (the old input blurs before the
* new one focuses) so the flag never flickers off mid-handoff.
*/
import { onCleanup } from "solid-js";
import { RenderableEvents } from "@opentui/core";
import { useNavigation } from "@/context/NavigationContext";
// Inputs (managed by this hook) currently holding focus.
let focusedCount = 0;
export function useInputFocusNav() {
const nav = useNavigation();
let current: any | undefined;
const onFocused = () => {
focusedCount++;
nav.setInputFocused(true);
};
const onBlurred = () => {
focusedCount = Math.max(0, focusedCount - 1);
if (focusedCount === 0) nav.setInputFocused(false);
};
const detach = (el: any) => {
el.off(RenderableEvents.FOCUSED, onFocused);
el.off(RenderableEvents.BLURRED, onBlurred);
// Treat a focused element being torn down as a blur so the counter
// doesn't leak and leave inputFocused stuck on.
if (el.focused) onBlurred();
};
const ref = (el: any) => {
if (current && current !== el) detach(current);
current = el;
if (el) {
el.on(RenderableEvents.FOCUSED, onFocused);
el.on(RenderableEvents.BLURRED, onBlurred);
// If the renderable is already focused when attached, count it.
if (el.focused) onFocused();
}
};
onCleanup(() => {
if (current) {
detach(current);
current = undefined;
}
});
return ref;
}

View File

@@ -1,13 +1,14 @@
/** /**
* Global multimedia key handler hook. * Global multimedia key handler hook.
* *
* Captures media-related key events (play/pause, volume, seek, speed) * Captures media-related key events (play/pause, volume, speed)
* regardless of which component is focused. Uses the event bus to * regardless of which component is focused. Uses the event bus to
* decouple key detection from audio control logic. * decouple key detection from audio control logic.
* *
* Volume and speed are app-level settings — adjustable with or without * Volume and speed are app-level settings — adjustable with or without
* an episode loaded (they apply to the next playback and persist). Seek * an episode loaded (they apply to the next playback and persist). Seek
* is playback-dependent, so it still requires a loaded episode. * is NOT handled here: it lives on the yazi keybind router (`<` / `>` =
* shift+, / shift+.), so the arrow keys stay free for navigation.
*/ */
import { useKeyboard } from "@opentui/solid"; import { useKeyboard } from "@opentui/solid";
@@ -17,31 +18,13 @@ export type MediaKeyAction =
| "media.toggle" | "media.toggle"
| "media.volumeUp" | "media.volumeUp"
| "media.volumeDown" | "media.volumeDown"
| "media.seekForward"
| "media.seekBackward"
| "media.speedCycle"; | "media.speedCycle";
/** Key-to-action mappings for multimedia controls */
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
// Common terminal media keys — these overlap with Player.tsx local
// bindings, but Player guards on `props.focused` so the global
// handler fires independently when the player tab is *not* active.
//
// When Player IS focused both handlers fire, but since the audio
// actions are idempotent (toggle = toggle, seek = additive) having
// them called twice for the same keypress is avoided by the event
// bus approach — the audio hook only processes event-bus events, and
// Player.tsx calls audio methods directly. We therefore guard with
// a "playerFocused" flag passed via options.
};
export interface MultimediaKeysOptions { export interface MultimediaKeysOptions {
/** When true, skip handling (Player.tsx handles keys locally) */ /** When true, skip handling (Player.tsx handles keys locally) */
playerFocused?: () => boolean; playerFocused?: () => boolean;
/** When true, skip handling (text input has focus) */ /** When true, skip handling (text input has focus) */
inputFocused?: () => boolean; inputFocused?: () => boolean;
/** Whether an episode is currently loaded */
hasEpisode?: () => boolean;
} }
/** /**
@@ -73,17 +56,9 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
emit("media.volumeDown", {}); emit("media.volumeDown", {});
break; break;
case "left":
if (!options.hasEpisode?.()) return;
emit("media.seekBackward", {});
break;
case "right":
if (!options.hasEpisode?.()) return;
emit("media.seekForward", {});
break;
case "s": case "s":
// Speed is shift+s (S) so plain `s` stays free for search.
if (!key.shift) return;
emit("media.speedCycle", {}); emit("media.speedCycle", {});
break; break;

View File

@@ -0,0 +1,16 @@
/**
* useSelectionMarker — reactive accessor for the row-selection marker glyph.
*
* When the `showSelectionMarker` setting is on, the focused row of every list
* renders ``; when off (the default), it renders a space so column alignment
* is preserved. Every list pane in the app reads the marker through this hook
* so the setting applies consistently everywhere.
*/
import { useAppStore } from "@/stores/app";
export function useSelectionMarker(): () => string {
const app = useAppStore();
return () =>
app.state().settings.showSelectionMarker ? "" : " ";
}

View File

@@ -1,4 +1,7 @@
const VERSION = "0.2.1"; import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.6.2";
interface CliArgs { interface CliArgs {
version: boolean; version: boolean;
@@ -37,160 +40,185 @@ if (cliArgs.version) {
process.exit(0); process.exit(0);
} }
// ── CLI handlers ──────────────────────────────────────────────────────
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
let latest: { feed: Feed; episode: Episode } | null = null
let latestDate = 0
for (const feed of feeds) {
if (feed.episodes.length === 0) continue
const ep = feed.episodes[0]
const epDate =
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
if (epDate > latestDate) {
latestDate = epDate
latest = { feed, episode: ep }
}
}
return latest
}
/** Search feeds by title and print matching shows */
function handleQuery(feeds: Feed[], query: string): void {
const normalizedQuery = query.toLowerCase()
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase()
return title.includes(normalizedQuery)
})
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`)
if (feeds.length > 0) {
console.log("\nAvailable shows:")
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`)
})
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`)
}
}
process.exit(0)
}
if (matches.length === 1) {
const feed = matches[0]
console.log(`\n${feed.podcast.title}`)
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
)
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate)
console.log(` ${idx + 1}. ${ep.title} (${date})`)
})
process.exit(0)
}
console.log(`\nClosest matches for "${query}":`)
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`)
})
process.exit(0)
}
/** Resolve and play an episode from `arg` (title path or "latest") */
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const normalizedArg = arg.toLowerCase()
let feedResult: Feed | null = null
let episodeResult: Episode | null = null
if (normalizedArg === "latest") {
const latest = findLatestEpisode(feeds)
if (latest) {
feedResult = latest.feed
episodeResult = latest.episode
}
} else {
const parts = normalizedArg.split("/")
const showQuery = parts[0]
const episodeQuery = parts[1]
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
)
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`)
process.exit(1)
}
const feed = matchingFeeds[0]
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
console.log(`No episodes available for: ${feed.podcast.title}`)
process.exit(1)
}
} else if (episodeQuery === "latest") {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
)
if (matchingEpisode) {
feedResult = feed
episodeResult = matchingEpisode
} else {
console.log(`Episode not found: ${episodeQuery}`)
console.log(`Available episodes for ${feed.podcast.title}:`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`)
})
process.exit(1)
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play")
process.exit(1)
}
console.log(`\nPlaying: ${episodeResult.title}`)
console.log(`Show: ${feedResult.podcast.title}`)
try {
const { createAudioBackend } = await import("./utils/audio-player")
const { fetchCoverArt } = await import("./utils/cover-art")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
// Stage the podcast cover so the system Now Playing shows
// artwork (mpv --cover-art-files), like the UI path does. Falls
// back to the episode's own image when the feed has no channel
// cover (URL-added feeds).
const coverUrl =
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
const coverArtPath = coverUrl
? await fetchCoverArt(coverUrl)
: null
await backend.play(episodeResult.audioUrl, {
mediaTitle: episodeResult.title,
coverArtPath: coverArtPath ?? undefined,
})
console.log("Playback started (use the UI to control)")
} else {
console.log("No audio URL available for this episode")
process.exit(1)
}
} catch (err) {
console.error("Playback error:", err)
process.exit(1)
}
}
if (cliArgs.query !== null || cliArgs.play !== null) { if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence") import("./utils/feeds-persistence")
.then(async ({ loadFeedsFromFile }) => { .then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile(); const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) { if (cliArgs.query !== null) {
const query = cliArgs.query; handleQuery(feeds, cliArgs.query)
const normalizedQuery = query.toLowerCase();
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase();
return title.includes(normalizedQuery);
});
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`);
if (feeds.length > 0) {
console.log("\nAvailable shows:");
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`);
});
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`);
}
}
process.exit(0);
}
if (matches.length === 1) {
const feed = matches[0];
console.log(`\n${feed.podcast.title}`);
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
);
}
console.log(
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate);
console.log(` ${idx + 1}. ${ep.title} (${date})`);
});
process.exit(0);
}
console.log(`\nClosest matches for "${query}":`);
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`);
});
process.exit(0);
} }
if (cliArgs.play !== null) { if (cliArgs.play !== null) {
const playArg = cliArgs.play; await handlePlay(feeds, cliArgs.play)
const normalizedArg = playArg.toLowerCase();
let feedResult: (typeof feeds)[0] | null = null;
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
if (normalizedArg === "latest") {
let latestFeed: (typeof feeds)[0] | null = null;
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
let latestDate = 0;
for (const feed of feeds) {
if (feed.episodes.length > 0) {
const ep = feed.episodes[0];
const epDate =
ep.pubDate instanceof Date
? ep.pubDate.getTime()
: Number(ep.pubDate);
if (epDate > latestDate) {
latestDate = epDate;
latestFeed = feed;
latestEpisode = ep;
}
}
}
feedResult = latestFeed;
episodeResult = latestEpisode;
} else {
const parts = normalizedArg.split("/");
const showQuery = parts[0];
const episodeQuery = parts[1];
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
);
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`);
process.exit(1);
}
const feed = matchingFeeds[0];
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
console.log(`No episodes available for: ${feed.podcast.title}`);
process.exit(1);
}
} else if (episodeQuery === "latest") {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
);
if (matchingEpisode) {
feedResult = feed;
episodeResult = matchingEpisode;
} else {
console.log(`Episode not found: ${episodeQuery}`);
console.log(`Available episodes for ${feed.podcast.title}:`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`);
});
process.exit(1);
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play");
process.exit(1);
}
console.log(`\nPlaying: ${episodeResult.title}`);
console.log(`Show: ${feedResult.podcast.title}`);
try {
const { createAudioBackend } = await import("./utils/audio-player");
const backend = createAudioBackend();
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl);
console.log("Playback started (use the UI to control)");
} else {
console.log("No audio URL available for this episode");
process.exit(1);
}
} catch (err) {
console.error("Playback error:", err);
process.exit(1);
}
} }
}) })
.catch((err) => { .catch((err) => {

View File

@@ -2,21 +2,31 @@
* DiscoverPage — yazi depth-stack view of discoverable podcasts. * DiscoverPage — yazi depth-stack view of discoverable podcasts.
* *
* depth 0 (current) — category list. Parent pane shows the muted * depth 0 (current) — category list. Parent pane shows the muted
* placeholder (1/7 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 {
@@ -27,19 +37,31 @@ import {
type DepthFrame, type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext"; 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 { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const DiscoverPaneCount = 1; export const DiscoverPaneCount = 1;
function DiscoverPage() { function DiscoverPage() {
// Static: detection never changes mid-session.
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 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);
@@ -54,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);
@@ -74,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) {
@@ -91,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);
} }
} }
@@ -109,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(() => {});
}, },
}; };
@@ -146,43 +291,87 @@ function DiscoverPage() {
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
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) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ────────── // Sibling <Show> blocks per depth (the known-good opentui disposal
// Stable <Show> gate (not a ternary root swap) so the parent list // pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
// mounts/unmounts cleanly on depth change. // children toggle on depth change, so the old subtree is disposed instead
// of left orphaned next to the new one (single <Show with fallback> and
// ternary root swaps both leak the previous root).
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}> <>
<For each={categories()}> <Show when={depth() === 0}>
{(cat, index) => { <TabListPane muted />
const lf = () => nav.depthFocus(0); </Show>
const ref = useScrollIntoView(() => index() === lf()); <Show when={depth() === 1}>
return ( <For each={categories()}>
<box {(cat, index) => {
ref={ref} const lf = () => nav.depthFocus(0);
flexDirection="row" const ref = useScrollIntoView(() => index() === lf());
gap={1} return (
paddingLeft={1} <box
paddingRight={1} ref={ref}
backgroundColor={focusBg(index(), lf(), false)} flexDirection="row"
> gap={1}
<text fg={focusFg(index(), nav.depthFocus(0), false)}> paddingRight={1}
{index() === nav.depthFocus(0) ? "" : " "} backgroundColor={focusBg(index(), lf(), false)}
</text> >
<text fg={focusFg(index(), nav.depthFocus(0), false)}> <text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name} {index() === nav.depthFocus(0) ? marker() : " "}
</text> </text>
</box> {nerd && (
); <text fg={focusFg(index(), nav.depthFocus(0), false)}>
}} {cat.icon}
</For> </text>
</Show> )}
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For>
</Show>
<Show when={depth() >= 2}>
<For each={podcasts()}>
{(podcast, index) => {
const lf = () => nav.depthFocus(1);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
</text>
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text flexShrink={0} fg={muted()}>[+]</text>
</Show>
</box>
);
}}
</For>
</Show>
</>
); );
// ── current pane ─────────────────────────────────────────────────────────── // ── current pane ───────────────────────────────────────────────────────────
@@ -199,7 +388,6 @@ function DiscoverPage() {
ref={ref} ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())} backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => { onMouseDown={() => {
@@ -209,8 +397,13 @@ function DiscoverPage() {
}} }}
> >
<text fg={focusFg(index(), lf(), isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "} {index() === lf() ? marker() : " "}
</text> </text>
{nerd && (
<text fg={focusFg(index(), lf(), isActive())}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text> <text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
</box> </box>
); );
@@ -218,12 +411,19 @@ 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={
<box padding={1}> <box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text> <Show
when={discoverStore.isLoading()}
fallback={
<text fg={muted()}>No podcasts found. :refresh</text>
}
>
<LoadingIndicator label="Discovering…" />
</Show>
</box> </box>
} }
> >
@@ -236,7 +436,6 @@ function DiscoverPage() {
ref={ref} ref={ref}
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())} backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => { onMouseDown={() => {
@@ -246,7 +445,7 @@ function DiscoverPage() {
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "} {index() === lf() ? marker() : " "}
</text> </text>
<text fg={focusFg(index(), lf(), isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{podcast.title} {podcast.title}
@@ -271,6 +470,59 @@ function DiscoverPage() {
); );
}} }}
</For> </For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
<Show when={depth() >= 2}>
<Show when={episodesLoading()}>
<box padding={1}>
<LoadingIndicator label="Loading episodes…" />
</box>
</Show>
<Show when={episodesError() && !episodesLoading()}>
<box padding={1}>
<text fg={theme.error}>{episodesError()}</text>
<box height={1} />
<text fg={muted()}>r: retry · h: back</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length === 0
}
>
<box padding={1}>
<text fg={muted()}>No episodes found. :refresh</text>
</box>
</Show>
<Show
when={
!episodesLoading() && !episodesError() && episodes().length > 0
}
>
<For each={episodes()}>
{(ep, index) => (
<EpisodeRow
episode={ep}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 2);
}}
/>
)}
</For>
</Show> </Show>
</Show> </Show>
</> </>
@@ -321,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={
@@ -340,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}>
@@ -362,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 (
@@ -373,9 +682,7 @@ function DiscoverPage() {
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
currentLabel={currentLabel} currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive} focused={isActive}
/> />
); );

View File

@@ -1,85 +0,0 @@
/**
* PodcastCard component - Reusable card for displaying podcast info
*/
import { Show, For } from "solid-js";
import type { Podcast } from "@/types/podcast";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type PodcastCardProps = {
podcast: Podcast;
selected: boolean;
compact?: boolean;
onSelect?: () => void;
onSubscribe?: () => void;
};
export function PodcastCard(props: PodcastCardProps) {
const { theme } = useTheme();
const handleSubscribeClick = () => {
props.onSubscribe?.();
};
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText selected={() => props.selected} primary>
<strong>{props.podcast.title}</strong>
</SelectableText>
<Show when={props.podcast.isSubscribed}>
<text fg={theme.success}>[+]</text>
</Show>
</box>
{/* Author */}
<Show when={props.podcast.author && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {props.podcast.author}
</SelectableText>
</Show>
{/* Description */}
<Show when={props.podcast.description && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
{props.podcast.description!.length > 80
? props.podcast.description!.slice(0, 80) + "..."
: props.podcast.description}
</SelectableText>
</Show>
{/**<box
flexDirection="row"
justifyContent="space-between"
marginTop={props.compact ? 0 : 1}
/>**/}
<box flexDirection="row" gap={1}>
<Show when={(props.podcast.categories ?? []).length > 0}>
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</Show>
</box>
<Show when={props.selected}>
<box onMouseDown={handleSubscribeClick}>
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,194 +0,0 @@
/**
* Feed detail view component for PodTUI
* Shows podcast info and episode list
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import type { Feed } from "@/types/feed";
import type { Episode } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedDetailProps {
feed: Feed;
focused?: boolean;
onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void;
}
export function FeedDetail(props: FeedDetailProps) {
const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [showInfo, setShowInfo] = createSignal(true);
const episodes = () => {
// Sort episodes by publication date (newest first)
return [...props.feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) {
return `${hrs}h ${mins % 60}m`;
}
return `${mins}m`;
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const handleKeyPress = (key: { name: string }) => {
const eps = episodes();
if (key.name === "escape" && props.onBack) {
props.onBack();
return;
}
if (key.name === "i") {
setShowInfo((v) => !v);
return;
}
if (key.name === "v") {
props.feed.podcast.onToggleVisibility?.(props.feed.id);
return;
}
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return") {
const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
return (
<box flexDirection="column" gap={1}>
{/* Header with back button */}
<box flexDirection="row" justifyContent="space-between">
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
</box>
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
</box>
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
</box>
</box>
{/* Podcast info section */}
<Show when={showInfo()}>
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<SelectableText selected={() => false} primary>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
{props.feed.podcast.author && (
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>by</SelectableText>
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
</box>
)}
<box height={1} />
<SelectableText selected={() => false} tertiary>
{props.feed.podcast.description?.slice(0, 200)}
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
</SelectableText>
<box height={1} />
<box flexDirection="row" gap={2}>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
</box>
<SelectableText selected={() => false} tertiary>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</SelectableText>
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
</box>
</box>
</Show>
{/* Episodes header */}
<box flexDirection="row" justifyContent="space-between">
<SelectableText selected={() => false} primary>
<strong>Episodes</strong>
</SelectableText>
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
</box>
{/* Episode list */}
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
<For each={episodes()}>
{(episode, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
if (props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
}}
>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{index() === selectedIndex() ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
{episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
</box>
</SelectableBox>
)}
</For>
</scrollbox>
{/* Help text */}
<text fg={theme.textMuted}>
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text>
</box>
);
}

View File

@@ -1,207 +0,0 @@
/**
* Feed filter component for PodTUI
* Toggle and filter options for feed list
*/
import { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedFilterProps {
filter: FeedFilter;
focused?: boolean;
onFilterChange: (filter: FeedFilter) => void;
}
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
export function FeedFilterComponent(props: FeedFilterProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "visibility") {
cycleVisibility();
} else if (focusField() === "sort") {
cycleSort();
} else if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
}
};
const cycleVisibility = () => {
const current = props.filter.visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
props.onFilterChange({ ...props.filter, visibility: next });
};
const cycleSort = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const currentIndex = sortOptions.indexOf(
props.filter.sortBy as FeedSortField,
);
const nextIndex = (currentIndex + 1) % sortOptions.length;
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
};
const togglePinned = () => {
props.onFilterChange({
...props.filter,
pinnedOnly: !props.filter.pinnedOnly,
});
};
const togglePrivate = () => {
props.onFilterChange({
...props.filter,
showPrivate: !props.filter.showPrivate,
});
};
const handleSearchInput = (value: string) => {
setSearchValue(value);
props.onFilterChange({ ...props.filter, searchQuery: value });
};
const visibilityLabel = () => {
const vis = props.filter.visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const visibilityColor = () => {
const vis = props.filter.visibility;
if (vis === "public") return theme.success;
if (vis === "private") return theme.warning;
return theme.text;
};
const sortLabel = () => {
const sort = props.filter.sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
case "updated":
default:
return "Updated";
}
};
return (
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Filter Feeds</strong>
</text>
<box flexDirection="row" gap={2} flexWrap="wrap">
{/* Visibility filter */}
<box
border
padding={0}
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
borderColor={theme.border}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
{/* Sort filter */}
<box
border
padding={0}
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
<text fg={theme.text}>{sortLabel()}</text>
</box>
</box>
{/* Pinned filter */}
<box
border
padding={0}
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</text>
</box>
</box>
{/* Private filter */}
<box
border
padding={0}
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
Private:
</text>
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
{props.filter.showPrivate ? "Yes" : "No"}
</text>
</box>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
<input
value={searchValue()}
onInput={handleSearchInput}
placeholder="Filter by name..."
focused={props.focused && focusField() === "search"}
width={25}
/>
</box>
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
</box>
);
}

View File

@@ -1,154 +0,0 @@
/**
* Feed item component for PodTUI
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedItemProps {
feed: Feed;
isSelected: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
compact?: boolean;
}
export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => {
return format(date, "MMM d");
};
const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length;
};
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]";
};
const visibilityColor = () => {
return props.feed.visibility === "public" ? theme.success : theme.warning;
};
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " ";
};
const { theme } = useTheme();
if (props.compact) {
// Compact single-line view
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => {}}
>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.feed.customName || props.feed.podcast.title}
</SelectableText>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
({episodeCount()})
</SelectableText>
)}
</SelectableBox>
);
}
// Full view with details
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {}}
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
secondary
>
{pinnedIndicator()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
</box>
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
{episodeCount()} episodes ({unplayedCount()} new)
</SelectableText>
)}
{props.showLastUpdated && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
Updated: {formatDate(props.feed.lastUpdated)}
</SelectableText>
)}
</box>
{props.feed.podcast.description && (
<SelectableText
selected={() => props.isSelected}
paddingLeft={4}
paddingTop={0}
tertiary
>
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</SelectableText>
)}
</SelectableBox>
);
}

View File

@@ -1,198 +0,0 @@
/**
* Feed list component for PodTUI
* Scrollable list of feeds with keyboard navigation and mouse support
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { FeedItem } from "./FeedItem";
import { useFeedStore } from "@/stores/feed";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedListProps {
focused?: boolean;
compact?: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
onSelectFeed?: (feed: Feed) => void;
onOpenFeed?: (feed: Feed) => void;
onFocusChange?: (focused: boolean) => void;
}
export function FeedList(props: FeedListProps) {
const { theme } = useTheme();
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false);
return;
}
const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return") {
const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") {
// Toggle pin on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
feedStore.togglePinned(feed.id);
}
} else if (key.name === "v") {
// Toggle visibility on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
feedStore.updateFeed(feed.id, { visibility: newVisibility });
}
} else if (key.name === "f") {
// Cycle visibility filter
cycleVisibilityFilter();
} else if (key.name === "s") {
// Cycle sort
cycleSortField();
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed);
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
};
const cycleSortField = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const current = feedStore.filter().sortBy as FeedSortField;
const idx = sortOptions.indexOf(current);
const next = sortOptions[(idx + 1) % sortOptions.length];
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
};
const visibilityLabel = () => {
const vis = feedStore.filter().visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const sortLabel = () => {
const sort = feedStore.filter().sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
}
};
const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index);
if (props.onSelectFeed) {
props.onSelectFeed(feed);
}
};
const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) {
props.onOpenFeed(feed);
}
};
return (
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text fg={theme.text}>
<strong>My Feeds</strong>
</text>
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
</box>
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
<text fg={theme.primary}>[s] {sortLabel()}</text>
</box>
</box>
</box>
{/* Feed list in scrollbox */}
<Show
when={filteredFeeds().length > 0}
fallback={
<box border padding={2} borderColor={theme.border}>
<text fg={theme.textMuted}>
No feeds found. Add podcasts from the Discover or Search tabs.
</text>
</box>
}
>
<scrollbox height={15} focused={props.focused}>
<For each={filteredFeeds()}>
{(feed, index) => (
<box onMouseDown={() => handleFeedClick(feed, index())}>
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
compact={props.compact}
showEpisodeCount={props.showEpisodeCount ?? true}
showLastUpdated={props.showLastUpdated ?? true}
/>
</box>
)}
</For>
</scrollbox>
</Show>
{/* Navigation help */}
<box paddingTop={0}>
<text fg={theme.textMuted}>
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
</text>
</box>
</box>
);
}

View File

@@ -16,11 +16,12 @@
* everything over `nav.action`; this page only handles list/preview data. * everything over `nav.action`; this page only handles list/preview data.
*/ */
import { createMemo, 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 {
@@ -31,19 +32,28 @@ 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 { 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";
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
type EpItem = { episode: Episode; feed: Feed }; type EpItem = { episode: Episode; feed: Feed };
function FeedPage() { function FeedPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
@@ -51,23 +61,92 @@ function FeedPage() {
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();
// ── flat episode list (depth 0 — the only depth Feed has) ──────────────── // ── flat episode list (depth 0 — the only depth Feed has) ────────────────
const episodes = createMemo<EpItem[]>( const episodes = createMemo<EpItem[]>(
() => 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 ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0); const focus = () => nav.depthFocus(0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(), rowCount() - 1);
const focusedOnMore = () =>
showFetchMore() && focusedRow() === episodes().length;
// -1 while the Fetch More row is focused so no episode row renders the
// cursor/highlight (the button is the focused row, not the last episode).
const focusedEpIdx = () => const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1); focusedOnMore()
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()]; ? -1
const curLen = () => episodes().length; : Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
// ── 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 (episodes().length > 0 && focus() >= episodes().length) if (rowCount() > 0 && focus() >= rowCount())
nav.setDepthFocus(episodes().length - 1, 0); nav.setDepthFocus(rowCount() - 1, 0);
}; };
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}`,
@@ -76,12 +155,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:
@@ -118,6 +191,10 @@ function FeedPage() {
// ── open ─────────────────────────────────────────────────────────────────── // ── open ───────────────────────────────────────────────────────────────────
function open() { function open() {
if (focusedOnMore()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
playEpisode(focusedItem()); playEpisode(focusedItem());
} }
@@ -136,6 +213,18 @@ function FeedPage() {
const item = focusedItem(); const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id); if (item) nav.toggleSelected(item.episode.id);
}, },
download: () => {
const item = focusedItem();
if (item) downloadStore.startDownload(item.episode, item.feed.id);
},
"delete-download": () => {
const item = focusedItem();
if (!item) return;
const id = item.episode.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
refresh: () => { refresh: () => {
feedStore.refreshAllFeeds().catch(() => {}); feedStore.refreshAllFeeds().catch(() => {});
}, },
@@ -160,15 +249,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 : theme.text;
const currentLabel = () => `Feed · ${episodes().length}`; const currentLabel = () => `Feed · ${episodes().length}`;
@@ -180,116 +260,113 @@ function FeedPage() {
<Show <Show
when={episodes().length > 0} when={episodes().length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1} alignItems="center">
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text> <Show
when={feedStore.isLoadingFeeds()}
fallback={
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
}
>
<LoadingIndicator />
</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}
paddingLeft={1} subtitle={() => item.feed.customName || item.feed.podcast.title}
paddingRight={1} index={() => listWindow()[0] + index()}
backgroundColor={focusBg(index(), fi(), isActive())} focused={focusedEpIdx}
onMouseDown={() => { active={isActive}
nav.setActivePane(DEPTH_CENTER_PANE); selected={() => nav.isSelected(item.episode.id)}
nav.setDepthFocus(index(), 0); downloadLabel={() => downloadLabel(item.episode.id)}
}} downloadColor={() => downloadColor(item.episode.id)}
> marker={marker}
<box flexDirection="row" gap={1}> onMouseDown={() => {
<text fg={focusFg(index(), fi(), isActive())}> nav.setActivePane(DEPTH_CENTER_PANE);
{index() === fi() ? "" : " "} nav.setDepthFocus(listWindow()[0] + index(), 0);
</text> }}
<text fg={focusFg(index(), fi(), isActive())}> />
{item.episode.episodeNumber )}
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi() ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text 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()}>
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0);
}}
/>
</Show>
<Show when={feedStore.isLoadingFeeds()}> <Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}> <box alignItems="center" paddingTop={1}>
<LoadingIndicator /> <LoadingIndicator />
</box> </box>
</Show> </Show>
</Show> </Show>
); );
// ── preview pane: hovered-episode detail ─────────────────────────────────── // ── 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={focusedItem()} <Show when={focusedOnMore()}>
fallback={ <FetchMorePreview
<box padding={1}> isLoadingMore={() => feedStore.isLoadingMore()}
<text fg={muted()}>No episode focused</text> fetchMoreMode={fetchMoreMode}
</box> manualText={() =>
} "Load the next batch of older episodes across all feeds (Enter)."
> }
{(item) => ( />
<box flexDirection="column" gap={1} padding={1}> </Show>
<text fg={theme.textPrimary ?? theme.text}> <Show when={!focusedOnMore()}>
<strong> <Show
{item().episode.episodeNumber when={focusedItem()}
? `#${item().episode.episodeNumber} ` fallback={
: ""} <box padding={1}>
{item().episode.title} <text fg={muted()}>No episode focused</text>
</strong> </box>
</text> }
<box flexDirection="row" gap={2}> >
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text> {(item) => (
<text fg={muted()}>{formatDuration(item().episode.duration)}</text> <EpisodePreview
<Show when={downloadLabel(item().episode.id)}> episode={() => item().episode}
<text fg={downloadColor(item().episode.id)}> subtitle={() =>
{downloadLabel(item().episode.id)} item().feed.customName || item().feed.podcast.title
</text> }
</Show> author={() => item().feed.podcast.author}
</box> downloadLabel={() => downloadLabel(item().episode.id)}
<text fg={muted()}> downloadColor={() => downloadColor(item().episode.id)}
{item().feed.customName || item().feed.podcast.title} hint={() => episodeHint(item())}
</text> />
<Show when={item().feed.podcast.author}> )}
<text fg={muted()}>by {item().feed.podcast.author}</text> </Show>
</Show> </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 · space: select · h back</text>
</box>
)}
</Show>
); );
return ( return (
@@ -297,9 +374,7 @@ function FeedPage() {
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
parentLabel="Up"
currentLabel={currentLabel} currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive} focused={isActive}
/> />
); );

View File

@@ -2,20 +2,24 @@
* MyShowsPage — yazi depth-stack view of subscribed shows. * MyShowsPage — yazi depth-stack view of subscribed shows.
* *
* depth 0 (current) — subscribed shows. Parent pane shows the muted * depth 0 (current) — subscribed shows. Parent pane shows the muted
* placeholder (1/7 slot kept). * placeholder (1/5 slot kept).
* 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
* as the Feed tab) that loads the next batch of episodes for that 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, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, createEffect, 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 { 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 {
@@ -27,24 +31,232 @@ 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 { 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, 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";
// ── 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() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const app = useAppStore();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const audio = useAudio(); const audio = useAudio();
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 stack = nav.depthStack; const stack = nav.depthStack;
const depth = nav.currentDepth; const depth = nav.currentDepth;
@@ -52,9 +264,27 @@ export function MyShowsPage() {
const shows = () => feedStore.getFilteredFeeds(); const shows = () => feedStore.getFilteredFeeds();
// Downloads of shows that are NOT subscribed (made from episode search) —
// listed as their own section under the shows list. Reads feeds() so an
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () => const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()]; /** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */
const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined;
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
};
const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub()) return undefined;
return shows()[focusedShowIdx()];
};
// depth-1 frame ctx = the drilled feed id // depth-1 frame ctx = the drilled feed id
const drilledShowId = (): string => stack()[1]?.ctx ?? ""; const drilledShowId = (): string => stack()[1]?.ctx ?? "";
@@ -67,34 +297,87 @@ export function MyShowsPage() {
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
); );
}); });
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of a drilled show's episode list
// advances that show's loaded window by 50 episodes — the per-show
// counterpart to the Feed page's row (which loads every feed). manual
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
const focusedOnMore = () =>
showFetchMore() && focusedRow() === episodes().length;
// -1 while the Fetch More row is focused so no episode row renders the
// cursor/highlight (the button is the focused row, not the last episode).
const focusedEpIdx = () => const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1); focusedOnMore()
const focusedEpisode = () => episodes()[focusedEpIdx()]; ? -1
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => (depth() === 0 ? shows().length : episodes().length); // ── Render window ────────────────────────────────────────────────────────
// The drilled show's list grows deep after repeated fetch-more presses;
// rendering every row per frame froze the UI. Render only a bounded slice
// around the focus (real indexes preserved) — the scrollbox still keeps
// the focused row in view. Spacers above/below the window restore the
// full content height so the scrollbar tracks the real list.
// Each episode row is 2 lines tall (title, date) — no subtitle here.
const LIST_WINDOW = 30;
const ROW_HEIGHT = 2;
const listWindow = createMemo<[number, number]>(() => {
const len = episodes().length;
// Focusing the Fetch More button keeps the window anchored at the
// last episode — no jump when the focus crosses onto the button.
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
return [
Math.max(0, f - LIST_WINDOW),
Math.min(len, f + LIST_WINDOW + 1),
];
});
const visibleEpisodes = createMemo(() => {
const [start, end] = listWindow();
return episodes().slice(start, end);
});
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
const ensureFocus = () => { const ensureFocus = () => {
if (shows().length > 0 && focus(0) >= shows().length) if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
nav.setDepthFocus(shows().length - 1, 0); nav.setDepthFocus(depth0Count() - 1, 0);
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length) if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
nav.setDepthFocus(episodes().length - 1, 1); nav.setDepthFocus(rowCount() - 1, 1);
}; };
onMount(ensureFocus); onMount(ensureFocus);
onMount(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return shows()[i]?.id; if (depth() === 0) {
if (i < shows().length) return shows()[i]?.id;
return unsubs()[i - shows().length]?.episodeId;
}
return episodes()[i]?.id; return episodes()[i]?.id;
}); });
}); });
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ───────────────────────────────────────────────────────────────── // ── 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:
@@ -128,9 +411,31 @@ export function MyShowsPage() {
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
}; };
/** Stream an unsubscribed-show download. The record carries only what was
* persisted at download time, so a minimal Episode is reconstructed. */
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
audio
.play({
id: d.episodeId,
podcastId: d.feedId,
title: d.episodeTitle ?? d.episodeId,
description: "",
audioUrl: d.audioUrl ?? "",
duration: 0,
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
})
.catch(() => {});
audioNav.setSource(AudioSource.SEARCH, d.feedId);
};
// ── drill / open ─────────────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
function open() { function open() {
if (depth() === 0) { if (depth() === 0) {
const d = focusedUnsub();
if (d) {
playUnsubscribedDownload(d);
return;
}
const show = selectedShow(); const show = selectedShow();
if (!show) return; if (!show) return;
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame); nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
@@ -139,6 +444,10 @@ export function MyShowsPage() {
return; return;
} }
if (depth() >= 1) { if (depth() >= 1) {
if (focusedOnMore()) {
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
return;
}
const ep = focusedEpisode(); const ep = focusedEpisode();
if (ep) playEpisode(ep); if (ep) playEpisode(ep);
} }
@@ -161,6 +470,41 @@ export function MyShowsPage() {
if (ep) nav.toggleSelected(ep.id); if (ep) nav.toggleSelected(ep.id);
} }
}, },
download: () => {
if (depth() < 1) return;
const ep = focusedEpisode();
if (ep) downloadStore.startDownload(ep, drilledShowId());
},
"delete-download": () => {
if (depth() === 0) {
const d = focusedUnsub();
if (d) {
downloadStore.cancelDownload(d.episodeId);
downloadStore.removeDownload(d.episodeId).catch(() => {});
}
return;
}
if (depth() < 1) return;
const ep = focusedEpisode();
if (!ep) return;
const id = ep.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
"whitelist-toggle": () => {
const prefs = app.state().preferences;
if (prefs.autoDownloadScope !== "whitelist") return;
// depth 0: the focused show; depth ≥1: the drilled show.
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
if (!id) return;
const cur = prefs.autoDownloadWhitelist ?? [];
const next = cur.includes(id)
? cur.filter((x) => x !== id)
: [...cur, id];
app.updatePreferences({ autoDownloadWhitelist: next });
feedStore.runAutoDownload();
},
refresh: () => { refresh: () => {
const show = selectedShow(); const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {}); if (show) feedStore.refreshFeed(show.id).catch(() => {});
@@ -196,19 +540,16 @@ 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 : theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title; const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
? `Shows (${shows().length})` ? `Shows (${shows().length})${
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
}`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (
@@ -217,20 +558,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}
paddingLeft={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() ? "" : " "} {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>
); );
}} }}
@@ -244,7 +595,7 @@ export function MyShowsPage() {
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */} {/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}> <Show when={depth() === 0}>
<Show <Show
when={shows().length > 0} when={depth0Count() > 0}
fallback={ fallback={
<box padding={1}> <box padding={1}>
<text fg={muted()}> <text fg={muted()}>
@@ -254,35 +605,53 @@ export function MyShowsPage() {
} }
> >
<For each={shows()}> <For each={shows()}>
{(feed, index) => { {(feed, index) => (
const lf = () => focusedShowIdx(); <ShowRow
const ref = useScrollIntoView(() => index() === lf()); feed={feed}
return ( title={showTitle(feed)}
<box index={index}
ref={ref} focused={focusedShowIdx}
flexDirection="row" active={isActive}
gap={1} marker={marker}
paddingLeft={1} wlScope={() =>
paddingRight={1} app.state().preferences.autoDownloadScope === "whitelist"
backgroundColor={focusBg(index(), lf(), isActive())} }
wlInList={() =>
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
feed.id,
)
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
<text fg={theme.textSecondary}>
Unsubscribed Show Downloads
</text>
</box>
<For each={unsubs()}>
{(d, index) => (
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={() => nav.depthFocus(0)}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
downloadColor={() => downloadColor(d.episodeId)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(shows().length + index(), 0);
}} }}
> />
<text fg={focusFg(index(), lf(), isActive())}> )}
{index() === lf() ? "" : " "} </For>
</text> </Show>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show> </Show>
</Show> </Show>
{/* depth ≥1: episodes */} {/* depth ≥1: episodes */}
@@ -295,56 +664,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
paddingLeft={1} episode={ep}
paddingRight={1} index={() => listWindow()[0] + index()}
backgroundColor={focusBg(index(), lf(), isActive())} focused={focusedEpIdx}
onMouseDown={() => { active={isActive}
nav.setActivePane(DEPTH_CENTER_PANE); selected={() => nav.isSelected(ep.id)}
nav.setDepthFocus(index(), 1); downloadLabel={() => downloadLabel(ep.id)}
}} downloadColor={() => downloadColor(ep.id)}
> marker={marker}
<box flexDirection="row" gap={1}> onMouseDown={() => {
<text fg={focusFg(index(), lf(), isActive())}> nav.setActivePane(DEPTH_CENTER_PANE);
{index() === lf() ? "" : " "} nav.setDepthFocus(listWindow()[0] + index(), 1);
</text> }}
<text fg={focusFg(index(), lf(), isActive())}> />
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} )}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf() ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For> </For>
<Show when={feedStore.isLoadingMore()}> <Show when={episodes().length - listWindow()[1] > 0}>
<box paddingLeft={2} paddingTop={1}> <box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
<LoadingIndicator /> </Show>
</box> <Show when={showFetchMore()}>
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
/>
</Show> </Show>
</Show> </Show>
</Show> </Show>
@@ -352,76 +712,96 @@ 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 show // depth 0 preview: hovered unsubscribed-show download, else the
// hovered show.
<Show <Show
when={selectedShow()} when={focusedUnsub()}
fallback={ fallback={
<box padding={1}> <Show
<text fg={muted()}>No show focused</text> when={selectedShow()}
</box> fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
} }
> >
{(show) => ( {(d) => (
<box flexDirection="column" gap={1} padding={1}> <UnsubscribedPreview
<text fg={theme.textPrimary ?? theme.text}> d={() => d()}
<strong>{showTitle(show())}</strong> downloadLabel={() => downloadLabel(d().episodeId)}
</text> downloadColor={() => downloadColor(d().episodeId)}
<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</text>
</box>
)} )}
</Show> </Show>
) : ( ) : (
// depth ≥1 preview: hovered episode // depth ≥1 preview: hovered episode (or the Fetch More row)
<Show <>
when={focusedEpisode()} <Show when={focusedOnMore()}>
fallback={ <FetchMorePreview
<box padding={1}> isLoadingMore={() => feedStore.isLoadingMore()}
<text fg={muted()}>No episode focused</text> fetchMoreMode={fetchMoreMode}
</box> manualText={() =>
} "Load the next batch of older episodes for this show (Enter)."
> }
{(ep) => ( />
<box flexDirection="column" gap={1} padding={1}> </Show>
<text fg={theme.textPrimary ?? theme.text}> <Show when={!focusedOnMore()}>
<strong> <Show
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} when={focusedEpisode()}
{ep().title} fallback={
</strong> <box padding={1}>
</text> <text fg={muted()}>No episode focused</text>
<box flexDirection="row" gap={2}> </box>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text> }
<text fg={muted()}>{formatDuration(ep().duration)}</text> >
<Show when={downloadLabel(ep().id)}> {(ep) => (
<text fg={downloadColor(ep().id)}> <EpisodePreview
{downloadLabel(ep().id)} episode={() => ep()}
</text> author={() => selectedShow()?.podcast.author}
</Show> downloadLabel={() => downloadLabel(ep().id)}
</box> downloadColor={() => downloadColor(ep().id)}
<Show when={selectedShow()?.podcast.author}> hint={() => episodeHint(ep().id)}
<text fg={muted()}>by {selectedShow()!.podcast.author}</text> />
</Show> )}
<box height={1} /> </Show>
<text fg={theme.textSecondary}> </Show>
{ep().description?.slice(0, 400) ?? "No description available."} </>
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
</box>
)}
</Show>
); );
return ( return (
@@ -429,9 +809,7 @@ export function MyShowsPage() {
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
currentLabel={currentLabel} currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive} focused={isActive}
/> />
); );

View File

@@ -14,70 +14,81 @@ type PlaybackControlsProps = {
onSpeedChange: (value: number) => void; onSpeedChange: (value: number) => void;
}; };
const BACKEND_LABELS: Record<BackendName, string> = {
mpv: "mpv",
none: "none",
};
export function PlaybackControls(props: PlaybackControlsProps) { export function PlaybackControls(props: PlaybackControlsProps) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<box <box
flexDirection="row" flexDirection="row"
flexWrap="wrap"
gap={1} gap={1}
alignItems="center" alignItems="center"
justifyContent="center"
border border
padding={1} padding={1}
borderColor={theme.border} borderColor={theme.border}
> >
<box {/* transport buttons — wrap as a unit, centered on their own line */}
border <box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
padding={0} <box
onMouseDown={props.onPrev} border
borderColor={theme.border} padding={0}
> onMouseDown={props.onPrev}
<text fg={theme.primary}>[Prev]</text> borderColor={theme.border}
>
<text fg={theme.primary} wrapMode="none">[Prev]</text>
</box>
<box
border
padding={0}
onMouseDown={props.onToggle}
borderColor={theme.border}
>
<text fg={theme.primary} wrapMode="none">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box
border
padding={0}
onMouseDown={props.onNext}
borderColor={theme.border}
>
<text fg={theme.primary} wrapMode="none">[Next]</text>
</box>
</box> </box>
{/* status group — always follows the buttons; wrap point is here */}
<box <box
border flexDirection="row"
padding={0} gap={1}
onMouseDown={props.onToggle} alignItems="center"
borderColor={theme.border} marginLeft={2}
flexShrink={0}
> >
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box
border
padding={0}
onMouseDown={props.onNext}
borderColor={theme.border}
>
<text fg={theme.primary}>[Next]</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Vol</text> <text fg={theme.textMuted}>Vol</text>
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text> <text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
<text fg={theme.textMuted}></text> <text fg={theme.textMuted}></text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Speed</text>
<text fg={theme.text}>{props.speed}x</text>
<text fg={theme.textMuted}>s</text>
</box>
{props.backendName && props.backendName !== "none" && (
<box flexDirection="row" gap={1} marginLeft={2}> <box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>via</text> <text fg={theme.textMuted}>Speed</text>
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text> <text fg={theme.text}>{props.speed}x</text>
<text fg={theme.textMuted}>S</text>
</box> </box>
)} </box>
{props.backendName === "none" && ( {/* audio warnings — wrap to their own (3rd) line when the row is tight */}
<box marginLeft={2}> {(props.backendName === "none" || props.hasAudioUrl === false) && (
<text fg={theme.warning}>No audio player found</text> <box
</box> flexDirection="row"
)} gap={1}
{props.hasAudioUrl === false && ( alignItems="center"
<box marginLeft={2}> flexShrink={0}
<text fg={theme.warning}>No audio URL</text> >
{props.backendName === "none" && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio player found</text>
</box>
)}
{props.hasAudioUrl === false && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio URL</text>
</box>
)}
</box> </box>
)} )}
</box> </box>

View File

@@ -10,10 +10,12 @@
* tab root. * tab root.
*/ */
import { Show } from "solid-js"; import { Show, onMount, onCleanup } from "solid-js";
import { PlaybackControls } from "./PlaybackControls"; import { PlaybackControls } from "./PlaybackControls";
import { ProgressBar } from "./ProgressBar";
import { RealtimeWaveform } from "./RealtimeWaveform"; import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
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";
@@ -26,7 +28,19 @@ export function PlayerPage() {
const audio = useAudio(); const audio = useAudio();
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const nav = useNavigation();
const viz = useVisualizer();
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
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
const vizEnabled = () => app.state().settings.visualizer.enabled;
// The page is mounted exactly while the Player tab is in focus (Shell
// renders only the active tab), so mount ⇔ focused. Report it to the
// visualizer store: losing focus starts the unload grace timer instead
// of killing the pipeline with the page; regaining focus restarts it.
onMount(() => viz.setFocused(true));
onCleanup(() => viz.setFocused(false));
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
@@ -79,18 +93,11 @@ export function PlayerPage() {
{ep().description?.slice(0, 500) ?? "No description available."} {ep().description?.slice(0, 500) ?? "No description available."}
</text> </text>
<RealtimeWaveform <ProgressBar />
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer; <Show when={vizEnabled()}>
// bars is width-derived in RealtimeWaveform; pass only the <RealtimeWaveform />
// audio-processing params here. </Show>
return {
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box> </box>
)} )}
</Show> </Show>
@@ -109,9 +116,13 @@ export function PlayerPage() {
/> />
<box height={1} /> <box height={1} />
<text fg={muted()}> {/* content prop (not a text child): the babel-preset-solid JSX
{"P play/pause N next B prev ◀▶ seek h back"} * transform HTML-escapes static string children (`<` → `&lt;`),
</text> * which opentui renders verbatim; content bypasses that. */}
<text
fg={muted()}
content={"P play/pause N next B prev < > seek h back"}
/>
</box> </box>
); );
@@ -119,10 +130,10 @@ export function PlayerPage() {
<PaneRow <PaneRow
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
parentLabel="Up"
currentLabel="Player" currentLabel="Player"
panes={2} panes={2}
focused={isActive} focused={isActive}
currentBorder={["left"]}
/> />
); );
} }

View File

@@ -0,0 +1,74 @@
/**
* ProgressBar — one-row, click-to-seek playback progress bar for the
* player pane. Played portion renders as full blocks (█) in the theme's
* primary color, the remainder as light shade blocks (░) in the muted
* color. The header time/percent text lives in PlayerPage — this is only
* the bar itself.
*/
import { useTerminalDimensions } from "@opentui/solid";
import type { Renderable } from "@opentui/core";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
// ── Component ────────────────────────────────────────────────────────
export function ProgressBar() {
const audio = useAudio();
const { theme } = useTheme();
const dimensions = useTerminalDimensions();
// The bar's renderable, captured for its absolute left edge: MouseEvent.x
// is terminal-absolute (not bar-relative), so local x needs the offset
// of the bar inside the 2-pane row (parent pane ≈ 20% of the width).
let bar: Renderable | undefined;
// Full content width of the player pane: the player is a 2-pane row
// (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars
// of border/padding chrome (same math as RealtimeWaveform's numBars).
const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8);
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
const playedChars = () => {
const duration = audio.duration();
if (duration <= 0) return 0;
return Math.round(clamp01(audio.position() / duration) * width());
};
const remainingColor = theme.muted || theme.text;
return (
<box
border
borderColor={theme.border}
padding={0}
flexDirection="row"
gap={0}
// The bar's block-char texts are non-selectable below: a drag
// over the bar is a seek gesture, not a text selection — otherwise
// mouse-up would copy █/░ to the clipboard via the global
// selection handler.
ref={(el) => {
bar = el;
}}
onMouseDown={(e: { x: number }) => {
const duration = audio.duration();
if (duration <= 0 || !bar) return;
// localX = 0 is the box border; content starts at localX = 1.
const localX = e.x - bar.x;
const ratio = Math.max(0, Math.min(1, (localX - 1) / width()));
void audio.seek(ratio * duration);
}}
>
{playedChars() > 0 && (
<text fg={theme.primary} selectable={false}>
{"\u2588".repeat(playedChars())}
</text>
)}
<text fg={remainingColor} selectable={false}>
{"\u2591".repeat(width() - playedChars())}
</text>
</box>
);
}

View File

@@ -1,62 +1,30 @@
/** /**
* RealtimeWaveform — live audio frequency visualization using cavacore. * RealtimeWaveform — renders the shared visualizer pipeline state.
* *
* Spawns an independent ffmpeg * The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
* process to decode the audio stream, feeds PCM samples through cavacore * visualizer store (`@/stores/visualizer`), not in this component, so it
* for FFT analysis, and renders frequency bars as colored terminal * survives PlayerPage unmounts: leaving the Player tab keeps the waveform
* characters at ~30fps. * warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
*
* This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the
* frequency bars once frames arrive, and a dotted placeholder when idle.
*/ */
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js"; import { createEffect, on } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid"; import { useTerminalDimensions } from "@opentui/solid";
import { import { useVisualizer } from "@/stores/visualizer";
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { PANE_RATIO } from "@/utils/navigation"; import { PANE_RATIO } from "@/utils/navigation";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) { export function RealtimeWaveform() {
const { theme } = useTheme(); const { theme } = useTheme();
const audio = useAudio(); const viz = useVisualizer();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
// Bar count scales with terminal width so the waveform fills its pane. // Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of // The player is a 2-pane row: current column = (current+preview) of
@@ -75,202 +43,49 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
); );
}; };
// ── Lifecycle: init cavacore once ────────────────────────────────── // Keep the store's bar count in sync with the terminal width; the store
// re-inits the running pipeline when it changes (terminal resize).
const initCava = () => { createEffect(on(numBars, (n) => viz.setBarCount(n)));
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
return false;
}
return true;
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides.
// bars is width-derived (see numBars); visualizerConfig supplies the
// audio-processing params (noise reduction, cutoffs, etc.).
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
};
cava.init(config);
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy();
}
sampleBuffer = null;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Feed samples to cavacore → get frequency bars
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
// Copy bar values to a new array for the signal
setBarData(Array.from(output as Float64Array));
};
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "",
audio.speed,
numBars,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(audio.position);
startVisualization(url, pos, speed);
} else {
stopVisualization();
}
},
),
);
// ── Seek detection: lightweight effect for position jumps ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0;
createEffect(
on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1);
}
}),
);
// Cleanup on unmount
onCleanup(() => {
stopVisualization();
if (reader) {
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy();
}
});
// ── Rendering ────────────────────────────────────────────────────── // ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
const renderLine = () => { const renderLine = () => {
const bars = barData(); const bars = viz.barData();
const count = numBars(); const count = numBars();
// If no data yet, show empty placeholder // Loading state: the braille spinner shows while the pipeline warms
// up — but only when there are no bars to render yet (first play /
// after an unload). On resume/seek the last bars stay on screen
// until fresh frames arrive, so the waveform never blanks out for
// the (multi-second, network-bound) cold start.
if (bars.length === 0 && viz.isLoading()) {
return <LoadingIndicator />;
}
if (bars.length === 0) { if (bars.length === 0) {
const placeholder = ".".repeat(count); const placeholder = ".".repeat(count);
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="column" gap={0}>
<text fg="#3b4252">{placeholder}</text> <text fg={theme.primary}>{placeholder}</text>
<text fg={theme.primary}>{placeholder}</text>
</box> </box>
); );
} }
const played = Math.floor(count * playedRatio()); const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590"; const top = pairs.map((pair) => pair.top).join("");
const futureColor = "#3b4252"; const bottom = pairs.map((pair) => pair.bottom).join("");
const playedChars = bars
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="column" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text> <text fg={theme.primary}>{top}</text>
<text fg={futureColor}>{futureChars || " "}</text> <text fg={theme.primary}>{bottom}</text>
</box> </box>
); );
}; };
const handleClick = (event: { x: number }) => {
const count = numBars();
const ratio = event.x / count;
const next = Math.max(
0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
);
audio.seek(next);
};
return ( return (
<box <box border borderColor={theme.border} padding={1}>
border
borderColor={theme.border}
padding={1}
onMouseDown={handleClick}
>
{renderLine()} {renderLine()}
</box> </box>
); );

View File

@@ -1,95 +0,0 @@
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type ResultCardProps = {
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const { theme } = useTheme();
const podcast = () => props.result.podcast;
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText
selected={() => props.selected}
primary
>
<strong>{podcast().title}</strong>
</SelectableText>
<SourceBadge
sourceId={props.result.sourceId}
sourceName={props.result.sourceName}
sourceType={props.result.sourceType}
/>
</box>
<Show when={podcast().isSubscribed}>
<text fg={theme.success}>[Subscribed]</text>
</Show>
</box>
<Show when={podcast().author}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {podcast().author}
</SelectableText>
</Show>
<Show when={podcast().description}>
{(description) => (
<SelectableText
selected={() => props.selected}
tertiary
>
{description().length > 120
? description().slice(0, 120) + "..."
: description()}
</SelectableText>
)}
</Show>
<Show when={(podcast().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(podcast().categories ?? []).slice(0, 3).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<Show when={!podcast().isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,75 +0,0 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
const { theme } = useTheme();
return (
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
<Show
when={props.result}
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
>
{(result) => (
<>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg={theme.textMuted}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textMuted}>{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
<text fg={theme.textMuted}>
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
</text>
<Show when={!result().podcast.isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={() => props.onSubscribe?.(result())}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -1,89 +0,0 @@
/**
* SearchHistory component for displaying and managing search history
*/
import { For, Show } from "solid-js"
import { useTheme } from "@/context/ThemeContext"
import { SelectableBox, SelectableText } from "@/components/Selectable"
type SearchHistoryProps = {
history: string[]
focused: boolean
selectedIndex: number
onSelect?: (query: string) => void
onRemove?: (query: string) => void
onClear?: () => void
onChange?: (index: number) => void
}
export function SearchHistory(props: SearchHistoryProps) {
const { theme } = useTheme();
const handleSearchClick = (index: number, query: string) => {
props.onChange?.(index)
props.onSelect?.(query)
}
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text fg={theme.error}>[Clear All]</text>
</box>
</Show>
</box>
<Show
when={props.history.length > 0}
fallback={
<box padding={1}>
<text fg={theme.textMuted}>No recent searches</text>
</box>
}
>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
const isSelected = () => index() === props.selectedIndex && props.focused
return (
<SelectableBox
selected={isSelected}
flexDirection="row"
justifyContent="space-between"
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => handleSearchClick(index(), query)}
>
<SelectableText
selected={isSelected}
tertiary
>
{">"}
</SelectableText>
<SelectableText
selected={isSelected}
primary
>
{query}
</SelectableText>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg={theme.error}>[x]</text>
</box>
</SelectableBox>
)
}}
</For>
</box>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -8,6 +8,10 @@
* query (muted, read-only); preview shows the detail of * query (muted, read-only); preview shows the detail of
* the focused result. * the focused result.
* *
* Search scope: `tab` (search-scope-toggle) flips between shows and episodes
* (clickable pills on the query depth too); toggling while viewing results
* re-runs the current query in the new scope.
*
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell * Typed input owns its keys while `nav.inputFocused()` is true (the Shell
* router yields). Escape defocuses the input (handled in Shell) so j/k/h * router yields). Escape defocuses the input (handled in Shell) so j/k/h
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the * navigation resumes; `s` (the `search` action) refocuses it. Enter on the
@@ -26,6 +30,11 @@ import {
} from "solid-js"; } from "solid-js";
import { useSearchStore } from "@/stores/search"; import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { DownloadStatus } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
@@ -37,20 +46,28 @@ import {
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source"; import type { SearchResult, SearchScope } from "@/types/source";
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 { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export const SearchPaneCount = 1; export const SearchPaneCount = 1;
function SearchPage() { function SearchPage() {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const toast = useToast();
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
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 stack = nav.depthStack; const stack = nav.depthStack;
const depth = nav.currentDepth; const depth = nav.currentDepth;
@@ -60,25 +77,21 @@ 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.
// Typing is the default only on the query depth (0); the results depth // The depth stack only SEEDS it on transitions (re-entering depth 0
// (1) is always list-navigation. Drive `inputFocused` straight off // focuses the input; mounting at depth 1 stays list-nav), gated on the
// `depth()` rather than seeding it `true` on mount and patching on change: // depth VALUE via a memo because setDepthFocus also writes the stack
// the depth stack persists across tab switches, so re-mounting this page // signal — without the memo every j/k at query depth re-focuses the input
// at depth 1 (e.g. after searching, leaving, and returning to the tab) // and strands the recents list.
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
// j/k (yielding to a non-existent input) and only the scrollbox's native
// scroll responds.
//
// The effect only re-runs on a depth transition, so Escape (defocus) and
// `s` (refocus) at the same depth are not clobbered.
onMount(() => nav.setInputFocused(depth() === 0)); onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false)); onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();
const isQueryDepth = createMemo(() => depth() === 0);
createEffect(() => { createEffect(() => {
nav.setInputFocused(depth() === 0); nav.setInputFocused(isQueryDepth());
}); });
// ── results (depth 1) ───────────────────────────────────────────────────── // ── results (depth 1) ─────────────────────────────────────────────────────
@@ -104,12 +117,44 @@ function SearchPage() {
// Register a visual-mode resolver for the results list (depth 1). // Register a visual-mode resolver for the results list (depth 1).
onMount(() => { onMount(() => {
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`; const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
nav.registerResolver(key, (i) => results()[i]?.podcast.id); nav.registerResolver(key, (i) => {
const r = results()[i];
return r?.kind === "episode" ? r.episode.id : r?.podcast.id;
});
}); });
// ── helpers ───────────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const runSearch = (query: string) => { const runSearch = (query: string) => {
const q = query.trim(); const q = query.trim();
if (!q) return; if (!q) return;
@@ -129,10 +174,89 @@ function SearchPage() {
runSearch(query); runSearch(query);
}; };
const handleSubscribe = (result: SearchResult) => { /** Set show/episode scope; when viewing results, re-run the current query
// Actually add the feed to the feed store, then mark the result subscribed * so the list switches immediately (the toggle is otherwise invisible on
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {}); * a list of results). */
searchStore.markSubscribed(result.podcast.id); const applyScope = (next: SearchScope) => {
searchStore.setScope(next);
if (depth() >= 1) {
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
}
};
const toggleScope = () =>
applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast");
const handleSubscribe = async (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result
// subscribed. addFeed returns null when a feedless directory stub
// (delisted show) can't be resolved — tell the user why.
const feed = await feedStore
.addFeed(result.podcast, result.sourceId)
.catch(() => null);
if (!feed && !result.podcast.feedUrl) {
toast.show({
title: "Can't subscribe",
message:
"No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.",
variant: "error",
});
return;
}
if (feed) searchStore.markSubscribed(result.podcast.id);
};
/** The subscribed feed backing a search result, if any (matched by
* directory id or feed URL). */
const feedForResult = (r: SearchResult) =>
feedStore.feeds().find(
(f) =>
f.podcast.id === r.podcast.id ||
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
);
/** Download the focused episode: under its subscribed feed when the show
* is subscribed, otherwise as an "unsubscribed show" download (listed
* under Unsubscribed Show Downloads in My Shows / the download manager). */
const downloadFocusedEpisode = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.kind !== "episode") return;
const feed = feedForResult(r);
if (feed) downloadStore.startDownload(r.episode, feed.id);
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
};
const playFocusedEpisode = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.kind !== "episode") return;
audio.play(r.episode).catch(() => {});
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
};
const unsubscribeFocused = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || !r.podcast.isSubscribed) return;
const feed = feedForResult(r);
if (feed) {
feedStore.removeFeed(feed.id);
downloadStore
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
.catch(() => {});
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
}
};
/** Subscribe the focused result's show in place (episode or podcast
* result). `enter` plays episodes regardless of subscription, so an
* unsubscribed show's episode needs this explicit path. */
const subscribeFocused = () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.podcast.isSubscribed) return;
handleSubscribe(r);
}; };
// ── nav.action handler ────────────────────────────────────────────────────── // ── nav.action handler ──────────────────────────────────────────────────────
@@ -149,13 +273,29 @@ function SearchPage() {
"toggle-select": () => { "toggle-select": () => {
if (depth() === 1) { if (depth() === 1) {
const r = focusedResult(); const r = focusedResult();
if (r) nav.toggleSelected(r.podcast.id); if (r)
nav.toggleSelected(
r.kind === "episode" ? r.episode.id : r.podcast.id,
);
} }
}, },
download: () => downloadFocusedEpisode(),
"delete-download": () => {
if (depth() !== 1) return;
const r = focusedResult();
if (!r || r.kind !== "episode") return;
const id = r.episode.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
unsubscribe: () => unsubscribeFocused(),
subscribe: () => subscribeFocused(),
search: () => { search: () => {
// `s` refocuses the query input (typing mode) when on the query depth. // `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true); if (depth() === 0) nav.setInputFocused(true);
}, },
"search-scope-toggle": () => toggleScope(),
refresh: () => { refresh: () => {
const q = submittedQuery() || inputValue().trim(); const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {}); if (q) searchStore.search(q).catch(() => {});
@@ -176,7 +316,15 @@ function SearchPage() {
} }
if (depth() === 1) { if (depth() === 1) {
const r = focusedResult(); const r = focusedResult();
if (r) handleSubscribe(r); if (!r) return;
if (r.kind === "episode") {
// Any episode result streams directly — subscribed or not
// (matches Feed/My Shows). `a` subscribes an unsubscribed
// show's episode in place.
playFocusedEpisode();
return;
}
handleSubscribe(r);
} }
} }
@@ -205,18 +353,35 @@ function SearchPage() {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text; i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
// ── parent pane: previous-depth content (tab list at depth 0) ────────────── // ── parent pane: previous-depth content (tab list at depth 0) ──────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
// children toggle on depth change, so the old subtree is disposed instead
// of left orphaned next to the new one (single <Show with fallback> and
// ternary root swaps both leak the previous root).
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}> <>
<box flexDirection="column" gap={1} padding={1}> <Show when={depth() === 0}>
<text fg={theme.textSecondary}>Query</text> <TabListPane muted />
<text fg={muted()}>{submittedQuery() || "(empty)"}</text> </Show>
<box height={1} /> <Show when={depth() >= 1}>
<text fg={muted()}>h: back to query</text> <box flexDirection="column" gap={1} padding={1}>
</box> <text fg={theme.textSecondary}>Query</text>
</Show> <text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={theme.textSecondary}>
Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"}
</text>
<text fg={muted()}>h: back to query</text>
</box>
</Show>
</>
); );
// ── current pane ──────────────────────────────────────────────────────────── // ── current pane ────────────────────────────────────────────────────────────
@@ -228,16 +393,80 @@ function SearchPage() {
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text> <text fg={muted()}>Query:</text>
<input <input
ref={focusNavRef}
value={inputValue()} value={inputValue()}
onInput={setInputValue} onInput={setInputValue}
onSubmit={() => handleSubmit()} onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..." onMouseDown={(evt) => {
// Clicking the input must focus it (typing mode).
// preventDefault stops opentui's click auto-focus from
// grabbing the pane scrollbox instead; setting the flag
// drives the `focused` prop → renderable focus → the
// useInputFocusNav FOCUSED handler.
evt.preventDefault();
nav.setInputFocused(true);
}}
onKeyDown={(evt) => {
// While the input owns keys the Shell router never sees
// Tab, so the scope toggle must be handled here (the
// pills and the tab keybind cover the defocused cases).
if (evt.name === "tab") {
evt.preventDefault();
toggleScope();
}
}}
placeholder={
searchStore.scope() === "episode"
? "Enter episode, guest, topic..."
: "Enter podcast name..."
}
focused={inputActive()} focused={inputActive()}
width={28} width={28}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/> />
</box> </box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textSecondary}>Scope:</text>
<box
backgroundColor={
searchStore.scope() === "podcast" ? theme.primary : undefined
}
onMouseDown={() => applyScope("podcast")}
>
<text
fg={
searchStore.scope() === "podcast"
? theme.surface
: muted()
}
>
{" "}
Shows{" "}
</text>
</box>
<box
backgroundColor={
searchStore.scope() === "episode" ? theme.primary : undefined
}
onMouseDown={() => applyScope("episode")}
>
<text
fg={
searchStore.scope() === "episode"
? theme.surface
: muted()
}
>
{" "}
Episodes{" "}
</text>
</box>
<text fg={muted()}>tab to toggle</text>
</box>
<Show when={searchStore.isSearching()}> <Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text> <LoadingIndicator label="Searching…" />
</Show> </Show>
<Show when={searchStore.error()}> <Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text> <text fg={theme.error}>{searchStore.error()}</text>
@@ -258,23 +487,47 @@ function SearchPage() {
{(query, index) => { {(query, index) => {
const lf = () => focus(0); const lf = () => focus(0);
const ref = useScrollIntoView(() => index() === lf()); const ref = useScrollIntoView(() => index() === lf());
// While the input is focused (typing), the list is not
// in focus: no bg, no accent fg, no `` on any entry.
const typing = () => inputActive();
return ( return (
<box <box
ref={ref} ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())} backgroundColor={
typing()
? undefined
: focusBg(index(), lf(), isActive())
}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
// A recent is an action, not an item: clicking
// it re-runs that search (focus-only would be
// invisible — the input still owns the keys).
selectRecent(query);
}} }}
> >
<text fg={focusFg(index(), lf(), isActive())}> <text
{index() === lf() ? "" : " "} fg={
typing()
? theme.text
: focusFg(index(), lf(), isActive())
}
>
{index() === lf() && !typing() ? marker() : " "}
</text>
<text
fg={
typing()
? theme.text
: focusFg(index(), lf(), isActive())
}
>
{query}
</text> </text>
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
</box> </box>
); );
}} }}
@@ -284,7 +537,7 @@ function SearchPage() {
<text fg={muted()}> <text fg={muted()}>
{inputActive() {inputActive()
? "Enter to search · Esc to defocus" ? "Enter to search · Esc to defocus"
: "j/k recents · s to type · h back"} : "j/k recents · s to type · tab scope · h back"}
</text> </text>
</box> </box>
</Show> </Show>
@@ -294,11 +547,20 @@ function SearchPage() {
when={results().length > 0} when={results().length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1}>
<text fg={muted()}> <Show
{searchStore.query() when={searchStore.isSearching()}
? "No results found" fallback={
: "Enter a search term to find podcasts"} <text fg={muted()}>
</text> {searchStore.query()
? "No results found"
: searchStore.scope() === "episode"
? "Enter a search term to find episodes"
: "Enter a search term to find podcasts"}
</text>
}
>
<LoadingIndicator label="Searching…" />
</Show>
</box> </box>
} }
> >
@@ -306,12 +568,18 @@ function SearchPage() {
{(result, index) => { {(result, index) => {
const fi = () => focusedResultIdx(); const fi = () => focusedResultIdx();
const ref = useScrollIntoView(() => index() === fi()); const ref = useScrollIntoView(() => index() === fi());
// Episode download status badge ("" when absent).
const dlLabel = () =>
result.kind === "episode"
? downloadLabel(result.episode.id)
: "";
const dlEpId = () =>
result.kind === "episode" ? result.episode.id : "";
return ( return (
<box <box
ref={ref} ref={ref}
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())} backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => { onMouseDown={() => {
@@ -321,11 +589,18 @@ function SearchPage() {
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}> <text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "} {index() === fi() ? marker() : " "}
</text> </text>
<text fg={focusFg(index(), fi(), isActive())}> <text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title} {result.kind === "episode"
? result.episode.title
: result.podcast.title}
</text> </text>
<Show when={dlLabel()}>
<text fg={downloadColor(dlEpId())}>
{dlLabel()}
</text>
</Show>
<Show when={result.podcast.isSubscribed}> <Show when={result.podcast.isSubscribed}>
<text <text
fg={index() === fi() ? theme.surface : theme.success} fg={index() === fi() ? theme.surface : theme.success}
@@ -334,14 +609,24 @@ function SearchPage() {
</text> </text>
</Show> </Show>
</box> </box>
<Show when={result.podcast.author}> {result.kind === "episode" ? (
<text <text
fg={index() === fi() ? theme.surface : muted()} fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2} paddingLeft={2}
> >
by {result.podcast.author} {result.podcast.title} ·{" "}
{formatDate(result.episode.pubDate)}
</text> </text>
</Show> ) : (
<Show when={result.podcast.author}>
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
)}
</box> </box>
); );
}} }}
@@ -359,6 +644,10 @@ function SearchPage() {
<strong>Search</strong> <strong>Search</strong>
</text> </text>
<text fg={muted()}>Type a query, press Enter to search.</text> <text fg={muted()}>Type a query, press Enter to search.</text>
<text fg={muted()}>
Tab toggles Shows Episodes (episode search finds guests
and topics).
</text>
<text fg={muted()}>Esc defocuses the input; h goes back.</text> <text fg={muted()}>Esc defocuses the input; h goes back.</text>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}>Recent · {recents().length}</text> <text fg={theme.textSecondary}>Recent · {recents().length}</text>
@@ -375,62 +664,138 @@ function SearchPage() {
</box> </box>
} }
> >
{(result) => ( {(result) => {
<box flexDirection="column" gap={1} padding={1}> const r = result();
<text fg={theme.text}> if (r.kind === "episode") {
<strong>{result().podcast.title}</strong> return (
</text> <box flexDirection="column" gap={1} padding={1}>
<Show when={result().podcast.author}> <text fg={theme.text}>
<text fg={muted()}>by {result().podcast.author}</text> <strong>{r.episode.title}</strong>
</Show> </text>
<Show when={result().podcast.description}> <text fg={theme.textSecondary}>{r.podcast.title}</text>
<text fg={theme.textSecondary}> <Show when={r.podcast.author}>
{result().podcast.description!.slice(0, 400) ?? <text fg={muted()}>by {r.podcast.author}</text>
"No description available."} </Show>
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""} <Show when={r.episode.description}>
</text> <text fg={theme.textSecondary}>
</Show> {r.episode.description!.slice(0, 400)}
<Show when={(result().podcast.categories ?? []).length > 0}> {(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
<box flexDirection="row" gap={1}> </text>
<For each={(result().podcast.categories ?? []).slice(0, 4)}> </Show>
{(cat) => <text fg={theme.warning}>[{cat}]</text>} <box flexDirection="row" gap={2}>
</For> <text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<Show when={downloadLabel(r.episode.id)}>
<text fg={downloadColor(r.episode.id)}>
{downloadLabel(r.episode.id)}
</text>
</Show>
</box>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={r.sourceName}>
<text fg={muted()}>Source: {r.sourceName}</text>
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (a)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>
Subscribed · x: unsubscribe
</text>
</Show>
<box height={1} />
<Show
when={r.podcast.isSubscribed}
fallback={
<text fg={muted()}>
enter: play · a: subscribe · d: download · h: back to query
</text>
}
>
<text fg={muted()}>
enter: play · d: download · x: unsubscribe
{downloadStore.getDownloadStatus(r.episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· h: back to query
</text>
</Show>
</box> </box>
</Show> );
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text> }
<text fg={muted()}> return (
Updated: {formatDate(result().podcast.lastUpdated)} <box flexDirection="column" gap={1} padding={1}>
</text> <text fg={theme.text}>
<Show when={result().sourceName}> <strong>{r.podcast.title}</strong>
<text fg={muted()}>Source: {result().sourceName}</text> </text>
</Show> <Show when={r.podcast.author}>
<box height={1} /> <text fg={muted()}>by {r.podcast.author}</text>
<Show when={!result().podcast.isSubscribed}> </Show>
<text fg={theme.primary}>[+] Subscribe (enter)</text> <Show when={r.podcast.description}>
</Show> <text fg={theme.textSecondary}>
<Show when={result().podcast.isSubscribed}> {r.podcast.description!.slice(0, 400)}
<text fg={theme.success}>Already subscribed</text> {(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
</Show> </text>
<box height={1} /> </Show>
<text fg={muted()}>enter: subscribe · h: back to query</text> <Show when={(r.podcast.categories ?? []).length > 0}>
</box> <box flexDirection="row" gap={1}>
)} <For each={(r.podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<text fg={muted()}>
Feed:{" "}
{r.podcast.feedUrl ||
"not listed by source — resolves on subscribe"}
</text>
<text fg={muted()}>
Updated: {formatDate(r.podcast.lastUpdated)}
</text>
<Show when={r.sourceName}>
<text fg={muted()}>Source: {r.sourceName}</text>
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>
Subscribed · x: unsubscribe
</text>
</Show>
<box height={1} />
<text fg={muted()}>
enter: subscribe
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
· h: back to query
</text>
</box>
);
}}
</Show> </Show>
); );
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
? `Search · ${recents().length} recent` ? `Search · ${recents().length} recent`
: `Results · ${results().length}`; : `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
return ( return (
<PaneRow <PaneRow
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
currentLabel={currentLabel} currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive} focused={isActive}
/> />
); );

View File

@@ -1,80 +0,0 @@
/**
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index);
};
return (
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
);
}

View File

@@ -1,38 +0,0 @@
import { SourceType } from "@/types/source";
import { useTheme } from "@/context/ThemeContext";
type SourceBadgeProps = {
sourceId: string;
sourceName?: string;
sourceType?: SourceType;
};
const typeLabel = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "API";
if (sourceType === SourceType.RSS) return "RSS";
if (sourceType === SourceType.CUSTOM) return "Custom";
return "Source";
};
// No module-level typeColor here — it needs the theme from the component.
// The correct definition lives inside SourceBadge below.
export function SourceBadge(props: SourceBadgeProps) {
const { theme } = useTheme();
const label = () => props.sourceName || props.sourceId;
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return theme.primary;
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg={theme.textMuted}>{label()}</text>
</box>
);
}

View File

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

View File

@@ -1,38 +1,58 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => { const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value let current = value;
return [() => current, (next) => { return [
current = next () => current,
}] (next) => {
} current = next;
},
];
};
import { SyncStatus } from "./SyncStatus" import { SyncStatus } from "./SyncStatus";
import { useTheme } from "@/context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export function ExportDialog() { export function ExportDialog() {
const { theme } = useTheme(); const { theme } = useTheme();
const filename = createSignal("podcast-sync.json") const filename = createSignal("podcast-sync.json");
const format = createSignal<"json" | "xml">("json") const format = createSignal<"json" | "xml">("json");
// Yield navigation keybinds to the Shell router while the input is focused.
const filenameRef = useInputFocusNav();
return ( return (
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}> <box
<box style={{ flexDirection: "row", gap: 1 }}> border
<text fg={theme.text}>File:</text> title="Export"
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} /> style={{ padding: 1, flexDirection: "column", gap: 1 }}
</box> >
<box style={{ flexDirection: "row", gap: 1 }}> <box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text> <text fg={theme.text}>File:</text>
<tab_select <input
options={[ ref={filenameRef}
{ name: "JSON", description: "Portable" }, value={filename[0]()}
{ name: "XML", description: "Structured" }, onInput={filename[1]}
]} style={{ width: 30 }}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")} textColor={theme.text}
/> focusedTextColor={theme.accent}
</box> cursorColor={theme.accent}
<box border borderColor={theme.border}> />
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text> </box>
</box> <box style={{ flexDirection: "row", gap: 1 }}>
<SyncStatus /> <text fg={theme.text}>Format:</text>
</box> <tab_select
) options={[
{ name: "JSON", description: "Portable" },
{ name: "XML", description: "Structured" },
]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>
Export {format[0]()} to {filename[0]()}
</text>
</box>
<SyncStatus />
</box>
);
} }

View File

@@ -1,24 +1,31 @@
import { detectFormat } from "@/utils/file-detector"; import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = { type FilePickerProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
export function FilePicker(props: FilePickerProps) { export function FilePicker(props: FilePickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const format = detectFormat(props.value); // Yield navigation keybinds to the Shell router while the input is focused.
const inputRef = useInputFocusNav();
const format = detectFormat(props.value);
return ( return (
<box style={{ flexDirection: "column", gap: 1 }}> <box style={{ flexDirection: "column", gap: 1 }}>
<input <input
value={props.value} ref={inputRef}
onInput={props.onChange} value={props.value}
placeholder="/path/to/sync-file.json" onInput={props.onChange}
style={{ width: 40 }} placeholder="/path/to/sync-file.json"
/> style={{ width: 40 }}
<text fg={theme.text}>Format: {format}</text> textColor={theme.text}
</box> focusedTextColor={theme.accent}
); cursorColor={theme.accent}
/>
<text fg={theme.text}>Format: {format}</text>
</box>
);
} }

View File

@@ -2,10 +2,38 @@
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as * PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is * SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
* 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 Whitelist — shown only when scope is "whitelist": search
* field over subscribed shows; suggestions toggle
* in/out with Space (j/k to move, Esc to browse).
* • Episode Cache Mode — date or count bound for the episode list
* (default: date)
* • Episode Cache Count — N most recent episodes when mode is count
* (default: 25)
* • Episode Cache Days — rolling N-day window when mode is date
* (default: 60)
*/ */
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
import { RenderableEvents, type InputRenderable } from "@opentui/core";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import type { ThemeName } from "@/types/settings"; import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import {
NavMode,
useNavigation,
DEPTH_CENTER_PANE,
type PaneId,
} from "@/context/NavigationContext";
import { on } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { TABS } from "@/utils/navigation";
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
import type { Feed } from "@/types/feed";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
@@ -17,13 +45,32 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "custom", label: "Custom" }, { value: "custom", label: "Custom" },
]; ];
const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
{ value: "all", label: "All" },
{ value: "none", label: "None" },
{ value: "whitelist", label: "Whitelist" },
];
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
{ value: "date", label: "Date" },
{ value: "count", label: "Count" },
];
function cacheModeLabel(mode: EpisodeCacheMode): string {
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
}
function scopeLabel(scope: AutoDownloadScope): string {
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
}
export function usePreferencesItems(): SettingItem[] { export function usePreferencesItems(): SettingItem[] {
const app = useAppStore(); const app = useAppStore();
const feedStore = useFeedStore();
const settings = () => app.state().settings; const settings = () => app.state().settings;
const prefs = () => app.state().preferences; const prefs = () => app.state().preferences;
return [ const items: SettingItem[] = [
{ {
id: "theme", id: "theme",
label: "Theme", label: "Theme",
@@ -39,6 +86,31 @@ export function usePreferencesItems(): SettingItem[] {
app.setTheme(THEME_LABELS[next].value); app.setTheme(THEME_LABELS[next].value);
}, },
}, },
{
id: "transparentBackground",
label: "Transparent Background",
kind: "toggle",
display: () =>
settings().transparentBackground ? "On" : "Off",
help: () =>
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateSettings({
transparentBackground: !settings().transparentBackground,
}),
},
{
id: "showSelectionMarker",
label: "Selection Marker",
kind: "toggle",
display: () => (settings().showSelectionMarker ? "On" : "Off"),
help: () =>
`Show the marker on the focused row of every list (tabs, shows, episodes, results).\nType: toggle\nDefault: off\nCurrent: ${settings().showSelectionMarker ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateSettings({
showSelectionMarker: !settings().showSelectionMarker,
}),
},
{ {
id: "fontSize", id: "fontSize",
label: "Font Size", label: "Font Size",
@@ -84,11 +156,430 @@ export function usePreferencesItems(): SettingItem[] {
kind: "toggle", kind: "toggle",
display: () => (prefs().autoDownload ? "On" : "Off"), display: () => (prefs().autoDownload ? "On" : "Off"),
help: () => help: () =>
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`, `Download the ${prefs().autoDownloadCount} most recent episodes of your shows automatically (see Count/Scope below).\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () => {
app.updatePreferences({ autoDownload: !prefs().autoDownload });
feedStore.runAutoDownload();
},
},
{
id: "autoDownloadCount",
label: "Auto Download Count",
kind: "number",
display: () => `${prefs().autoDownloadCount} per show`,
help: () =>
`How many of the most recent episodes to auto-download per in-scope show.\nType: number (any positive integer)\nDefault: 2\nCurrent: ${prefs().autoDownloadCount}\nj/k to /+1 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.max(1, prefs().autoDownloadCount + dir);
app.updatePreferences({ autoDownloadCount: next });
feedStore.runAutoDownload();
},
renderEditor: () => (
<NumberInputEditor
label="Auto Download Count"
value={() => prefs().autoDownloadCount}
commit={(n) => {
app.updatePreferences({ autoDownloadCount: n });
feedStore.runAutoDownload();
}}
/>
),
},
{
id: "autoDownloadScope",
label: "Auto Download Scope",
kind: "select",
display: () => scopeLabel(prefs().autoDownloadScope),
help: () =>
`Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on the focused show; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const idx = SCOPE_LABELS.findIndex(
(s) => s.value === prefs().autoDownloadScope,
);
const next =
SCOPE_LABELS[(idx + dir + SCOPE_LABELS.length) % SCOPE_LABELS.length]
.value;
app.updatePreferences({ autoDownloadScope: next });
feedStore.runAutoDownload();
},
},
{
id: "autoJumpToPlayer",
label: "Auto Jump to Player",
kind: "toggle",
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
help: () =>
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () => toggle: () =>
app.updatePreferences({ app.updatePreferences({
autoDownload: !prefs().autoDownload, autoJumpToPlayer: !prefs().autoJumpToPlayer,
}), }),
}, },
{
id: "episodeCacheMode",
label: "Episode Cache Mode",
kind: "select",
display: () => cacheModeLabel(prefs().episodeCacheMode),
help: () =>
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const idx = CACHE_MODE_LABELS.findIndex(
(s) => s.value === prefs().episodeCacheMode,
);
const next =
CACHE_MODE_LABELS[
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
].value;
app.updatePreferences({ episodeCacheMode: next });
},
},
{
id: "episodeCacheCount",
label: "Episode Cache Count",
kind: "number",
display: () =>
prefs().episodeCacheMode === "count"
? `${prefs().episodeCacheCount} eps`
: "(date mode)",
help: () =>
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to /+1 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.max(1, prefs().episodeCacheCount + dir);
app.updatePreferences({ episodeCacheCount: next });
},
renderEditor: () => (
<NumberInputEditor
label="Episode Cache Count"
value={() => prefs().episodeCacheCount}
commit={(n) => {
app.updatePreferences({
episodeCacheCount: Math.max(1, n),
});
}}
/>
),
},
{
id: "episodeCacheDays",
label: "Episode Cache Days",
kind: "number",
display: () =>
prefs().episodeCacheMode === "date"
? `${prefs().episodeCacheDays} days`
: "(count mode)",
help: () =>
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to /+5 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.min(
365,
Math.max(1, prefs().episodeCacheDays + dir * 5),
);
app.updatePreferences({ episodeCacheDays: next });
},
renderEditor: () => (
<NumberInputEditor
label="Episode Cache Days"
value={() => prefs().episodeCacheDays}
commit={(n) => {
app.updatePreferences({
episodeCacheDays: Math.min(365, Math.max(1, n)),
});
}}
/>
),
},
{
id: "fetchMore",
label: "Fetch More",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
{
id: "refreshInterval",
label: "Feed Refresh Interval",
kind: "number",
display: () => `${prefs().refreshIntervalMinutes} min`,
help: () =>
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to /+5 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.min(
120,
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
);
app.updatePreferences({ refreshIntervalMinutes: next });
},
renderEditor: () => (
<NumberInputEditor
label="Feed Refresh Interval (minutes)"
value={() => prefs().refreshIntervalMinutes}
commit={(n) => {
app.updatePreferences({
refreshIntervalMinutes: Math.min(120, n),
});
}}
/>
),
},
]; ];
// Whitelist management only appears while scope is set to "whitelist".
if (prefs().autoDownloadScope === "whitelist") {
items.push({
id: "autoDownloadWhitelist",
label: "Auto Download Whitelist",
kind: "editor",
display: () => `${prefs().autoDownloadWhitelist.length} shows`,
help: () =>
`Shows included in auto-download (scope: whitelist).\nSearch your subscribed shows; suggestions toggle in/out with Space.\nType: editor\nCurrent: ${prefs().autoDownloadWhitelist.length} shows`,
renderEditor: () => <WhitelistEditor />,
});
}
return items;
}
// ── Number editor ────────────────────────────────────────────────────────────
// Lets the user type any positive integer (Enter commits; Esc defocuses and
// j/k ±1 cycling takes over — SettingsPage's depth-2 step handler).
function NumberInputEditor(props: {
label: string;
value: () => number;
commit: (n: number) => void;
}) {
const { theme } = useTheme();
const ref = useInputFocusNav();
const [draft, setDraft] = createSignal(String(props.value()));
const [error, setError] = createSignal<string | null>(null);
const submit = () => {
const n = Number(draft().trim());
if (!Number.isInteger(n) || n < 1) {
setError("Enter a whole number ≥ 1");
return;
}
props.commit(n);
setError(null);
};
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>{props.label}</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Episodes per show:</text>
<input
ref={ref}
value={draft()}
onInput={(v) => {
setDraft(v);
setError(null);
}}
onSubmit={submit}
focused
width={8}
textColor={theme.text}
focusedTextColor={theme.accent}
/>
</box>
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
</Show>
<text fg={theme.muted ?? theme.textMuted}>
Type a number, Enter to apply · Esc to browse (j/k ±1) · h back
</text>
</box>
);
}
// ── Whitelist editor ─────────────────────────────────────────────────────────
// Search field over subscribed shows + a navigable suggestion list. Space
// (toggle-select) toggles the focused show in/out of the whitelist; Enter
// does the same. While the input is focused, keys type; Esc (handled in the
// Shell) defocuses so j/k move the list.
//
// Transient UI state lives at module level so preference updates (which
// rebuild the item list) never reset the search or yank focus back into the
// input mid-browse.
//
// The nav.action listener is registered ONCE at module level, not per
// component instance: toggling a show updates preferences, which remounts
// the editor (SettingsPage re-resolves the item's renderEditor), and
// re-registering the listener via onMount/onCleanup during a bus emit
// mutates the handler set mid-iteration — the event bus then re-delivers to
// the fresh listener forever. A single stable listener guarded by an active
// flag sidesteps that entirely.
const [wlQuery, setWlQuery] = createSignal("");
const [wlCursor, setWlCursor] = createSignal(0);
const [wlTyping, setWlTyping] = createSignal(true);
let wlEditorActive = false;
// Indirection for refocusing the search input from the module-level nav.action
// listener (which cannot call useNavigation — that needs the provider).
let wlFocusInput: (() => void) | null = null;
function wlSuggestions(): Feed[] {
const q = wlQuery().trim().toLowerCase();
const all = useFeedStore().getFilteredFeeds();
if (!q) return all;
return all.filter((f) =>
(f.customName || f.podcast.title).toLowerCase().includes(q),
);
}
/** Keep the cursor inside the (possibly shrinking) suggestion list. */
function wlCursorClamped(): number {
return Math.min(wlCursor(), Math.max(wlSuggestions().length - 1, 0));
}
function wlToggle(feedId: string): void {
const app = useAppStore();
const cur = app.state().preferences.autoDownloadWhitelist ?? [];
const next = cur.includes(feedId)
? cur.filter((id) => id !== feedId)
: [...cur, feedId];
app.updatePreferences({ autoDownloadWhitelist: next });
useFeedStore().runAutoDownload();
}
const wlOnAction = (data: {
action: KeybindActionName;
tab: TABS;
pane: PaneId;
mode: NavMode;
}) => {
// Fire at most once per dispatch: the editor is only ever open inside the
// Settings tab's depth-2 pane, so scope on tab + pane and gate on the
// mount flag (which flips during remounts without re-registering).
if (!wlEditorActive) return;
if (data.tab !== TABS.SETTINGS) return;
if (data.pane !== DEPTH_CENTER_PANE) return;
const list = wlSuggestions();
if (list.length === 0) return;
switch (data.action) {
case "move-down":
setWlCursor((c) => Math.min(c + 1, list.length - 1));
break;
case "move-up":
setWlCursor((c) => Math.max(c - 1, 0));
break;
case "toggle-select":
case "open":
wlToggle(list[wlCursorClamped()].id);
break;
case "search":
// `s` while browsing re-enters typing mode (mirrors SearchPage).
wlFocusInput?.();
break;
}
};
on("nav.action", wlOnAction);
function WhitelistEditor() {
const { theme } = useTheme();
const nav = useNavigation();
const feedStore = useFeedStore();
const app = useAppStore();
const whitelist = () => app.state().preferences.autoDownloadWhitelist ?? [];
const inList = (feedId: string) => whitelist().includes(feedId);
onMount(() => {
wlEditorActive = true;
// Restore the last typing/browsing mode across the remounts that
// preference updates trigger. nav.inputFocused() drives the input's
// focused prop (deterministic Esc-to-blur, same as SearchPage), so
// keep the store in sync with the persisted module mode.
nav.setInputFocused(wlTyping());
wlFocusInput = () => nav.setInputFocused(true);
onCleanup(() => {
wlEditorActive = false;
nav.setInputFocused(false);
wlFocusInput = null;
});
});
const focusNavRef = useInputFocusNav();
const inputRef = (el: InputRenderable | null | undefined) => {
focusNavRef(el);
if (el) {
// Sync the persisted mode with real focus changes so remounts
// (e.g. after a toggle) restore the right state.
el.on(RenderableEvents.FOCUSED, () => setWlTyping(true));
el.on(RenderableEvents.BLURRED, () => setWlTyping(false));
}
};
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>Auto Download Whitelist</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Search:</text>
<input
ref={inputRef}
value={wlQuery()}
onInput={setWlQuery}
focused={nav.inputFocused()}
placeholder="Type to filter shows…"
width={30}
textColor={theme.text}
focusedTextColor={theme.accent}
/>
</box>
<Show when={wlSuggestions().length === 0}>
<text fg={theme.muted ?? theme.textMuted}>
No subscribed shows match.
</text>
</Show>
<For each={wlSuggestions()}>
{(feed, index) => {
// While the input is focused (typing), no row shows the
// accent highlight or `` — only the input is "in focus".
const focused = () =>
!nav.inputFocused() && index() === wlCursorClamped();
const ref = useScrollIntoView(focused);
const marker = useSelectionMarker();
const bg = () => (focused() ? theme.primary : undefined);
const fg = () => (focused() ? theme.surface : theme.text);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
setWlCursor(index());
// Click toggles membership directly (works even
// while typing, where Space is input text).
wlToggle(feed.id);
}}
>
<text fg={fg()}>{focused() ? marker() : " "}</text>
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
<text fg={fg()}>
{feed.customName || feed.podcast.title}
</text>
</box>
);
}}
</For>
<text fg={theme.muted ?? theme.textMuted}>
Type to search · Esc to browse · j/k move · Space toggles · s to
type · h back
</text>
</box>
);
} }

View File

@@ -7,7 +7,7 @@
* *
* Renders entirely through `<PaneRow>` (parent | current | preview): * Renders entirely through `<PaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2); * parent = previous depth's list (sections at depth 1, items at depth 2);
* blank placeholder at depth 0 (1/7 slot kept). * blank placeholder at depth 0 (1/5 slot kept).
* current = the current-depth list (or editor at depth 2); the only * current = the current-depth list (or editor at depth 2); the only
* focusable column. * focusable column.
* preview = help/preview text for the hovered item in current. * preview = help/preview text for the hovered item in current.
@@ -27,6 +27,7 @@ import {
type PaneId, type PaneId,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { SettingItem, SettingsSectionDef } from "./types"; import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel"; import { usePreferencesItems } from "./PreferencesPanel";
@@ -37,6 +38,7 @@ import { useDownloadItems } from "./DownloadManager";
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";
export const SettingsPaneCount = 1; export const SettingsPaneCount = 1;
@@ -45,29 +47,38 @@ const SECTIONS: SettingsSectionDef[] = [
id: 0, id: 0,
label: "Sync", label: "Sync",
description: "Import/export subscriptions and sync status.", description: "Import/export subscriptions and sync status.",
icon: NF_ICONS.sync,
}, },
{ {
id: 1, id: 1,
label: "Sources", label: "Sources",
description: "Podcast search/RSS sources — add, enable, remove.", description: "Podcast search/RSS sources — add, enable, remove.",
icon: NF_ICONS.sources,
}, },
{ {
id: 2, id: 2,
label: "Preferences", label: "Preferences",
description: "Theme, font, playback speed, explicit/auto-download.", description: "Theme, font, playback speed, explicit/auto-download.",
icon: NF_ICONS.preferences,
}, },
{ {
id: 3, id: 3,
label: "Visualizer", label: "Visualizer",
description: "Audio visualizer: bars, sensitivity, cutoffs.", description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
icon: NF_ICONS.visualizer,
}, },
{ {
id: 4, id: 4,
label: "Downloads", label: "Downloads",
description: "Manage downloaded episodes — delete by show or individually.", description: "Manage downloaded episodes — delete by show or individually.",
icon: NF_ICONS.downloads,
}, },
]; ];
// Static: detection never changes mid-session. Module-level because the Row
// component below (a sibling module function) needs it too.
const nerd = supportsNerdFonts();
/** Resolve the items for a section id at render time. */ /** Resolve the items for a section id at render time. */
function sectionItems(sectionId: number): SettingItem[] { function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) { switch (sectionId) {
@@ -129,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}`,
@@ -195,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);
@@ -209,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];
@@ -267,12 +275,6 @@ export function SettingsPage() {
if (d === 1) return sectionForDepth1()?.label ?? "Items"; if (d === 1) return sectionForDepth1()?.label ?? "Items";
return editorItem()?.label ?? "Editor"; return editorItem()?.label ?? "Editor";
}; };
const parentLabel = () => {
const d = depth();
if (d === 1) return "Sections";
if (d === 2) return sectionForDepth1()?.label ?? "";
return "Up";
};
// ── parent pane: previous-depth list (blank at depth 0) ──────────────── // ── parent pane: previous-depth list (blank at depth 0) ────────────────
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid // Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
@@ -292,6 +294,7 @@ export function SettingsPage() {
{(section, index) => ( {(section, index) => (
<Row <Row
label={section.label} label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={false} active={false}
/> />
@@ -321,6 +324,7 @@ export function SettingsPage() {
{(section, index) => ( {(section, index) => (
<Row <Row
label={section.label} label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={isActive()} active={isActive()}
onMouseDown={() => { onMouseDown={() => {
@@ -384,9 +388,7 @@ export function SettingsPage() {
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
parentLabel={parentLabel}
currentLabel={currentLabel} currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive} focused={isActive}
/> />
); );
@@ -415,6 +417,7 @@ function Row(props: {
focused: boolean; focused: boolean;
active: boolean; active: boolean;
hint?: string; hint?: string;
icon?: string;
onMouseDown?: () => void; onMouseDown?: () => void;
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -424,19 +427,25 @@ function Row(props: {
: props.focused : props.focused
? theme.border ? theme.border
: undefined; : undefined;
const fg = () => (props.focused && props.active ? theme.surface : theme.text); const fg = () =>
props.focused && props.active
? theme.surface
: props.focused
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused); const ref = useScrollIntoView(() => props.focused);
const marker = useSelectionMarker();
return ( return (
<box <box
ref={ref} ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={bg()} backgroundColor={bg()}
onMouseDown={props.onMouseDown} onMouseDown={props.onMouseDown}
> >
<text fg={fg()}>{props.focused ? "" : " "}</text> <text fg={fg()}>{props.focused ? marker() : " "}</text>
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
<text fg={fg()}>{props.label}</text> <text fg={fg()}>{props.label}</text>
<Show when={props.value}> <Show when={props.value}>
<box flexGrow={1} /> <box flexGrow={1} />

View File

@@ -11,15 +11,24 @@
* right-pane key conflicts). * right-pane key conflicts).
*/ */
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show, onMount } from "solid-js";
import { Renderable } from "@opentui/core";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { useDialog } from "@/ui/dialog";
import { useToast } from "@/ui/toast";
import {
resolveSourceCredentials,
savePodcastIndexCredentials,
} from "@/utils/source-credentials";
import { SourceType } from "@/types/source"; import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source"; import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
export function useSourceItems(): SettingItem[] { export function useSourceItems(): SettingItem[] {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const dialog = useDialog();
const typeBadge = (s: PodcastSource) => const typeBadge = (s: PodcastSource) =>
s.type === SourceType.API s.type === SourceType.API
@@ -47,8 +56,20 @@ export function useSourceItems(): SettingItem[] {
kind: "toggle", kind: "toggle",
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`, display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
help: () => help: () =>
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`, s.id === "podcastindex"
toggle: () => feedStore.toggleSource(s.id), ? `Source: ${s.name} (open podcast directory)\nEnabled: ${s.enabled}\nSpace to ${s.enabled ? "disable" : "enable"}: enabling asks for API keys.\nKeys are masked in the UI and stored in the macOS keychain\n(encrypted at rest), falling back to config.json when the\nkeychain is unavailable; they are kept when disabled.`
: `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
toggle: () => {
// Enabling Podcast Index requires credentials: ask first
// (prefilled with the stored key, masked) instead of flipping
// the source into a key-less "on" state. Disabling never
// clears the stored credentials.
if (s.id === "podcastindex" && !s.enabled) {
dialog.push(() => <PodcastIndexCredentialsDialog />);
return;
}
feedStore.toggleSource(s.id);
},
}); });
} }
@@ -61,6 +82,9 @@ function AddSourceForm() {
const [name, setName] = createSignal(""); const [name, setName] = createSignal("");
const [url, setUrl] = createSignal(""); const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
// Yield navigation keybinds to the Shell router while either input is focused.
const nameRef = useInputFocusNav();
const urlRef = useInputFocusNav();
const submit = () => { const submit = () => {
const u = url().trim(); const u = url().trim();
@@ -94,15 +118,20 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Name:</text> <text fg={theme.textMuted}>Name:</text>
<input <input
ref={nameRef}
value={name()} value={name()}
onInput={setName} onInput={setName}
placeholder="My Custom Feed" placeholder="My Custom Feed"
width={25} width={25}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/> />
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>URL:</text> <text fg={theme.textMuted}>URL:</text>
<input <input
ref={urlRef}
value={url()} value={url()}
onInput={(v) => { onInput={(v) => {
setUrl(v); setUrl(v);
@@ -110,6 +139,9 @@ function AddSourceForm() {
}} }}
placeholder="https://example.com/feed.rss" placeholder="https://example.com/feed.rss"
width={35} width={35}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/> />
</box> </box>
<box <box
@@ -139,3 +171,152 @@ function AddSourceForm() {
</box> </box>
); );
} }
/** Mask a stored credential for prefill: first 3 chars then "...". */
const maskCredential = (value: string): string => `${value.slice(0, 3)}...`;
/** Credentials popup shown when enabling the Podcast Index source. Prefilled
* (masked) with stored credentials so re-enabling just needs Enter; leaving
* a masked field untouched keeps the stored value. Credentials are saved to
* the macOS keychain (encrypted at rest) with a plaintext config.json
* fallback when the keychain is unavailable. */
function PodcastIndexCredentialsDialog() {
const feedStore = useFeedStore();
const { theme } = useTheme();
const dialog = useDialog();
const toast = useToast();
const source = feedStore.sources().find((s) => s.id === "podcastindex");
const [key, setKey] = createSignal("");
const [secret, setSecret] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [saving, setSaving] = createSignal(false);
// Yield navigation keybinds to the Shell router while an input is focused.
const keyRef = useInputFocusNav();
const secretRef = useInputFocusNav();
let keyEl: Renderable | null | undefined;
let secretEl: Renderable | null | undefined;
onMount(() => {
// Prefill stored credentials (masked) when re-enabling after a
// disable — toggling off never clears them. Masked either way, so a
// plaintext-stored key never appears in full in the UI.
if (source) {
resolveSourceCredentials(source)
.then((stored) => {
if (stored?.apiKey) setKey(maskCredential(stored.apiKey));
if (stored?.apiSecret) setSecret(maskCredential(stored.apiSecret));
})
.catch(() => {});
}
setTimeout(() => keyEl?.focus(), 1);
});
const save = async () => {
if (saving()) return;
const stored = source
? await resolveSourceCredentials(source).catch(() => null)
: null;
const keyValue = key().trim();
const secretValue = secret().trim();
// A field still showing its masked prefill means "keep what's stored".
const apiKey =
stored?.apiKey && keyValue === maskCredential(stored.apiKey)
? stored.apiKey
: keyValue;
const apiSecret =
stored?.apiSecret && secretValue === maskCredential(stored.apiSecret)
? stored.apiSecret
: secretValue;
if (!apiKey || !apiSecret) {
setError(
"Both API key and secret are required (free at podcastindex.org)",
);
return;
}
setSaving(true);
const ok = await savePodcastIndexCredentials(apiKey, apiSecret).catch(
() => false,
);
setSaving(false);
if (!ok) {
// Keychain unavailable (non-macOS, locked, sandboxed): plaintext
// fallback on the source so the fallback search still works.
feedStore.updateSource("podcastindex", {
hasCredentials: true,
credentialStorage: "plaintext",
apiKey,
apiSecret,
enabled: true,
});
toast.show({
title: "Credentials stored in config.json",
message: "macOS keychain unavailable — API keys saved unencrypted.",
variant: "warning",
});
dialog.pop();
return;
}
feedStore.updateSource("podcastindex", {
hasCredentials: true,
credentialStorage: "keychain",
enabled: true,
});
dialog.pop();
};
return (
<box
border
title="Podcast Index API Keys"
padding={1}
flexDirection="column"
gap={1}
>
<text fg={theme.textMuted}>
Free key + secret from https://podcastindex.org/. Used as a
fallback when other sources return fewer than 3 results.
</text>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>API Key:</text>
<input
ref={(el: Renderable | null | undefined) => {
keyRef(el);
keyEl = el;
}}
value={key()}
onInput={setKey}
onSubmit={() => secretEl?.focus()}
placeholder="e.g. UXKCGDSYGUUEVQJSYDZH"
width={30}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>API Secret:</text>
<input
ref={(el: Renderable | null | undefined) => {
secretRef(el);
secretEl = el;
}}
value={secret()}
onInput={setSecret}
onSubmit={() => save()}
placeholder="e.g. yzJe2eE7XV-3eY576dyRZ6wXyAbndh6LUrCZ8KN|"
width={40}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
<Show when={saving()}>
<text fg={theme.textMuted}>Storing credentials...</text>
</Show>
<text fg={theme.textMuted}>
[Enter] save · [Esc] cancel keys stay stored when disabled.
</text>
</box>
);
}

View File

@@ -3,21 +3,13 @@
* Export dialogs render as depth-2 editors. No own useKeyboard. * Export dialogs render as depth-2 editors. No own useKeyboard.
*/ */
import { createSignal } from "solid-js";
import { ImportDialog } from "./ImportDialog"; import { ImportDialog } from "./ImportDialog";
import { ExportDialog } from "./ExportDialog"; import { ExportDialog } from "./ExportDialog";
import { SyncStatus } from "./SyncStatus";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
// Module-level state so the action items can open their dialogs as depth-2 // closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show. // (the syncEditor signal) was removed as dead — nothing ever read it.
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>( export function closeSyncEditor() {}
null,
);
export { syncEditor };
export function closeSyncEditor() {
setSyncEditor(null);
}
export function useSyncItems(): SettingItem[] { export function useSyncItems(): SettingItem[] {
return [ return [
@@ -49,9 +41,3 @@ export function useSyncItems(): SettingItem[] {
}, },
]; ];
} }
/** Renders the live sync status block (used by the Settings page header for the
* Sync section, when relevant). */
export function SyncStatusBlock() {
return <SyncStatus />;
}

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