41 Commits

Author SHA1 Message Date
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
116 changed files with 5942 additions and 1749 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
@@ -66,17 +66,19 @@ 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
- 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 +89,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.

132
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,132 @@ 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 {
console.warn(
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
);
}
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
if (existsSync(icnsSrc)) {
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
} else {
console.warn(
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
);
}
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
Bun.write(
join(appRoot, "Contents", "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PodTui</string>
<key>CFBundleDisplayName</key>
<string>PodTui</string>
<key>CFBundleIdentifier</key>
<string>com.mikefreno.podtui</string>
<key>CFBundleExecutable</key>
<string>podtui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>0.3.1</string>
<key>CFBundleVersion</key>
<string>0.3.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
// ID cert for release builds); default ad-hoc.
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
signIdentity,
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
// resolves the Now Playing client from the registering process's
// code-signing identifier — without an explicit --identifier codesign
// stamps "mpv" (its basename) and the audio center shows a blank
// placeholder. Must run after the bundle sign above (a later bundle
// re-seal would re-derive the basename identifier).
const signMpv = Bun.spawnSync([
"codesign",
"--force",
"-s",
signIdentity,
"--identifier",
"com.mikefreno.podtui",
join(macosDir, "mpv"),
]);
if (signMpv.exitCode !== 0) {
console.warn(
`Warning: nested mpv signing failed (${signMpv.stderr
.toString()
.trim()}) — Now Playing attribution won't work`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([
"tar",
"-czf",

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,27 @@
"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"],
"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,15 @@ export type KeybindActionName =
| "quit"
| "help"
| "search"
| "search-scope-toggle"
| "filter"
| "sort"
| "toggle-hidden"
| "refresh"
| "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

@@ -13,6 +13,8 @@
*/
import { createSignal, onCleanup } from "solid-js";
import { unlinkSync } from "fs";
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
import {
createAudioBackend,
detectPlayers,
@@ -109,6 +111,11 @@ function registerExitTeardown(): void {
} catch {
/* best-effort at exit */
}
try {
unlinkSync(coverTempPath());
} catch {
/* best-effort at exit */
}
};
process.on("exit", teardown);
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
@@ -122,17 +129,21 @@ function registerExitTeardown(): void {
function startPolling(): void {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return;
if (!backend || !isPlaying() || pollInFlight) return;
pollInFlight = true;
try {
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms)
// Save progress every ~5 seconds (33 ticks * 150ms)
pollCount++;
if (pollCount % 10 === 0) {
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
@@ -156,8 +167,10 @@ function startPolling(): void {
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 500);
}, 150);
}
function stopPolling(): void {
@@ -167,6 +180,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 +201,13 @@ 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 || "";
const coverArtPath = feed?.podcast.coverUrl
? await fetchCoverArt(feed.podcast.coverUrl)
: null;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
@@ -194,6 +219,8 @@ 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,
});
setCurrentEpisode(episode);
@@ -206,7 +233,7 @@ async function play(episode: Episode): Promise<void> {
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: episode.podcastId,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
@@ -357,10 +384,22 @@ 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 coverArtPath = feed?.podcast.coverUrl
? await fetchCoverArt(feed.podcast.coverUrl)
: null;
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startPolling();
@@ -421,14 +460,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 +546,6 @@ export function useAudio(): AudioControls {
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed();
if (refCount <= 0) {

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,16 +56,6 @@ 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":
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.4.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,6 +31,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";
@@ -34,17 +39,22 @@ 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;
@@ -67,17 +77,40 @@ 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 ? shows().length : 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() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
nav.setDepthFocus(rowCount() - 1, 1);
};
onMount(ensureFocus);
@@ -88,6 +121,17 @@ export function MyShowsPage() {
});
});
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
@@ -139,6 +183,10 @@ export function MyShowsPage() {
return;
}
if (depth() >= 1) {
if (focusedOnMore()) {
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
return;
}
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
@@ -161,6 +209,33 @@ 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() < 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(() => {});
@@ -225,12 +300,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>
@@ -260,12 +334,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 +352,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,6 +360,19 @@ 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>
);
}}
@@ -307,7 +398,6 @@ export function MyShowsPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -316,26 +406,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 +449,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>
@@ -381,20 +515,47 @@ export function MyShowsPage() {
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
<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>
) : (
// 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 +582,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

@@ -12,6 +12,7 @@
import { Show } from "solid-js";
import { PlaybackControls } from "./PlaybackControls";
import { ProgressBar } from "./ProgressBar";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
@@ -79,6 +80,8 @@ export function PlayerPage() {
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<ProgressBar />
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
@@ -109,9 +112,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 +126,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

@@ -15,6 +15,7 @@ import {
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
@@ -25,19 +26,6 @@ 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;
@@ -53,6 +41,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
// frame against the running peak so a loud start can't pin every bar
// at full height and quiet content still gets normalized up.
const scaler = createBarScaler();
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
@@ -88,6 +81,29 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
return true;
};
// ── Smooth position clock ──────────────────────────────────────────
//
// audio.position() updates at the useAudio poll rate (~150ms). Between
// polls, interpolate the position from wall time so the FFT window
// tracks the audio continuously instead of stepping. The 0.5s cap
// prevents extrapolating far beyond reality when the player stalls
// (e.g. network re-buffering).
let lastPolledPosition = 0;
let lastPolledAt = 0;
const smoothPosition = () => {
const pos = audio.position();
const now = performance.now();
if (pos !== lastPolledPosition) {
lastPolledPosition = pos;
lastPolledAt = now;
return pos;
}
if (lastPolledAt === 0) return pos;
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
@@ -98,14 +114,26 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// Initialize cavacore with current resolution + any overrides.
// bars is width-derived (see numBars); visualizerConfig supplies the
// audio-processing params (noise reduction, cutoffs, etc.).
// autosens is disabled (after the spread so it always wins): cava's
// autosens gain-ramps during silence then clips everything to 1.0
// when audio arrives — the JS peak scaler handles dynamics instead.
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
autosens: 0,
};
cava.init(config);
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it (at 44.1kHz mono the window is 8192
// samples — FFTbassbufferSize × channels; a 512-sample frame would
// leave the tail garbage).
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
@@ -139,17 +167,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Sample the FFT window at the player's position, not the decode
// head — the reader decodes independently (paced at the player's
// clock rate with a LEAD_SECONDS burst head start) and only the
// position clock ties the bars to what's actually playing.
const target = smoothPosition();
const count = reader.read(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
const output = cava.execute(sampleBuffer);
// Copy bar values to a new array for the signal
setBarData(Array.from(output as Float64Array));
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
};
createEffect(
@@ -209,11 +239,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
const renderLine = () => {
const bars = barData();
const count = numBars();
@@ -221,51 +246,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
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,7 @@ import {
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { useToast } from "@/ui/toast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
@@ -37,20 +42,25 @@ 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 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 +74,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,7 +125,10 @@ 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 ─────────────────────────────────────────────────────────────────
@@ -129,10 +153,36 @@ 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);
};
// ── nav.action handler ──────────────────────────────────────────────────────
@@ -149,13 +199,17 @@ 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,
);
}
},
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(() => {});
@@ -212,15 +266,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 +299,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 +393,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 +443,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 +453,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>
}
>
@@ -315,7 +479,6 @@ function SearchPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -325,10 +488,12 @@ 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={result.podcast.isSubscribed}>
<text
@@ -338,14 +503,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 +538,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 +558,109 @@ 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>
<text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<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 (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>
enter: subscribe to show · h: back to query
</text>
</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}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · 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

@@ -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,272 @@ 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 });
},
},
];
// 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.",
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

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

@@ -30,13 +30,18 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
showSelectionMarker: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoDownloadCount: 2,
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
};
const defaultState: AppState = {

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

@@ -12,6 +12,7 @@ import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode";
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;
@@ -201,6 +202,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,
@@ -306,6 +328,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
}

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 */
@@ -42,6 +45,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[]>([]);
@@ -184,6 +242,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;
@@ -209,68 +278,114 @@ 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,
);
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) {
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);
}
@@ -280,7 +395,30 @@ function createFeedStore() {
const loadedFeeds = await loadFeedsFromFile();
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
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();
})();
@@ -359,7 +497,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);
@@ -399,64 +537,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 {
@@ -487,11 +648,13 @@ function createFeedStore() {
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
loadMoreAllFeeds,
hasMoreAcrossAll,
addSource,
removeSource,
toggleSource,
updateSource,
setAutoDownload,
runAutoDownload: runAutoDownloadNow,
};
}

View File

@@ -4,11 +4,12 @@
*/
import { createSignal } from "solid-js";
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
import type { SearchResult, SearchScope } from "../types/source";
const STORAGE_KEY = "podtui_search_history";
const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 20;
export interface SearchState {
@@ -41,6 +42,27 @@ function saveHistory(history: string[]): void {
}
}
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
function loadScope(): SearchScope {
if (typeof localStorage === "undefined") return "podcast";
try {
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
return stored === "episode" ? "episode" : "podcast";
} catch {
return "podcast";
}
}
/** Save search scope to localStorage */
function saveScope(scope: SearchScope): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
} catch {
// Ignore errors
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
@@ -50,6 +72,13 @@ export function createSearchStore() {
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** 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 +139,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) {
@@ -187,6 +221,7 @@ export function createSearchStore() {
error,
history,
selectedSources,
scope,
// Actions
search,
@@ -195,6 +230,7 @@ export function createSearchStore() {
clearHistory,
removeFromHistory,
setSelectedSources,
setScope,
markSubscribed,
};
}

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

@@ -81,14 +81,30 @@ export type AppSettings = {
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;
};
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

@@ -33,13 +33,18 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
showSelectionMarker: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoDownloadCount: 2,
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
};
const defaultState: AppState = {

View File

@@ -11,7 +11,8 @@
import { platform } from "os";
import { existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { dirname, join } from "path";
import type { Socket, Subprocess } from "bun";
// ── Types ────────────────────────────────────────────────────────────
@@ -46,6 +47,8 @@ export interface PlayOptions {
startPosition?: number;
volume?: number;
speed?: number;
mediaTitle?: string;
coverArtPath?: string;
}
// ── Utilities ────────────────────────────────────────────────────────
@@ -72,19 +75,35 @@ function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
}
/**
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
* makes macOS attribute its Now Playing session to PodTui — source-app icon
* and name in Control Center — instead of a blank placeholder for an
* unbundled binary. Falls back to PATH so dev runs and Linux keep working.
*/
function resolveMpvBinary(): string | null {
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled)) return bundled;
} catch {
/* process.execPath unusable — fall through to PATH */
}
return which("mpv");
}
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
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 _position = 0;
private _duration = 0;
private _volume = 100;
private _speed = 1;
private pollTimer: ReturnType<typeof setInterval> | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
@@ -100,7 +119,7 @@ export class MpvBackend implements AudioBackend {
}
const args = [
"mpv",
resolveMpvBinary() ?? "mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
@@ -109,6 +128,16 @@ export class MpvBackend implements AudioBackend {
`--speed=${opts?.speed ?? 1}`,
];
if (opts?.mediaTitle) {
args.push(`--force-media-title=${opts.mediaTitle}`);
}
if (opts?.coverArtPath) {
// Explicit cover file → albumart track → macOS Now Playing artwork
// (works for remote streams, not just local downloads).
args.push(`--cover-art-files=${opts.coverArtPath}`);
}
if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`);
}
@@ -129,14 +158,13 @@ export class MpvBackend implements AudioBackend {
// Wait for socket to appear (mpv creates it async)
await this.waitForSocket(2000);
// Start polling position
this.startPolling();
// Position is fetched live from mpv on each getPosition() call (see
// below) — the UI polls it, so no internal poll timer is needed.
// Detect process exit
this.proc.exited
.then(() => {
this._playing = false;
this.stopPolling();
})
.catch(() => {});
}
@@ -149,79 +177,6 @@ export class MpvBackend implements AudioBackend {
}
}
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() {},
},
});
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;
}
}
return null;
} catch {
return null;
}
}
/** 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 {
@@ -246,65 +201,85 @@ export class MpvBackend implements AudioBackend {
}
}
/** Get a property value from mpv via IPC */
private async getProperty(name: string): Promise<number> {
/**
* Get a property value from mpv via IPC.
*
* Resolves the parsed numeric value, or `undefined` when the read fails
* (socket error, timeout, unparseable response, or the property being
* unavailable — e.g. `time-pos` before playback starts). Failure is
* distinct from a legitimate `0` so callers can keep the last known
* value instead of snapping the position clock to zero on a transient
* error; the next poll retries.
*
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
* onto the same connection, so we line-buffer and only settle on the
* line that carries the command response (`request_id` set). The socket
* is closed once the response is handled — leaving it open leaks an fd
* per poll, while closing it before mpv processes the request drops the
* reply.
*/
private async getProperty(name: string): Promise<number | undefined> {
try {
return await new Promise<number>((resolve) => {
let result = 0;
const timeout = setTimeout(() => resolve(result), 300);
return await new Promise<number | undefined>((resolve) => {
let settled = false;
let sock: Socket | null = null;
let buf = "";
const done = (value: number | undefined) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
sock?.end();
} catch {
/* ignore */
}
resolve(value);
};
const timeout = setTimeout(() => done(undefined), 300);
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) {
sock = socket;
socket.write(
JSON.stringify({ command: ["get_property", name] }) + "\n",
);
},
data(_socket, data) {
buf += Buffer.from(data).toString();
let nl = buf.indexOf("\n");
while (nl !== -1) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
nl = buf.indexOf("\n");
try {
const parsed = JSON.parse(line);
// Events carry no request_id; only settle on
// the actual command response.
if (parsed?.request_id === undefined) continue;
if (parsed?.data !== undefined) {
done(Number(parsed.data) || 0);
} else {
done(undefined);
}
return;
} catch {
/* skip malformed lines */
}
}
},
error() {
done(undefined);
},
close() {
done(undefined);
},
},
}).catch(() => {
clearTimeout(timeout);
resolve(0);
});
}).catch(() => done(undefined));
});
} 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");
}
}, 500);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
return undefined;
}
}
@@ -319,7 +294,6 @@ export class MpvBackend implements AudioBackend {
}
async stop(): Promise<void> {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
@@ -359,12 +333,20 @@ export class MpvBackend implements AudioBackend {
}
async getPosition(): Promise<number> {
// Live-fetch `time-pos` so the position clock is as fresh as the
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
// failure, keep the last known value rather than returning 0.
if (this._playing && this.proc) {
const pos = await this.getProperty("time-pos");
if (pos !== undefined) this._position = pos;
}
return this._position;
}
async getDuration(): Promise<number> {
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
const dur = await this.getProperty("duration");
if (dur !== undefined && dur > 0) this._duration = dur;
}
return this._duration;
}
@@ -418,7 +400,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 +434,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

@@ -4,10 +4,15 @@
* 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.
* and serves windows *at a requested playback position* to the caller.
*
* This is independent from the actual playback backend — it's a
* read-only "tap" on the audio for FFT analysis purposes.
* read-only "tap" on the audio for FFT analysis purposes. Sync with the
* player is maintained by pacing decode at the player's clock rate
* (`-readrate <speed>`) while front-loading a burst of LEAD_SECONDS
* (`-readrate_initial_burst`) so the decode head leads the player
* position by a stable lead — read() samples at the exact position the
* player reports, never at the decode head.
*/
/** PCM output format constants */
@@ -15,8 +20,34 @@ 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;
/**
* How many samples to buffer (~10 seconds).
* Large enough to absorb the gap between mpv's startup latency (0.53s,
* more for network streams at speed) and the reader's decode head, plus
* short player stalls. Samples older than the ring window are never needed
* again — the renderer only samples at the current playback position.
*/
const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10;
/**
* Decode-head lead over the player position, in seconds.
*
* `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio
* immediately on start, then pace at realtime (`-readrate speed`) after.
* The decode head thus leads the player by ~LEAD_SECONDS from the very
* first frame. read() samples at the player's current position, which is
* always behind the head — so it finds freshly decoded samples there
* instead of clamping to stale data.
*
* Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open
* + first-packet latency) and, since both advance at the same rate, never
* catches up — the bars lag by ε (up to several seconds on network
* streams). The burst eliminates that constant offset.
*
* Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the
* lead audio hasn't wrapped out by the time the player reaches it.
*/
const LEAD_SECONDS = 3;
export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */
@@ -32,11 +63,14 @@ export interface AudioStreamReaderOptions {
*/
let globalGeneration = 0;
import type { Subprocess } from "bun";
export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null;
private proc: Subprocess | null = null;
private ringBuffer: Float64Array;
private writePos = 0;
private totalSamplesWritten = 0;
private startPosition = 0;
private _running = false;
private generation = 0;
readonly url: string;
@@ -67,8 +101,10 @@ export class AudioStreamReader {
* 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.
* @param speed Playback speed multiplier (default: 1). Paces ffmpeg
* at the player's advance rate so decode tracks the
* player clock; `-readrate_initial_burst` front-loads
* a LEAD_SECONDS head start.
*/
start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running
@@ -81,25 +117,42 @@ export class AudioStreamReader {
// Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit.
this.generation = ++globalGeneration;
this.startPosition = Math.max(0, startPosition);
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
const args = [
"ffmpeg",
"-loglevel",
"quiet",
// 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",
// Pace input at the player's advance rate (speed× native). Combined
// with -readrate_initial_burst below, the decode head starts
// LEAD_SECONDS ahead of the player and advances at the same rate —
// read() samples at the player position and always finds fresh data.
"-readrate",
String(readRate),
// Front-load LEAD_SECONDS of audio immediately so the decode head
// leads the player from the very first frame. Without this, ffmpeg
// starts ε behind mpv (input-open + first-packet latency) and,
// pacing at the same rate, never catches up — bars lag by ε.
"-readrate_initial_burst",
String(LEAD_SECONDS),
];
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
// input-open when the input is a local file, killing the process
// before any PCM is produced. Only pass them for network URLs.
if (/^https?:\/\//i.test(this.url)) {
args.push(
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
);
}
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition));
@@ -107,12 +160,9 @@ export class AudioStreamReader {
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));
}
// No atempo filter: the renderer samples the *source* audio at the
// player's current position, so output samples map 1:1 to input time
// (stream index = (targetSeconds - startPosition) * sampleRate).
args.push(
"-ac",
String(CHANNELS),
@@ -155,31 +205,48 @@ export class AudioStreamReader {
}
/**
* Read available samples into the provided buffer.
* Returns the number of samples actually copied.
* Read the visualization window ending at `targetSeconds` of playback.
*
* The player (mpv) and this decoder are independent processes, so the
* decode head and the actual playback position drift apart (startup skew,
* stalls, speed changes). Instead of sampling the decode head, we select
* the window *at* the position the player reports, clamped to the nearest
* available samples when the target hasn't been decoded yet (decode head
* behind) or has already wrapped out of the ring (long stall).
*
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
* @param targetSeconds - Playback position (input seconds) to sample.
* @returns Number of samples written to `out`.
*/
read(out: Float64Array): number {
const available = Math.min(
out.length,
this.totalSamplesWritten,
this.ringBuffer.length,
read(out: Float64Array, targetSeconds: number): number {
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
const headSample = this.totalSamplesWritten - 1;
const coveredStart = Math.max(
0,
this.totalSamplesWritten - this.ringBuffer.length,
);
const targetSample = Math.max(
0,
Math.round((targetSeconds - this.startPosition) * this.sampleRate),
);
// Window end: the target, clamped to what's been decoded so far.
const endSample = Math.min(targetSample, headSample);
// Window start: at most out.length samples back, clamped to what the
// ring still holds (target older than the ring -> serve the oldest
// available window, which is the closest to the target).
const startSample = Math.max(
coveredStart,
Math.min(endSample, endSample - out.length + 1),
);
const available = endSample - startSample + 1;
if (available <= 0) return 0;
// 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);
const ringLen = this.ringBuffer.length;
for (let i = 0; i < available; i++) {
out[i] = this.ringBuffer[(startSample + i) % ringLen];
}
return available;
@@ -255,25 +322,3 @@ export class AudioStreamReader {
}
}
}
/**
* 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;
};
}

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

@@ -0,0 +1,57 @@
/**
* Cover-art staging for the system Now Playing session.
*
* macOS shows the media session's albumart in the audio center (Control
* Center / lock screen). mpv reads it from `--cover-art-files` (loads the
* file as an albumart video track), so the podcast cover is staged to a temp
* file BEFORE playback starts and passed to mpv.
*
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
* which would silently drop every cover in shipped builds. curl is present
* on macOS and Linux. Bounded: a slow cover server must never stall audio,
* so an 8s cap drops the art.
*/
import { tmpdir } from "os";
import { join } from "path";
import { unlinkSync, statSync } from "fs";
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
export async function fetchCoverArt(url: string): Promise<string | null> {
const path = coverTempPath();
try {
unlinkSync(path);
} catch {
/* no stale cover */
}
try {
return await Promise.race([
(async () => {
const proc = Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
path,
url,
]);
const code = await proc.exited;
if (code !== 0) return null;
try {
return statSync(path).size > 0 ? path : null;
} catch {
return null;
}
})(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
]);
} catch {
return null;
}
}

View File

@@ -71,11 +71,15 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"open",
"open-interactive",
"search",
"search-scope-toggle",
"filter",
"sort",
"toggle-hidden",
"refresh",
"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,22 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
help: ["~", "f1"],
// list ops
search: ["s"],
"search-scope-toggle": ["tab"],
filter: ["f"],
sort: [","],
"toggle-hidden": ["."],
refresh: ["r"],
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()
}

View File

@@ -1,107 +1,30 @@
import type { Podcast } from "../types/podcast"
import type { Episode } from "../types/episode"
import { SourceType } from "../types/source"
import type { PodcastSource, SearchResult } from "../types/source"
import { detectContentType, ContentType } from "../utils/rss-content-detector"
import { htmlToText } from "../utils/html-to-text"
import { resolveSourceCredentials } from "../utils/source-credentials"
type SearcherResult = SearchResult[]
const delay = async (min = 200, max = 500) =>
new Promise((resolve) => setTimeout(resolve, min + Math.random() * max))
const hashString = (input: string): number => {
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
const slugify = (input: string): string =>
input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const sourceLabel = (source: PodcastSource): string =>
source.name || source.id
const buildPodcast = (
idBase: string,
title: string,
description: string,
author: string,
categories: string[],
source: PodcastSource
): Podcast => ({
id: idBase,
title,
description,
feedUrl: `https://example.com/${slugify(title)}/feed.xml`,
author,
categories,
lastUpdated: new Date(),
isSubscribed: false,
})
const makeResults = (query: string, source: PodcastSource, seedOffset = 0): SearcherResult => {
const seed = hashString(`${source.id}:${query}`) + seedOffset
const baseTitles = [
"Daily Briefing",
"Studio Sessions",
"Signal & Noise",
"The Long Play",
"Off the Record",
]
const descriptors = [
"Deep dives into",
"A fast-paced look at",
"Smart conversations about",
"A weekly roundup of",
"Curated stories on",
]
const categories = ["Technology", "Business", "Science", "Culture", "News"]
return baseTitles.map((base, index) => {
const title = `${query} ${base}`
const desc = `${descriptors[index % descriptors.length]} ${query.toLowerCase()} from ${sourceLabel(source)}.`
const author = `${sourceLabel(source)} Network`
const cat = [categories[(seed + index) % categories.length]]
const podcast = buildPodcast(
`search-${source.id}-${seed + index}`,
title,
desc,
author,
cat,
source
)
return {
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
podcast,
score: 1 - index * 0.08,
}
})
}
const searchRSSSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(200, 450)
return makeResults(query, source, 1)
}
type ItunesResult = {
collectionId?: number
collectionName?: string
artistName?: string
feedUrl?: string
/** Null for shows delisted from Apple Podcasts (directory stub records). */
feedUrl?: string | null
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
releaseDate?: string
collectionViewUrl?: string
}
type ItunesResponse = {
@@ -109,6 +32,26 @@ type ItunesResponse = {
results: ItunesResult[]
}
type ItunesEpisodeResult = {
trackId?: number
trackName?: string
collectionId?: number
collectionName?: string
artistName?: string
description?: string
/** Null for episodes of delisted shows (directory stub records). */
feedUrl?: string | null
episodeUrl?: string
/** Duration in milliseconds. */
trackTimeMillis?: number
releaseDate?: string
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
trackViewUrl?: string
collectionViewUrl?: string
}
const buildItunesUrl = (query: string, source: PodcastSource) => {
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
const url = new URL(baseUrl)
@@ -124,8 +67,167 @@ const buildItunesUrl = (query: string, source: PodcastSource) => {
return url.toString()
}
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName || !result.feedUrl) return null
/** Same as buildItunesUrl but targets episodes instead of shows — this is how
* guest/name searches find specific episodes (the term matches episode titles
* and show notes). */
const buildItunesEpisodeUrl = (query: string, source: PodcastSource) => {
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
const url = new URL(baseUrl)
const params = url.searchParams
params.set("term", query.trim())
params.set("media", "podcast")
params.set("entity", "podcastEpisode")
params.set("country", source.country ?? "US")
params.set("lang", source.language ?? "en_us")
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")
return url.toString()
}
// ── Podcast Index (fallback directory) ─────────────────────────────────────
// Open, community-run directory that includes shows Apple never lists or has
// delisted. Requires a user-supplied key + secret (podcastindex.org) and is
// used only as a fallback when primary sources return few results (see
// search.ts). Feed-first: results carry the feed URL directly, so there is no
// delisted-show stub resolution step like iTunes has.
type PodcastIndexResult = {
id?: number
title?: string
/** Current feed URL. */
url?: string
/** Show website. */
link?: string
description?: string
author?: string
image?: string
artwork?: string
/** Unix epoch seconds of the feed's last update. */
lastUpdateTime?: number
/** Apple directory id when known (nullable — not all shows are on Apple). */
itunesId?: number | null
language?: string
explicit?: boolean
/** True when the feed is unreachable — drop these. */
dead?: boolean
episodeCount?: number
/** Category id -> name. */
categories?: Record<string, string>
newestItemPubdate?: number
}
type PodcastIndexResponse = {
status?: string | boolean
feeds?: PodcastIndexResult[]
}
const sha1Hex = async (input: string): Promise<string> => {
const data = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest("SHA-1", data)
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
/** Podcast Index auth is header-based: X-Auth-Key + X-Auth-Date (unix epoch
* seconds) + Authorization = sha1(key + secret + epoch). No query params.
* Credentials resolve from the source's storage backend: the OS keychain
* (encrypted at rest) by default, or the source's plaintext fields when the
* keychain was unavailable at save time. */
const buildPodcastIndexHeaders = async (
source: PodcastSource,
): Promise<Record<string, string>> => {
const credentials = await resolveSourceCredentials(source)
const key = credentials?.apiKey
const secret = credentials?.apiSecret
if (!key || !secret) {
throw new Error(
`${source.name} credentials are missing — enable the source in Settings → Sources to enter them`,
)
}
const epoch = Math.floor(Date.now() / 1000).toString()
const signature = await sha1Hex(key + secret + epoch)
return {
"User-Agent": "PodTUI/1.0",
"X-Auth-Key": key,
"X-Auth-Date": epoch,
Authorization: signature,
}
}
const buildPodcastIndexUrl = (query: string, source: PodcastSource) => {
const url = new URL(source.baseUrl)
url.searchParams.set("q", query.trim())
url.searchParams.set("max", "25")
return url.toString()
}
export const mapPodcastIndexResult = (
result: PodcastIndexResult,
source: PodcastSource,
): Podcast | null => {
if (!result.title || !result.url) return null
const id = result.id
? `podcastindex-${result.id}`
: `podcastindex-${slugify(result.title)}`
const descriptionParts = [result.title]
if (result.author) descriptionParts.push(`by ${result.author}`)
if (result.episodeCount !== undefined)
descriptionParts.push(`${result.episodeCount} episodes`)
return {
id,
title: result.title,
description: descriptionParts.join(" • "),
feedUrl: result.url,
author: result.author,
categories: result.categories
? Object.values(result.categories)
: undefined,
coverUrl: result.image || result.artwork,
language: result.language,
websiteUrl: result.link,
lastUpdated: result.lastUpdateTime
? new Date(result.lastUpdateTime * 1000)
: new Date(),
isSubscribed: false,
}
}
const searchPodcastIndexSource = async (
query: string,
source: PodcastSource,
): Promise<SearcherResult> => {
const headers = await buildPodcastIndexHeaders(source)
const response = await fetch(buildPodcastIndexUrl(query, source), {
headers,
})
if (!response.ok) {
throw new Error(`${source.name} search failed: ${response.status}`)
}
const data = (await response.json()) as PodcastIndexResponse
const results = (data.feeds ?? [])
.filter((item) => !item.dead)
.map((item) => mapPodcastIndexResult(item, source))
.filter((item): item is Podcast => Boolean(item))
return results.map((podcast, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "podcast" as const,
podcast,
score: 1 - index * 0.02,
}))
}
export const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName) return null
const id = result.collectionId
? `itunes-${result.collectionId}`
@@ -135,11 +237,18 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
// Shows delisted from Apple Podcasts (e.g. The Daily Wire's shows) come back
// as metadata-only stub records with feedUrl null. Keep the stub so the show
// stays findable; the real feed is resolved from the directory page at
// subscribe time (see itunes-feed-resolver).
const feedUrl = result.feedUrl ?? ""
return {
id,
title: result.collectionName,
description: descriptionParts.join(" • "),
feedUrl: result.feedUrl,
feedUrl,
directoryUrl: feedUrl ? undefined : result.collectionViewUrl,
author: result.artistName,
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
coverUrl: result.artworkUrl600 || result.artworkUrl100,
@@ -148,7 +257,57 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
}
}
const searchAPISource = async (
/**
* Clean an iTunes description: detect HTML vs plain text and convert HTML to
* readable plain text (mirrors rss-parser's cleanField). iTunes show notes
* are often raw HTML.
*/
const cleanDescription = (raw: string): string => {
if (!raw) return ""
const decoded = raw
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
if (detectContentType(decoded) === ContentType.HTML) {
return htmlToText(decoded)
}
return decoded
}
/**
* Map an iTunes episode record to a search result: the episode itself plus its
* parent show (as a Podcast) so subscribing works exactly like a show result.
* Returns null when the record is missing a track or collection name.
*/
export const mapItunesEpisodeResult = (
result: ItunesEpisodeResult,
source: PodcastSource,
): { podcast: Podcast; episode: Episode } | null => {
if (!result.trackName || !result.collectionName) return null
const podcast = mapItunesResult(result, source)
if (!podcast) return null
const episode: Episode = {
id: result.trackId
? `itunes-ep-${result.trackId}`
: `itunes-ep-${slugify(result.trackName)}`,
podcastId: podcast.id,
title: result.trackName,
description: cleanDescription(result.description ?? ""),
audioUrl: result.episodeUrl ?? "",
duration: result.trackTimeMillis
? Math.round(result.trackTimeMillis / 1000)
: 0,
pubDate: result.releaseDate ? new Date(result.releaseDate) : new Date(),
}
return { podcast, episode }
}
const searchItunesSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -156,7 +315,7 @@ const searchAPISource = async (
const response = await fetch(url)
if (!response.ok) {
throw new Error(`iTunes search failed: ${response.status}`)
throw new Error(`${source.name} search failed: ${response.status}`)
}
const data = (await response.json()) as ItunesResponse
@@ -168,28 +327,106 @@ const searchAPISource = async (
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "podcast" as const,
podcast,
score: 1 - index * 0.02,
}))
}
const searchCustomSource = async (
/** Dispatch API-source search by source id: iTunes is the primary directory,
* Podcast Index the user-configured fallback (also usable directly). */
const searchAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(300, 650)
return makeResults(query, source, 13)
switch (source.id) {
case "podcastindex":
return searchPodcastIndexSource(query, source)
default:
return searchItunesSource(query, source)
}
}
const searchItunesEpisodeSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
const url = buildItunesEpisodeUrl(query, source)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${source.name} episode search failed: ${response.status}`)
}
const data = (await response.json()) as { results: ItunesEpisodeResult[] }
const results = data.results
.map((item) => mapItunesEpisodeResult(item, source))
.filter(
(item): item is { podcast: Podcast; episode: Episode } => Boolean(item),
)
return results.map(({ podcast, episode }, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "episode" as const,
podcast,
episode,
score: 1 - index * 0.02,
}))
}
/** Episode-scope API dispatch: only iTunes supports episode-by-term text
* search; Podcast Index has no such endpoint (its episode search is
* by-person only), so it contributes nothing to episode scope. */
const searchEpisodeAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.id === "podcastindex") return []
return searchItunesEpisodeSource(query, source)
}
/**
* RSS-type sources have no directory search backend: a feed URL identifies one
* show, and no API exists to search across "the RSS directory". Return no
* results rather than fabricating them.
*/
const searchRSSSource = async (): Promise<SearcherResult> => []
/**
* Custom sources are RSS feeds added by URL (SourceManager) — same
* no-backend story, so they contribute nothing to directory search.
*/
const searchCustomSource = async (): Promise<SearcherResult> => []
export const searchSourceByType = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource(query, source)
return searchRSSSource()
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource(query, source)
return searchCustomSource()
}
return searchAPISource(query, source)
}
/**
* Episode-scope dispatch: same backend rules as searchSourceByType — only
* API sources (iTunes) can search episodes; RSS/custom sources have no
* directory backend.
*/
export const searchEpisodesByType = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource()
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource()
}
return searchEpisodeAPISource(query, source)
}

View File

@@ -1,54 +0,0 @@
# 01. Rearchitect nav model — remove the sidebar pane
meta:
id: yazi-remake-01
feature: yazi-remake
priority: P1
depends_on: []
tags: [implementation, nav-model, tests-required]
objective:
- Remove the always-on `SIDEBAR_PANE` concept from the navigation context so `activeTab` is plain tab state (not a pane), establishing clean parent|current|preview semantics for the yazi remake.
deliverables:
- `src/context/NavigationContext.tsx` — delete `SIDEBAR_PANE` constant and all references; `activeTab` is no longer a pane
- `src/utils/navigation.ts` — update `TabPaneCount` semantics; depth-tabs = 1 focusable pane (current), the 3 visible columns are a render concern not 3 panes
- Updated header/comment block describing the parent|current|preview model
- `swipe()` / `popDepth()` reworked: depth-tabs `l`=drill (`open`), `h`=pop (noop at depth 0); fixed-pane tabs `h/l` move between parent/current/preview
- Tab-enter resets focus to `DEPTH_CENTER_PANE` (current pane), not a sidebar
steps:
- Audit every reference to `SIDEBAR_PANE` across the codebase (grep)
- In `NavigationContext.tsx`: delete the `SIDEBAR_PANE = -1` export and the `focusedIndex`/`setFocusedIndex` SIDEBAR_PANE branch added previously
- Set the initial `activePane` signal and the tab-switch createEffect to reset to `DEPTH_CENTER_PANE` (the current pane), not `SIDEBAR_PANE`
- Rework `swipe()` to clamp to `[0, paneCount-1]` for fixed-pane tabs (the sidebar is no longer in the chain); depth-tabs don't use `swipe` for drill/pop (that lives in Shell dispatch)
- In `utils/navigation.ts`: confirm `TabPaneCount` reflects focusable content panes only (depth-tabs = 1, Search = 3, Player = 1); update `PANE_RATIO` leave-behind note (ratio change happens in task 02)
- Update the file header comment block to describe parent|current|preview
- Run `lens_diagnostics` on the two files
tests:
- Unit: `focusedIndex(DEPTH_CENTER_PANE)` on a depth-tab returns the top frame's focus; `setFocusedIndex` writes to the top frame (Arrange a tab with a 2-frame stack, Act by calling setFocusedIndex, Assert topFrame.focus updated)
- Integration: tab-switch effect sets `activePane` to `DEPTH_CENTER_PANE` (not -1); `swipe(-1, 3)` on a fixed tab clamps to 0 not -1
- e2e (harness): app boots with `nav.state.pane === 0` (current), not -1
acceptance_criteria:
- No symbol `SIDEBAR_PANE` exists anywhere in `src/`
- Initial `activePane` === `DEPTH_CENTER_PANE` (0)
- Tab-enter sets `activePane` to `DEPTH_CENTER_PANE`
- `swipe()` lower bound is 0 (no `-1`)
validation:
- `grep -rn "SIDEBAR_PANE" src/` returns nothing
- `bun run build` passes
- `lens_diagnostics` paths=[`src/context/NavigationContext.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
notes:
- This task unblocks 03/04/05/06. It must not delete `DEPTH_CENTER_PANE` — that constant is generalised to "the current pane" and retained
- `SIDEBAR_ACTIONS` (added in Shell in a prior turn) is removed in task 06 (the keybind rewrite), not here — but Shell will temporarily fail to compile after this task until 05/06 land; that's expected and the build command ignores type errors, so gate success on grep + targeted diagnostics, not the full build

View File

@@ -1,56 +0,0 @@
# 02. Build the reusable 3-pane layout primitive (1:3:3 ratio, stable parent slot)
meta:
id: yazi-remake-02
feature: yazi-remake
priority: P1
depends_on: []
tags: [implementation, layout, tests-required]
objective:
- Create one reusable `<YaziPaneRow>` primitive that renders three bordered columns (parent | current | preview) at a 1:3:3 grow ratio with a stable 1/7 parent slot even when blank, so every list tab shares an identical, layout-stable shell.
deliverables:
- `src/components/YaziPaneRow.tsx` — new component: props `parent`, `current`, `preview` (Solid JSX/accessors), `parentLabel`, `currentLabel`, `previewLabel`, `focused` (boolean, defaults to current)
- `src/utils/navigation.ts``PANE_RATIO` updated to `{ parent: 1, current: 3, preview: 3 }` (was `{ parent: 1, current: 4, preview: 3 }`)
- Each pane: bordered `scrollbox` + slim header label row (height=1)
- Parent pane keeps its 1/7 `flexGrow` slot even when empty (renders a muted placeholder, never `width:0`)
- Focus ring (border color = accent on current; muted `border` on parent & preview)
steps:
- Set `PANE_RATIO = { parent: 1, current: 3, preview: 3 }` in `utils/navigation.ts`
- Create `YaziPaneRow.tsx` exporting a component that lays out three `<box flexGrow={PANE_RATIO.x}>` columns in a row
- Each column: a height-1 header `<box>` with the label text, then a `<scrollbox height="100%" border borderColor=…>` rendering the passed children
- Thread a `theme` via `useTheme()` inside the primitive (don't require callers to pass colors)
- `focused` prop controls which column gets the accent border — default current; parent & preview always muted
- Ensure the parent column renders a muted placeholder box (e.g. a single `<text fg={muted}>—</text>` or empty) when its children are null, but critically keeps `flexGrow={PANE_RATIO.parent}` so width never collapses
- Add a JSDoc header describing the yazi 1:3:3 contract
- Run diagnostics on the new file
tests:
- Unit: the primitive renders three boxes with flexGrow 1/3/3 regardless of null children (Arrange null parent, render, Assert three columns present with correct flexGrow)
- Integration: toggling `focused` swaps the accent border onto the requested column
- e2e (harness): a page using the primitive shows three equal-ratio columns with the parent column visibly non-zero width even when blank
acceptance_criteria:
- `PANE_RATIO` is `{ parent: 1, current: 3, preview: 3 }`
- `YaziPaneRow` accepts parent/current/preview children + labels + focused
- Parent column width never collapses to 0 (stable 1/7 slot)
- Only the focused column shows the accent border
validation:
- `grep -n "PANE_RATIO" src/utils/navigation.ts` shows the new 1:3:3 values
- `lens_diagnostics` paths=[`src/components/YaziPaneRow.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
- Harness: render a throwaway page using `<YaziPaneRow>`; confirm 3 columns at 1:3:3 via the frame
notes:
- Independent of task 01 (no nav-state dependency) — can be built in parallel
- Callers (tasks 03/04) pass their own parent/current/preview JSX; the primitive is purely structural
- opentui scrollbox: use `focused` only on the current pane so scroll focus follows the cursor

View File

@@ -1,58 +0,0 @@
# 03. Convert Feed/MyShows/Discover/Settings to the shared parent|current|preview primitive
meta:
id: yazi-remake-03
feature: yazi-remake
priority: P2
depends_on: [yazi-remake-01, yazi-remake-02]
tags: [implementation, pages, tests-required]
objective:
- Rewrite the four depth-stack list tabs to render through `<YaziPaneRow>`, with the previous-depth list now visible in the parent pane (blank at depth 0), the current-depth list in current, and the hovered item in preview — eliminating per-page bespoke 3-column JSX.
deliverables:
- `src/pages/Feed/FeedPage.tsx` — rewritten to use `<YaziPaneRow>`; parent = previous-depth list, current = current-depth list, preview = hovered item detail
- `src/pages/MyShows/MyShowsPage.tsx` — same conversion
- `src/pages/Discover/DiscoverPage.tsx` — same conversion
- `src/pages/Settings/SettingsPage.tsx` — same conversion (sections → items → editor)
- Each page's `nav.action` handler retained but only acts on the current pane
- All per-page bespoke row/flexbox 3-column JSX removed
steps:
- For each of the four pages, read the current implementation to extract the parent/current/preview content builders
- Wrap the page body in `<YaziPaneRow parent={…} current={…} preview={…} focused={isActive} />`
- Parent pane: render the previous-depth frame's list (depth-1). At depth 0 the parent receives null/placeholder (the primitive keeps the slot)
- Current pane: the current-depth list, focusable, with `onMouseDown` row handlers calling `nav.setActivePane(DEPTH_CENTER_PANE)` + `nav.setDepthFocus(i, depth)`
- Preview pane: hovered-item detail derived from `focusedIndex(DEPTH_CENTER_PANE)` (unchanged logic, just relocated into the preview slot)
- Keep `pushDepth`/`popDepth` calls in the `open` action (drill) — behaviour unchanged, only layout changes
- Remove the old inline `<box flexGrow={PANE_RATIO.parent/current/preview}>` columns in favour of the primitive
- Verify each page's `nav.action` handler guards on `data.pane === DEPTH_CENTER_PANE && nav.activePane() === DEPTH_CENTER_PANE`
tests:
- Unit: each page's `open` action pushes a frame and the parent pane switches from blank to the previous list (Arrange depth 0, Act open, Assert stack length 2 and parent renders the old list)
- Integration: `h` (pop) returns parent to blank at depth 0; `l` (drill) populates parent with the previous list
- e2e (harness): Feed depth 0→1→2 shows parent blank → previous feeds list → previous episodes list; Settings sections→items→editor shows the chain in the parent pane
acceptance_criteria:
- All four pages render via `<YaziPaneRow>` (no bespoke 3-column JSX remains)
- Parent pane is blank at depth 0, populated at depth ≥ 1
- Drilling (l/Enter) populates the parent with the previous-depth list
- Popping (h) empties the parent back to blank at depth 0
- j/k move focus only within the current pane
validation:
- `grep -rn "YaziPaneRow" src/pages/` returns 4 files
- `lens_diagnostics` paths over the four page files severity=error → 0 findings
- Harness walk: `init` → navigate Feed → `l` (drill) → `l` (drill) → `h` (pop) → `h` (pop); confirm parent slot transitions blank→list→list→blank
notes:
- Depends on 01 (pane model) and 02 (the primitive) being merged
- The already-working `<Show when={item}>{(item) => (… item() …)}</Show>` accessor pattern for opentui `<Show>` callbacks must be preserved in preview panes
- Keep `LoadingIndicator` usages where they exist

View File

@@ -1,52 +0,0 @@
# 04. Fit Search and Player into the 3-pane (1:3:3) model
meta:
id: yazi-remake-04
feature: yazi-remake
priority: P2
depends_on: [yazi-remake-01, yazi-remake-02]
tags: [implementation, pages, tests-required]
objective:
- Bring the two fixed-layout tabs (Search, Player) into the same 1:3:3 parent|current|preview shell, deciding per-page whether to adopt the depth-stack or stay fixed-3-pane, while applying the new ratios throughout.
deliverables:
- `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`; parent = query input + recent-search history, current = results list, preview = focused-result detail
- `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`; current = now-playing transport, preview = episode description/notes, parent = blank placeholder (or compact episode list if available)
- Decision recorded in each file's header comment: depth-stack vs fixed-3-pane
steps:
- Read both pages to understand their current pane semantics
- Search: map INPUT→parent, RESULTS→current, DETAIL→preview inside `<YaziPaneRow>`. If the 1/7 parent slot is too narrow for the input box, widen parent for Search only by passing an override ratio OR move the query into current and results into parent — pick the option that keeps the input usable and document it
- Search: keep the `inputFocused` effect (Shell yields keys to `<input>` when current-pane focus is on the query) — adapt to whichever pane the input lives in
- Player: single content pane; parent = blank/placeholder (1/7), current = transport + progress + controls (3/7), preview = episode art/description/notes (3/7). If no preview data, render a muted placeholder but keep the slot
- Confirm fixed-pane tab swipe (h/l between parent/current/preview) still routes correctly for Search
- Run diagnostics
tests:
- Unit: Search's `handleSubmit` swipes to the results pane and sets focus index 0 (Arrange empty results, Act submit, Assert activePane === results pane & focusedIndex 0)
- Integration: Player renders with parent blank and the transport in current
- e2e (harness): Search shows query | results | detail at 1:3:3; Player shows blank | transport | notes at 1:3:3
acceptance_criteria:
- Both pages render via `<YaziPaneRow>` at 1:3:3
- Search input remains typeable (Shell yields keys when the query pane is focused)
- Player's transport is in the current pane with focus
- No layout collapse: parent & preview keep their slots even if blank
validation:
- `grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` returns 2 files
- `lens_diagnostics` paths over both files severity=error → 0 findings
- Harness: navigate to Search, type a query, press Enter, see results in current + detail in preview; navigate to Player, see transport + notes
notes:
- Depends on 01 (pane model — though Search is fixed-pane, the model cleanup affects `swipe` bounds) and 02 (the primitive)
- If Search input at 1/7 is genuinely too tight (~14 cols at 100w), prefer moving the query into the current pane for Search only and the results into parent — but confirm width with the harness before committing
- Player is single-content; the 1:3:3 with blanks is mostly cosmetic but keeps the layout globally consistent

View File

@@ -1,56 +0,0 @@
# 05. Rebuild Shell chrome — drop sidebar, add yazi bottom status/tab bar
meta:
id: yazi-remake-05
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-01]
tags: [implementation, shell-chrome, tests-required]
objective:
- Remove the always-on left tab sidebar entirely and replace it with a full-width page area above a slim yazi-style bottom bar that surfaces the active tab, depth/counts, selection, now-playing, and a discoverable tab strip.
deliverables:
- `src/components/Shell.tsx` — sidebar JSX deleted; render `LayerGraph[tab]()` full-width + a rebuilt bottom status/command bar
- Bottom bar (normal mode): mode label, `TAB_LABEL[tab] · depth N · i/len` (or `pane i/n` for fixed tabs), selection count `●N`, now-playing `♪ title`, pending-keybind hint, and a compact tab strip `[1]Feed [2]MyShows …` with the active tab marked
- Bottom bar (command mode): `:` prompt + buffer + error (unchanged, just relocated if needed)
- Help overlay kept; now-playing relocated from the old sidebar footer into the status bar
steps:
- Read `Shell.tsx` and delete the entire left tab sidebar `<box flexDirection="column" width={14}>…` block
- Replace the middle row with a single full-width `<box flexGrow={1}>{LayerGraph[nav.activeTab()]()}</box>`
- Rebuild the bottom bar: a height-1 `<box flexDirection="row">` with the fragments described above
- Tab strip: render `Object.values(TABS)` filtered to numbers; for each tab show `[N] Label` with the active tab inverted/highlighted (accent bg or `≡` marker)
- Status fragment: `nav.activePane() === DEPTH_CENTER_PANE ? (isDepthTab ? \`depth ${currentDepth()}\` : \`pane ${activePane()+1}/${count}\`) : 'tabs'` — but since the sidebar is gone, default to the depth/pane string (focus starts on current)
- Relocate `nowPlaying()` text from the sidebar footer into the bottom bar
- Keep `runCommand`, `handleCommandKey`, the help overlay, and `playEpisodeAndSwitch` untouched
- Run diagnostics
tests:
- Unit: `nowPlaying()` formats `♪ <truncated title>` (Arrange a current episode, Assert the string)
- Integration: switching tabs updates the tab strip's active marker and the status tab label
- e2e (harness): `init` shows no left sidebar, a full-width page, and a bottom bar containing the tab strip + `Feed · depth 0`; cycling tabs moves the strip's active marker
acceptance_criteria:
- No `width={14}` sidebar `<box>` remains in `Shell.tsx`
- The active page fills the full content width
- The bottom bar shows the active tab, depth, counts, selection, now-playing, and the tab strip
- The active tab is visually marked in the strip
validation:
- `grep -n "width={14}" src/components/Shell.tsx` returns nothing
- `grep -n "LayerGraph" src/components/Shell.tsx` shows the full-width render
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
- Harness: `init` frame has no sidebar column and shows the tab strip in the last row
notes:
- Depends on 01 (the pane model: focus starts on current, so the status fragment no longer needs the `SIDEBAR_PANE` branch)
- Task 06 rewrites the dispatch keybinds in this same file; do the chrome here and leave the dispatch `SIDEBAR_ACTIONS` branch for 06 to remove (or remove it here if 01 already deleted the constant — coordinate with 01)
- `playEpisodeAndSwitch` and the command bar must keep working

View File

@@ -1,56 +0,0 @@
# 06. Rewire keybinds — h/l drill+pop, digits switch tabs, focus starts on current
meta:
id: yazi-remake-06
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-01, yazi-remake-05]
tags: [implementation, keybinds, tests-required]
objective:
- Rewire the Shell dispatch so the sidebar's special-cased j/k branch is gone, h/l drill/pop on depth-tabs and swipe on fixed tabs, digit keys + `[ ]` are the sole tab switcher, and app focus starts on the current pane.
deliverables:
- `src/components/Shell.tsx` (dispatch) — `SIDEBAR_ACTIONS` set + the `if (nav.activePane() === SIDEBAR_PANE)` branch deleted
- `h`/`l` unified: depth-tabs `l`=current-drills (`open` emit), `h`=current-pops (noop at depth 0); fixed-pane tabs `h/l`=`swipe(∓1, count)`
- `1`-`6` / `tab-goto-*`, `tab-next`/`tab-prev` (`[`/`]`) — the only tab switchers
- Initial focus + tab-enter land on `DEPTH_CENTER_PANE`
- `keybinds.jsonc` reviewed (update labels/help only if needed)
steps:
- Read the current `dispatch()` (post task 01 it references a deleted `SIDEBAR_PANE` — fix the compile here)
- Remove the `SIDEBAR_ACTIONS` constant and its branch
- In the `default` case, implement: digit/tab-goto → `setActiveTab`; `swipe-prev` → (depth-tab & current & depth>0) `popDepth` else (depth-tab & current & depth==0) noop else `swipe(-1, count)`; `swipe-next` → (depth-tab & current) emit `open` else `swipe(1, count)`
- Move/list actions (`move-down/up`, `jump-*`, `page-*`, `goto-top/bottom`) flow to `PAGE_ACTIONS``emit("nav.action")` for the current pane only
- Confirm `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches unchanged
- Verify the app boot path sets focus to current (task 01 set the signal; confirm dispatch doesn't override)
- Run diagnostics + harness key sequence
tests:
- Unit: `dispatch("move-down")` on a depth-tab current pane emits `nav.action {action:"move-down"}` (Arrange current pane, Act, Assert emit)
- Integration: `dispatch("swipe-next")` on a depth-tab at depth 0 emits `open` (drill); `dispatch("swipe-prev")` at depth 1 pops to depth 0; at depth 0 `swipe-prev` is a noop
- e2e (harness): `l` drills (depth 0→1, parent populates), `h` pops (1→0, parent blanks), `1`/`2`/`3` switch tabs, `j`/`k` move the current list cursor without changing depth
acceptance_criteria:
- No `SIDEBAR_PANE` or `SIDEBAR_ACTIONS` references in `Shell.tsx`
- `h` at depth 0 is a noop (does not error, does not change pane)
- `l` at current on a depth-tab drills (depth+1)
- Digit keys switch tabs; focus lands on current pane
- `j`/`k` move within current only
validation:
- `grep -n "SIDEBAR" src/components/Shell.tsx` returns nothing
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
- Harness: `init` (focus on current) → `l` (depth 1, parent filled) → `l` (depth 2) → `h` (depth 1) → `h` (depth 0, parent blank) → `3` (Discover tab, focus on current) → `j`/`k` move
notes:
- Depends on 01 (pane model: `swipe` bounds, no SIDEBAR) and 05 (dispatch lives in the rebuilt Shell)
- If `keybinds.jsonc` has a `tab-next`/`tab-prev` mapping conflict, resolve here
- The noop `h` at depth 0 should feel inert (yazi: at root, `h` does nothing)

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