55 Commits

Author SHA1 Message Date
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
133 changed files with 10093 additions and 2345 deletions

View File

@@ -37,7 +37,7 @@ jobs:
plat: darwin
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Bun
uses: oven-sh/setup-bun@v2
@@ -50,7 +50,10 @@ jobs:
- name: Install fftw (cavacore build dependency)
run: |
if uname -s | grep -qi darwin; then
brew install fftw
# mpv is required for the release bundle: build.ts copies it into
# PodTui.app (signed with the podtui bundle identifier) so macOS
# Now Playing shows the PodTui icon instead of a blank placeholder.
brew install fftw mpv
else
sudo apt-get update
sudo apt-get install -y libfftw3-dev
@@ -66,17 +69,29 @@ jobs:
env:
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
run: |
# The embedded runtime reads the launching process's CWD bunfig.toml.
# This repo's bunfig lists a preload the standalone can't resolve
# ("preload not found"), so kicking the binary from the workspace root
# would falsely fail every build. cd into a clean dir first.
# The binary is compiled with bunfig autoload disabled
# (autoloadBunfig: false in build.ts), so it must boot even from a
# directory holding a bunfig.toml with a top-level preload the
# standalone can't resolve. Plant one to make this a real regression
# test for "preload not found".
SMOKE_DIR=$(mktemp -d)
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
cd "$SMOKE_DIR"
./podtui-*/podtui --version
# macOS tarballs must ship PodTui.app with a working bundled mpv
# carrying the podtui bundle identifier — otherwise Now Playing
# attribution silently regresses to a blank icon.
if [ "${{ matrix.plat }}" = "darwin" ]; then
MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv
test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; }
$MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; }
codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \
|| { echo "bundled mpv lacks podtui signing identifier"; exit 1; }
fi
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
path: dist/podtui-*.tar.gz
@@ -87,12 +102,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download all binaries
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
- name: Publish release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: |

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.DS_Store
.harness/
.ralpi
notes.md

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 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-mac` / `make dist-linux` | Aliases for `dist` on their platform (CI runs these) |
| `make clean` | Remove `dist/` |
## Repository layout
@@ -90,19 +91,21 @@ Cavacore smoke test: `bun tests/cavacore-smoke.ts`
## Gotchas (read before touching anything)
1. **Never add a top-level `preload` to `bunfig.toml`.**
A compiled PodTui binary's embedded runtime reads the *launching process's*
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
`preload not found`. This is why `bunfig.toml` has **no** top-level
`preload`; dev-mode preloading happens via explicit `--preload` flags in
`package.json`. The `[test]` section *does* keep a preload — that only
affects `bun test`.
1. **The compiled binary must keep bunfig autoload disabled.**
`build.ts` compiles the standalone with `autoloadBunfig: false`, so its
embedded runtime *never* reads the launching CWD's `bunfig.toml`. Without
that flag, a top-level `preload` in the CWD bunfig (common in Bun project
dirs) resolves against the CWD rather than the binary and kills startup
with `preload not found`. Don't remove the flag. Preloads for dev/test
belong in the explicit `--preload` flags in `package.json` and the
`[test]` section of `bunfig.toml` — not as a top-level entry.
2. **Smoke-test the compiled binary from a bunfig-free dir.**
Because of (1), `./dist/podtui --version` run from the repo root launched
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
before booting. Do the same when testing a release build locally.
2. **Smoke-test the binary from a dir with a poisoned bunfig.**
The CI smoke test unpacks the tarball into a `mktemp` dir, drops a
`bunfig.toml` containing an unresolvable top-level `preload` next to it,
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.**
`brew install` may print “load commands do not fit in the header … needs
@@ -166,7 +169,7 @@ Releases are built and published from **tags**
test: `brew install mikefreno/tap/podtui`.
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
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`
entries, keep the `LICENSE` asset source (the workflow above uploads
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
@@ -190,13 +193,36 @@ make dist # builds the binary + tarball for THIS machine only
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
- **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
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
`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,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
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
## Standalone binary + native-libs tarball for the current platform.
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
## from a bunfig-free dir (see release.yml).
## Built with bunfig autoload disabled (build.ts sets autoloadBunfig: false),
## so the embedded runtime ignores any bunfig.toml in the launching directory.
dist:
bun run build.ts --compile

242
README.md
View File

@@ -1,7 +1,6 @@
# PodTui
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
A keyboard-first, terminal podcast client 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
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:
at launch it fills the current pane, and drilling into a tab's contents slides
it into the parent pane.
- **Three-pane view** — parent / current / preview (Up | Current | Preview),
mirroring yazi's pane model.
- **Three-pane view** — parent / current / preview (Up | Current | Preview).
- **Podcast feeds** — add feeds, browse episodes, and manage your library
(My Shows, Discover, Feed tabs).
- **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
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
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
tmux, GNOME Terminal, etc.).
- An **audio player** on `PATH`. PodTui auto-detects in priority order:
| Player | Platforms | Seek | Speed | Position tracking |
|----------|----------------|:----:|:-----:|:------------------|
| `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`.
Ghostty, tmux etc.).
- **mpv** on `PATH` for audio playback. PodTui drives mpv over JSON IPC, so
seek, speed, and position tracking all work. Without `mpv` on `PATH`,
playback is a silent no-op (the `none` backend) — see
[Troubleshooting](#troubleshooting).
## Installation
@@ -47,12 +51,16 @@ Linux (arm64/x64). Pick whichever fits your platform.
### 1. Homebrew (macOS)
```sh
brew install mikefreno/tap/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.
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
through a copy of mpv that lives **inside the bundle**, so macOS attributes
the Now Playing session to PodTui — the Control Center / lock-screen entry
shows the PodTui name and icon, and podcast cover art as its artwork —
rather than a blank placeholder for an unbundled binary. Installers can drop
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
### 2. Standalone tarball (all platforms)
@@ -71,69 +79,29 @@ sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
> The tarball contains `podtui` plus `libopentui.<ext>` and
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
> 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)
```bash
# Status: PKGBUILD ready, not yet on the AUR (see note below)
yay -S podtui-bin # once published
```
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and
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.
Requires an AUR helper ([paru](https://github.com/morgan/paru)); the package
pulls in `mpv` as a dependency.
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready
> in `packaging/aur/` and can be built locally today:
>
> ```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.
> **Not yet on the AUR.** The `podtui-bin` package is staged and awaiting
> publication (AUR account registrations are currently suspended). Until it
> lands, use the standalone tarball above.
### 4. From source
Requires [Bun](https://bun.sh) ≥ 1.2.
```bash
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.
PodTui is written in TypeScript and runs on [Bun](https://bun.sh). To build
from source (development, distro packaging, unreleased versions), see
[CONTRIBUTING.md](CONTRIBUTING.md).
## Usage
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~`
for the in-app help.
Launch `podtui`. Press `~` for the in-app help.
### Command-line flags
@@ -145,30 +113,73 @@ for the in-app help.
### 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 |
|------|--------|
| `j` / `k` | Move cursor down / up |
| `J` / `K` | Jump 5 lines |
| `j` / `k` (or `down` / `up`) | Move down / up |
| `J` / `K` | Jump 5 lines down / up |
| `ctrl-d` / `ctrl-u` | Page down / up |
| `ctrl-f` / `ctrl-b` | Full page down / up |
| `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…) |
| `Space` | Select / toggle selection |
| `shift-enter` | Open with the interactive variant |
**Selection**
| Keys | Action |
|------|--------|
| `Space` | Toggle selection |
| `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) |
| `[` / `]` | 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 |
| `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
@@ -177,56 +188,49 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
| File | Purpose |
|------|---------|
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) |
| `sources.json` | Custom feed sources |
| `config.json` | Unified settings (theme, playback speed, download path), feeds, and custom feed sources |
| `downloads.json` | Downloaded episode metadata |
| `keybinds.jsonc` | Keybinding remaps (see above) |
| `themes/` | Optional custom theme files |
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
the same OpenTUI environment variables.
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
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
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)
```
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
### 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
to your GitHub Release automatically:
## Troubleshooting
```bash
make dist # build the standalone binary + tarball for THIS platform
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
```
**`preload not found` at startup** — this used to happen when the binary was
launched from a Bun project directory whose `bunfig.toml` had a `preload`
entry. Releases are compiled with bunfig autoload disabled
(`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
settings into `--compile` output, and the solid JSX transform is registered in
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
it from any normal directory.
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
## 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).
```
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)
```
## Building from source / contributing
PodTui loads its native libraries relative to the binary, so **keep them in
the same directory**. The compiled binary embeds the Bun runtime, so it runs
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
is no cross-compilation.
Development setup, the test suite, packaging, and the release process are
documented in [CONTRIBUTING.md](CONTRIBUTING.md).
## 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

Binary file not shown.

145
build.ts
View File

@@ -82,6 +82,12 @@ if (COMPILE) {
plugins: [solidPlugin],
compile: {
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}`);
@@ -110,6 +116,145 @@ if (COMPILE) {
const s = join("dist", 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"));
}
}
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
// Playing session to PodTui — the source-app icon + name in Control
// Center / lock screen — instead of a blank placeholder for an
// unbundled binary. AudioPlayer's resolver prefers this sibling.
if (platform === "darwin") {
const appRoot = join(tarRoot, "PodTui.app");
const macosDir = join(appRoot, "Contents", "MacOS");
const resDir = join(appRoot, "Contents", "Resources");
mkdirSync(macosDir, { recursive: true });
mkdirSync(resDir, { recursive: true });
copyFileSync(outfile, join(macosDir, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
}
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
const mpvPath =
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
if (mpvPath) {
copyFileSync(mpvPath, join(macosDir, "mpv"));
} else {
// A darwin release tarball without a bundled mpv silently ships
// without Now Playing attribution (blank icon). Fail loudly so CI
// can't produce it — the runner must have mpv installed.
console.error(
"Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)",
);
process.exit(1);
}
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
if (existsSync(icnsSrc)) {
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
} else {
console.warn(
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
);
}
// Version for the bundle comes from src/index.tsx (single source of
// truth — release.yml requires bumping it in the tag commit).
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
const bundleVersion = versionMatch?.[1];
if (!bundleVersion) {
console.error("Error: could not read VERSION from src/index.tsx");
process.exit(1);
}
Bun.write(
join(appRoot, "Contents", "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PodTui</string>
<key>CFBundleDisplayName</key>
<string>PodTui</string>
<key>CFBundleIdentifier</key>
<string>com.mikefreno.podtui</string>
<key>CFBundleExecutable</key>
<string>podtui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>${bundleVersion}</string>
<key>CFBundleVersion</key>
<string>${bundleVersion}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
// ID cert for release builds); default ad-hoc.
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
signIdentity,
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
// resolves the Now Playing client from the registering process's
// code-signing identifier — without an explicit --identifier codesign
// stamps "mpv" (its basename) and the audio center shows a blank
// placeholder. Must run after the bundle sign above (a later bundle
// re-seal would re-derive the basename identifier).
const signMpv = Bun.spawnSync([
"codesign",
"--force",
"-s",
signIdentity,
"--identifier",
"com.mikefreno.podtui",
join(macosDir, "mpv"),
]);
if (signMpv.exitCode !== 0) {
console.warn(
`Warning: nested mpv signing failed (${signMpv.stderr
.toString()
.trim()}) — Now Playing attribution won't work`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([
"tar",
"-czf",

View File

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

View File

@@ -51,5 +51,13 @@ package() {
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
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"
}

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

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

View File

@@ -1,23 +1,30 @@
import { createSignal, createMemo, onCleanup } from "solid-js";
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export function LoadingIndicator() {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
/**
* Animated braille spinner with an optional label (e.g. "Refreshing…").
* The spinner is rendered in the theme primary color; the label in muted.
*/
export function LoadingIndicator(props: { label?: string }) {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length);
}, 65);
const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length);
}, 65);
onCleanup(() => clearInterval(interval));
onCleanup(() => clearInterval(interval));
const currentChar = createMemo(() => spinnerChars[index()]);
const currentChar = createMemo(() => spinnerChars[index()]);
return (
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} />
</box>
);
return (
<box flexDirection="row" gap={1} alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} />
<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.
*
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns
* grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
* so the ratio is exact regardless of content width — a column's content can
* never stretch its slot.
* Implements yazi's `mgr.ratio` contract: three columns grow at
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
* so every list tab renders an identical, layout-stable shell. Columns use
* `flexBasis={0}` so the ratio is exact regardless of content width — a
* column's content can never stretch its slot.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 1/5 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
* carries the active-border focus ring when `focused` is truthy.
* preview — detail of the hovered item in `current`; always muted border.
* is the ONLY bordered column — left/right edges only, always
* muted (no active-border highlight, focused or not).
* preview — detail of the hovered item in `current`. Borderless, no header.
*
* The primitive is purely structural: callers pass their own JSX per column
* (static elements or accessors) plus header labels. Theme colors are resolved
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
* (static elements or accessors) plus the current-column title. Theme colors
* are resolved internally via `useTheme()`. Only the current column's
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
* stay in the current pane).
*
* Example:
* <PaneRow
* parent={parentList}
* current={currentList}
* preview={detail}
* parentLabel="Up"
* currentLabel="List · 42"
* previewLabel="Detail"
* focused={isActive}
* />
*/
import { createMemo, Show } 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 { PANE_RATIO } from "@/utils/navigation";
@@ -50,12 +53,12 @@ export type PaneRowProps = {
/** Preview column content (detail of the hovered item). Omit/undefined
* together with `panes={2}` to render a 2-pane parent|current row. */
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;
previewLabel?: PaneLabel;
/** Whether the current column carries the active-border focus ring. Defaults to
* true; pass `false` (or a signal) when the row is inactive. Parent and
* preview columns always render muted borders. */
/** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
* true; pass `false` (or a signal) when the row is inactive. Does NOT change
* border colors — the current column's border is always muted. */
focused?: boolean | (() => boolean);
/** Number of visible columns. `3` (default) = parent|current|preview;
* `2` = parent|current (preview omitted, current grows to fill). */
@@ -96,16 +99,15 @@ function Pane(props: {
grow: number;
label: () => string;
content: () => JSX.Element | undefined;
borderColor: () => RGBA;
border: boolean | BorderSides[];
scrollFocused: () => boolean;
}) {
const themeContext = useTheme();
const theme = themeContext.theme;
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// Memoize accessor results so the prop expressions below stay reactive
// when the underlying signals (e.g. `focused`) change.
const borderColor = createMemo(() => props.borderColor());
// Memoize the scroll-focus accessor result so the prop expression below
// stays reactive when the underlying signal (e.g. `focused`) changes.
const scrollFocused = createMemo(() => props.scrollFocused());
return (
@@ -115,24 +117,32 @@ function Pane(props: {
flexBasis={0}
height="100%"
>
{/* ── slim header label row ─────────────────────────────────────────── */}
<box
height={1}
paddingLeft={1}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
>
<text fg={theme.textSecondary}>{props.label()}</text>
</box>
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
{/* ── title row: rendered only when the pane carries a label ────────── */}
<Show when={props.label() !== ""}>
<box
height={1}
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
height="100%"
focused={scrollFocused()}
border
borderColor={borderColor()}
border={props.border}
// Only supply colors when a border is requested — opentui flips a
// 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"
@@ -147,9 +157,7 @@ function Pane(props: {
// ── Row primitive ───────────────────────────────────────────────────────────
export function PaneRow(props: PaneRowProps) {
const { theme } = useTheme();
/** true → the current column gets the active-border focus ring. */
/** true → the current column's scrollbox is focused (scroll follows cursor). */
const focused = createMemo(() => {
const f = props.focused;
return typeof f === "function" ? f() : (f ?? true);
@@ -161,9 +169,9 @@ export function PaneRow(props: PaneRowProps) {
const currentContent = normalizeContent(props.current);
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 previewLabel = createMemo(() => resolveLabel(props.previewLabel));
// 2-pane mode (parent|current) grows the current column to fill the
// preview slot. Defaults to 3 (parent|current|preview).
@@ -176,29 +184,29 @@ export function PaneRow(props: PaneRowProps) {
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/5) — previous-depth list; always muted ─────────────── */}
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
<Pane
grow={PANE_RATIO.parent}
label={parentLabel}
label={currentLabel}
content={parentContent}
borderColor={() => theme.border}
border={false}
scrollFocused={() => false}
/>
{/* ── current — the focused list; active-border ring when focused ──────────── */}
{/* ── current — the focused list; left/right borders only ─────────── */}
<Pane
grow={currentGrow()}
label={currentLabel}
label={() => ""}
content={currentContent}
borderColor={() => (focused() ? theme.borderActive : theme.border)}
border={["left", "right"]}
scrollFocused={() => focused()}
/>
{/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
<Show when={panes() === 3}>
<Pane
grow={PANE_RATIO.preview}
label={previewLabel}
label={() => ""}
content={previewContent}
borderColor={() => theme.border}
border={false}
scrollFocused={() => false}
/>
</Show>

View File

@@ -11,8 +11,8 @@
* event bus. There is no sidebar pane.
*/
import { createSignal, Show, For } from "solid-js";
import { useKeyboard, useRenderer } from "@opentui/solid";
import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext";
@@ -23,20 +23,11 @@ import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast";
import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation";
import { TABS } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { PaneRow } from "@/components/PaneRow";
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() {
const theme = useTheme();
const t = theme.theme;
@@ -225,11 +216,18 @@ export function Shell() {
);
// ── 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();
if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
return `${title}`;
const feeds = feedStore.getFilteredFeeds();
const feed =
feeds.find((f) => f.podcast.id === ep.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
return feed
? `${feed.customName || feed.podcast.title}${ep.title}`
: `${ep.title}`;
};
const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
@@ -239,6 +237,68 @@ export function Shell() {
.map((s) => s.key)
.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 (
<box
flexDirection="column"
@@ -271,9 +331,7 @@ export function Shell() {
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
</box>
}
parentLabel="Up"
currentLabel="Tabs"
previewLabel=""
focused
/>
</Show>
@@ -296,26 +354,19 @@ export function Shell() {
<text fg={t.accent} paddingLeft={1}>
{modeLabel()}
</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}>
<text fg={t.warning} paddingLeft={1}>
{nav.selectedIds().length}
</text>
</Show>
<Show when={nowPlaying()}>
<text fg={t.primary} paddingLeft={1}>
{nowPlaying()}
</text>
<Show when={nowPlayingText()}>
<box flexGrow={1} paddingLeft={1}>
{/* content prop (not a text child): the babel-preset-solid JSX
* transform HTML-escapes static string children (`<` → `&lt;`),
* which opentui renders verbatim; content bypasses that. */}
<text fg={t.primary} content={visible()} />
</box>
</Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>
@@ -396,6 +447,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
["enter", "open"],
["r", "refresh"],
["s", "search"],
[p("search-scope-toggle"), "shows/episodes"],
["f", "filter"],
[",", "sort"],
[".", "hidden"],

View File

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

@@ -59,19 +59,28 @@
"help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
"search": ["s"],
"filter": ["f"],
"search": ["s"],
// 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": [","],
"toggle-hidden": ["."],
"refresh": ["r"],
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
"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) ──────────────────────────────────────────
// 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.
"audio-toggle": ["P"], // play / pause (shift+p)
"audio-next": ["N"], // next episode (shift+n)
"audio-prev": ["B"], // prev episode (shift+b)
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
"audio-seek-forward": ["shift-."], // seek forward (> = shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (< = shift+,)
}

View File

@@ -63,11 +63,16 @@ export type KeybindActionName =
| "quit"
| "help"
| "search"
| "search-scope-toggle"
| "filter"
| "sort"
| "toggle-hidden"
| "refresh"
| "subscribe"
| "unsubscribe"
| "download"
| "delete-download"
| "whitelist-toggle"
| "audio-toggle"
| "audio-next"
| "audio-prev"

View File

@@ -13,7 +13,7 @@
*
* parent | current | preview
*
* Layout ratios (1/5 : 2/5 : 2/5 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*
* nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused.

View File

@@ -12,7 +12,12 @@
* ```
*/
import { createSignal, onCleanup } from "solid-js";
import { onCleanup } from "solid-js";
import {
cachedCoverPath,
fetchCoverArt,
prefetchCoverArt,
} from "../utils/cover-art";
import {
createAudioBackend,
detectPlayers,
@@ -20,11 +25,36 @@ import {
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed,
setSpeed,
backendName,
setBackendName,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "../utils/audio-signals";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode";
import {
loadLastPlayerFromFile,
saveLastPlayerToFile,
saveLastPlayerSync,
} from "../utils/app-persistence";
import type { Episode, Progress } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useFeedStore } from "../stores/feed";
@@ -43,6 +73,8 @@ export interface AudioControls {
// Actions
play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
@@ -62,17 +94,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(0.7);
const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null);
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
// Playback signals are declared in utils/audio-signals.ts (imported above)
// so non-component consumers (the visualizer store) can subscribe without
// mounting a useAudio() owner.
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.
* restored at boot) and the first play action must start the backend
* instead of unpausing it. */
let startedPlayback = false;
/** Completion fraction at/above which an episode is NOT restored at boot. */
const RESTORE_COMPLETION_THRESHOLD = 0.98;
/** True when saved progress is below the restore cutoff. Episodes with no
* progress (never reached the persist threshold) or unknown duration count
* as eligible — they restore from the start. */
function isRestoreEligible(progress: Progress | undefined): boolean {
if (!progress || progress.duration <= 0) return true;
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
}
function ensureBackend(): AudioBackend {
if (!backend) {
@@ -99,6 +140,17 @@ function registerExitTeardown(): void {
exitTeardownRegistered = true;
const teardown = (): void => {
stopPolling();
// Persist "what's loaded in the player right now" synchronously —
// process.exit(0) runs this handler synchronously and an async write
// would never land. The next launch restores this episode paused.
try {
const ep = currentEpisode();
if (ep) {
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
}
} catch {
/* best-effort at exit */
}
try {
backend?.dispose();
} catch {
@@ -119,45 +171,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 {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return;
if (!backend || pollInFlight) return;
pollInFlight = true;
try {
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms)
pollCount++;
if (pollCount % 10 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
if (isPlaying()) {
// Track ended (eof-reached observed) or process died. Check
// BEFORE pause reconciliation: mpv keeps the file open at EOF
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
finalizeTrackEnd();
return;
}
}
// Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false);
stopPolling();
// Save final position on track end
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
// mpv can pause itself outside PodTUI. Reconcile instead of
// staying stuck on "playing" with a frozen waveform
// (getPosition would just re-read the same frozen time-pos).
const paused = await backend.getPauseState();
if (paused === true) {
reconcileExternalPause();
return;
}
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (33 ticks * 150ms)
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
// Paused — watch for playback restarted from outside (AirPods,
// lock-screen/media-center play). Only while the player is
// still alive: a dead player while we thought we were paused
// means the track ended (mpv quits at EOF) or it crashed.
if (!backend.isAlive()) {
finalizeTrackEnd();
return;
}
const paused = await backend.getPauseState();
if (paused === false) {
reconcileExternalResume();
}
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 500);
}, 150);
}
function stopPolling(): void {
@@ -167,6 +292,11 @@ 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.
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
@@ -183,6 +313,16 @@ async function play(episode: Episode): Promise<void> {
const vol = volume();
const spd = storeSpeed || speed();
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Cover art must NEVER gate playback (it was a curl subprocess blocking
// play() by up to 8s). Serve the disk-cached file synchronously when it
// exists; on a miss, start playback bare and fetch in the background —
// the backend applies late art at runtime (mpv video-add).
const coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
@@ -194,19 +334,36 @@ async function play(episode: Episode): Promise<void> {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
coverArtPath: coverArtPath ?? undefined,
});
if (coverUrl && !coverArtPath) {
fetchCoverArt(coverUrl)
.then((path) => {
if (path && currentEpisode()?.id === episode.id) {
b.addCoverArt(path).catch(() => {});
}
})
.catch(() => {});
}
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
// Remember this episode as "loaded in the player" so the next launch
// can restore it paused (cleared by stop()).
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
// Register with platform media controls
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: episode.podcastId,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
@@ -223,12 +380,79 @@ async function play(episode: Episode): Promise<void> {
}
}
/**
* Load an episode into the player WITHOUT starting playback. The player tab
* renders it paused at its saved position; the first play action starts the
* backend from there (see togglePlayback). Used to restore the last player
* session at boot.
*/
async function load(episode: Episode): Promise<void> {
ensureBackend();
setError(null);
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
// Show the saved position so the player tab reflects where playback
// will resume; episodes at/above the completion threshold start from 0.
const progressStore = useProgressStore();
const saved = progressStore.get(episode.id);
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
setPosition(pos);
if (episode.duration) setDuration(episode.duration);
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
setSpeed(storeSpeed || speed());
// Surface the loaded-but-paused track to the OS media controls.
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (pos > 0) media.setPosition(pos);
// Preload the episode into the backend PAUSED: mpv opens the stream and
// fills its demuxer cache while parked, so the user's first Play flips
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
// — a failed preload just makes the first play take the cold path.
if (episode.audioUrl && backend) {
const coverUrl = feed?.podcast.coverUrl;
if (coverUrl) prefetchCoverArt(coverUrl);
const backendSnap = backend;
backendSnap
.preload(episode.audioUrl, {
volume: volume(),
speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined,
mediaTitle: podcastTitle
? `${podcastTitle}${episode.title}`
: episode.title,
coverArtPath: coverUrl
? (cachedCoverPath(coverUrl) ?? undefined)
: undefined,
})
.catch(() => {});
}
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
stopPolling();
// Polling stays armed (paused-watch mode): playback can be resumed
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
// and the poll must be live to catch it.
const ep = currentEpisode();
if (ep) {
// Save progress on pause
@@ -267,7 +491,15 @@ async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
await resume();
if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
}
}
@@ -284,9 +516,13 @@ async function stop(): Promise<void> {
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
startedPlayback = false;
stopPolling();
emit("player.stop", {});
// Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
@@ -319,6 +555,10 @@ async function doSetVolume(vol: number): Promise<void> {
}
}
setVolume(clamped);
// Sync back to app store (persisted to config.json for the next launch).
const appStore = useAppStore();
appStore.updateSettings({ volume: clamped });
}
async function doSetSpeed(spd: number): Promise<void> {
@@ -357,12 +597,24 @@ async function switchBackend(name: BackendName): Promise<void> {
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
const feedStore = useFeedStore();
const feed = feedStore
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
@@ -371,6 +623,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.
*
@@ -381,13 +673,29 @@ export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend();
// Sync initial speed from app store
// Sync initial speed/volume from app store (reuse the previous session's
// playback levels; defaults are 1x and 100%).
if (refCount === 0) {
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
// Volume re-syncs once settings finish loading (async config read)
// so a level persisted last session is applied at boot.
appStore
.whenReady()
.then(() => {
const storeVolume = appStore.state().settings.volume;
if (storeVolume !== undefined && storeVolume !== volume()) {
setVolume(storeVolume);
}
})
.catch(() => {});
// Restore the last player session once at boot (loaded, not playing).
restoreLastSession().catch(() => {});
}
refCount++;
@@ -421,14 +729,6 @@ export function useAudio(): AudioControls {
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 next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
@@ -515,8 +815,6 @@ export function useAudio(): AudioControls {
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed();
if (refCount <= 0) {
@@ -545,6 +843,7 @@ export function useAudio(): AudioControls {
availablePlayers,
play,
load,
pause,
resume,
togglePlayback,

View File

@@ -1,13 +1,14 @@
/**
* 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
* decouple key detection from audio control logic.
*
* Volume and speed are app-level settings — adjustable with or without
* 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";
@@ -17,8 +18,6 @@ export type MediaKeyAction =
| "media.toggle"
| "media.volumeUp"
| "media.volumeDown"
| "media.seekForward"
| "media.seekBackward"
| "media.speedCycle";
export interface MultimediaKeysOptions {
@@ -26,8 +25,6 @@ export interface MultimediaKeysOptions {
playerFocused?: () => boolean;
/** When true, skip handling (text input has focus) */
inputFocused?: () => boolean;
/** Whether an episode is currently loaded */
hasEpisode?: () => boolean;
}
/**
@@ -59,17 +56,9 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
emit("media.volumeDown", {});
break;
case "left":
if (!options.hasEpisode?.()) return;
emit("media.seekBackward", {});
break;
case "right":
if (!options.hasEpisode?.()) return;
emit("media.seekForward", {});
break;
case "s":
// Speed is shift+s (S) so plain `s` stays free for search.
if (!key.shift) return;
emit("media.speedCycle", {});
break;

View File

@@ -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,7 +1,7 @@
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.3.0";
const VERSION = "0.5.0";
interface CliArgs {
version: boolean;
@@ -182,9 +182,18 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
try {
const { createAudioBackend } = await import("./utils/audio-player")
const { fetchCoverArt } = await import("./utils/cover-art")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl)
// Stage the podcast cover so the system Now Playing shows
// artwork (mpv --cover-art-files), like the UI path does.
const coverArtPath = feedResult.podcast.coverUrl
? await fetchCoverArt(feedResult.podcast.coverUrl)
: null
await backend.play(episodeResult.audioUrl, {
mediaTitle: `${feedResult.podcast.title}${episodeResult.title}`,
coverArtPath: coverArtPath ?? undefined,
})
console.log("Playback started (use the UI to control)")
} else {
console.log("No audio URL available for this episode")

View File

@@ -27,18 +27,24 @@ import {
type DepthFrame,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const DiscoverPaneCount = 1;
function DiscoverPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
@@ -158,34 +164,46 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
// 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 = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For>
</Show>
<>
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For>
</Show>
</>
);
// ── current pane ───────────────────────────────────────────────────────────
@@ -202,7 +220,6 @@ function DiscoverPage() {
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -212,8 +229,13 @@ function DiscoverPage() {
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(index(), lf(), isActive())}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
</box>
);
@@ -226,7 +248,14 @@ function DiscoverPage() {
when={podcasts().length > 0}
fallback={
<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>
}
>
@@ -239,7 +268,6 @@ function DiscoverPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -249,7 +277,7 @@ function DiscoverPage() {
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{podcast.title}
@@ -274,6 +302,11 @@ function DiscoverPage() {
);
}}
</For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
</Show>
</Show>
</>
@@ -376,9 +409,7 @@ function DiscoverPage() {
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

@@ -16,9 +16,10 @@
* 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 { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
@@ -31,6 +32,7 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
@@ -38,12 +40,15 @@ import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1;
type EpItem = { episode: Episode; feed: Feed };
function FeedPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
@@ -51,23 +56,54 @@ function FeedPage() {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
const episodes = createMemo<EpItem[]>(
() => feedStore.getAllEpisodesChronological() as EpItem[],
);
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0);
const focusedRow = () =>
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 = () =>
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
const curLen = () => episodes().length;
focusedOnMore()
? -1
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
const moreRef = useScrollIntoView(() => focusedOnMore());
const ensureFocus = () => {
if (episodes().length > 0 && focus() >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 0);
if (rowCount() > 0 && focus() >= rowCount())
nav.setDepthFocus(rowCount() - 1, 0);
};
onMount(ensureFocus);
// Auto mode: reaching the bottom row loads the next batch. Guarded by
// isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreAllFeeds().catch(() => {});
});
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -118,6 +154,10 @@ function FeedPage() {
// ── open ───────────────────────────────────────────────────────────────────
function open() {
if (focusedOnMore()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
playEpisode(focusedItem());
}
@@ -136,6 +176,18 @@ function FeedPage() {
const item = focusedItem();
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: () => {
feedStore.refreshAllFeeds().catch(() => {});
},
@@ -185,7 +237,16 @@ function FeedPage() {
when={episodes().length > 0}
fallback={
<box padding={1}>
<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 label="Refreshing…" />
</Show>
</box>
}
>
@@ -198,7 +259,6 @@ function FeedPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -207,31 +267,55 @@ function FeedPage() {
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
<text
flexShrink={0}
fg={focusFg(index(), fi(), isActive())}
>
{index() === fi() ? marker() : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
<text
wrapMode="none"
truncate
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()}>
{/* podcast name on its own row — readable at a glance; the
50% current pane fits it in full for typical names, and
truncate keeps the row one line tall either way */}
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={index() === fi() ? theme.surface : theme.textSecondary}
>
{item.feed.customName || item.feed.podcast.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : theme.info}
>
{formatDate(item.episode.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : muted()}
>
{formatDuration(item.episode.duration)}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
@@ -240,60 +324,117 @@ function FeedPage() {
);
}}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
</Show>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
</Show>
);
// ── preview pane: hovered-episode detail ───────────────────────────────────
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const previewContent = () => (
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
<strong>[Fetch More]</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h back</text>
<text fg={muted()}>enter: load more · h back</text>
</box>
)}
</Show>
</Show>
<Show when={!focusedOnMore()}>
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(item().episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· space: select · h back
</text>
</box>
)}
</Show>
</Show>
</>
);
return (
@@ -301,9 +442,7 @@ function FeedPage() {
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel="Up"
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

@@ -6,14 +6,18 @@
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column.
*
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
* as the Feed tab) that loads the next batch of episodes for that show.
*
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
@@ -27,24 +31,30 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Episode, DownloadedEpisode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const app = useAppStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
@@ -52,9 +62,28 @@ export function MyShowsPage() {
const shows = () => feedStore.getFilteredFeeds();
// Downloads of shows that are NOT subscribed (made from episode search) —
// listed as their own section under the shows list. Reads feeds() so an
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
/** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */
const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined;
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
};
const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub()) return undefined;
return shows()[focusedShowIdx()];
};
// depth-1 frame ctx = the drilled feed id
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
@@ -67,27 +96,64 @@ export function MyShowsPage() {
(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 ?? "manual";
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
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 = () =>
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
const focusedEpisode = () => episodes()[focusedEpIdx()];
focusedOnMore()
? -1
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
const ensureFocus = () => {
if (shows().length > 0 && focus(0) >= shows().length)
nav.setDepthFocus(shows().length - 1, 0);
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 1);
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
nav.setDepthFocus(depth0Count() - 1, 0);
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
nav.setDepthFocus(rowCount() - 1, 1);
};
onMount(ensureFocus);
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return shows()[i]?.id;
if (depth() === 0) {
if (i < shows().length) return shows()[i]?.id;
return unsubs()[i - shows().length]?.episodeId;
}
return episodes()[i]?.id;
});
});
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
@@ -128,9 +194,31 @@ export function MyShowsPage() {
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
/** Stream an unsubscribed-show download. The record carries only what was
* persisted at download time, so a minimal Episode is reconstructed. */
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
audio
.play({
id: d.episodeId,
podcastId: d.feedId,
title: d.episodeTitle ?? d.episodeId,
description: "",
audioUrl: d.audioUrl ?? "",
duration: 0,
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
})
.catch(() => {});
audioNav.setSource(AudioSource.SEARCH, d.feedId);
};
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
const d = focusedUnsub();
if (d) {
playUnsubscribedDownload(d);
return;
}
const show = selectedShow();
if (!show) return;
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
@@ -139,6 +227,10 @@ export function MyShowsPage() {
return;
}
if (depth() >= 1) {
if (focusedOnMore()) {
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
return;
}
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
@@ -161,6 +253,41 @@ export function MyShowsPage() {
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: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
@@ -208,7 +335,9 @@ export function MyShowsPage() {
const currentLabel = () =>
depth() === 0
? `Shows (${shows().length})`
? `Shows (${shows().length})${
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
}`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
@@ -225,12 +354,11 @@ export function MyShowsPage() {
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
@@ -247,7 +375,7 @@ export function MyShowsPage() {
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
when={depth0Count() > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
@@ -260,12 +388,16 @@ export function MyShowsPage() {
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -274,7 +406,7 @@ export function MyShowsPage() {
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
@@ -282,10 +414,88 @@ export function MyShowsPage() {
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
<text fg={theme.textSecondary}>
Unsubscribed Show Downloads
</text>
</box>
<For each={unsubs()}>
{(d, index) => {
// Rows continue after the shows list.
const rowIdx = () => shows().length + index();
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => rowIdx() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(rowIdx(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(rowIdx(), lf(), isActive())}
>
{rowIdx() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(rowIdx(), lf(), isActive())}
>
{d.episodeTitle ?? d.episodeId}
</text>
<Show when={downloadLabel(d.episodeId)}>
<text
flexShrink={0}
fg={downloadColor(d.episodeId)}
>
{downloadLabel(d.episodeId)}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={
rowIdx() === lf()
? theme.surface
: theme.textSecondary
}
>
{d.podcastTitle ?? d.feedId}
</text>
</box>
</box>
);
}}
</For>
</Show>
</Show>
</Show>
{/* depth ≥1: episodes */}
@@ -307,7 +517,6 @@ export function MyShowsPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -316,26 +525,41 @@ export function MyShowsPage() {
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
<text
flexShrink={0}
fg={focusFg(index(), lf(), isActive())}
>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf() ? theme.surface : theme.info}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
@@ -344,9 +568,38 @@ export function MyShowsPage() {
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(
episodes().length,
focusedRow(),
isActive(),
)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
</Show>
</Show>
@@ -357,44 +610,110 @@ export function MyShowsPage() {
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered show
// depth 0 preview: hovered unsubscribed-show download, else the
// hovered show.
<Show
when={selectedShow()}
when={focusedUnsub()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ??
"No description."}
</text>
<box height={1} />
<text fg={muted()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope ===
"whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}
</text>
</box>
)}
</Show>
}
>
{(show) => (
{(d) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
{d().podcastTitle ?? d().feedId}
</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>
{formatDate(new Date(d().pubDate!))}
</text>
</Show>
<Show when={downloadLabel(d().episodeId)}>
<text fg={downloadColor(d().episodeId)}>
{downloadLabel(d().episodeId)}
</text>
</Show>
</box>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
Downloaded from episode search the show is not
subscribed.
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
<text fg={muted()}>
enter: play · D: delete download · h: back
</text>
</box>
)}
</Show>
) : (
// depth ≥1 preview: hovered episode
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes for this show (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
}
>
</Show>
<Show when={!focusedOnMore()}>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
@@ -421,20 +740,34 @@ export function MyShowsPage() {
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}{" "}
· space: select · h: back
</text>
</box>
)}
</Show>
</Show>
);
</>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
/**
* 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}
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}>{"\u2588".repeat(playedChars())}</text>
)}
<text fg={remainingColor}>
{"\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
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps.
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
* visualizer store (`@/stores/visualizer`), not in this component, so it
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
*
* This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the
* frequency bars once frames arrive, and a dotted placeholder when idle.
*/
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import { createEffect, on } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { useAudio } from "@/hooks/useAudio";
import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** 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 ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
export function RealtimeWaveform() {
const { theme } = useTheme();
const audio = useAudio();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
const viz = useVisualizer();
// Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of
@@ -75,197 +43,49 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
);
};
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
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);
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;
const count = reader.read(sampleBuffer);
if (count === 0) return;
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);
}
}),
);
onCleanup(() => {
stopVisualization();
if (reader) {
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy();
}
});
// Keep the store's bar count in sync with the terminal width; the store
// re-inits the running pipeline when it changes (terminal resize).
createEffect(on(numBars, (n) => viz.setBarCount(n)));
// ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
const renderLine = () => {
const bars = barData();
const bars = viz.barData();
const count = numBars();
// Loading state: the braille spinner shows while the pipeline warms
// up — but only when there are no bars to render yet (first play /
// after an unload). On resume/seek the last bars stay on screen
// until fresh frames arrive, so the waveform never blanks out for
// the (multi-second, network-bound) cold start.
if (bars.length === 0 && viz.isLoading()) {
return <LoadingIndicator />;
}
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
<box flexDirection="column" gap={0}>
<text fg={theme.primary}>{placeholder}</text>
<text fg={theme.primary}>{placeholder}</text>
</box>
);
}
const played = Math.floor(count * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
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("");
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
const top = pairs.map((pair) => pair.top).join("");
const bottom = pairs.map((pair) => pair.bottom).join("");
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
<box flexDirection="column" gap={0}>
<text fg={theme.primary}>{top}</text>
<text fg={theme.primary}>{bottom}</text>
</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 (
<box
border
borderColor={theme.border}
padding={1}
onMouseDown={handleClick}
>
<box border borderColor={theme.border} padding={1}>
{renderLine()}
</box>
);

View File

@@ -8,6 +8,10 @@
* query (muted, read-only); preview shows the detail of
* 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
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
@@ -26,6 +30,11 @@ import {
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { DownloadStatus } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
@@ -37,20 +46,28 @@ import {
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
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 { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export const SearchPaneCount = 1;
function SearchPage() {
const searchStore = useSearchStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const toast = useToast();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
@@ -64,21 +81,32 @@ function SearchPage() {
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
//
// Typing is the default only on the query depth (0); the results depth
// (1) is always list-navigation. Drive `inputFocused` straight off
// `depth()` rather than seeding it `true` on mount and patching on change:
// the depth stack persists across tab switches, so re-mounting this page
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
// 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 input's REAL focus is the source of truth for the flag:
// useInputFocusNav (the same hook the Settings forms use) flips
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// and the renderable in lockstep. That matters when the user clicks OFF the
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// event drops the flag, so the Shell router immediately resumes j/k/h
// instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
//
// The effect only re-runs on a depth transition, so Escape (defocus) and
// `s` (refocus) at the same depth are not clobbered.
// The depth stack still SEEDS the flag on transitions, since the query
// depth defaults to typing: re-entering depth 0 (h back from results, or a
// fresh mount) focuses the input; mounting at depth 1 (returning to the
// tab after a search) stays list-navigation — a stuck-on flag there would
// have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();
const isQueryDepth = createMemo(() => depth() === 0);
createEffect(() => {
nav.setInputFocused(depth() === 0);
nav.setInputFocused(isQueryDepth());
});
// ── results (depth 1) ─────────────────────────────────────────────────────
@@ -104,12 +132,44 @@ function SearchPage() {
// Register a visual-mode resolver for the results list (depth 1).
onMount(() => {
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 ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const runSearch = (query: string) => {
const q = query.trim();
if (!q) return;
@@ -129,10 +189,89 @@ function SearchPage() {
runSearch(query);
};
const handleSubscribe = (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result subscribed
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
searchStore.markSubscribed(result.podcast.id);
/** Set show/episode scope; when viewing results, re-run the current query
* so the list switches immediately (the toggle is otherwise invisible on
* a list of results). */
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 ──────────────────────────────────────────────────────
@@ -149,13 +288,29 @@ function SearchPage() {
"toggle-select": () => {
if (depth() === 1) {
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: () => {
// `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true);
},
"search-scope-toggle": () => toggleScope(),
refresh: () => {
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
@@ -176,7 +331,15 @@ function SearchPage() {
}
if (depth() === 1) {
const r = focusedResult();
if (r) handleSubscribe(r);
if (!r) return;
if (r.kind === "episode") {
// Any episode result streams directly — subscribed or not
// (matches Feed/My Shows). `a` subscribes an unsubscribed
// show's episode in place.
playFocusedEpisode();
return;
}
handleSubscribe(r);
}
}
@@ -212,15 +375,28 @@ function SearchPage() {
: theme.text;
// ── 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 = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={muted()}>h: back to query</text>
</box>
</Show>
<>
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textSecondary}>Query</text>
<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 ────────────────────────────────────────────────────────────
@@ -232,16 +408,80 @@ function SearchPage() {
<box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text>
<input
ref={focusNavRef}
value={inputValue()}
onInput={setInputValue}
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()}
width={28}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</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()}>
<text fg={theme.warning}>Searching...</text>
<LoadingIndicator label="Searching…" />
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
@@ -262,23 +502,47 @@ function SearchPage() {
{(query, index) => {
const lf = () => focus(0);
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 (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
backgroundColor={
typing()
? undefined
: focusBg(index(), lf(), isActive())
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
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())}>
{index() === lf() ? "" : " "}
<text
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 fg={focusFg(index(), lf(), isActive())}>{query}</text>
</box>
);
}}
@@ -288,7 +552,7 @@ function SearchPage() {
<text fg={muted()}>
{inputActive()
? "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>
</box>
</Show>
@@ -298,11 +562,20 @@ function SearchPage() {
when={results().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
<Show
when={searchStore.isSearching()}
fallback={
<text fg={muted()}>
{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>
}
>
@@ -310,12 +583,18 @@ function SearchPage() {
{(result, index) => {
const fi = () => focusedResultIdx();
const ref = useScrollIntoView(() => index() === fi());
// Episode download status badge ("" when absent).
const dlLabel = () =>
result.kind === "episode"
? downloadLabel(result.episode.id)
: "";
const dlEpId = () =>
result.kind === "episode" ? result.episode.id : "";
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -325,11 +604,18 @@ function SearchPage() {
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
{index() === fi() ? marker() : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title}
{result.kind === "episode"
? result.episode.title
: result.podcast.title}
</text>
<Show when={dlLabel()}>
<text fg={downloadColor(dlEpId())}>
{dlLabel()}
</text>
</Show>
<Show when={result.podcast.isSubscribed}>
<text
fg={index() === fi() ? theme.surface : theme.success}
@@ -338,14 +624,24 @@ function SearchPage() {
</text>
</Show>
</box>
<Show when={result.podcast.author}>
{result.kind === "episode" ? (
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
{result.podcast.title} ·{" "}
{formatDate(result.episode.pubDate)}
</text>
</Show>
) : (
<Show when={result.podcast.author}>
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
)}
</box>
);
}}
@@ -363,6 +659,10 @@ function SearchPage() {
<strong>Search</strong>
</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>
<box height={1} />
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
@@ -379,61 +679,138 @@ function SearchPage() {
</box>
}
>
{(result) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<Show when={result().podcast.author}>
<text fg={muted()}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400)}
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
{(result) => {
const r = result();
if (r.kind === "episode") {
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{r.episode.title}</strong>
</text>
<text fg={theme.textSecondary}>{r.podcast.title}</text>
<Show when={r.podcast.author}>
<text fg={muted()}>by {r.podcast.author}</text>
</Show>
<Show when={r.episode.description}>
<text fg={theme.textSecondary}>
{r.episode.description!.slice(0, 400)}
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<box flexDirection="row" gap={2}>
<text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<Show when={downloadLabel(r.episode.id)}>
<text fg={downloadColor(r.episode.id)}>
{downloadLabel(r.episode.id)}
</text>
</Show>
</box>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
{(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>
</Show>
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
<text fg={muted()}>
Updated: {formatDate(result().podcast.lastUpdated)}
</text>
<Show when={result().sourceName}>
<text fg={muted()}>Source: {result().sourceName}</text>
</Show>
<box height={1} />
<Show when={!result().podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back to query</text>
</box>
)}
);
}
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{r.podcast.title}</strong>
</text>
<Show when={r.podcast.author}>
<text fg={muted()}>by {r.podcast.author}</text>
</Show>
<Show when={r.podcast.description}>
<text fg={theme.textSecondary}>
{r.podcast.description!.slice(0, 400)}
{(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<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>
<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>
);
const currentLabel = () =>
depth() === 0
? `Search · ${recents().length} recent`
: `Results · ${results().length}`;
: `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

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

View File

@@ -32,6 +32,9 @@ export function ExportDialog() {
value={filename[0]()}
onInput={filename[1]}
style={{ width: 30 }}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box style={{ flexDirection: "row", gap: 1 }}>

View File

@@ -21,6 +21,9 @@ export function FilePicker(props: FilePickerProps) {
onInput={props.onChange}
placeholder="/path/to/sync-file.json"
style={{ width: 40 }}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
<text fg={theme.text}>Format: {format}</text>
</box>

View File

@@ -2,10 +2,36 @@
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
* driven by the Shell router via nav.action.
*
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
* • Auto Download — master toggle (default: off)
* • Auto Download Count — X most recent episodes per show (default: 2,
* any positive integer — type it in the editor)
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
* • Auto Download Whitelist — shown only when scope is "whitelist": search
* field over subscribed shows; suggestions toggle
* in/out with Space (j/k to move, Esc to browse).
*/
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
import { RenderableEvents, type InputRenderable } from "@opentui/core";
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, ThemeName } from "@/types/settings";
import type { Feed } from "@/types/feed";
import type { SettingItem } from "./types";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
@@ -17,13 +43,24 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "custom", label: "Custom" },
];
const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
{ value: "all", label: "All" },
{ value: "none", label: "None" },
{ value: "whitelist", label: "Whitelist" },
];
function scopeLabel(scope: AutoDownloadScope): string {
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
}
export function usePreferencesItems(): SettingItem[] {
const app = useAppStore();
const feedStore = useFeedStore();
const settings = () => app.state().settings;
const prefs = () => app.state().preferences;
return [
const items: SettingItem[] = [
{
id: "theme",
label: "Theme",
@@ -52,6 +89,18 @@ export function usePreferencesItems(): SettingItem[] {
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",
label: "Font Size",
@@ -97,11 +146,52 @@ export function usePreferencesItems(): SettingItem[] {
kind: "toggle",
display: () => (prefs().autoDownload ? "On" : "Off"),
help: () =>
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoDownload: !prefs().autoDownload,
}),
`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",
@@ -115,5 +205,298 @@ export function usePreferencesItems(): SettingItem[] {
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
{
id: "fetchMore",
label: "Fetch More",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
{
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

@@ -27,6 +27,7 @@ import {
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel";
@@ -37,6 +38,7 @@ import { useDownloadItems } from "./DownloadManager";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const SettingsPaneCount = 1;
@@ -45,29 +47,38 @@ const SECTIONS: SettingsSectionDef[] = [
id: 0,
label: "Sync",
description: "Import/export subscriptions and sync status.",
icon: NF_ICONS.sync,
},
{
id: 1,
label: "Sources",
description: "Podcast search/RSS sources — add, enable, remove.",
icon: NF_ICONS.sources,
},
{
id: 2,
label: "Preferences",
description: "Theme, font, playback speed, explicit/auto-download.",
icon: NF_ICONS.preferences,
},
{
id: 3,
label: "Visualizer",
description: "Audio visualizer: bars, sensitivity, cutoffs.",
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
icon: NF_ICONS.visualizer,
},
{
id: 4,
label: "Downloads",
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. */
function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) {
@@ -267,12 +278,6 @@ export function SettingsPage() {
if (d === 1) return sectionForDepth1()?.label ?? "Items";
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) ────────────────
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
@@ -292,6 +297,7 @@ export function SettingsPage() {
{(section, index) => (
<Row
label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()}
active={false}
/>
@@ -321,6 +327,7 @@ export function SettingsPage() {
{(section, index) => (
<Row
label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()}
active={isActive()}
onMouseDown={() => {
@@ -384,9 +391,7 @@ export function SettingsPage() {
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={parentLabel}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
@@ -415,6 +420,7 @@ function Row(props: {
focused: boolean;
active: boolean;
hint?: string;
icon?: string;
onMouseDown?: () => void;
}) {
const { theme } = useTheme();
@@ -431,17 +437,18 @@ function Row(props: {
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused);
const marker = useSelectionMarker();
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={bg()}
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>
<Show when={props.value}>
<box flexGrow={1} />

View File

@@ -11,16 +11,24 @@
* 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 { 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 type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types";
export function useSourceItems(): SettingItem[] {
const feedStore = useFeedStore();
const dialog = useDialog();
const typeBadge = (s: PodcastSource) =>
s.type === SourceType.API
@@ -48,8 +56,20 @@ export function useSourceItems(): SettingItem[] {
kind: "toggle",
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
help: () =>
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
toggle: () => feedStore.toggleSource(s.id),
s.id === "podcastindex"
? `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);
},
});
}
@@ -103,6 +123,9 @@ function AddSourceForm() {
onInput={setName}
placeholder="My Custom Feed"
width={25}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box flexDirection="row" gap={1}>
@@ -116,6 +139,9 @@ function AddSourceForm() {
}}
placeholder="https://example.com/feed.rss"
width={35}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box
@@ -145,3 +171,152 @@ function AddSourceForm() {
</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

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

View File

@@ -41,5 +41,7 @@ export interface SettingsSectionDef {
id: number;
label: string;
description: string;
/** Nerd Font glyph for the section row (rendered only when supported). */
icon: string;
items?: () => SettingItem[];
}

View File

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

View File

@@ -20,17 +20,17 @@ export interface DiscoverCategory {
}
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" },
{ id: "technology", name: "Technology", icon: ">" },
{ id: "science", name: "Science", icon: "~" },
{ id: "comedy", name: "Comedy", icon: ")" },
{ id: "news", name: "News", icon: "!" },
{ id: "business", name: "Business", icon: "$" },
{ id: "health", name: "Health", icon: "+" },
{ id: "education", name: "Education", icon: "?" },
{ id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" },
{ id: "all", name: "All", icon: "\uF0CA" },
{ id: "technology", name: "Technology", icon: "\uF2DB" },
{ id: "science", name: "Science", icon: "\uF0C3" },
{ id: "comedy", name: "Comedy", icon: "\uF118" },
{ id: "news", name: "News", icon: "\uF1EA" },
{ id: "business", name: "Business", icon: "\uF0B1" },
{ id: "health", name: "Health", icon: "\uF21E" },
{ id: "education", name: "Education", icon: "\uF19D" },
{ id: "sports", name: "Sports", icon: "\uF1E3" },
{ id: "true-crime", name: "True Crime", icon: "\uF00E" },
{ id: "arts", name: "Arts", icon: "\uF1FC" },
];
// ── Remote featured-shows manifest ───────────────────────────────────────────

View File

@@ -10,12 +10,32 @@ import { createSignal } from "solid-js";
import { DownloadStatus } from "../types/episode";
import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode";
import type { Podcast } from "../types/podcast";
import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
import { useFeedStore } from "./feed";
const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2;
/** Prefix for synthetic feed ids of unsubscribed-show downloads (search
* downloads). The id doubles as the file subdirectory name, so it must be
* filesystem-safe. */
const UNSUBSCRIBED_FEED_PREFIX = "unsub-";
/** Deterministic synthetic feed id for a show that isn't subscribed: groups
* its search downloads together (and names their file subdirectory) without
* colliding with real feed ids (UUIDs). */
function unsubscribedFeedId(podcast: Pick<Podcast, "feedUrl" | "title">): string {
const base = podcast.feedUrl || podcast.title;
const slug = base
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return `${UNSUBSCRIBED_FEED_PREFIX}${slug || "podcast"}`;
}
/** Serializable download record for persistence */
interface DownloadRecord {
episodeId: string;
@@ -27,6 +47,12 @@ interface DownloadRecord {
error: string | null;
audioUrl: string;
episodeTitle: string;
/** ISO publication date, for unsubscribed-show downloads. */
pubDate?: string;
/** Show title, for downloads whose show isn't subscribed. */
podcastTitle?: string;
/** The show's RSS feed URL (re-classifies the download once subscribed). */
podcastFeedUrl?: string;
}
/** Queue item for pending downloads */
@@ -80,6 +106,11 @@ function createDownloadStore() {
speed: 0,
fileSize: rec.fileSize,
error: rec.error,
episodeTitle: rec.episodeTitle || undefined,
audioUrl: rec.audioUrl || undefined,
pubDate: rec.pubDate || undefined,
podcastTitle: rec.podcastTitle || undefined,
podcastFeedUrl: rec.podcastFeedUrl || undefined,
});
}
return map;
@@ -105,8 +136,11 @@ function createDownloadStore() {
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
fileSize: dl.fileSize,
error: dl.error,
audioUrl: qItem?.audioUrl ?? "",
episodeTitle: qItem?.episodeTitle ?? "",
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
pubDate: dl.pubDate,
podcastTitle: dl.podcastTitle,
podcastFeedUrl: dl.podcastFeedUrl,
});
}
const filePath = getConfigFilePath(DOWNLOADS_FILE);
@@ -201,6 +235,27 @@ function createDownloadStore() {
speed: 0,
error: null,
});
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art.
const coverUrl = useFeedStore()
.feeds()
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
if (coverUrl && result.filePath) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
fetch(coverUrl)
.then(async (r) => {
if (!r.ok) return;
await Bun.write(
coverPath,
new Uint8Array(await r.arrayBuffer()),
);
})
.catch(() => {});
}
}
} else {
updateDownload(item.episodeId, {
status: DownloadStatus.FAILED,
@@ -238,8 +293,20 @@ function createDownloadStore() {
return null;
};
/** Optional metadata for a download whose show isn't subscribed (search
* downloads) — without it the record cannot render a title or be
* re-classified once the show is subscribed. */
interface UnsubscribedMeta {
podcastTitle: string;
podcastFeedUrl?: string;
}
/** Start downloading an episode */
const startDownload = (episode: Episode, feedId: string): void => {
const startDownload = (
episode: Episode,
feedId: string,
meta?: UnsubscribedMeta,
): void => {
const existing = downloads().get(episode.id);
if (
existing?.status === DownloadStatus.DOWNLOADING ||
@@ -258,6 +325,11 @@ function createDownloadStore() {
speed: 0,
fileSize: episode.fileSize ?? 0,
error: null,
episodeTitle: episode.title,
audioUrl: episode.audioUrl,
pubDate: episode.pubDate.toISOString(),
podcastTitle: meta?.podcastTitle,
podcastFeedUrl: meta?.podcastFeedUrl,
};
setDownloads((prev) => {
@@ -278,6 +350,21 @@ function createDownloadStore() {
processQueue();
};
/** Start downloading an episode of a show that is NOT subscribed. The
* download gets a deterministic synthetic feed id (also its file
* subdirectory) plus the show's metadata so it can render under
* "Unsubscribed Show Downloads" and re-classify if the user later
* subscribes to the show. */
const startUnsubscribedDownload = (
episode: Episode,
podcast: Podcast,
): void => {
startDownload(episode, unsubscribedFeedId(podcast), {
podcastTitle: podcast.title,
podcastFeedUrl: podcast.feedUrl || undefined,
});
};
/** Cancel a download */
const cancelDownload = (episodeId: string): void => {
// Abort active download
@@ -306,6 +393,11 @@ function createDownloadStore() {
try {
const { unlink } = await import("fs/promises");
await unlink(dl.filePath);
const dot = dl.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = dl.filePath.slice(0, dot) + ".jpg";
await unlink(coverPath);
}
} catch {
// File may already be gone
}
@@ -321,10 +413,18 @@ function createDownloadStore() {
};
/** Remove every download (active/queued/completed) belonging to a feed —
* abort in-flight transfers, drop queued items, delete files + metadata. */
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
* abort in-flight transfers, drop queued items, delete files + metadata.
* Also removes downloads of the same show made while it was unsubscribed
* (matched by podcastFeedUrl) so unsubscribing purges search downloads
* of that show too. */
const removeDownloadsForFeed = async (
feedId: string,
podcastFeedUrl?: string,
): Promise<void> => {
const eps = Array.from(downloads().values()).filter(
(d) => d.feedId === feedId,
(d) =>
d.feedId === feedId ||
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
);
for (const d of eps) {
cancelDownload(d.episodeId);
@@ -337,6 +437,24 @@ function createDownloadStore() {
return Array.from(downloads().values());
};
/** Downloads whose show is not subscribed — the "Unsubscribed Show
* Downloads" list shown in My Shows and the settings download manager.
* Reads feeds() so the list re-classifies (drops out) the moment the
* user subscribes to the show. Matched by feed id, or by the show's
* feed URL (covers downloads made before the show was subscribed). */
const getUnsubscribedDownloads = (): DownloadedEpisode[] => {
const feeds = useFeedStore().feeds();
return Array.from(downloads().values()).filter((d) => {
if (feeds.some((f) => f.id === d.feedId)) return false;
if (d.podcastFeedUrl) {
return !feeds.some(
(f) => f.podcast.feedUrl === d.podcastFeedUrl,
);
}
return true;
});
};
/** Get the current queue */
const getQueue = (): QueueItem[] => {
return queue();
@@ -354,11 +472,13 @@ function createDownloadStore() {
getDownload,
getDownloadedFilePath,
getAllDownloads,
getUnsubscribedDownloads,
getQueue,
getActiveCount,
// Actions
startDownload,
startUnsubscribedDownload,
cancelDownload,
removeDownload,
removeDownloadsForFeed,

View File

@@ -11,6 +11,8 @@ import type { Episode } from "../types/episode";
import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser";
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials";
import {
loadFeedsFromFile,
saveFeedsToFile,
@@ -18,6 +20,7 @@ import {
saveSourcesToFile,
} from "../utils/feeds-persistence";
import { useDownloadStore } from "./download";
import { useAppStore } from "./app";
import { DownloadStatus } from "../types/episode";
/** Max episodes to load per page/chunk */
@@ -26,6 +29,13 @@ const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20;
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
* the background refresh loop. */
const FETCH_TIMEOUT_MS = 20_000;
/** Default minutes between automatic background feed refreshes. */
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
const fullEpisodeCache = new Map<string, Episode[]>();
@@ -42,6 +52,61 @@ function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources);
}
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
* keychain, marking the source hasCredentials and stripping the plaintext.
* When the keychain is unavailable the plaintext stays (marked as the
* plaintext storage backend) so the source keeps working.
* Returns the same array when nothing needed migrating. */
async function migratePlaintextCredentials(
sources: PodcastSource[],
): Promise<PodcastSource[]> {
let changed = false;
const migrated: PodcastSource[] = [];
for (const source of sources) {
if (
source.id === "podcastindex" &&
source.apiKey &&
source.apiSecret &&
!source.hasCredentials
) {
const ok = await savePodcastIndexCredentials(
source.apiKey,
source.apiSecret,
);
if (ok) {
migrated.push({
...source,
apiKey: undefined,
apiSecret: undefined,
hasCredentials: true,
credentialStorage: "keychain",
});
} else {
migrated.push({
...source,
hasCredentials: true,
credentialStorage: "plaintext",
});
}
changed = true;
continue;
}
migrated.push(source);
}
return changed ? migrated : sources;
}
/** True when two episode lists hold the same episodes (id-set equality,
* order-insensitive). Refreshes compare fetched content against this so an
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
* "updated" sort — instead of reordering the list on every background
* refresh. */
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
if (a.length !== b.length) return false;
const ids = new Set(a.map((e) => e.id));
return b.every((e) => ids.has(e.id));
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
@@ -143,20 +208,27 @@ function createFeedStore() {
);
};
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
* Returns NULL when the feed could not be fetched (network error, non-OK
* response, timeout) — callers must treat null as "unchanged" and keep
* the previously loaded episodes. A failed refresh must never look like
* an empty feed, or the store would wipe a subscribed show's episodes. */
const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[]> => {
): Promise<Episode[] | null> => {
try {
const response = await fetch(feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
// Hung feeds must not stall a refresh batch (or the background
// refresh loop) indefinitely.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return [];
if (!response.ok) return null;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
@@ -169,7 +241,7 @@ function createFeedStore() {
return allEpisodes.slice(0, limit);
} catch {
return [];
return null;
}
};
@@ -184,6 +256,17 @@ function createFeedStore() {
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
): Promise<Feed | null> => {
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
// feed URL; resolve the real feed from its directory page before
// subscribing. Refuse when it can't be resolved rather than adding a
// broken feed.
if (!podcast.feedUrl) {
if (!podcast.directoryUrl) return null;
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
if (!resolved) return null;
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
}
// Guard: don't add a feed we already have (matched by feedUrl)
if (hasFeedByUrl(podcast.feedUrl)) {
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
@@ -198,7 +281,7 @@ function createFeedStore() {
const newFeed: Feed = {
id: feedId,
podcast,
episodes,
episodes: episodes ?? [],
visibility,
sourceId,
lastUpdated: new Date(),
@@ -209,81 +292,184 @@ function createFeedStore() {
saveFeeds(updated);
return updated;
});
// Global auto-download: newly subscribed shows join the next pass.
runAutoDownload();
return newFeed;
};
/** Auto-download newest episodes for a feed */
const autoDownloadEpisodes = (
feedId: string,
newEpisodes: Episode[],
count: number,
) => {
/** Download the N most recent episodes of every in-scope show, per the
* global auto-download preferences (master toggle + scope + whitelist +
* count). Skips episodes already downloaded, queued, or in flight;
* retries failed ones. Idempotent — safe to run after any settings
* change, feed refresh, or subscribe. */
const runAutoDownload = (): void => {
const app = useAppStore();
const prefs = app.state().preferences;
if (!prefs.autoDownload || prefs.autoDownloadScope === "none") return;
const whitelist = prefs.autoDownloadWhitelist ?? [];
const count = Math.max(1, prefs.autoDownloadCount ?? 2);
const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
for (const feed of feeds()) {
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
prefs.autoDownloadScope === "whitelist" &&
!whitelist.includes(feed.id)
) {
dlStore.startDownload(ep, feedId);
continue;
}
const sorted = [...feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
for (const ep of sorted.slice(0, count)) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feed.id);
}
}
}
};
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
* only when the content actually changed (see sameEpisodes). Returns the
* ORIGINAL array reference when nothing changed so callers skip
* persistence entirely — a refresh that fetched identical episodes must
* not re-sort the "updated" view. */
const applyRefreshedEpisodes = (
prev: Feed[],
feedId: string,
episodes: Episode[],
): Feed[] => {
let changed = false;
const updated = prev.map((f) => {
if (f.id !== feedId) return f;
if (sameEpisodes(f.episodes, episodes)) return f;
changed = true;
return { ...f, episodes, lastUpdated: new Date() };
});
return changed ? updated : prev;
};
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
// Fetch failed (null): keep the currently loaded episodes untouched.
if (!episodes) return;
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (updated !== prev) saveFeeds(updated);
return updated;
});
// Auto-download new episodes if enabled for this feed
if (feed.autoDownload) {
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
if (newEpisodes.length > 0) {
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
}
}
// Global auto-download: ensure the N most recent episodes of in-scope
// shows are available offline after every refresh (idempotent).
runAutoDownload();
};
/** Refresh all feeds */
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
* atomic update. Per-feed incremental setFeeds re-sorted the list once
* per completion (each refresh bumped lastUpdated and the "updated" sort
* re-ran), which showed up as the list order flapping until the batch
* finished. */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
const currentFeeds = feeds();
for (const feed of currentFeeds) {
await refreshFeed(feed.id);
}
const results = await Promise.all(
currentFeeds.map(async (feed) => [
feed.id,
await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feed.id,
),
] as const),
);
setFeeds((prev) => {
let updated = prev;
for (const [feedId, episodes] of results) {
// A failed fetch (null) leaves that feed untouched.
if (!episodes) continue;
updated = applyRefreshedEpisodes(updated, feedId, episodes);
}
if (updated !== prev) saveFeeds(updated);
return updated;
});
// Global auto-download: one idempotent pass after the batch.
runAutoDownload();
} finally {
setIsLoadingFeeds(false);
}
};
// Resolves once the persisted feeds are loaded and visible to feeds() —
// before the background refresh so boot-time consumers (player-session
// restore) don't wait on the network.
const { promise: feedsReady, resolve: resolveFeedsReady } =
Promise.withResolvers<void>();
(async () => {
const loadedFeeds = await loadFeedsFromFile();
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
resolveFeedsReady();
const loadedSources = await loadSourcesFromFile<PodcastSource>();
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
// The default "rss" placeholder source fabricated fake search results
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
// too. User-added custom feeds keep their own ids and are untouched.
const migratedSources =
loadedSources?.filter((source) => source.id !== "rss") ?? [];
// Default sources fill gaps in persisted configs (so new defaults like
// the Podcast Index fallback reach existing installs), while a
// persisted source with the same id always wins over its default —
// user edits (keys, enabled, country) are never clobbered.
const mergedSources = [
...migratedSources,
...DEFAULT_SOURCES.filter(
(defaultSource) =>
!migratedSources.some((s) => s.id === defaultSource.id),
),
];
if (mergedSources.length > 0) {
// One-time credential migration: sources persisted with plaintext
// apiKey/apiSecret (pre-keychain builds) move into the macOS
// keychain and are stripped from config.json.
const secured = await migratePlaintextCredentials(mergedSources);
setSources(secured);
if (secured !== mergedSources) saveSources(secured);
}
await refreshAllFeeds();
})();
// ── Background refresh ──────────────────────────────────────────────────
// New episodes only reach the app while it runs if feeds are re-fetched
// on a schedule: startup and manual `r` alone leave a subscribed show's
// latest episode invisible until the user restarts (or presses r). A
// self-rescheduling timer re-reads the interval preference on every tick
// so a settings change takes effect without a restart, and skips a tick
// that would overlap an in-flight refresh (manual or background).
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleNextRefresh = () => {
if (refreshTimer) clearTimeout(refreshTimer);
const minutes = Math.max(
1,
useAppStore().state().preferences.refreshIntervalMinutes ??
DEFAULT_REFRESH_INTERVAL_MINUTES,
);
refreshTimer = setTimeout(() => {
if (!isLoadingFeeds()) {
refreshAllFeeds().catch(() => {});
}
scheduleNextRefresh();
}, minutes * 60_000);
};
scheduleNextRefresh();
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
@@ -359,7 +545,7 @@ function createFeedStore() {
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (sourceId === "itunes" || sourceId === "rss") return false;
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);
@@ -385,6 +571,16 @@ function createFeedStore() {
return feeds().find((f) => f.id === feedId);
};
/** Find an episode by ID across all loaded feeds (undefined when the
* episode isn't in any loaded window, e.g. an unsubscribed show). */
const findEpisode = (episodeId: string): Episode | undefined => {
for (const feed of feeds()) {
const ep = feed.episodes.find((e) => e.id === episodeId);
if (ep) return ep;
}
return undefined;
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
@@ -399,64 +595,87 @@ function createFeedStore() {
return loaded < cached.length;
};
/** Load the next chunk of episodes for one feed from the cache.
* No global guard — callers own the `isLoadingMore` flag so batches
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
const loadMoreEpisodesForFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed
if (!cached) {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
);
if (newCount <= currentCount) return; // nothing more to load
episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Load the next chunk of episodes for a feed from the cache.
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
const loadMoreEpisodes = async (feedId: string) => {
if (isLoadingMore()) return;
const feed = getFeed(feedId);
if (!feed) return;
setIsLoadingMore(true);
try {
let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed
if (!cached) {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
);
if (newCount <= currentCount) return; // nothing more to load
episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f,
);
saveFeeds(updated);
return updated;
});
await loadMoreEpisodesForFeed(feedId);
} finally {
setIsLoadingMore(false);
}
};
/** Set auto-download settings for a feed */
const setAutoDownload = (
feedId: string,
enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
* still has cached episodes — powers the Feed page's "[Fetch More]". */
const loadMoreAllFeeds = async () => {
if (isLoadingMore()) return;
setIsLoadingMore(true);
try {
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
for (const feed of pending) {
await loadMoreEpisodesForFeed(feed.id);
}
} finally {
setIsLoadingMore(false);
}
};
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
return {
@@ -467,10 +686,15 @@ function createFeedStore() {
selectedFeedId,
isLoadingMore,
/** Resolves once persisted feeds are loaded from disk (before the
* background refresh). */
whenReady: () => feedsReady,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
findEpisode,
getSelectedFeed,
hasMoreEpisodes,
isLoadingFeeds,
@@ -487,11 +711,13 @@ function createFeedStore() {
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
loadMoreAllFeeds,
hasMoreAcrossAll,
addSource,
removeSource,
toggleSource,
updateSource,
setAutoDownload,
runAutoDownload: runAutoDownloadNow,
};
}

View File

@@ -53,11 +53,17 @@ async function initProgress(): Promise<void> {
setProgressMap(parsed);
}
// Fire-and-forget init
initProgress();
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
// consumers (e.g. player-session restore) can await the file load.
const progressInit = initProgress();
function createProgressStore() {
return {
/**
* Resolves once the persisted progress map has been loaded from disk.
*/
whenReady: () => progressInit,
/**
* Get progress for a specific episode.
*/

View File

@@ -4,12 +4,16 @@
*/
import { createSignal } from "solid-js";
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
import {
loadSearchHistoryFromFile,
saveSearchHistoryToFile,
} from "../utils/app-persistence";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
import type { SearchResult, SearchScope } from "../types/source";
const STORAGE_KEY = "podtui_search_history";
const MAX_HISTORY = 20;
const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 10;
export interface SearchState {
query: string;
@@ -20,22 +24,37 @@ export interface SearchState {
const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */
function loadHistory(): string[] {
if (typeof localStorage === "undefined") return [];
/** Normalize raw history: drop blanks, dedupe case-insensitively (newest
* wins), cap at MAX_HISTORY. */
function sanitizeHistory(items: string[]): string[] {
const seen = new Set<string>();
const cleaned: string[] = [];
for (const item of items) {
const trimmed = item.trim();
const key = trimmed.toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
cleaned.push(trimmed);
}
return cleaned.slice(0, MAX_HISTORY);
}
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
function loadScope(): SearchScope {
if (typeof localStorage === "undefined") return "podcast";
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
return stored === "episode" ? "episode" : "podcast";
} catch {
return [];
return "podcast";
}
}
/** Save search history to localStorage */
function saveHistory(history: string[]): void {
/** Save search scope to localStorage */
function saveScope(scope: SearchScope): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
} catch {
// Ignore errors
}
@@ -48,8 +67,24 @@ export function createSearchStore() {
const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [history, setHistory] = createSignal<string[]>([]);
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** Load search history from file (fire-and-forget; recents appear as
* soon as the file is read). */
async function init(): Promise<void> {
const loaded = await loadSearchHistoryFromFile();
if (loaded.length > 0) setHistory(sanitizeHistory(loaded));
}
init();
/** Set the search scope (shows vs episodes) and persist it. */
const setScope = (next: SearchScope) => {
setScopeState(next);
saveScope(next);
};
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds();
@@ -110,9 +145,14 @@ export function createSearchStore() {
return;
}
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
const searchResults =
scope() === "episode"
? await searchEpisodes(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
: await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
setResults(applySubscribedStatus(searchResults));
} catch (e) {
@@ -130,9 +170,8 @@ export function createSearchStore() {
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
const updated = sanitizeHistory([q, ...prev]);
saveSearchHistoryToFile(updated);
return updated;
});
};
@@ -140,14 +179,14 @@ export function createSearchStore() {
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveHistory([]);
saveSearchHistoryToFile([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
saveHistory(updated);
saveSearchHistoryToFile(updated);
return updated;
});
};
@@ -179,6 +218,27 @@ export function createSearchStore() {
);
};
/** Mark a podcast as unsubscribed in results (after an in-place
* unsubscribe from the results list). */
const markUnsubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId;
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: false,
},
};
}
return result;
}),
);
};
return {
// State
query,
@@ -187,6 +247,7 @@ export function createSearchStore() {
error,
history,
selectedSources,
scope,
// Actions
search,
@@ -195,7 +256,9 @@ export function createSearchStore() {
clearHistory,
removeFromHistory,
setSelectedSources,
setScope,
markSubscribed,
markUnsubscribed,
};
}

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

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

View File

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

View File

@@ -33,10 +33,6 @@ export interface Feed {
isPinned: boolean
/** Feed color for UI */
color?: string
/** Whether auto-download is enabled for this feed */
autoDownload?: boolean
/** Number of newest episodes to auto-download (0 = all new) */
autoDownloadCount?: number
}
/** Feed item for display in lists */

View File

@@ -12,8 +12,12 @@ export interface Podcast {
description: string
/** Cover image URL */
coverUrl?: string
/** RSS feed URL */
/** RSS feed URL. Empty when the directory lists the show without a feed
* (e.g. shows delisted from Apple Podcasts); see directoryUrl. */
feedUrl: string
/** Directory listing page (e.g. Apple Podcasts) for shows whose feed URL
* the directory omits — used to resolve the real feed at subscribe time. */
directoryUrl?: string
/** Author/creator name */
author?: string
/** Podcast categories */

View File

@@ -62,6 +62,8 @@ export type DesktopTheme = {
};
export type VisualizerSettings = {
/** Master on/off switch for the player's realtime waveform (default: on). */
enabled: boolean;
/** Number of frequency bars (8128, default: 64) */
bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
@@ -78,17 +80,37 @@ export type AppSettings = {
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
/** Playback volume 01 (default: 1 = 100%). */
volume: number;
downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
/** Show the `` cursor marker on the focused row of every list (default: off). */
showSelectionMarker: boolean;
visualizer: VisualizerSettings;
};
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */
export type AutoDownloadScope = "all" | "none" | "whitelist";
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
/** Most recent episodes to auto-download per in-scope show (default: 2). */
autoDownloadCount: number;
/** Shows auto-download covers: all / none / whitelist (default: all). */
autoDownloadScope: AutoDownloadScope;
/** Feed ids in the auto-download whitelist (used when scope is "whitelist"). */
autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */
refreshIntervalMinutes: number;
};
export type AppState = {

View File

@@ -2,6 +2,9 @@
* Podcast source type definitions for PodTUI
*/
import type { Episode } from "./episode"
import type { Podcast } from "./podcast"
/** Source type enumeration */
export enum SourceType {
/** RSS feed URL */
@@ -22,8 +25,21 @@ export interface PodcastSource {
type: SourceType
/** Base URL for the source */
baseUrl: string
/** API key (if required) */
/** API key — live only when the keychain is unavailable and the source
* uses the plaintext fallback (credentialStorage "plaintext"). Legacy
* plaintext keys are migrated to the OS keychain on load and stripped. */
apiKey?: string
/** API secret (e.g. Podcast Index signature auth) — same lifecycle as
* apiKey: held in the OS keychain by default, live on the source only
* under the plaintext fallback. */
apiSecret?: string
/** True when this source's credentials are stored. A source is usable once
* enabled. */
hasCredentials?: boolean
/** Where this source's credentials live: the OS keychain (encrypted at
* rest) by default, or config.json as a plaintext fallback when the
* keychain is unavailable (e.g. non-macOS). */
credentialStorage?: "keychain" | "plaintext"
/** Whether source is enabled */
enabled: boolean
/** Source icon/logo URL */
@@ -78,20 +94,39 @@ export enum SearchSortField {
POPULARITY = "popularity",
}
/** Search result */
export interface SearchResult {
/** What a directory search targets: shows or individual episodes. */
export type SearchScope = "podcast" | "episode"
/** Fields shared by every search result. */
export interface SearchResultBase {
/** Source that returned this result */
sourceId: string
/** Source display name */
sourceName?: string
/** Source type */
sourceType?: SourceType
/** Podcast data */
podcast: import("./podcast").Podcast
/** Relevance score (0-1) */
score?: number
}
/** A show found by directory search. */
export interface PodcastSearchResult extends SearchResultBase {
kind: "podcast"
/** Podcast data */
podcast: Podcast
}
/** A single episode found by directory search. `podcast` is its parent show
* — used for display context and for subscribing to the show. */
export interface EpisodeSearchResult extends SearchResultBase {
kind: "episode"
podcast: Podcast
episode: Episode
}
/** Search result */
export type SearchResult = PodcastSearchResult | EpisodeSearchResult
/** Default podcast sources */
export const DEFAULT_SOURCES: PodcastSource[] = [
{
@@ -106,11 +141,14 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
allowExplicit: true,
},
{
id: "rss",
name: "RSS Feed",
type: SourceType.RSS,
baseUrl: "",
enabled: true,
description: "Add podcasts via RSS feed URL",
id: "podcastindex",
name: "Podcast Index",
type: SourceType.API,
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
enabled: false,
description:
"Open podcast directory. Fallback when other sources return few results; requires a free API key + secret from podcastindex.org.",
language: "en",
allowExplicit: true,
},
]

View File

@@ -274,7 +274,7 @@ function CommandDialog(props: {
{/* Search input */}
<box marginBottom={1}>
<text fg={theme.textMuted}>{"> "}</text>
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
<text fg={theme.accent}>{filter() || "Type to search commands..."}</text>
</box>
{/* Command list */}

View File

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

View File

@@ -0,0 +1,363 @@
/**
* Position-indexed PCM cache for visualization.
*
* One ffmpeg process decodes the episode's audio at 4x realtime (with an
* 8s initial burst — fast enough to serve bars and seeks instantly, throttled
* enough that a remote episode isn't ripped at 84x while mpv is trying to
* start playback) into an in-memory cache indexed by ABSOLUTE playback time.
* The renderer then reads the PCM
* window ending at the player's current position with zero sync machinery:
* there is no pacing (-readrate), no lead-burst, no decode-head/player
* drift math, no ring wrap, and nothing that knows or cares about pause,
* resume, seek, or playback speed — those all collapse to "read at a
* different position in the cache".
*
* Pause/resume contract (the failure mode of the old design):
* - pauseDecode() kills ffmpeg but KEEPS the cache. Resume reads from it
* instantly and resumes the tail decode in the background.
* - Reads outside decoded coverage (startup, seek into an undecoded hole)
* return 0 — the renderer HOLDS the last rendered frame rather than
* freezing on a clamped buffer or decaying into junk bars.
*
* Seeks into undecoded territory start a fresh SEGMENT (a second decode
* pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree.
*
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
* high-cutoff of the visualizer's FFT config.
*
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for
* file inputs).
*/
import type { Subprocess } from "bun";
/** PCM output format constants */
export const PCM_SAMPLE_RATE = 22050;
const BYTES_PER_SAMPLE = 2; // s16le
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
/**
* Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0;
interface Segment {
/** Playback seconds where this segment's first sample sits. */
baseSec: number;
/** Sample buffer; capacity >= written, doubled on overflow. */
samples: Int16Array;
/** Samples written so far (== decoded length of the segment). */
written: number;
/** ffmpeg reached stream EOF while writing this segment — nothing more
* will ever arrive after its end. */
finished: boolean;
}
export interface EpisodePcmCacheOptions {
/** Audio URL or file path to decode */
url: string;
/** Sample rate (default: 22050) */
sampleRate?: number;
}
export class EpisodePcmCache {
private proc: Subprocess | null = null;
private segments: Segment[] = [];
private generation = 0;
private _decoding = false;
/** Base offset (playback seconds) of the running decode pass; null when idle. */
private activeBaseSec: number | null = null;
readonly url: string;
readonly sampleRate: number;
constructor(options: EpisodePcmCacheOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
}
/** Whether an ffmpeg decode pass is currently running. */
get decoding(): boolean {
return this._decoding;
}
/** End (playback seconds) of the furthest-decoded segment. */
get coverageEndSec(): number {
let end = 0;
for (const seg of this.segments) {
const segEnd = seg.baseSec + seg.written / this.sampleRate;
if (segEnd > end) end = segEnd;
}
return end;
}
/** Whether the furthest segment finished at stream EOF. */
get decodeFinished(): boolean {
let maxEnd = -1;
let finished = false;
for (const seg of this.segments) {
const segEnd = seg.baseSec + seg.written / this.sampleRate;
if (segEnd > maxEnd) {
maxEnd = segEnd;
finished = seg.finished;
}
}
return finished;
}
/**
* Start decoding at `fromSec` of playback time into a fresh segment.
* Kills any in-flight pass first; existing segments stay readable.
*/
startDecode(fromSec: number): void {
this.killProcess();
if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization");
}
this.generation = ++globalGeneration;
const myGeneration = this.generation;
const segment: Segment = {
baseSec: Math.max(0, fromSec),
samples: new Int16Array(INITIAL_CAPACITY_SAMPLES),
written: 0,
finished: false,
};
this.segments.push(segment);
const args = ["ffmpeg", "-loglevel", "quiet"];
// Pace the decode at 4x realtime (with an 8s initial burst) instead of
// flat-out: unthrottled decode measures ~84x realtime, which pulls the
// ENTIRE episode from the network within the first minute of playback
// (~160MB/hr) and starves mpv's own buffering right at startup. 4x
// still fills the cache 4x faster than playback consumes it, lands a
// 75-min episode in ~19 min of background work, and the burst makes
// the first bars available immediately.
args.push("-readrate", "4", "-readrate_initial_burst", "8");
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
// input-open when the input is a local file, killing the process
// before any PCM is produced. Only pass them for network URLs.
if (/^https?:\/\//i.test(this.url)) {
args.push(
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
);
}
// Seek before input for network efficiency (container-level skip is
// near-instant for mp3/aac; no pre-position decode burn).
if (fromSec > 0) {
args.push("-ss", String(Math.max(0, fromSec)));
}
args.push(
"-i",
this.url,
"-ac",
"1",
"-ar",
String(this.sampleRate),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-",
);
this.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
this._decoding = true;
this.activeBaseSec = segment.baseSec;
this.readLoop(myGeneration, segment);
this.proc.exited
.then((code) => {
if (this.generation === myGeneration) {
this._decoding = false;
this.activeBaseSec = null;
// Exit 0 == decoded to stream EOF.
if (code === 0) segment.finished = true;
}
})
.catch(() => {
if (this.generation === myGeneration) {
this._decoding = false;
this.activeBaseSec = null;
}
});
}
/**
* Whether `sec` of playback time has decoded PCM on hand.
*/
covers(sec: number): boolean {
const idx = Math.round(sec * this.sampleRate);
for (const seg of this.segments) {
const base = Math.round(seg.baseSec * this.sampleRate);
if (idx >= base && idx < base + seg.written) return true;
}
return false;
}
/**
* Make sure decode is progressing toward `sec`: no-op while a pass is
* running or the episode is fully decoded; otherwise resumes the tail
* decode from the frontier (when `sec` is inside coverage) or starts a
* new segment at `sec` (seek into a hole / resume past cached audio).
*/
ensureDecodeAround(sec: number): void {
if (this._decoding) {
// A decode pass fills monotonically FORWARD from its base. Only a
// target at/after the active base is eventually covered by it —
// a target BEHIND the base (seek into an undecoded hole ahead of
// the active pass) never is: kill the pass and restart at sec.
if (this.activeBaseSec !== null && sec >= this.activeBaseSec) return;
this.startDecode(Math.max(0, sec));
return;
}
if (this.covers(sec)) {
// Covered here: continue the tail so the cache keeps filling
// past the position (unless the whole episode is decoded).
if (this.decodeFinished) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
return;
}
// Seek into an undecoded region: start a fresh segment there.
this.startDecode(Math.max(0, sec));
}
/**
* Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects).
*
* Returns the number of samples written: `out.length` on a full hit, 0
* when the window is not (fully) decoded yet — the caller HOLDS the
* last rendered frame instead of rendering partial/stale data.
*/
readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0;
const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) {
const base = Math.round(seg.baseSec * this.sampleRate);
if (startIdx < base || endIdx >= base + seg.written) continue;
const rel = startIdx - base;
const src = seg.samples;
for (let i = 0; i < out.length; i++) {
out[i] = src[rel + i];
}
return out.length;
}
return 0;
}
/**
* Pause contract: kill the ffmpeg pass but KEEP every decoded segment.
* Resume later serves bars from the cache instantly.
*/
pauseDecode(): void {
this.generation = ++globalGeneration;
this._decoding = false;
this.activeBaseSec = null;
this.killProcess();
}
/** Kill the decode pass AND drop all cached audio. */
stop(): void {
this.pauseDecode();
this.segments = [];
}
/** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void {
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
}
/** Internal: continuously reads stdout from ffmpeg and appends samples
* to the segment at their absolute playback-time offsets. */
private async readLoop(myGeneration: number, segment: Segment): Promise<void> {
const stdout = this.proc?.stdout;
if (!stdout || typeof stdout === "number") return;
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
// s16 sample pairs can straddle pipe chunk boundaries: carry a lone
// trailing byte into the next chunk (dropping it would byte-flip
// every sample that follows).
let carry: number | null = null;
try {
while (this.generation === myGeneration) {
const { done, value } = await reader.read();
if (done || this.generation !== myGeneration) break;
if (!value || value.byteLength === 0) continue;
let view: Uint8Array = value;
if (carry !== null) {
const merged = new Uint8Array(1 + value.byteLength);
merged[0] = carry;
merged.set(value, 1);
view = merged;
carry = null;
}
if (view.byteLength % BYTES_PER_SAMPLE !== 0) {
carry = view[view.byteLength - 1];
view = view.subarray(0, view.byteLength - 1);
}
const sampleCount = view.byteLength / BYTES_PER_SAMPLE;
if (sampleCount === 0) continue;
if (segment.written + sampleCount > segment.samples.length) {
const grown = new Int16Array(
Math.max(
segment.samples.length * 2,
segment.written + sampleCount,
),
);
grown.set(segment.samples.subarray(0, segment.written));
segment.samples = grown;
}
// Int16Array view over the byte buffer: s16le is the platform's
// native endianness on every supported target (arm64/x64 are LE).
const src = new Int16Array(
view.buffer,
view.byteOffset,
sampleCount,
);
segment.samples.set(src, segment.written);
segment.written += sampleCount;
}
} catch {
// Stream ended or process killed — expected during stop()
} finally {
try {
reader.releaseLock();
} catch {
/* ignore */
}
}
}
}

View File

@@ -6,12 +6,31 @@
* restart. When mpv isn't installed there is no fallback: the no-op backend
* surfaces "No audio player found" honestly rather than degrading through
* players that can't change speed/volume without restarting.
*
* The backend owns ONE RESIDENT mpv daemon (`--idle=yes --keep-open=yes`)
* for the app's lifetime instead of spawning a fresh player per episode:
*
* - Play/pause/seek are IPC commands on a persistent Unix-socket
* connection — no process spawn, no socket connect/disconnect churn per
* poll, no `waitForSocket` on the play path. Measured command latency is
* single-digit ms; a mid-episode resume after pause takes ~300ms on a
* network stream.
* - State (time-pos, pause, duration) is OBSERVED (`observe_property`):
* mpv pushes time-pos at ~20Hz while playing, so `getPosition()` /
* `getPauseState()` read a cache instead of round-tripping the socket on
* every 150ms UI tick. External pauses (AirPod removal, system sleep,
* Now Playing center) arrive as pause property events with zero polling.
* - A restored session can PRELOAD: the episode is loaded paused so mpv
* fills its demuxer cache ahead of time; the first real play just flips
* `pause` to false — the ~2s network open is paid at boot, not on the
* user's first Play.
*/
import { platform } from "os";
import { existsSync } from "fs";
import { existsSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { dirname, join } from "path";
import type { Socket, Subprocess } from "bun";
// ── Types ────────────────────────────────────────────────────────────
@@ -30,6 +49,18 @@ export interface AudioState {
export interface AudioBackend {
readonly name: BackendName;
play(url: string, opts?: PlayOptions): Promise<void>;
/**
* Load the URL paused WITHOUT starting playback, so the player buffers
* ahead of the user's first Play (used for boot session restore).
* A subsequent play() of the SAME url flips pause off — near-instant.
*/
preload(url: string, opts?: PlayOptions): Promise<void>;
/**
* Attach a cover-art image to the currently-loaded file at runtime
* (mpv `video-add`). Lets play() start without waiting on art; the
* Now Playing artwork pops in when the download lands.
*/
addCoverArt(path: string): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
@@ -39,6 +70,15 @@ export interface AudioBackend {
getPosition(): Promise<number>;
getDuration(): Promise<number>;
isPlaying(): boolean;
/** Live pause state: `true` paused, `false` playing, `undefined` when
* unknown (player unreachable / not yet loaded). Unlike `isPlaying()` —
* which reflects only commands PodTUI sent — this reflects the player's
* real state, including pauses initiated OUTSIDE PodTUI (system
* sleep/lock, AirPod removal, device swap, OS media keys, the Now
* Playing center). */
getPauseState(): Promise<boolean | undefined>;
/** True while the player process is running (regardless of pause). */
isAlive(): boolean;
dispose(): void;
}
@@ -46,6 +86,8 @@ export interface PlayOptions {
startPosition?: number;
volume?: number;
speed?: number;
mediaTitle?: string;
coverArtPath?: string;
}
// ── Utilities ────────────────────────────────────────────────────────
@@ -68,278 +110,575 @@ function which(cmd: string): string | null {
return null;
}
let mpvInstance = 0;
function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
// Per-instance, not just per-pid: tests (and backend switching) create
// several MpvBackend objects in ONE bun process — a pid-only path makes
// every daemon bind the same socket, so later daemons unlink the path
// out from under earlier ones and IPC cross-talks between backends.
return join(
tmpdir(),
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
);
}
/**
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
* makes macOS attribute its Now Playing session to PodTui — source-app icon
* and name in Control Center — instead of a blank placeholder for an
* unbundled binary.
*
* The bundled copy is verified to actually launch: it links against brew's
* dylibs by absolute path, and a Homebrew ffmpeg major upgrade can break it
* (dylib gone → immediate non-zero exit). If the bundled binary can't run,
* fall back to PATH mpv so audio keeps working — the icon degrades to blank
* rather than playback dying. Probed once per process.
*/
let resolvedMpv: string | null | undefined; // undefined = not yet probed
function mpvLaunches(binary: string): boolean {
try {
const proc = Bun.spawnSync([binary, "--version"], { timeout: 3000 });
return proc.exitCode === 0;
} catch {
return false;
}
}
function resolveMpvBinary(): string | null {
if (resolvedMpv !== undefined) return resolvedMpv;
let resolved: string | null = null;
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled) && mpvLaunches(bundled)) {
resolved = bundled;
}
} catch {
/* process.execPath unusable — fall through to PATH */
}
if (!resolved) resolved = which("mpv");
resolvedMpv = resolved;
return resolved;
}
// ── mpv JSON IPC connection ─────────────────────────────────────────
//
// One persistent Unix-socket connection to the resident mpv daemon. Lines
// from mpv are either command responses (`request_id` present — correlated
// to the pending promise) or unsolicited traffic (property-change events
// from `observe_property`, end-file, ...), dispatched to the event handler.
interface MpvResponse {
error?: string;
data?: unknown;
request_id?: number;
}
interface MpvEvent {
event: string;
/** Observation id for property-change events. */
id?: number;
name?: string;
data?: unknown;
reason?: string;
error?: string;
}
type MpvEventHandler = (msg: MpvEvent) => void;
class MpvConnection {
private sock: Socket | null = null;
private buf = "";
private nextId = 1;
private pending = new Map<number, (msg: MpvResponse) => void>();
private eventWaiters = new Map<string, Array<(msg: MpvEvent) => void>>();
onEvent: MpvEventHandler = () => {};
async connect(path: string): Promise<void> {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let settled = false;
Bun.connect({
unix: path,
socket: {
open: (socket) => {
this.sock = socket;
if (!settled) {
settled = true;
resolve();
}
},
data: (_socket, data) => this.onData(data),
error: (_socket, err) => {
if (!settled) {
settled = true;
reject(err);
}
this.handleTeardown();
},
close: () => this.handleTeardown(),
},
}).catch((err) => {
if (!settled) {
settled = true;
reject(err);
}
});
await promise;
}
private onData(data: Uint8Array): void {
this.buf += Buffer.from(data).toString();
let nl = this.buf.indexOf("\n");
while (nl !== -1) {
const line = this.buf.slice(0, nl);
this.buf = this.buf.slice(nl + 1);
nl = this.buf.indexOf("\n");
if (!line.trim()) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line) as Record<string, unknown>;
} catch {
continue; // skip malformed lines
}
if (msg.request_id !== undefined) {
const resolve = this.pending.get(msg.request_id as number);
if (resolve) {
this.pending.delete(msg.request_id as number);
resolve(msg as MpvResponse);
}
} else if (typeof msg.event === "string") {
const event = msg as unknown as MpvEvent;
this.onEvent(event);
const waiters = this.eventWaiters.get(event.event);
if (waiters) {
this.eventWaiters.delete(event.event);
for (const w of waiters) w(event);
}
}
}
}
/** Socket died / daemon gone: fail all pending commands so no caller
* hangs on a dead connection. */
private handleTeardown(): void {
for (const resolve of this.pending.values()) {
resolve({ error: "connection-lost" });
}
this.pending.clear();
this.sock = null;
}
/** Send a command and await mpv's response (correlated by request_id).
* Resolves `{ error: "timeout" }` instead of hanging when mpv stalls. */
send(command: unknown[], timeoutMs = 2000): Promise<MpvResponse> {
const sock = this.sock;
if (!sock) return Promise.resolve({ error: "not-connected" });
const id = this.nextId++;
const { promise, resolve } = Promise.withResolvers<MpvResponse>();
const timeout = setTimeout(() => {
if (this.pending.delete(id)) resolve({ error: "timeout" });
}, timeoutMs);
this.pending.set(id, (msg) => {
clearTimeout(timeout);
resolve(msg);
});
sock.write(JSON.stringify({ command, request_id: id }) + "\n");
return promise;
}
/** One-shot wait for an mpv event by name. Register BEFORE the command
* that triggers it. Resolves null on timeout instead of hanging. */
waitEvent(name: string, timeoutMs = 5000): Promise<MpvEvent | null> {
const { promise, resolve } = Promise.withResolvers<MpvEvent | null>();
const list = this.eventWaiters.get(name) ?? [];
list.push(resolve);
this.eventWaiters.set(name, list);
setTimeout(() => {
const current = this.eventWaiters.get(name);
if (current) {
this.eventWaiters.set(
name,
current.filter((w) => w !== resolve),
);
}
resolve(null);
}, timeoutMs);
return promise;
}
close(): void {
try {
this.sock?.end();
} catch {
/* ignore */
}
this.handleTeardown();
}
}
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
// One resident daemon for the app's lifetime, controlled over a single
// persistent JSON IPC connection with property observation.
/** Property observation ids (correlate property-change events). */
const OBS_TIME_POS = 1;
const OBS_PAUSE = 2;
const OBS_DURATION = 3;
const OBS_EOF = 4;
export class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv";
private proc: ReturnType<typeof Bun.spawn> | null = null;
private proc: Subprocess | null = null;
private socketPath = mpvSocketPath();
private _playing = false;
private conn: MpvConnection | null = null;
/** Guarantee daemon startup runs once (concurrent play/preload). */
private startPromise: Promise<void> | null = null;
// Command intent: what PodTUI asked the player to do.
private _intentPlaying = false;
/** The file currently loaded via loadfile (null = idle). */
private _loadedUrl: string | null = null;
/** The current file was loadfile'd paused (preload) and not yet played. */
private _loadedPaused = false;
/** Set on end-file reason "eof"/"error"; cleared by the next loadfile. */
private _ended = false;
// Observed (player-reported) state, pushed by mpv property-change events.
private _position = 0;
private _duration = 0;
/** null until the first pause observation arrives. */
private _paused: boolean | null = null;
private _volume = 100;
private _speed = 1;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private _exited = false;
/** Last playback error reported via end-file reason "error". */
private _playbackError: string | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
// ── Daemon lifecycle ─────────────────────────────────────────────
private async ensureDaemon(): Promise<void> {
if (this.proc && !this._exited && this.conn) return;
if (this.startPromise) return this.startPromise;
this.startPromise = this.spawnDaemon().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
private async spawnDaemon(): Promise<void> {
// Clean up stale socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs");
unlinkSync(this.socketPath);
}
unlinkSync(this.socketPath);
} catch {
/* ignore */
}
const args = [
"mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
`--input-ipc-server=${this.socketPath}`,
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
`--speed=${opts?.speed ?? 1}`,
];
if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`);
}
args.push(url);
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
this._playing = true;
this._position = opts?.startPosition ?? 0;
this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1;
// Wait for socket to appear (mpv creates it async)
await this.waitForSocket(2000);
// Start polling position
this.startPolling();
// Detect process exit
this.proc = Bun.spawn(
[
resolveMpvBinary() ?? "mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
// Stay alive after finishing/unloading files; PodTUI owns one mpv
// for its whole session and switches episodes via loadfile.
"--idle=yes",
"--keep-open=yes",
// Cap the demuxer cache. mpv's defaults (150MiB) make it race
// to fill while a preload sits paused — measured 45MB pulled
// within 12s of a boot-restore preload, saturating the link
// exactly when everything else is starting up. ~90s forward
// target / 40MiB hard cap is a few MB at podcast bitrates:
// plenty for instant resume + stall resilience.
"--cache-secs=90",
"--demuxer-max-bytes=40MiB",
"--demuxer-max-back-bytes=20MiB",
`--input-ipc-server=${this.socketPath}`,
],
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
);
this._exited = false;
this.proc.exited
.then(() => {
this._playing = false;
this.stopPolling();
this._exited = true;
this._intentPlaying = false;
this._loadedUrl = null;
this._paused = null;
})
.catch(() => {});
}
private async waitForSocket(timeoutMs: number): Promise<void> {
// mpv creates the socket asynchronously (measured ~600ms cold spawn).
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (existsSync(this.socketPath)) return;
while (Date.now() - start < 3000) {
if (this._exited) break;
if (existsSync(this.socketPath)) break;
await new Promise((r) => setTimeout(r, 50));
}
const conn = new MpvConnection();
conn.onEvent = (msg) => this.handleEvent(msg);
await conn.connect(this.socketPath);
this.conn = conn;
// Observe the state the UI polls: mpv then pushes changes at ~20Hz
// while playing and broadcasts external changes (AirPods pull, OS
// media keys) with zero polling from our side.
await this.send(["observe_property", OBS_TIME_POS, "time-pos"]);
await this.send(["observe_property", OBS_PAUSE, "pause"]);
await this.send(["observe_property", OBS_DURATION, "duration"]);
// With --keep-open=yes mpv does NOT emit end-file at natural EOF — it
// sets eof-reached=true (and pauses at the last frame) instead. That
// property is the track-end signal; end-file only covers unload/error.
await this.send(["observe_property", OBS_EOF, "eof-reached"]);
}
private async ipc(command: unknown[]): Promise<unknown> {
try {
const socket = await Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
// Response handling is done by reading below
},
error(_socket, err) {},
close() {},
open() {},
},
});
private async send(
command: unknown[],
): Promise<MpvResponse> {
if (!this.conn) return { error: "not-connected" };
return this.conn.send(command);
}
const payload = JSON.stringify({ command }) + "\n";
socket.write(payload);
// Read response with timeout
const response = await new Promise<string>((resolve) => {
let buf = "";
const reader = setInterval(() => {
// Check if we got a response already
if (buf.includes("\n")) {
clearInterval(reader);
resolve(buf);
}
}, 10);
setTimeout(() => {
clearInterval(reader);
resolve(buf);
}, 200);
});
socket.end();
if (response) {
try {
return JSON.parse(response.split("\n")[0]);
} catch {
return null;
private handleEvent(msg: MpvEvent): void {
if (msg.event === "property-change") {
if (msg.id === OBS_TIME_POS) {
// `data` is number while playing; unavailable → undefined while
// idle. Keep last known on transient gaps, reset on idle.
if (typeof msg.data === "number") this._position = msg.data;
} else if (msg.id === OBS_PAUSE) {
if (typeof msg.data === "boolean") this._paused = msg.data;
} else if (msg.id === OBS_DURATION) {
if (typeof msg.data === "number" && msg.data > 0) {
this._duration = msg.data;
}
} else if (msg.id === OBS_EOF) {
// Natural end-of-file (or a brand-new load reporting false).
this._ended = msg.data === true;
if (this._ended) this._intentPlaying = false;
}
return null;
} catch {
return null;
return;
}
}
/** Send a command over mpv's IPC and get the parsed response data. */
private async ipcCommand(command: unknown[]): Promise<unknown> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
});
const payload = JSON.stringify({ command }) + "\n";
conn.write(payload);
// Give mpv a moment to process, then read via a fresh connection
await new Promise((r) => setTimeout(r, 30));
conn.end();
return null;
} catch {
return null;
}
}
/** Send a fire-and-forget command (no response needed) */
private async send(command: unknown[]): Promise<void> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
});
conn.write(JSON.stringify({ command }) + "\n");
// Don't wait, just schedule a close
setTimeout(() => {
try {
conn.end();
} catch {}
}, 50);
} catch {
/* ignore */
}
}
/** Get a property value from mpv via IPC */
private async getProperty(name: string): Promise<number> {
try {
return await new Promise<number>((resolve) => {
let result = 0;
const timeout = setTimeout(() => resolve(result), 300);
Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
try {
const text = Buffer.from(data).toString();
const parsed = JSON.parse(text.split("\n")[0]);
if (parsed?.data !== undefined) {
result = Number(parsed.data) || 0;
}
} catch {
/* ignore parse errors */
}
clearTimeout(timeout);
resolve(result);
},
error() {
clearTimeout(timeout);
resolve(0);
},
close() {},
open(socket) {
socket.write(
JSON.stringify({ command: ["get_property", name] }) + "\n",
);
},
},
}).catch(() => {
clearTimeout(timeout);
resolve(0);
});
});
} catch {
return 0;
}
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(async () => {
if (!this._playing || !this.proc) return;
this._position = await this.getProperty("time-pos");
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
if (msg.event === "end-file") {
if (msg.reason === "eof") {
this._ended = true;
this._intentPlaying = false;
} else if (msg.reason === "error") {
this._ended = true;
this._intentPlaying = false;
this._playbackError = msg.error ?? "mpv failed to play the stream";
}
}, 500);
return;
}
if (msg.event === "file-loaded") {
this._ended = false;
}
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
// ── File presentation options ────────────────────────────────────
//
// force-media-title and cover-art-files are set as global properties
// BEFORE loadfile (verified: runtime-settable; values containing commas
// would corrupt the per-file options string). Numbers (volume, speed,
// start, pause) ride as per-file options on loadfile itself so each
// loadfile is self-contained.
private async applyPresentation(opts?: PlayOptions): Promise<void> {
await this.send([
"set_property",
"force-media-title",
opts?.mediaTitle ?? "",
]);
await this.send([
"set_property",
"cover-art-files",
opts?.coverArtPath ?? "",
]);
}
private loadfileOptions(opts: PlayOptions | undefined, paused: boolean): string {
const parts: string[] = [`pause=${paused ? "yes" : "no"}`];
if (opts?.startPosition && opts.startPosition > 0) {
parts.push(`start=${Math.max(0, opts.startPosition)}`);
}
const vol = Math.round((opts?.volume ?? 1) * 100);
if (Number.isFinite(vol)) parts.push(`volume=${vol}`);
const speed = opts?.speed ?? 1;
if (Number.isFinite(speed) && speed > 0) parts.push(`speed=${speed}`);
return parts.join(",");
}
/**
* Every loadfile (play, preload, replay) runs under this mutex: useAudio
* fires the boot preload unawaited, so without serialization a user
* pressing Play mid-preload would send loadfile(no-pause) followed by the
* in-flight preload's loadfile(pause=yes) — and the stale preload would
* pause the file the user just started. The mutex also prevents
* presentation options (title/cover) of one episode from interleaving
* with the loadfile of another.
*/
private loadMutex: Promise<unknown> = Promise.resolve();
private runLoadExclusive<T>(fn: () => Promise<T>): Promise<T> {
const result = this.loadMutex.then(fn);
this.loadMutex = result.catch(() => {});
return result;
}
private async loadFileLocked(
url: string,
opts: PlayOptions | undefined,
paused: boolean,
): Promise<void> {
await this.applyPresentation(opts);
// Paused preload of a mid-episode restore: pass NO start= option and
// seek while paused instead. mpv defers --start stream work (open,
// header probe, demuxer seek) until playback begins — measured: the
// demuxer cache stays EMPTY during the whole preload and the eventual
// unpause pays 4.3s. A time-pos seek while paused executes at once,
// so the stream opens and buffers during the preload, and the first
// real Play is a sub-second unpause.
const pausedSeek =
paused && opts?.startPosition && opts.startPosition > 0
? opts.startPosition
: null;
const loadOpts =
pausedSeek && opts ? { ...opts, startPosition: undefined } : opts;
// Register the file-loaded waiter BEFORE loadfile: the event can
// arrive between the command response and listener setup otherwise.
const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null;
const resp = await this.send([
"loadfile",
url,
"replace",
-1,
this.loadfileOptions(loadOpts, paused),
]);
if (resp.error && resp.error !== "success") {
throw new Error(`mpv loadfile failed: ${resp.error}`);
}
if (pausedSeek) {
// time-pos sent before file-loaded is silently dropped by mpv
// (no file yet) — the preload then parked at 0 and the restore
// position was lost. Wait for the open, then seek.
await fileLoaded;
await this.send(["set_property", "time-pos", pausedSeek]);
this._position = pausedSeek;
}
this._loadedUrl = url;
this._loadedPaused = paused;
this._ended = false;
this._playbackError = null;
this._position = opts?.startPosition ?? 0;
this._duration = 0;
this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1;
}
// ── AudioBackend ─────────────────────────────────────────────────
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.ensureDaemon();
// Mark intent before the mutex: a boot preload queued behind this
// play checks it and skips its own stale paused-load.
this._intentPlaying = true;
await this.runLoadExclusive(async () => {
// Fast path: this exact URL was PRELOADED paused (boot restore) —
// mpv has been buffering it since boot, so flipping pause off starts
// audio ~instantly. Re-acquire the start position only when it
// moved meaningfully since the preload (progress saved meanwhile).
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
const target = opts?.startPosition ?? this._position;
if (Math.abs(target - this._position) > 2) {
await this.send(["set_property", "time-pos", target]);
this._position = target;
}
await this.send([
"set_property",
"volume",
Math.round((opts?.volume ?? 1) * 100),
]);
await this.send(["set_property", "speed", opts?.speed ?? 1]);
if (opts?.coverArtPath) {
// File is already loaded: cover-art-files only applies at
// load, so add the art as a runtime albumart track instead.
await this.send(["set_property", "cover-art-files", opts.coverArtPath]);
await this.send(["video-add", opts.coverArtPath]);
}
if (opts?.mediaTitle) {
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
}
await this.send(["set_property", "pause", false]);
this._loadedPaused = false;
return;
}
await this.loadFileLocked(url, opts, false);
});
}
async preload(url: string, opts?: PlayOptions): Promise<void> {
await this.ensureDaemon();
await this.runLoadExclusive(async () => {
// Already loaded (paused park, or actively playing because the
// user pressed Play while this preload was queued — either way
// the file is in the player and must not be clobbered).
if (this._loadedUrl === url) return;
await this.loadFileLocked(url, opts, true);
this._intentPlaying = false;
});
}
async addCoverArt(path: string): Promise<void> {
if (!this._loadedUrl) return;
// Keep the property pointing at the latest art too, so a subsequent
// loadfile of the same episode carries it.
await this.send(["set_property", "cover-art-files", path]);
await this.send(["video-add", path]);
}
async pause(): Promise<void> {
await this.send(["set_property", "pause", true]);
this._playing = false;
this._intentPlaying = false;
}
async resume(): Promise<void> {
if (this._ended && this._loadedUrl) {
// Play pressed on a finished episode: replay from the top.
this._ended = false;
const url = this._loadedUrl;
await this.runLoadExclusive(async () => {
await this.loadFileLocked(
url,
{ volume: this._volume / 100, speed: this._speed },
false,
);
});
this._intentPlaying = true;
return;
}
if (this._loadedPaused && this._loadedUrl) {
// Deferred first play of a preloaded file.
this._loadedPaused = false;
}
this._ended = false;
await this.send(["set_property", "pause", false]);
this._playing = true;
this._intentPlaying = true;
}
async stop(): Promise<void> {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
if (this.conn && this._loadedUrl) {
await this.send(["stop"]);
}
this._playing = false;
this._intentPlaying = false;
this._loadedUrl = null;
this._loadedPaused = false;
this._ended = false;
this._position = 0;
// Clean up socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs");
unlinkSync(this.socketPath);
}
} catch {
/* ignore */
}
this._duration = 0;
await this.send(["set_property", "cover-art-files", ""]);
}
async seek(seconds: number): Promise<void> {
@@ -359,22 +698,55 @@ export class MpvBackend implements AudioBackend {
}
async getPosition(): Promise<number> {
// Observed at ~20Hz by mpv — no socket roundtrip on the UI poll.
return this._position;
}
async getDuration(): Promise<number> {
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
}
return this._duration;
}
isPlaying(): boolean {
return this._playing;
return this._intentPlaying && this.isAlive() && !this._ended;
}
async getPauseState(): Promise<boolean | undefined> {
if (!this.isAlive() || this._paused === null) return undefined;
return this._paused;
}
isAlive(): boolean {
return this.proc !== null && !this._exited;
}
/** Last mpv playback failure (end-file reason "error"), if any. */
getPlaybackError(): string | null {
return this._playbackError;
}
dispose(): void {
this.stop();
const conn = this.conn;
this.conn = null;
if (conn) {
// Ask nicely, then force: dispose runs inside process-exit
// handlers where awaiting is not guaranteed to complete.
conn.send(["quit"], 500).catch(() => {});
}
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
this._exited = true;
this._intentPlaying = false;
try {
unlinkSync(this.socketPath);
} catch {
/* ignore */
}
}
}
@@ -383,6 +755,8 @@ export class MpvBackend implements AudioBackend {
class NoopBackend implements AudioBackend {
readonly name: BackendName = "none";
async play(): Promise<void> {}
async preload(): Promise<void> {}
async addCoverArt(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async stop(): Promise<void> {}
@@ -398,6 +772,13 @@ class NoopBackend implements AudioBackend {
isPlaying(): boolean {
return false;
}
async getPauseState(): Promise<boolean | undefined> {
// Nothing plays on the no-op backend — never externally paused.
return false;
}
isAlive(): boolean {
return false;
}
dispose(): void {}
}
@@ -418,7 +799,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = [];
const mpvPath = which("mpv");
const mpvPath = resolveMpvBinary();
if (mpvPath) {
players.push({
name: "mpv",
@@ -452,13 +833,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend;
}
return which("mpv") ? new MpvBackend() : new NoopBackend();
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv":
return which("mpv") ? new MpvBackend() : null;
return resolveMpvBinary() ? new MpvBackend() : null;
case "none":
return new NoopBackend();
}

View File

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

View File

@@ -1,279 +0,0 @@
/**
* Real-time audio stream reader for visualization.
*
* Spawns a separate ffmpeg process that decodes the same audio URL
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
* and provides them to the caller on demand.
*
* This is independent from the actual playback backend — it's a
* read-only "tap" on the audio for FFT analysis purposes.
*/
/** PCM output format constants */
const SAMPLE_RATE = 44100;
const CHANNELS = 1;
const BYTES_PER_SAMPLE = 2; // s16le
/** How many samples to buffer (~1 second) */
const RING_BUFFER_SAMPLES = SAMPLE_RATE;
export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */
url: string;
/** Sample rate (default: 44100) */
sampleRate?: number;
}
/**
* Monotonically increasing generation counter.
* Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0;
export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null;
private ringBuffer: Float64Array;
private writePos = 0;
private totalSamplesWritten = 0;
private _running = false;
private generation = 0;
readonly url: string;
private sampleRate: number;
constructor(options: AudioStreamReaderOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
}
/** Whether the reader is actively reading samples. */
get running(): boolean {
return this._running;
}
/** Total number of samples written since start(). */
get samplesWritten(): number {
return this.totalSamplesWritten;
}
/**
* Start the ffmpeg decode process and begin reading PCM data.
*
* If already running, the previous process is killed first.
* Uses a generation counter to guarantee that only one read loop
* is ever active — stale loops from killed processes bail out
* immediately.
*
* @param startPosition Seek position in seconds (default: 0).
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
* atempo filter so visualization stays in sync with audio.
*/
start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running
this.killProcess();
if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization");
}
// Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit.
this.generation = ++globalGeneration;
const args = [
"ffmpeg",
"-loglevel",
"quiet",
// Read input at native frame rate so decoded PCM stays in sync with
// real-time playback. Without -re, ffmpeg greedily decodes the whole
// file as fast as possible: the ring buffer fills with audio seconds
// ahead of the player (laggy bars), then the process exits when it
// hits EOF (bars freeze ~10s in).
"-re",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
];
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition));
}
args.push("-i", this.url);
// Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) {
args.push("-af", buildAtempoChain(speed));
}
args.push(
"-ac",
String(CHANNELS),
"-ar",
String(this.sampleRate),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-",
);
this.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
this._running = true;
this.writePos = 0;
this.totalSamplesWritten = 0;
const myGeneration = this.generation;
this.readLoop(myGeneration);
// Detect process exit
this.proc.exited
.then(() => {
// Only clear _running if this is still the current generation
if (this.generation === myGeneration) {
this._running = false;
}
})
.catch(() => {
if (this.generation === myGeneration) {
this._running = false;
}
});
}
/**
* Read available samples into the provided buffer.
* Returns the number of samples actually copied.
*
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
* @returns Number of samples written to `out`.
*/
read(out: Float64Array): number {
const available = Math.min(
out.length,
this.totalSamplesWritten,
this.ringBuffer.length,
);
if (available <= 0) return 0;
// Read the most recent `available` samples from the ring buffer
const readStart =
(this.writePos - available + this.ringBuffer.length) %
this.ringBuffer.length;
if (readStart + available <= this.ringBuffer.length) {
out.set(this.ringBuffer.subarray(readStart, readStart + available));
} else {
const firstChunk = this.ringBuffer.length - readStart;
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
}
return available;
}
/**
* Stop the ffmpeg process and clean up.
* Safe to call multiple times. Guarantees the read loop exits.
*/
stop(): void {
// Bump generation to invalidate any running read loop
this.generation = ++globalGeneration;
this._running = false;
this.killProcess();
this.writePos = 0;
this.totalSamplesWritten = 0;
}
/**
* Restart the reader at a new position and/or speed.
*/
restart(startPosition = 0, speed = 1): void {
this.start(startPosition, speed);
}
/** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void {
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
}
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
private async readLoop(myGeneration: number): Promise<void> {
const stdout = this.proc?.stdout;
if (!stdout || typeof stdout === "number") return;
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
try {
while (this.generation === myGeneration) {
const { done, value } = await reader.read();
if (done || this.generation !== myGeneration) break;
if (!value || value.byteLength === 0) continue;
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
if (sampleCount === 0) continue;
const int16View = new Int16Array(
value.buffer,
value.byteOffset,
sampleCount,
);
for (let i = 0; i < sampleCount; i++) {
this.ringBuffer[this.writePos] = int16View[i];
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
this.totalSamplesWritten++;
}
}
} catch {
// Stream ended or process killed — expected during stop()
} finally {
try {
reader.releaseLock();
} catch {
/* ignore */
}
}
}
}
/**
* Build an ffmpeg atempo filter chain for a given speed.
* atempo only accepts values in [0.5, 100.0], so we chain
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
*/
function buildAtempoChain(speed: number): string {
const parts: string[] = [];
let remaining = Math.max(0.25, Math.min(4, speed));
while (remaining > 100) {
parts.push("atempo=100.0");
remaining /= 100;
}
while (remaining < 0.5) {
parts.push("atempo=0.5");
remaining /= 0.5;
}
parts.push(`atempo=${remaining}`);
return parts.join(",");
}

102
src/utils/bar-mapping.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Pure bar-scaling helpers for the terminal waveform.
*
* barChars maps a 0..16 level to the two characters of a 2-row bar built
* from Unicode lower block elements (U+2581..U+2588). The partial block
* sits in the TOP row (its glyph bottom edge = row bottom), so a full
* block below makes a visually continuous 2-cell column — the "double the
* default height" requirement (each bar = 2 terminal rows, 16 heights).
*
* createBarScaler is a stateful fast-attack / slow-release peak follower
* that replaces cava's autosens: a loud start cannot pin every bar at
* full height (the peak follower absorbs it) and quiet content gets
* normalized up.
*/
// ── Types ────────────────────────────────────────────────────────────
export interface BarScalerOptions {
/** Peak follower decay per frame (default: 0.985) */
release?: number;
/** Power curve applied after normalization (default: 0.7) */
curve?: number;
/** Silence threshold — below this the input is treated as silent (default: 1e-6) */
epsilon?: number;
}
// ── Constants ────────────────────────────────────────────────────────
/** Number of discrete bar heights (2 rows × 8 block levels). */
export const BAR_LEVELS = 16;
/** Lower block elements, index 0 = space (silence) through full block (max). */
const LOWER = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
// ── Bar mapping ──────────────────────────────────────────────────────
/**
* Map a bar level (0..16) to the two characters that render it as a
* 2-row column: top row + bottom row.
*
* level 0 → { top: " ", bottom: " " }
* level 1..8 → { top: " ", bottom: LOWER[level] }
* level 9..16 → { top: LOWER[level - 8], bottom: "\u2588" }
*/
export function barChars(level: number): { top: string; bottom: string } {
const raw = Math.floor(level);
const lvl = Number.isFinite(raw)
? Math.max(0, Math.min(BAR_LEVELS, raw))
: 0;
if (lvl === 0) return { top: " ", bottom: " " };
if (lvl <= 8) return { top: " ", bottom: LOWER[lvl] };
return { top: LOWER[lvl - 8], bottom: "\u2588" };
}
// ── Peak-follower scaler ─────────────────────────────────────────────
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
/**
* Create a stateful bar scaler. Each call normalizes its input against a
* peak follower (instant attack, multiplicative release), then applies a
* power curve so low-energy content remains visible. Returns a new
* number[] per call.
*/
export function createBarScaler(
opts?: BarScalerOptions,
): (values: ArrayLike<number>) => number[] {
const release = opts?.release ?? 0.985;
const curve = opts?.curve ?? 0.7;
const epsilon = opts?.epsilon ?? 1e-6;
let peak = 0;
return (values: ArrayLike<number>): number[] => {
let frameMax = 0;
for (let i = 0; i < values.length; i++) {
const magnitude = Math.abs(values[i]);
if (magnitude > frameMax) frameMax = magnitude;
}
// Fast attack, slow release
peak = frameMax > peak ? frameMax : peak * release;
const gain = peak > epsilon ? 1 / peak : 0;
const output = new Array<number>(values.length);
for (let i = 0; i < values.length; i++) {
output[i] = Math.pow(clamp01(values[i] * gain), curve);
}
return output;
};
}

141
src/utils/cover-art.ts Normal file
View File

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

View File

@@ -71,11 +71,16 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"open",
"open-interactive",
"search",
"search-scope-toggle",
"filter",
"sort",
"toggle-hidden",
"refresh",
"subscribe",
"unsubscribe",
"download",
"delete-download",
"whitelist-toggle",
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */

View File

@@ -132,8 +132,6 @@ export type AppEvents = {
"media.toggle": {};
"media.volumeUp": {};
"media.volumeDown": {};
"media.seekForward": {};
"media.seekBackward": {};
"media.speedCycle": {};
};

View File

@@ -0,0 +1,61 @@
/**
* iTunes feed resolution for shows delisted from Apple Podcasts.
*
* The iTunes Search API returns `feedUrl: null` for shows that left Apple
* Podcasts (e.g. The Daily Wire's shows in 2021) — the directory keeps a
* metadata-only stub. The show's public Apple Podcasts page still embeds the
* real feed URL in its JSON state (`showOffer.feedUrl`), so subscribing can
* resolve it from there.
*/
/** `"feedUrl":"https://..."` as embedded in the Apple page's JSON state. */
const FEED_URL_RE = /"feedUrl"\s*:\s*"(https?:\/\/[^"]+)"/
/**
* Extract the show's feed URL from an Apple Podcasts page's HTML.
*
* The page embeds `showOffer` blocks for the show AND for related shows, each
* with its own feedUrl, and Apple serves multiple JSON variants — the main
* show's showOffer may sit adjacent to its adamId or thousands of chars later.
* Anchor on the collection id from `directoryUrl` (`"adamId":"<id>"`) and take
* the FIRST feedUrl after it (the main show's content precedes related shows'
* in the document). Falls back to the first feedUrl in the document only when
* the id isn't present in the URL. Returns null when no trustworthy match
* exists (page restructured, no feed) — callers must not guess.
*/
export const extractFeedUrlFromPage = (
html: string,
directoryUrl: string,
): string | null => {
const idMatch = /[?/]id(\d+)/.exec(directoryUrl)
if (!idMatch) {
const fallback = FEED_URL_RE.exec(html)
return fallback ? fallback[1] : null
}
const adamIdx = html.search(new RegExp(`"adamId"\\s*:\\s*"${idMatch[1]}"`))
if (adamIdx < 0) return null
const fromAdam = new RegExp(FEED_URL_RE.source, "g")
fromAdam.lastIndex = adamIdx
const match = fromAdam.exec(html)
return match ? match[1] : null
}
/**
* Resolve a delisted show's RSS feed from its Apple Podcasts page.
* Returns null on network failure or when the page has no resolvable feed.
*/
export const resolveItunesFeedUrl = async (
directoryUrl: string,
): Promise<string | null> => {
try {
const response = await fetch(directoryUrl, {
headers: { "User-Agent": "PodTUI/1.0" },
})
if (!response.ok) return null
return extractFeedUrlFromPage(await response.text(), directoryUrl)
} catch {
return null
}
}

View File

@@ -60,17 +60,24 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
help: ["~", "f1"],
// list ops
search: ["s"],
"search-scope-toggle": ["tab"],
filter: ["f"],
sort: [","],
"toggle-hidden": ["."],
refresh: ["r"],
// a subscribes the focused show/episode result in place (x unsubscribes)
subscribe: ["a"],
unsubscribe: ["x"],
// downloads
download: ["d"],
"delete-download": ["D"],
"whitelist-toggle": ["w"],
// audio transport (preserved; shifted single keys, no collisions)
"audio-toggle": ["P"],
"audio-next": ["N"],
"audio-prev": ["B"],
"audio-seek-forward": ["shift-."],
"audio-seek-backward": ["shift-,"],
"audio-seek-forward": ["shift-."], // > = shift+.
"audio-seek-backward": ["shift-,"], // < = shift+,
};
/** Copy keybinds.jsonc to user config directory on first run */

View File

@@ -57,13 +57,13 @@ export function rootFrameFor(
// terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
//
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
// give `current` the combined 4/5.
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
// give `current` the combined 8/10 (80%).
export const PANE_RATIO = {
parent: 1,
current: 2,
preview: 2,
parent: 2,
current: 5,
preview: 3,
} as const;
// Number of *focusable* content panes per tab. The three visible columns

115
src/utils/nerd-fonts.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Nerd Font support detection + icon codepoints for PodTui.
*
* The app prepends Nerd Font glyphs to hard-defined list rows (tabs, Discover
* categories, Settings sections, the Feed "Fetch More" row). When the user's
* terminal font is NOT Nerd Font capable those glyphs must not render at all —
* no tofu boxes, no empty columns — so every call site gates the icon on
* `supportsNerdFonts()`.
*
* Detection is a heuristic (see `supportsNerdFonts`); the `PODTUI_NERD_FONTS`
* env override wins over everything so a wrong guess is always fixable.
* Under tmux the outer terminal decides — `TMUX` being set counts as
* capable (the multiplexer passes glyphs through), matching the same choice
* made for `screen`-prefixed TERM values. See README → Configuration → Fonts.
*
* This module is deliberately free of Solid/JSX imports so it stays
* unit-testable in isolation.
*/
// ── Detection ────────────────────────────────────────────────────────────────
// Memoized: the terminal does not change mid-session, so detect once.
let cached: boolean | null = null;
/**
* True when the terminal is (very likely) using a Nerd Font-patched font.
*
* Order:
* a. `PODTUI_NERD_FONTS` env override ("1"/"true" → true, "0"/"false" →
* false) — wins over everything.
* b. Allowlist: TERM_PROGRAM ∈ {iTerm.app, WezTerm, vscode, ghostty, rio,
* hyper, tabby, contour}, or TERM starts with {xterm-kitty, foot,
* alacritty, contour, screen} (tmux/screen passthrough — the outer
* terminal decides), or WT_SESSION set (Windows Terminal), or TMUX set.
* Case-insensitive.
* c. Everything else (Terminal.app default SF Mono, plain xterm, unknown)
* → false.
*/
export function supportsNerdFonts(): boolean {
if (cached !== null) return cached;
// a. Env override wins over everything.
const override = process.env.PODTUI_NERD_FONTS?.trim().toLowerCase();
if (override === "1" || override === "true") {
cached = true;
return cached;
}
if (override === "0" || override === "false") {
cached = false;
return cached;
}
// b. Allowlist.
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? "";
const term = process.env.TERM?.toLowerCase() ?? "";
const TERM_PROGRAM_ALLOWLIST: Record<string, true> = {
"iterm.app": true,
wezterm: true,
vscode: true,
ghostty: true,
rio: true,
hyper: true,
tabby: true,
contour: true,
};
const TERM_PREFIX_ALLOWLIST = [
"xterm-kitty",
"foot",
"alacritty",
"contour",
"screen",
];
cached =
TERM_PROGRAM_ALLOWLIST[termProgram] === true ||
TERM_PREFIX_ALLOWLIST.some((prefix) => term.startsWith(prefix)) ||
!!process.env.WT_SESSION ||
!!process.env.TMUX;
// c. Everything else falls through to false.
return cached;
}
// ── Icon codepoints ──────────────────────────────────────────────────────────
// Font Awesome codepoints in the Nerd Font PUA range — stable across Nerd
// Font versions. Keyed by the semantic names the list rows use.
export const NF_ICONS: Record<string, string> = {
feed: "\uF09E",
shows: "\uF005",
discover: "\uF14E",
search: "\uF002",
player: "\uF144",
settings: "\uF013",
sync: "\uF021",
sources: "\uF143",
preferences: "\uF1DE",
visualizer: "\uF080",
downloads: "\uF019",
all: "\uF0CA",
technology: "\uF2DB",
science: "\uF0C3",
comedy: "\uF118",
news: "\uF1EA",
business: "\uF0B1",
health: "\uF21E",
education: "\uF19D",
sports: "\uF1E3",
"true-crime": "\uF00E",
arts: "\uF1FC",
more: "\uF141",
add: "\uF067",
};
/** Glyph for a named icon when Nerd Fonts are supported, else "". */
export function nfIcon(name: string): string {
return supportsNerdFonts() ? NF_ICONS[name] ?? "" : "";
}

View File

@@ -1,4 +1,4 @@
import { searchSourceByType } from "./source-searcher";
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser";
import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source";
@@ -17,6 +17,12 @@ const rateLimitState = new Map<string, number[]>();
const RATE_LIMIT_WINDOW_MS = 60000;
const RATE_LIMIT_MAX_CALLS = 20;
/** Minimum results a primary search must return before the Podcast Index
* fallback runs — the open directory is only consulted when Apple's came up
* thin, exactly the case where it adds shows Apple lacks. */
const FALLBACK_MIN_RESULTS = 3;
const FALLBACK_SOURCE_ID = "podcastindex";
const throttleSource = async (sourceId: string) => {
const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS;
@@ -36,9 +42,9 @@ const throttleSource = async (sourceId: string) => {
rateLimitState.set(sourceId, updated);
};
const buildCacheKey = (query: string, sourceIds: string[]) => {
const buildCacheKey = (query: string, sourceIds: string[], prefix: string) => {
const keySources = [...sourceIds].sort().join(",");
return `${query.toLowerCase()}::${keySources}`;
return `${prefix}:${query.toLowerCase()}::${keySources}`;
};
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
@@ -47,8 +53,12 @@ const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
const map = new Map<string, SearchResult>();
for (const result of results) {
// Episodes dedupe on the episode id; shows on feedUrl/id/title. The two
// scopes never mix within one result set, so keys can't collide.
const key =
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
result.kind === "episode"
? `episode:${result.episode.id}`
: result.podcast.feedUrl || result.podcast.id || result.podcast.title;
const existing = map.get(key);
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
map.set(key, result);
@@ -87,6 +97,7 @@ export const searchByFeedUrl = async (
sourceId: "direct-rss",
sourceName: "RSS Feed",
sourceType: SourceType.RSS,
kind: "podcast",
// parseRSSFeed marks feeds subscribed; a search result should start
// unsubscribed so the store can flag it correctly if already added.
podcast: { ...podcast, isSubscribed: false },
@@ -98,11 +109,20 @@ export const searchByFeedUrl = async (
}
};
export const searchPodcasts = async (
type SourceSearcher = (
query: string,
source: PodcastSource,
) => Promise<SearchResult[]>;
const searchSources = async (
query: string,
sourceIds: string[],
sources: PodcastSource[],
searcher: SourceSearcher,
cachePrefix: string,
options: SearchOptions = {},
/** Optional source id consulted as a low-result fallback (show scope only). */
fallbackSourceId?: string,
): Promise<SearchResult[]> => {
const trimmed = query.trim();
if (!trimmed) return [];
@@ -124,6 +144,7 @@ export const searchPodcasts = async (
const cacheKey = buildCacheKey(
trimmed,
activeSources.map((s) => s.id),
cachePrefix,
);
const cached = searchCache.get(cacheKey);
if (cached && isCacheValid(cached, cacheTtl)) {
@@ -137,7 +158,7 @@ export const searchPodcasts = async (
activeSources.map(async (source) => {
try {
await throttleSource(source.id);
const sourceResults = await searchSourceByType(trimmed, source);
const sourceResults = await searcher(trimmed, source);
results.push(...sourceResults);
} catch (error) {
errors.push(error as Error);
@@ -146,7 +167,32 @@ export const searchPodcasts = async (
);
const deduped = dedupeResults(results);
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
let sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
// Low-result fallback: when the primary sources came back thin, consult
// the fallback source — but only when it's enabled AND keyed (a key-less
// default must never send requests) and it didn't already run as a primary
// source above. A fallback failure never sinks the primary results.
if (sorted.length < FALLBACK_MIN_RESULTS && fallbackSourceId) {
const fallback = sources.find(
(s) =>
s.id === fallbackSourceId &&
s.enabled &&
s.hasCredentials === true &&
!activeSources.includes(s),
);
if (fallback) {
try {
await throttleSource(fallback.id);
const fallbackResults = await searcher(trimmed, fallback);
sorted = dedupeResults([...sorted, ...fallbackResults]).sort(
(a, b) => (b.score ?? 0) - (a.score ?? 0),
);
} catch (error) {
errors.push(error as Error);
}
}
}
if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources");
@@ -156,4 +202,39 @@ export const searchPodcasts = async (
return sorted;
};
export const searchPodcasts = (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {},
): Promise<SearchResult[]> =>
searchSources(
query,
sourceIds,
sources,
searchSourceByType,
"show",
options,
FALLBACK_SOURCE_ID,
);
/** Episode-scope search: find individual episodes (e.g. a guest appearing
* across shows). Shares the source guard, rate limiting, and cache with
* searchPodcasts; the cache key is scoped separately so the two result
* kinds never collide for the same query. */
export const searchEpisodes = (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {},
): Promise<SearchResult[]> =>
searchSources(
query,
sourceIds,
sources,
searchEpisodesByType,
"episode",
options,
);

View File

@@ -0,0 +1,100 @@
/**
* Credential storage for keyed podcast sources.
*
* Preferred storage is the macOS keychain (encrypted at rest by the OS),
* written through the `security` CLI — no native dependencies. When the
* keychain is unavailable (non-macOS, locked, sandboxed) credentials fall
* back to plaintext on the source itself (config.json) so the source still
* works; `credentialStorage` on the source records which backend was used.
*
* Credentials are never presented in full — the UI always masks them (first
* 3 chars + "..."). The keychain password is passed as an argv value to
* `add-generic-password` (standard practice for CLI-driven keychain writes;
* the item lands in the login keychain immediately).
*/
import type { PodcastSource } from "../types/source"
const KEYCHAIN_SERVICE = "podtui"
const KEYCHAIN_ACCOUNT = "podcastindex"
export type Credentials = {
apiKey: string
apiSecret: string
}
/** Run a `security` subcommand; resolves with exit status + stdout. */
async function runSecurity(
args: string[],
): Promise<{ ok: boolean; stdout: string }> {
try {
const proc = Bun.spawn({
cmd: ["security", ...args],
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const exitCode = await proc.exited
return { ok: exitCode === 0, stdout }
} catch {
return { ok: false, stdout: "" }
}
}
/** Store Podcast Index credentials in the macOS keychain. True on success. */
export async function savePodcastIndexCredentials(
apiKey: string,
apiSecret: string,
): Promise<boolean> {
const payload = JSON.stringify({ apiKey, apiSecret })
const { ok } = await runSecurity([
"add-generic-password",
"-a",
KEYCHAIN_ACCOUNT,
"-s",
KEYCHAIN_SERVICE,
"-w",
payload,
"-U",
])
return ok
}
/** Read Podcast Index credentials from the macOS keychain. Null when absent
* or unreadable (non-macOS, item deleted, keychain locked). */
export async function loadPodcastIndexCredentials(): Promise<Credentials | null> {
const { ok, stdout } = await runSecurity([
"find-generic-password",
"-a",
KEYCHAIN_ACCOUNT,
"-s",
KEYCHAIN_SERVICE,
"-w",
])
if (!ok) return null
try {
const parsed = JSON.parse(stdout.trim()) as Credentials
if (!parsed.apiKey || !parsed.apiSecret) return null
return parsed
} catch {
return null
}
}
/** Resolve a source's stored credentials: its plaintext fields when saved
* with the plaintext fallback, else the macOS keychain. Null when the
* source has no usable credentials. */
export async function resolveSourceCredentials(
source: PodcastSource,
): Promise<Credentials | null> {
if (source.credentialStorage === "plaintext") {
return source.apiKey && source.apiSecret
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
: null
}
return loadPodcastIndexCredentials()
}

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