54 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
b0bfa41028 bump VERSION to 0.3.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m12s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-09 22:24:58 -04:00
25307f83e9 fix: up highlight made legible for transparent bg, bring back mouse nav 2026-08-09 22:21:30 -04:00
db285530b6 feat: private feeds, all input fields supersede keyboard nav 2026-08-09 15:39:02 -04:00
e1cdd6b2a5 finished hygenie 2026-08-09 14:38:33 -04:00
2abdbaa4e9 cleaning up code 2026-08-09 09:54:52 -04:00
1d06156b8b docs: repoint Homebrew tap to mikefreno/tap after repo rename 2026-08-09 09:19:05 -04:00
c63e9e1b9c bump VERSION to 0.2.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m15s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-08 08:02:53 -04:00
0facfff51b docs: document AUR packaging sync in release steps; CI uploads LICENSE asset 2026-08-08 07:17:27 -04:00
3ef19f80b8 drop oauth plan (copy config) 2026-08-08 07:10:59 -04:00
13a31aabdc docs: mark AUR package as pending registration reopen 2026-08-08 07:09:07 -04:00
8dbdebfd30 AUR: fetch LICENSE as release asset, install to licenses dir; sync .SRCINFO 2026-08-07 20:11:43 -04:00
529817323d add MIT license, podtui-bin AUR PKGBUILD, fix README install sections 2026-08-07 20:09:30 -04:00
ace883b505 docs: reference scripts/release-tag.sh in Releasing section 2026-08-07 19:38:20 -04:00
186 changed files with 8742 additions and 6933 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,14 @@ 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: artifacts/**/*.tar.gz
files: |
artifacts/**/*.tar.gz
LICENSE

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
@@ -132,8 +135,12 @@ Releases are built and published from **tags**
### Steps
1. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0``0.2.0`). Commit and push.
2. Tag and push:
1. Run `scripts/release-tag.sh` (interactive: pick major/minor/patch/custom,
confirms the plan, bumps `VERSION` in `src/index.tsx`, commits, tags
`vX.Y.Z`, and pushes branch + tag to every remote). If the version bump is
already committed but the tag is missing, it offers a tag-only path.
`--dry-run` prints the plan without doing anything.
2. Equivalent manual commands:
```bash
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
@@ -155,18 +162,25 @@ Releases are built and published from **tags**
4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
`mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/podtui/podtui`.
test: `brew install mikefreno/tap/podtui`.
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
staged, not yet published (AUR account registrations are closed; see the
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
`bash packaging/aur/gen-srcinfo.sh`.
### Manual fallback
If you ever need to sync the tap by hand (or before the hourly job runs):
```bash
cd <clone of mikefreno/homebrew-podtui>
cd <clone of mikefreno/homebrew-tap>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
```
@@ -179,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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Michael Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
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.

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

240
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/podtui/podtui # requires mpv: brew install mpv
brew install mikefreno/tap/podtui
```
> The formula installs the standalone binary plus its two native libraries
> side by side (see [Packaging model](#packaging-model)). It does **not**
> depend on Bun.
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)
@@ -61,63 +69,39 @@ Grab `podtui-<platform>-<arch>.tar.gz` from the latest
put `podtui` on your `PATH`:
```bash
curl -sSL -o podtui.tar.gz \
curl -sS -o /tmp/podtui.tar.gz \
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
tar -xzf podtui.tar.gz
sudo install -m755 podtui /usr/local/bin/podtui
sudo mkdir -p /opt/podtui
sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1
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
yay -S podtui
yay -S podtui-bin # once published
```
or build from the PKGBUILD (`podtui-bin`). The package installs the released
binary and its sibling libraries.
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` 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
@@ -129,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
@@ -161,60 +188,53 @@ 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
TBD — choose and document a license before first release.
MIT. See [LICENSE](LICENSE).
## Related

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",

BIN
bun.lockb

Binary file not shown.

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

@@ -1,5 +1,5 @@
{
"version": 2,
"version": 3,
"podcasts": [
{
"id": "discover-daily",
@@ -7,7 +7,9 @@
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
"feedUrl": "http://rss.art19.com/the-daily",
"author": "The New York Times",
"categories": ["News", "Politics"]
"categories": [
"News & Politics"
]
},
{
"id": "discover-up-first",
@@ -15,7 +17,9 @@
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
"author": "NPR",
"categories": ["News"]
"categories": [
"News & Politics"
]
},
{
"id": "discover-npr-politics",
@@ -23,23 +27,29 @@
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
"author": "NPR",
"categories": ["News", "Politics"]
"categories": [
"News & Politics"
]
},
{
"id": "discover-code-switch",
"title": "Code Switch",
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
"feedUrl": "https://feeds.npr.org/510312/podcast.xml",
"author": "NPR",
"categories": ["News", "Culture", "Politics"]
"id": "discover-ben-shapiro",
"title": "The Ben Shapiro Show",
"description": "Ben Shapiro delivers unapologetically conservative commentary on the biggest news stories of the day, blending sharp analysis with his trademark fact-based approach.",
"feedUrl": "https://feeds.megaphone.fm/benshow",
"author": "The Daily Wire",
"categories": [
"News & Politics"
]
},
{
"id": "discover-rough-translation",
"title": "Rough Translation",
"description": "How are the things we're talking about covered in the rest of the world? NPR's Rough Translation takes you to far-off places and shows you the unexpected.",
"feedUrl": "https://feeds.npr.org/510324/podcast.xml",
"author": "NPR",
"categories": ["News", "Culture"]
"id": "discover-advisory-opinions",
"title": "Advisory Opinions",
"description": "Host Sarah Isgur and permanent guest David French have twice-weekly conversations about the law, the courts, their collision with politics, and why it all matters — from The Dispatch.",
"feedUrl": "https://feeds.megaphone.fm/DISPME4573820108",
"author": "The Dispatch",
"categories": [
"News & Politics"
]
},
{
"id": "discover-crime-junkie",
@@ -47,7 +57,9 @@
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
"author": "audiochuck",
"categories": ["True Crime"]
"categories": [
"True Crime"
]
},
{
"id": "discover-serial",
@@ -55,7 +67,10 @@
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
"author": "Serial Productions & The New York Times",
"categories": ["True Crime", "Storytelling"]
"categories": [
"True Crime",
"Storytelling"
]
},
{
"id": "discover-intelligence-matters",
@@ -63,7 +78,10 @@
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
"feedUrl": "https://rss.art19.com/intelligence-matters",
"author": "CBS News",
"categories": ["True Crime", "Politics", "News"]
"categories": [
"True Crime",
"News & Politics"
]
},
{
"id": "discover-smartless",
@@ -71,7 +89,10 @@
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
"feedUrl": "https://rss.art19.com/smartless",
"author": "Jason Bateman, Sean Hayes, Will Arnett",
"categories": ["Comedy", "Entertainment"]
"categories": [
"Comedy",
"Entertainment"
]
},
{
"id": "discover-this-past-weekend",
@@ -79,7 +100,9 @@
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
"author": "Theo Von",
"categories": ["Comedy"]
"categories": [
"Comedy"
]
},
{
"id": "discover-joe-rogan",
@@ -87,7 +110,10 @@
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
"author": "Joe Rogan",
"categories": ["Comedy", "Entertainment"]
"categories": [
"Comedy",
"Entertainment"
]
},
{
"id": "discover-comedy-bang-bang",
@@ -95,7 +121,9 @@
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
"author": "Earwolf",
"categories": ["Comedy"]
"categories": [
"Comedy"
]
},
{
"id": "discover-office-ladies",
@@ -103,7 +131,10 @@
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
"feedUrl": "https://rss.art19.com/office-ladies",
"author": "Earwolf",
"categories": ["Comedy", "Entertainment"]
"categories": [
"Comedy",
"Entertainment"
]
},
{
"id": "discover-how-did-this-get-made",
@@ -111,7 +142,10 @@
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
"author": "Earwolf",
"categories": ["Comedy", "Film"]
"categories": [
"Comedy",
"Film"
]
},
{
"id": "discover-wait-wait",
@@ -119,7 +153,10 @@
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
"author": "NPR",
"categories": ["Comedy", "News"]
"categories": [
"Comedy",
"News & Politics"
]
},
{
"id": "discover-new-heights",
@@ -127,7 +164,10 @@
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
"feedUrl": "https://rss.art19.com/new-heights",
"author": "Jason & Travis Kelce",
"categories": ["Sports", "Comedy"]
"categories": [
"Sports",
"Comedy"
]
},
{
"id": "discover-bill-simmons",
@@ -135,7 +175,10 @@
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
"author": "The Ringer",
"categories": ["Sports", "Entertainment"]
"categories": [
"Sports",
"Entertainment"
]
},
{
"id": "discover-acquired",
@@ -143,7 +186,10 @@
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
"feedUrl": "https://feeds.transistor.fm/acquired",
"author": "Ben Gilbert & David Rosenthal",
"categories": ["Business", "Technology"]
"categories": [
"Business",
"Technology"
]
},
{
"id": "discover-all-in",
@@ -151,7 +197,11 @@
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
"categories": ["Business", "Technology", "Politics"]
"categories": [
"Business",
"Technology",
"Politics"
]
},
{
"id": "discover-planet-money",
@@ -159,7 +209,10 @@
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
"author": "NPR",
"categories": ["Business", "Economics"]
"categories": [
"Business",
"Economics"
]
},
{
"id": "discover-how-i-built-this",
@@ -167,7 +220,10 @@
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
"author": "NPR / Wondery",
"categories": ["Business", "Technology"]
"categories": [
"Business",
"Technology"
]
},
{
"id": "discover-freakonomics",
@@ -175,7 +231,11 @@
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
"author": "Stephen J. Dubner",
"categories": ["Business", "Economics", "Society"]
"categories": [
"Business",
"Economics",
"Society"
]
},
{
"id": "discover-darknet-diaries",
@@ -183,7 +243,10 @@
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
"feedUrl": "https://podcast.darknetdiaries.com/",
"author": "Jack Rhysider",
"categories": ["Technology", "True Crime"]
"categories": [
"Technology",
"True Crime"
]
},
{
"id": "discover-changelog",
@@ -191,7 +254,10 @@
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
"feedUrl": "https://changelog.fm/rss",
"author": "Changelog Media",
"categories": ["Technology", "Software Engineering"]
"categories": [
"Technology",
"Software Engineering"
]
},
{
"id": "discover-twit",
@@ -199,7 +265,9 @@
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
"feedUrl": "https://feeds.twit.tv/twit.xml",
"author": "TWiT",
"categories": ["Technology"]
"categories": [
"Technology"
]
},
{
"id": "discover-radiolab",
@@ -207,7 +275,10 @@
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
"feedUrl": "http://feeds.wnyc.org/radiolab",
"author": "WNYC Studios",
"categories": ["Science", "Storytelling"]
"categories": [
"Science",
"Storytelling"
]
},
{
"id": "discover-huberman-lab",
@@ -215,7 +286,10 @@
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
"author": "Scicomm Media",
"categories": ["Health", "Science"]
"categories": [
"Health",
"Science"
]
},
{
"id": "discover-skeptics-guide",
@@ -223,7 +297,10 @@
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
"author": "Steven Novella",
"categories": ["Science", "Philosophy"]
"categories": [
"Science",
"Philosophy"
]
},
{
"id": "discover-throughline",
@@ -231,7 +308,10 @@
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
"author": "NPR",
"categories": ["History", "Politics"]
"categories": [
"History",
"Politics"
]
},
{
"id": "discover-hardcore-history",
@@ -239,7 +319,9 @@
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
"author": "Dan Carlin",
"categories": ["History"]
"categories": [
"History"
]
},
{
"id": "discover-history-of-rome",
@@ -247,7 +329,9 @@
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
"author": "Mike Duncan",
"categories": ["History"]
"categories": [
"History"
]
},
{
"id": "discover-philosophize-this",
@@ -255,7 +339,10 @@
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
"feedUrl": "https://philosophizethis.libsyn.com/rss",
"author": "Stephen West",
"categories": ["Philosophy", "Education"]
"categories": [
"Philosophy",
"Education"
]
},
{
"id": "discover-very-bad-wizards",
@@ -263,7 +350,10 @@
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
"feedUrl": "https://feeds.libsyn.com/474285/rss",
"author": "Tamler Sommers & David Pizarro",
"categories": ["Philosophy", "Science"]
"categories": [
"Philosophy",
"Science"
]
},
{
"id": "discover-big-picture",
@@ -271,7 +361,10 @@
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
"feedUrl": "https://rss.art19.com/the-big-picture",
"author": "The Ringer",
"categories": ["Film", "Entertainment"]
"categories": [
"Film",
"Entertainment"
]
},
{
"id": "discover-all-songs-considered",
@@ -279,7 +372,9 @@
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
"author": "NPR Music",
"categories": ["Music"]
"categories": [
"Music"
]
},
{
"id": "discover-switched-on-pop",
@@ -287,7 +382,9 @@
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
"author": "Vox Media / Panoply",
"categories": ["Music"]
"categories": [
"Music"
]
},
{
"id": "discover-hit-parade",
@@ -295,7 +392,10 @@
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
"feedUrl": "https://feeds.megaphone.fm/hitparade",
"author": "Slate",
"categories": ["Music", "History"]
"categories": [
"Music",
"History"
]
},
{
"id": "discover-song-exploder",
@@ -303,7 +403,10 @@
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
"feedUrl": "https://songexploder.net/rss",
"author": "Hrishikesh Hirway",
"categories": ["Music", "Arts"]
"categories": [
"Music",
"Arts"
]
},
{
"id": "discover-blank-check",
@@ -311,7 +414,10 @@
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
"feedUrl": "https://audioboom.com/channels/4278829.rss",
"author": "Griffin Newman & David Sims",
"categories": ["Film", "Comedy"]
"categories": [
"Film",
"Comedy"
]
},
{
"id": "discover-99-invisible",
@@ -319,7 +425,11 @@
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
"author": "Roman Mars",
"categories": ["Design", "Arts", "Culture"]
"categories": [
"Design",
"Arts",
"Culture"
]
},
{
"id": "discover-gastropod",
@@ -327,7 +437,11 @@
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
"feedUrl": "https://gastropod.com/feed",
"author": "Cynthia Graber & Nicola Twilley",
"categories": ["Food", "Science", "History"]
"categories": [
"Food",
"Science",
"History"
]
},
{
"id": "discover-this-american-life",
@@ -335,7 +449,10 @@
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
"author": "This American Life",
"categories": ["Storytelling", "Culture"]
"categories": [
"Storytelling",
"Culture"
]
},
{
"id": "discover-ted-talks-daily",
@@ -343,7 +460,10 @@
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
"author": "TED",
"categories": ["Education", "Storytelling"]
"categories": [
"Education",
"Storytelling"
]
},
{
"id": "discover-tim-ferriss",
@@ -351,7 +471,10 @@
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
"author": "Tim Ferriss",
"categories": ["Self-Improvement", "Business"]
"categories": [
"Self-Improvement",
"Business"
]
},
{
"id": "discover-jordan-harbinger",
@@ -359,7 +482,9 @@
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
"author": "Jordan Harbinger",
"categories": ["Self-Improvement"]
"categories": [
"Self-Improvement"
]
},
{
"id": "discover-on-purpose",
@@ -367,7 +492,10 @@
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
"author": "Jay Shetty",
"categories": ["Self-Improvement", "Health"]
"categories": [
"Self-Improvement",
"Health"
]
},
{
"id": "discover-10-percent-happier",
@@ -375,7 +503,11 @@
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
"feedUrl": "https://rss.art19.com/ten-percent-happier",
"author": "Dan Harris",
"categories": ["Self-Improvement", "Health", "Philosophy"]
"categories": [
"Self-Improvement",
"Health",
"Philosophy"
]
},
{
"id": "discover-school-of-greatness",
@@ -383,7 +515,10 @@
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
"author": "Lewis Howes",
"categories": ["Self-Improvement", "Business"]
"categories": [
"Self-Improvement",
"Business"
]
},
{
"id": "discover-sysk",
@@ -391,7 +526,10 @@
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
"categories": ["Education", "Comedy"]
"categories": [
"Education",
"Comedy"
]
},
{
"id": "discover-in-our-time",
@@ -399,7 +537,11 @@
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
"author": "BBC Radio 4",
"categories": ["History", "Education", "Philosophy"]
"categories": [
"History",
"Education",
"Philosophy"
]
}
]
}

View File

@@ -18,21 +18,15 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77",
"babel-preset-solid": "1.9.9",
"date-fns": "^4.1.0",
"solid-js": "^1.9.9",
"uuid": "^13.0.0",
"zustand": "^5.0.11"
"solid-js": "^1.9.9"
}
}

41
packaging/aur/.SRCINFO Normal file
View File

@@ -0,0 +1,41 @@
pkgbase = podtui-bin
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
pkgver = 0.2.0
pkgrel = 1
url = https://github.com/mikefreno/podtui
arch = x86_64
arch = aarch64
license = MIT
depends = mpv
provides = podtui
conflicts = podtui
options = !strip
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
pkgname = podtui-bin
pkgver = 0.2.0
pkgrel = 1
url = https://github.com/mikefreno/podtui
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
arch = x86_64
arch = aarch64
license = MIT
depends = mpv
provides = podtui
conflicts = podtui
options = !strip
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc

63
packaging/aur/PKGBUILD Normal file
View File

@@ -0,0 +1,63 @@
# Maintainer: Michael Freno <michael.freno@gmail.com>
# Contributor: Michael Freno <michael.freno@gmail.com>
# podtui-bin — TUI podcast/audiobook player with synchronized audio-waveform
# visualization. Serves the official standalone release binary and its two FFI
# sibling libraries (libcavacore.so + libopentui.so) from GitHub Releases.
#
# The embedded Bun runtime is statically linked into the binary — no Bun, no
# fftw needed at runtime (fftw3 is linked statically into libcavacore.so).
pkgname=podtui-bin
_pkgname=podtui
pkgver=0.2.0
pkgrel=1
pkgdesc="Terminal podcast and audio player with synchronized audio-waveform visualization"
url="https://github.com/mikefreno/podtui"
arch=('x86_64' 'aarch64')
license=('MIT')
depends=('mpv') # sole audio backend; no-op without it
provides=("${_pkgname}")
conflicts=("${_pkgname}")
options=('!strip') # standalone binary, pre-minified
source_x86_64=(
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-x64.tar.gz"
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
)
source_aarch64=(
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-arm64.tar.gz"
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
)
sha256sums_x86_64=(
'5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc'
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
)
sha256sums_aarch64=(
'c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5'
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
)
package() {
local libdir
case "$CARCH" in
x86_64) libdir="podtui-linux-x64" ;;
aarch64) libdir="podtui-linux-arm64" ;;
esac
# Binary + native FFI libs must stay side by side in /usr/lib/podtui/;
# a /usr/bin symlink works because the embedded Bun runtime resolves
# process.execPath through symlinks (verified against the compiled binary).
install -Dm755 "${srcdir}/${libdir}/podtui" "${pkgdir}/usr/lib/podtui/podtui"
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"
}

View File

@@ -0,0 +1,60 @@
#!/bin/bash
# gen-srcinfo.sh — emit .SRCINFO for the podtui-bin PKGBUILD without makepkg.
# Emits the same field set/ordering makepkg --printsrcinfo produces for this
# PKGBUILD shape (single package, per-arch source + sha256sums arrays).
set -euo pipefail
cd "$(dirname "$0")"
# shellcheck disable=SC1091
. ./PKGBUILD
emit() { printf '\t%s = %s\n' "$1" "$2"; }
emit_multi() { # $1 field, rest values
local f="$1"
shift
for v in "$@"; do emit "$f" "$v"; done
}
pkgbase_section() {
echo "pkgbase = ${pkgname}"
for f in pkgdesc pkgver pkgrel url; do
v="${!f}"
[ -n "${v:-}" ] && emit "$f" "$v"
done
[ -n "${install:-}" ] && emit install "$install"
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
emit_arch_arrays
}
emit_arch_arrays() {
for a in "${arch[@]}"; do
src_name="source_${a}"
sha_name="sha256sums_${a}"
src_val="${src_name}[@]"
sha_val="${sha_name}[@]"
[ "${#src_name}" -gt 0 ] && emit_multi "source_${a}" "${!src_val}"
emit_multi "sha256sums_${a}" "${!sha_val}"
done
}
pkgbase_section
echo ""
echo "pkgname = ${pkgname}"
for v in pkgver pkgrel url; do
val="${!v}"
[ -n "${val:-}" ] && emit "$v" "$val"
done
emit pkgdesc "$pkgdesc"
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
emit_arch_arrays

11
packaging/podtui.desktop Normal file
View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { ErrorBoundary } from "solid-js";
import { useSelectionHandler, useRenderer } from "@opentui/solid";
import { useAuthStore } from "@/stores/auth";
import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { Clipboard } from "@/utils/clipboard";
@@ -19,23 +18,21 @@ const DEBUG = import.meta.env.DEBUG;
export function App() {
const nav = useNavigation();
const auth = useAuthStore();
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).
@@ -52,6 +49,7 @@ export function App() {
});
const backgroundColor = () =>
themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface;

View File

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

View File

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

View File

@@ -1,180 +0,0 @@
/**
* Code validation component for PodTUI
* 8-character alphanumeric code input for sync authentication
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { AUTH_CONFIG } from "@/config/auth";
import { useTheme } from "@/context/ThemeContext";
interface CodeValidationProps {
focused?: boolean;
onBack?: () => void;
}
type FocusField = "code" | "submit" | "back";
export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [code, setCode] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("code");
const [codeError, setCodeError] = createSignal<string | null>(null);
const fields: FocusField[] = ["code", "submit", "back"];
/** Format code as user types (uppercase, alphanumeric only) */
const handleCodeInput = (value: string) => {
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
// Limit to max length
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
setCode(limited);
// Clear error when typing
if (codeError()) {
setCodeError(null);
}
};
const validateCode = (value: string): boolean => {
if (!value) {
setCodeError("Code is required");
return false;
}
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
setCodeError(
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
);
return false;
}
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
setCodeError("Code must contain only letters and numbers");
return false;
}
setCodeError(null);
return true;
};
const handleSubmit = async () => {
if (!validateCode(code())) {
return;
}
const success = await auth.validateCode(code());
if (!success && auth.error) {
setCodeError(auth.error.message);
}
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "tab") {
if (focusField() === "submit") {
handleSubmit();
} else if (focusField() === "back" && props.onBack) {
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack();
}
};
const codeProgress = () => {
const len = code().length;
const max = AUTH_CONFIG.codeValidation.codeLength;
return `${len}/${max}`;
};
const codeDisplay = () => {
const current = code();
const max = AUTH_CONFIG.codeValidation.codeLength;
const filled = current.split("");
const empty = Array(max - filled.length).fill("_");
return [...filled, ...empty].join(" ");
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Enter Sync Code</strong>
</text>
<box height={1} />
<text fg={theme.textMuted}>
Enter your 8-character sync code to link your account.
</text>
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
<box height={1} />
{/* Code display */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "code" ? theme.primary : undefined}>
Code ({codeProgress()}):
</text>
<box border padding={1} borderColor={theme.border}>
<text
fg={
code().length === AUTH_CONFIG.codeValidation.codeLength
? theme.success
: theme.warning
}
>
{codeDisplay()}
</text>
</box>
{/* Hidden input for actual typing */}
<input
value={code()}
onInput={handleCodeInput}
placeholder=""
focused={props.focused && focusField() === "code"}
width={30}
/>
{codeError() && <text fg={theme.error}>{codeError()}</text>}
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "submit" ? theme.primary : undefined}>
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
[Esc] Back to Login
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
</box>
);
}

View File

@@ -1,24 +1,30 @@
import { createSignal, createMemo, onCleanup } from "solid-js";
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
//TODO: Watch for actual loading state (fetching feeds)
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>
);
}

215
src/components/PaneRow.tsx Normal file
View File

@@ -0,0 +1,215 @@
/**
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
*
* 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 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
* 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 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}
* currentLabel="List · 42"
* focused={isActive}
* />
*/
import { createMemo, Show } from "solid-js";
import type { JSX } from "solid-js";
import type { RGBA, BorderSides } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ───────────────────────────────────────────────────────────────────
type PaneContent = JSX.Element | (() => JSX.Element);
type PaneLabel = string | (() => string);
export type PaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted
* placeholder — the 1/5 slot is always preserved). */
parent?: PaneContent;
/** Current column content (the focused list). */
current?: PaneContent;
/** Preview column content (detail of the hovered item). Omit/undefined
* together with `panes={2}` to render a 2-pane parent|current row. */
preview?: PaneContent;
/** 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;
/** 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). */
panes?: 2 | 3;
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function resolveLabel(v: PaneLabel | undefined): string {
if (v == null) return "";
return typeof v === "function" ? v() : v;
}
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately avoid Solid's `children()` helper: it flattens accessor
* children into a stable resolved-nodes array and won't re-resolve on a
* truthy→truthy root swap (e.g. the current pane switching between a
* depth-1 list fragment and a depth-2 editor), freezing the previous
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
* expression — a tracked `insert` effect that disposes the old subtree and
* mounts the new whenever the accessor returns a different element identity. */
function normalizeContent(
v: PaneContent | undefined,
): () => JSX.Element | undefined {
if (v == null) return () => undefined;
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
}
function Placeholder(props: { color: () => RGBA }) {
return (
<box padding={1}>
<text fg={props.color()}></text>
</box>
);
}
// ── Pane column ─────────────────────────────────────────────────────────────
function Pane(props: {
grow: number;
label: () => string;
content: () => JSX.Element | undefined;
border: boolean | BorderSides[];
scrollFocused: () => boolean;
}) {
const themeContext = useTheme();
const theme = themeContext.theme;
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// 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 (
<box
flexDirection="column"
flexGrow={props.grow}
flexBasis={0}
height="100%"
>
{/* ── 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={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"
: theme.background
}
>
{props.content() ?? <Placeholder color={muted} />}
</scrollbox>
</box>
);
}
// ── Row primitive ───────────────────────────────────────────────────────────
export function PaneRow(props: PaneRowProps) {
/** 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);
});
// Normalize static JSX and accessor children into reactive accessors
// (see normalizeContent for why we avoid Solid's `children()` helper).
const parentContent = normalizeContent(props.parent);
const currentContent = normalizeContent(props.current);
const previewContent = normalizeContent(props.preview);
// 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));
// 2-pane mode (parent|current) grows the current column to fill the
// preview slot. Defaults to 3 (parent|current|preview).
const panes = createMemo(() => props.panes ?? 3);
const currentGrow = createMemo(() =>
panes() === 2
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
<Pane
grow={PANE_RATIO.parent}
label={currentLabel}
content={parentContent}
border={false}
scrollFocused={() => false}
/>
{/* ── current — the focused list; left/right borders only ─────────── */}
<Pane
grow={currentGrow()}
label={() => ""}
content={currentContent}
border={["left", "right"]}
scrollFocused={() => focused()}
/>
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
<Show when={panes() === 3}>
<Pane
grow={PANE_RATIO.preview}
label={() => ""}
content={previewContent}
border={false}
scrollFocused={() => false}
/>
</Show>
</box>
);
}

View File

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

View File

@@ -11,30 +11,22 @@
* event bus. There is no sidebar pane.
*/
import { createSignal, Show, For } from "solid-js";
import { useKeyboard } 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";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useAudioNavStore } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus";
import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation";
import { TABS } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { YaziPaneRow } from "@/components/YaziPaneRow";
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",
};
import { PaneRow } from "@/components/PaneRow";
export function Shell() {
const theme = useTheme();
@@ -42,12 +34,25 @@ export function Shell() {
const nav = useNavigation();
const k = useKeybinds();
const audio = useAudio();
const renderer = useRenderer();
const audioNav = useAudioNavStore();
const toast = useToast();
const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false);
// ── Auto jump to Player on podcast start ───────────────────────────────────
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
// "player.started" — distinct from "player.play", which also fires on
// resume), switch to the Player tab and drop into its content pane.
on("player.started", () => {
const app = useAppStore();
if (app.state().preferences.autoJumpToPlayer) {
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
}
});
/** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) {
@@ -83,74 +88,60 @@ export function Shell() {
}
// ── Command bar dispatch ────────────────────────────────────────────────────
const COMMANDS: Record<string, (arg: string) => void> = {
quit: () => process.exit(0),
exit: () => process.exit(0),
q: () => process.exit(0),
refresh: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
r: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
play: () => audio.togglePlayback().catch(() => {}),
pause: () => audio.togglePlayback().catch(() => {}),
p: () => audio.togglePlayback().catch(() => {}),
next: () => advanceEpisode(1),
n: () => advanceEpisode(1),
prev: () => advanceEpisode(-1),
seek: (arg) => {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
},
feed: () => nav.setActiveTab(TABS.FEED),
f: () => nav.setActiveTab(TABS.FEED),
shows: () => nav.setActiveTab(TABS.MYSHOWS),
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
discover: () => nav.setActiveTab(TABS.DISCOVER),
d: () => nav.setActiveTab(TABS.DISCOVER),
search: () => nav.setActiveTab(TABS.SEARCH),
player: () => nav.setActiveTab(TABS.PLAYER),
settings: () => nav.setActiveTab(TABS.SETTINGS),
set: () => nav.setActiveTab(TABS.SETTINGS),
help: () => setShowHelp((v) => !v),
h: () => setShowHelp((v) => !v),
};
function runCommand(raw: string) {
const cmd = raw.trim();
if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim();
switch (name) {
case "q":
case "quit":
case "exit":
return process.exit(0);
case "refresh":
case "r":
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
});
break;
case "play":
case "pause":
case "p":
audio.togglePlayback().catch(() => {});
break;
case "next":
case "n":
advanceEpisode(1);
break;
case "prev":
advanceEpisode(-1);
break;
case "seek": {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
break;
}
case "feed":
case "f":
nav.setActiveTab(TABS.FEED);
break;
case "shows":
case "myshows":
nav.setActiveTab(TABS.MYSHOWS);
break;
case "discover":
case "d":
nav.setActiveTab(TABS.DISCOVER);
break;
case "search":
nav.setActiveTab(TABS.SEARCH);
break;
case "player":
nav.setActiveTab(TABS.PLAYER);
break;
case "settings":
case "set":
nav.setActiveTab(TABS.SETTINGS);
break;
case "help":
case "h":
setShowHelp((v) => !v);
break;
default:
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
}
const unknownCommand = () => {
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
};
(COMMANDS[name] ?? unknownCommand)(arg);
}
// ── Command-mode key handling ───────────────────────────────────────────────
@@ -206,6 +197,11 @@ export function Shell() {
if (evt.name === "escape") {
evt.preventDefault();
nav.setInputFocused(false);
// Actually blur the focused renderable too — setting the flag alone
// leaves the opentui input owning keys, so nav keys would still be
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
// (and re-blurs the SearchPage input via its `focused` prop).
renderer.currentFocusedRenderable?.blur();
}
return;
}
@@ -220,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()} --`;
@@ -234,13 +237,77 @@ 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"
width="100%"
height="100%"
backgroundColor={t.surface}
>
flexDirection="column"
width="100%"
height="100%"
backgroundColor={
theme.transparentBackground() ? "transparent" : t.surface
}
>
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%">
<Show
@@ -252,7 +319,7 @@ export function Shell() {
}
>
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
<YaziPaneRow
<PaneRow
parent={
<box padding={1}>
<text fg={t.textMuted}></text>
@@ -264,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>
@@ -276,7 +341,11 @@ export function Shell() {
flexDirection="row"
height={1}
width="100%"
backgroundColor={t.backgroundPanel ?? t.background}
backgroundColor={
theme.transparentBackground()
? "transparent"
: (t.backgroundPanel ?? t.background)
}
>
<Show
when={nav.mode() === NavMode.COMMAND}
@@ -285,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>
@@ -385,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"],
@@ -458,18 +521,5 @@ function k_match_escape(evt: any): boolean {
);
}
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
* search) into the player tab. */
export function playEpisodeAndSwitch(
nav: ReturnType<typeof useNavigation>,
audio: ReturnType<typeof useAudio>,
episode: import("@/types/episode").Episode,
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees.
export type { Episode } from "@/types/episode";

View File

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

View File

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

View File

@@ -18,72 +18,106 @@
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 : 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;
return (
<box
width="100%"
height={1}
flexDirection="row"
paddingRight={1}
backgroundColor={focusBg(tab)}
>
{/* ── 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

@@ -1,210 +0,0 @@
/**
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
*
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
* grow at 1/7 : 3/7 : 3/7 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/7 slot when blank (never collapses to width 0).
* 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.
*
* 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).
*
* Example:
* <YaziPaneRow
* 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 { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ───────────────────────────────────────────────────────────────────
type PaneContent = JSX.Element | (() => JSX.Element);
type PaneLabel = string | (() => string);
export type YaziPaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted
* placeholder — the 1/7 slot is always preserved). */
parent?: PaneContent;
/** Current column content (the focused list). */
current?: PaneContent;
/** 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;
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. */
focused?: boolean | (() => boolean);
/** Number of visible columns. `3` (default) = parent|current|preview;
* `2` = parent|current (preview omitted, current grows to fill). */
panes?: 2 | 3;
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function resolveLabel(v: PaneLabel | undefined): string {
if (v == null) return "";
return typeof v === "function" ? v() : v;
}
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately do NOT use Solid's `children()` helper here: that helper
* flattens accessor children into a stable resolved-nodes array and is the
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
* switching between a depth-1 list fragment and a depth-2 editor — both
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
* root swap, freezing the previous subtree in place. Instead we hand the
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
* which Solid compiles into a tracked `insert` effect that disposes the old
* subtree and mounts the new whenever the accessor returns a different
* element identity. */
function normalizeContent(
v: PaneContent | undefined,
): () => JSX.Element | undefined {
if (v == null) return () => undefined;
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
}
function Placeholder(props: { color: () => RGBA }) {
return (
<box padding={1}>
<text fg={props.color()}></text>
</box>
);
}
// ── Pane column ─────────────────────────────────────────────────────────────
function YaziPane(props: {
grow: number;
label: () => string;
content: () => JSX.Element | undefined;
borderColor: () => RGBA;
scrollFocused: () => boolean;
}) {
const { theme } = useTheme();
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());
const scrollFocused = createMemo(() => props.scrollFocused());
return (
<box
flexDirection="column"
flexGrow={props.grow}
flexBasis={0}
height="100%"
>
{/* ── slim header label row ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>{props.label()}</text>
</box>
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
<scrollbox
height="100%"
focused={scrollFocused()}
border
borderColor={borderColor()}
backgroundColor={theme.background}
>
{/*
* Render the content accessor directly via a reactive expression.
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
* effect that re-runs whenever the accessor's tracked signals
* change (e.g. `depth()` swapping the root from a list fragment to
* an editor). Solid disposes the previously-rendered subtree and
* mounts the new element identity. `null`/`undefined` falls back
* to the muted placeholder so the parent pane keeps its 1/7 slot
* visibly blank at depth 0. This is the correct tool for root
* swapping — unlike Solid's `children()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox>
</box>
);
}
// ── Row primitive ───────────────────────────────────────────────────────────
export function YaziPaneRow(props: YaziPaneRowProps) {
const { theme } = useTheme();
/** true → the current column gets the active-border focus ring. */
const focused = createMemo(() => {
const f = props.focused;
return typeof f === "function" ? f() : (f ?? true);
});
// Normalize static JSX and accessor children into reactive accessors
// (see normalizeContent for why we avoid Solid's `children()` helper).
const parentContent = normalizeContent(props.parent);
const currentContent = normalizeContent(props.current);
const previewContent = normalizeContent(props.preview);
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
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).
const panes = createMemo(() => props.panes ?? 3);
const currentGrow = createMemo(() =>
panes() === 2
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
<YaziPane
grow={PANE_RATIO.parent}
label={parentLabel}
content={parentContent}
borderColor={() => theme.border}
scrollFocused={() => false}
/>
{/* ── current — the focused list; active-border ring when focused ──────────── */}
<YaziPane
grow={currentGrow()}
label={currentLabel}
content={currentContent}
borderColor={() => (focused() ? theme.borderActive : theme.border)}
scrollFocused={() => focused()}
/>
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
<Show when={panes() === 3}>
<YaziPane
grow={PANE_RATIO.preview}
label={previewLabel}
content={previewContent}
borderColor={() => theme.border}
scrollFocused={() => false}
/>
</Show>
</box>
);
}

View File

@@ -1,75 +0,0 @@
/**
* Authentication configuration for PodTUI
* Authentication is DISABLED by default - users can opt-in
*/
import { OAuthProvider, type OAuthProviderConfig } from "../types/auth"
/** Default auth enabled state - DISABLED by default */
export const DEFAULT_AUTH_ENABLED = false
/** Authentication configuration */
export const AUTH_CONFIG = {
/** Whether auth is enabled by default */
defaultEnabled: DEFAULT_AUTH_ENABLED,
/** Code validation settings */
codeValidation: {
/** Code length (8 characters) */
codeLength: 8,
/** Allowed characters (alphanumeric) */
allowedChars: /^[A-Z0-9]+$/,
/** Code expiration time in minutes */
expirationMinutes: 15,
},
/** Password requirements */
password: {
minLength: 8,
requireUppercase: false,
requireLowercase: false,
requireNumber: false,
requireSpecial: false,
},
/** Email validation */
email: {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
/** Local storage keys */
storage: {
authState: "podtui_auth_state",
user: "podtui_user",
lastLogin: "podtui_last_login",
},
} as const
/** OAuth provider configurations */
export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [
{
id: OAuthProvider.GOOGLE,
name: "Google",
enabled: false, // Not feasible in terminal
description: "Sign in with Google (requires browser redirect)",
},
{
id: OAuthProvider.APPLE,
name: "Apple",
enabled: false, // Not feasible in terminal
description: "Sign in with Apple (requires browser redirect)",
},
]
/** Terminal OAuth limitation message */
export const OAUTH_LIMITATION_MESSAGE = `
OAuth authentication (Google, Apple) is not directly available in terminal applications.
To use OAuth:
1. Visit the web portal in your browser
2. Sign in with your preferred provider
3. Generate a sync code
4. Enter the code here to link your account
Alternatively, use email/password authentication or file-based sync.
`.trim()

View File

@@ -11,7 +11,8 @@
//
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl.
// : / q command palette (q + Enter quits there), Q quick quit, ~ help.
// Audio transport kept on shifted keys / ctrl.
// ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"],
@@ -50,17 +51,28 @@
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// ── Command bar & help & quit ────────────────────────────────────────────
"command": [":"],
"quit": ["q", "ctrl-c"],
// ── Command palette & help & quit ────────────────────────────────────────
// q opens the command palette (neovim-style: type q + Enter to quit there).
// Q (shift+q) is the instant quick quit. ctrl-c also quits.
"command": [":", "q"],
"quit": ["Q", "ctrl-c"],
"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
@@ -68,6 +80,6 @@
"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

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

View File

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

View File

@@ -63,28 +63,20 @@ 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"
| "audio-seek-forward"
| "audio-seek-backward"
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
| "audio-seek-backward";
/** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
@@ -145,7 +137,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
}
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: {
function strokeFromEvent(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
@@ -153,7 +145,7 @@ export function strokeFromEvent(evt: {
}): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return {
key: (evt.name ?? "").toLowerCase(),
key: evt.name.toLowerCase(),
ctrl: !!evt.ctrl,
shift: !!evt.shift,
meta: !!evt.meta,
@@ -170,7 +162,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
}
/** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string {
function strokeLabel(s: Stroke): string {
let out = "";
if (s.ctrl) out += "C-";
if (s.meta) out += "M-";
@@ -179,7 +171,7 @@ export function strokeLabel(s: Stroke): string {
return out;
}
export function sequenceLabel(seq: Stroke[]): string {
function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" ");
}
@@ -337,17 +329,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
return best;
}
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => {
load().catch(() => {});
});
@@ -365,7 +346,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
pending,
match,
tryMatch,
isInverting,
print,
save,
load,

View File

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

View File

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

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,23 +129,26 @@ 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();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
// Update platform media position
const media = useMediaRegistry();
media.setPosition(pos);
}
@@ -157,8 +167,10 @@ function startPolling(): void {
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 500);
}, 150);
}
function stopPolling(): void {
@@ -168,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);
@@ -184,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;
@@ -195,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);
@@ -207,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);
@@ -215,6 +241,9 @@ async function play(episode: Episode): Promise<void> {
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
@@ -285,7 +314,6 @@ async function stop(): Promise<void> {
stopPolling();
emit("player.stop", {});
// Clear platform media controls
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
@@ -332,12 +360,8 @@ async function doSetSpeed(spd: number): Promise<void> {
setSpeed(clamped);
// Sync back to app store
try {
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
} catch {
// Store may not be available
}
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
async function switchBackend(name: BackendName): Promise<void> {
@@ -347,14 +371,12 @@ async function switchBackend(name: BackendName): Promise<void> {
const vol = volume();
const spd = speed();
// Stop current backend
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
// Create new backend
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
@@ -362,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();
@@ -388,14 +422,10 @@ export function useAudio(): AudioControls {
// Sync initial speed from app store
if (refCount === 0) {
try {
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
} catch {
// Store may not be available yet
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
}
@@ -430,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);
@@ -524,8 +546,6 @@ export function useAudio(): AudioControls {
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed();
if (refCount <= 0) {

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
/**
* Global multimedia key handler hook.
*
* 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,31 +18,13 @@ export type MediaKeyAction =
| "media.toggle"
| "media.volumeUp"
| "media.volumeDown"
| "media.seekForward"
| "media.seekBackward"
| "media.speedCycle";
/** Key-to-action mappings for multimedia controls */
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
// Common terminal media keys — these overlap with Player.tsx local
// bindings, but Player guards on `props.focused` so the global
// handler fires independently when the player tab is *not* active.
//
// When Player IS focused both handlers fire, but since the audio
// actions are idempotent (toggle = toggle, seek = additive) having
// them called twice for the same keypress is avoided by the event
// bus approach — the audio hook only processes event-bus events, and
// Player.tsx calls audio methods directly. We therefore guard with
// a "playerFocused" flag passed via options.
};
export interface MultimediaKeysOptions {
/** When true, skip handling (Player.tsx handles keys locally) */
playerFocused?: () => boolean;
/** When true, skip handling (text input has focus) */
inputFocused?: () => boolean;
/** Whether an episode is currently loaded */
hasEpisode?: () => boolean;
}
/**
@@ -73,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,115 @@
/**
* useScrollIntoView — keeps the ref'd row visible inside its enclosing
* `<scrollbox>` whenever the focus accessor is true.
*
* OpenTUI's `ScrollBoxRenderable` has built-in *keyboard* scrolling but does
* NOT auto-scroll to follow a programmatically-focused child (the app moves
* its own cursor via the yazi nav store, so the scrollbox never sees a key
* for row movement). Every scrollable panel therefore drifts out of view the
* moment the cursor crosses the viewport edge.
*
* Attach the returned `ref` callback to the element that represents the
* focused row of a scrollable list and call the hook with a `when()` that is
* true for exactly that row (e.g. `() => index() === focus()`). Whenever the
* accessor flips true, the nearest ScrollBoxRenderable is scrolled just enough
* to bring the element back into the viewport — a "nearest-edge" scroll:
* • scroll up only if the row's top is clipped above the viewport,
* • scroll down only if the row's bottom is clipped below the viewport,
* never snapping more than necessary (matches yazi list behaviour).
*
* Timing: for ordinary cursor movement (j/k) the list layout does not change
* — only background colour and the cursor glyph flip — so the focused row's
* Yoga-computed position is already valid when this effect fires, and the
* scroll is applied synchronously. On first mount / content population the
* layout for the new rows has not yet been computed, so the hook polls on a
* short timer until layout resolves (bounded so it can never loop forever).
*/
import { createEffect, onCleanup } from "solid-js";
/** Walk up the renderable parent chain to the nearest ScrollBoxRenderable,
* identified by its `viewport` + `content` + numeric `scrollTop`. */
function findScrollBox(node: any): any | null {
let p: any = node?.parent;
while (p) {
if (p.viewport && p.content && typeof p.scrollTop === "number") return p;
p = p.parent;
}
return null;
}
/** Maximum number of retries while waiting for Yoga layout to populate the
* row/viewport dimensions (handles the first-mount frame). */
const MAX_RETRIES = 12;
const RETRY_MS = 16;
export function useScrollIntoView(when: () => boolean) {
let el: any = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const ref = (node: any) => {
el = node;
};
const clearTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
/** Compute the target scrollTop that brings `el` into the viewport of its
* enclosing scrollbox, or `null` if no scroll is possible / needed yet.
* Returns the decision so the caller knows whether to poll again. */
const compute = (): { scroll: number | null; ready: boolean } => {
const node = el;
if (!node) return { scroll: null, ready: false };
const sb = findScrollBox(node);
if (!sb) return { scroll: null, ready: false };
const vp = sb.viewport;
const top: number = sb.scrollTop ?? 0;
const vpH: number = vp?.height ?? 0;
// The scrollbar's onChange sets `content.translateY = -scrollTop`, so
// the child's cumulative `.y` already includes `-scrollTop`; subtracting
// the viewport's stable `.y` and re-adding `scrollTop` recovers the
// row's layout-space offset within the content (scroll-independent).
const childTop: number = node.y ?? 0;
const childH: number = node.height ?? 0;
if (!vpH || !childH) return { scroll: null, ready: false };
const offset = childTop - (vp.y ?? 0) + top;
let target = top;
if (offset < top) target = offset;
else if (offset + childH > top + vpH) target = offset + childH - vpH;
const max = Math.max(0, (sb.scrollHeight ?? 0) - vpH);
if (target > max) target = max;
if (target < 0) target = 0;
target = Math.round(target);
if (target === Math.round(top)) return { scroll: null, ready: true };
return { scroll: target, ready: true };
};
const tryScroll = (retriesLeft: number) => {
const { scroll, ready } = compute();
if (!ready) {
if (retriesLeft > 0)
timer = setTimeout(() => tryScroll(retriesLeft - 1), RETRY_MS);
return;
}
if (scroll != null) {
const sb = findScrollBox(el);
if (sb) sb.scrollTo(scroll);
}
clearTimer();
};
createEffect(() => {
if (!when()) return;
clearTimer();
tryScroll(MAX_RETRIES);
});
onCleanup(() => {
clearTimer();
});
return ref;
}

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,225 +1,263 @@
const VERSION = "0.2.0";
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.4.0";
interface CliArgs {
version: boolean;
query: string | null;
play: string | null;
version: boolean;
query: string | null;
play: string | null;
}
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const result: CliArgs = {
version: false,
query: null,
play: null,
};
const args = process.argv.slice(2);
const result: CliArgs = {
version: false,
query: null,
play: null,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--version" || arg === "-v") {
result.version = true;
} else if (arg === "--query" || arg === "-q") {
result.query = args[i + 1] || "";
i++;
} else if (arg === "--play" || arg === "-p") {
result.play = args[i + 1] || "";
i++;
}
}
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--version" || arg === "-v") {
result.version = true;
} else if (arg === "--query" || arg === "-q") {
result.query = args[i + 1] || "";
i++;
} else if (arg === "--play" || arg === "-p") {
result.play = args[i + 1] || "";
i++;
}
}
return result;
return result;
}
const cliArgs = parseArgs();
if (cliArgs.version) {
console.log(`PodTUI version ${VERSION}`);
process.exit(0);
console.log(`PodTUI version ${VERSION}`);
process.exit(0);
}
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
let latest: { feed: Feed; episode: Episode } | null = null
let latestDate = 0
for (const feed of feeds) {
if (feed.episodes.length === 0) continue
const ep = feed.episodes[0]
const epDate =
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
if (epDate > latestDate) {
latestDate = epDate
latest = { feed, episode: ep }
}
}
return latest
}
/** Search feeds by title and print matching shows */
function handleQuery(feeds: Feed[], query: string): void {
const normalizedQuery = query.toLowerCase()
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase()
return title.includes(normalizedQuery)
})
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`)
if (feeds.length > 0) {
console.log("\nAvailable shows:")
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`)
})
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`)
}
}
process.exit(0)
}
if (matches.length === 1) {
const feed = matches[0]
console.log(`\n${feed.podcast.title}`)
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
)
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate)
console.log(` ${idx + 1}. ${ep.title} (${date})`)
})
process.exit(0)
}
console.log(`\nClosest matches for "${query}":`)
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`)
})
process.exit(0)
}
/** Resolve and play an episode from `arg` (title path or "latest") */
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const normalizedArg = arg.toLowerCase()
let feedResult: Feed | null = null
let episodeResult: Episode | null = null
if (normalizedArg === "latest") {
const latest = findLatestEpisode(feeds)
if (latest) {
feedResult = latest.feed
episodeResult = latest.episode
}
} else {
const parts = normalizedArg.split("/")
const showQuery = parts[0]
const episodeQuery = parts[1]
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
)
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`)
process.exit(1)
}
const feed = matchingFeeds[0]
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
console.log(`No episodes available for: ${feed.podcast.title}`)
process.exit(1)
}
} else if (episodeQuery === "latest") {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
)
if (matchingEpisode) {
feedResult = feed
episodeResult = matchingEpisode
} else {
console.log(`Episode not found: ${episodeQuery}`)
console.log(`Available episodes for ${feed.podcast.title}:`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`)
})
process.exit(1)
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play")
process.exit(1)
}
console.log(`\nPlaying: ${episodeResult.title}`)
console.log(`Show: ${feedResult.podcast.title}`)
try {
const { createAudioBackend } = await import("./utils/audio-player")
const { fetchCoverArt } = await import("./utils/cover-art")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
// Stage the podcast cover so the system Now Playing shows
// artwork (mpv --cover-art-files), like the UI path does.
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")
process.exit(1)
}
} catch (err) {
console.error("Playback error:", err)
process.exit(1)
}
}
if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile();
import("./utils/feeds-persistence")
.then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) {
const query = cliArgs.query;
const normalizedQuery = query.toLowerCase();
if (cliArgs.query !== null) {
handleQuery(feeds, cliArgs.query)
}
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase();
return title.includes(normalizedQuery);
});
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`);
if (feeds.length > 0) {
console.log("\nAvailable shows:");
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`);
});
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`);
}
}
process.exit(0);
}
if (matches.length === 1) {
const feed = matches[0];
console.log(`\n${feed.podcast.title}`);
if (feed.podcast.description) {
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
console.log(` ${idx + 1}. ${ep.title} (${date})`);
});
process.exit(0);
}
console.log(`\nClosest matches for "${query}":`);
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`);
});
process.exit(0);
}
if (cliArgs.play !== null) {
const playArg = cliArgs.play;
const normalizedArg = playArg.toLowerCase();
let feedResult: typeof feeds[0] | null = null;
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
if (normalizedArg === "latest") {
let latestFeed: typeof feeds[0] | null = null;
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
let latestDate = 0;
for (const feed of feeds) {
if (feed.episodes.length > 0) {
const ep = feed.episodes[0];
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
if (epDate > latestDate) {
latestDate = epDate;
latestFeed = feed;
latestEpisode = ep;
}
}
}
feedResult = latestFeed;
episodeResult = latestEpisode;
} else {
const parts = normalizedArg.split("/");
const showQuery = parts[0];
const episodeQuery = parts[1];
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery)
);
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`);
process.exit(1);
}
const feed = matchingFeeds[0];
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
console.log(`No episodes available for: ${feed.podcast.title}`);
process.exit(1);
}
} else if (episodeQuery === "latest") {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery)
);
if (matchingEpisode) {
feedResult = feed;
episodeResult = matchingEpisode;
} else {
console.log(`Episode not found: ${episodeQuery}`);
console.log(`Available episodes for ${feed.podcast.title}:`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`);
});
process.exit(1);
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play");
process.exit(1);
}
console.log(`\nPlaying: ${episodeResult.title}`);
console.log(`Show: ${feedResult.podcast.title}`);
try {
const { createAudioBackend } = await import("./utils/audio-player");
const backend = createAudioBackend();
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl);
console.log("Playback started (use the UI to control)");
} else {
console.log("No audio URL available for this episode");
process.exit(1);
}
} catch (err) {
console.error("Playback error:", err);
process.exit(1);
}
}
}).catch((err) => {
console.error("Error:", err);
process.exit(1);
});
if (cliArgs.play !== null) {
await handlePlay(feeds, cliArgs.play)
}
})
.catch((err) => {
console.error("Error:", err);
process.exit(1);
});
} else {
import("@opentui/solid").then(async ({ render, useRenderer }) => {
const { App } = await import("./App");
const { ThemeProvider } = await import("./context/ThemeContext");
const toast = await import("./ui/toast");
const { KeybindProvider } = await import("./context/KeybindContext");
const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command");
import("@opentui/solid").then(async ({ render, useRenderer }) => {
const { App } = await import("./App");
const { ThemeProvider } = await import("./context/ThemeContext");
const toast = await import("./ui/toast");
const { KeybindProvider } = await import("./context/KeybindContext");
const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command");
function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer();
renderer.disableStdoutInterception();
return props.children;
}
function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer();
renderer.disableStdoutInterception();
return props.children;
}
render(
() => (
<RendererSetup>
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
</RendererSetup>
),
{ useThread: false },
);
});
render(
() => (
<RendererSetup>
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
</RendererSetup>
),
{ useThread: false },
);
});
}

View File

@@ -2,13 +2,13 @@
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
*
* depth 0 (current) — category list. Parent pane shows the muted
* placeholder (1/7 slot kept).
* placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list.
* preview — detail of the hovered item (category summary, or
* podcast detail + subscribe action).
*
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
* column. Moving through categories at depth 0 updates the store's selected
@@ -27,19 +27,25 @@ 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 { YaziPaneRow } from "@/components/YaziPaneRow";
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 stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
@@ -146,7 +152,11 @@ function DiscoverPage() {
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text;
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () =>
depth() === 0
@@ -154,30 +164,46 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// 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) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), nav.depthFocus(0), 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 ───────────────────────────────────────────────────────────
@@ -188,12 +214,12 @@ function DiscoverPage() {
<For each={categories()}>
{(cat, index) => {
const lf = () => focusedCatIdx();
const selected = () => cat.id === discoverStore.selectedCategory();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -203,14 +229,14 @@ function DiscoverPage() {
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf() ? theme.surface : theme.accent}>
*
{nerd && (
<text fg={focusFg(index(), lf(), isActive())}>
{cat.icon}
</text>
</Show>
)}
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
</box>
);
}}
@@ -222,18 +248,26 @@ 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>
}
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = () => focusedPodIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -243,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}
@@ -268,6 +302,11 @@ function DiscoverPage() {
);
}}
</For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
</Show>
</Show>
</>
@@ -276,7 +315,7 @@ function DiscoverPage() {
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered category
// depth 0 preview: shows for the hovered category
<Show
when={focusedCategory()}
fallback={
@@ -286,16 +325,35 @@ function DiscoverPage() {
}
>
{(cat) => (
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="column" gap={0} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{cat().name}</strong>
</text>
<text fg={theme.textSecondary}>
{(cat() as any).description ??
`Browse top podcasts in ${cat().name}.`}
</text>
<Show when={(cat() as any).description}>
<text fg={theme.textSecondary}>{(cat() as any).description}</text>
</Show>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
<Show
when={podcasts().length > 0}
fallback={
<text fg={muted()}>
No shows in this category yet. :refresh
</text>
}
>
<For each={podcasts()}>
{(pod) => (
<box flexDirection="column" gap={0}>
<text fg={theme.text}>{pod.title}</text>
<Show when={pod.author}>
<text fg={muted()} paddingLeft={2}>
by {pod.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</box>
)}
</Show>
@@ -347,13 +405,11 @@ function DiscoverPage() {
);
return (
<YaziPaneRow
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,15 +10,16 @@
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
* shows the full flat episodes list immediately.
*
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
* Renders entirely through `<PaneRow>` (the shared parent|current|preview
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
* root. j/k move only within the current column. The Shell router drives
* 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,18 +32,23 @@ 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";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow";
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();
@@ -50,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}`,
@@ -117,6 +154,10 @@ function FeedPage() {
// ── open ───────────────────────────────────────────────────────────────────
function open() {
if (focusedOnMore()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
playEpisode(focusedItem());
}
@@ -135,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(() => {});
},
@@ -167,7 +220,11 @@ function FeedPage() {
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
@@ -180,18 +237,28 @@ 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>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === fi());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -200,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>
@@ -233,70 +324,125 @@ 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 (
<YaziPaneRow
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel="Up"
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

@@ -2,18 +2,22 @@
* MyShowsPage — yazi depth-stack view of subscribed shows.
*
* depth 0 (current) — subscribed shows. Parent pane shows the muted
* placeholder (1/7 slot kept).
* placeholder (1/5 slot kept).
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column.
*
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
* 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,23 +31,30 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow";
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;
@@ -66,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);
@@ -87,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) => {
@@ -138,6 +183,10 @@ export function MyShowsPage() {
return;
}
if (depth() >= 1) {
if (focusedOnMore()) {
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
return;
}
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
@@ -160,10 +209,47 @@ 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(() => {});
},
unsubscribe: () => {
if (depth() !== 0) return;
const show = selectedShow();
if (show) {
// unsubscribe = remove feed + purge its downloaded files
feedStore.removeFeed(show.id);
downloadStore.removeDownloadsForFeed(show.id).catch(() => {});
ensureFocus();
}
},
};
function step(delta: number) {
nav.move(delta, curLen());
@@ -188,7 +274,11 @@ export function MyShowsPage() {
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text;
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
@@ -197,7 +287,6 @@ export function MyShowsPage() {
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
@@ -205,16 +294,17 @@ export function MyShowsPage() {
<For each={shows()}>
{(feed, 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(), lf(), false)}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
@@ -243,11 +333,17 @@ export function MyShowsPage() {
<For each={shows()}>
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -256,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)}
@@ -264,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>
);
}}
@@ -283,11 +392,12 @@ export function MyShowsPage() {
<For each={episodes()}>
{(ep, index) => {
const lf = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -296,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>
@@ -324,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>
@@ -361,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</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}>
@@ -401,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 (
<YaziPaneRow
<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

@@ -4,7 +4,7 @@
* depth 0 (parent) — tab list (muted, read-only).
* depth 0 (current) — the single now-playing pane (rich view + controls).
*
* No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause,
* No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
* page only renders the now-playing surface. `h` at depth 0 returns to the
* tab root.
@@ -12,12 +12,13 @@
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";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
export const PlayerPaneCount = 1;
@@ -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,17 +112,20 @@ 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>
);
return (
<YaziPaneRow
<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);
@@ -116,7 +144,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}
reader.start(position, speed);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
@@ -140,19 +167,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer);
if (count === 0) return;
// 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;
// Feed samples to cavacore → get frequency bars
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(
@@ -198,7 +225,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}),
);
// Cleanup on unmount
onCleanup(() => {
stopVisualization();
if (reader) {
@@ -213,64 +239,34 @@ 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();
// If no data yet, show empty placeholder
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

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

View File

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

View File

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

View File

@@ -8,6 +8,10 @@
* query (muted, read-only); preview shows the detail of
* 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,19 +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 { YaziPaneRow } from "@/components/YaziPaneRow";
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;
@@ -63,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) ─────────────────────────────────────────────────────
@@ -103,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 ─────────────────────────────────────────────────────────────────
@@ -128,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 ──────────────────────────────────────────────────────
@@ -148,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(() => {});
@@ -204,18 +259,35 @@ function SearchPage() {
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
// 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 ────────────────────────────────────────────────────────────
@@ -227,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>
@@ -256,22 +392,48 @@ function SearchPage() {
<For each={recents()}>
{(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>
);
}}
@@ -281,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>
@@ -291,22 +453,32 @@ 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>
}
>
<For each={results()}>
{(result, index) => {
const fi = () => focusedResultIdx();
const ref = useScrollIntoView(() => index() === fi());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -316,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
@@ -329,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>
);
}}
@@ -354,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>
@@ -370,62 +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) ??
"No description available."}
{(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 (
<YaziPaneRow
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
/**
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
*
* • "Delete All Downloads" — action item; Enter wipes every download.
* • one item per show — action item; Enter deletes all that show's
* downloads (file + metadata, aborts in-flight).
* • one item per episode — action item; Enter deletes a single download.
*
* Titles resolve from the feed store at render time (reactive), falling back
* to the episode id when the feed is no longer loaded. Movement flows through
* nav.action — no own useKeyboard (matches the other panels).
*/
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import type { DownloadedEpisode } from "@/types/episode";
import type { SettingItem } from "./types";
/** Format a byte count as a compact human string. */
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
return `${n} B`;
}
/** Short status badge for an episode download. */
function statusLabel(s: DownloadStatus): string {
switch (s) {
case DownloadStatus.QUEUED:
return "queued";
case DownloadStatus.DOWNLOADING:
return "downloading";
case DownloadStatus.COMPLETED:
return "done";
case DownloadStatus.FAILED:
return "failed";
default:
return "";
}
}
/** Episode title for a download, resolved from the feed store (reactive). */
function episodeTitle(
feedStore: ReturnType<typeof useFeedStore>,
d: DownloadedEpisode,
): string {
const feed = feedStore.getFeed(d.feedId);
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
return ep?.title ?? d.episodeId;
}
/** Show title for a download's feed id. */
function feedTitle(
feedStore: ReturnType<typeof useFeedStore>,
feedId: string,
): string {
const feed = feedStore.getFeed(feedId);
return feed ? feed.customName || feed.podcast.title : feedId;
}
export function useDownloadItems(): SettingItem[] {
const downloadStore = useDownloadStore();
const feedStore = useFeedStore();
const downloads = () => downloadStore.getAllDownloads();
const items: SettingItem[] = [
{
id: "clear-all",
label: "Delete All Downloads",
kind: "action",
display: () => `${downloads().length} files`,
help: () =>
`Delete every downloaded episode (files + metadata) and clear the\nqueue. Enter to run.`,
run: () => {
for (const d of downloads()) {
downloadStore.cancelDownload(d.episodeId);
downloadStore.removeDownload(d.episodeId).catch(() => {});
}
},
},
];
// Group downloads by feed so each show gets a delete-by-show item.
const byFeed = new Map<string, DownloadedEpisode[]>();
for (const d of downloads()) {
const arr = byFeed.get(d.feedId) ?? [];
arr.push(d);
byFeed.set(d.feedId, arr);
}
for (const [feedId, eps] of byFeed) {
const size = eps.reduce((s, e) => s + e.fileSize, 0);
items.push({
id: `feed:${feedId}`,
label: `Show: ${feedTitle(feedStore, feedId)}`,
kind: "action",
display: () => `${eps.length} · ${fmtBytes(size)}`,
help: () =>
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
run: () => {
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
},
});
}
// One item per individual episode download.
for (const d of downloads()) {
items.push({
id: `ep:${d.episodeId}`,
label: episodeTitle(feedStore, d),
kind: "action",
display: () =>
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
help: () =>
`Delete this single download (file + metadata). Enter to run.`,
run: () => {
downloadStore.removeDownload(d.episodeId).catch(() => {});
},
});
}
return items;
}

View File

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

View File

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

@@ -1,178 +0,0 @@
/**
* Login screen component for PodTUI
* Email/password login with links to code validation and OAuth
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { useTheme } from "@/context/ThemeContext";
import { AUTH_CONFIG } from "@/config/auth";
interface LoginScreenProps {
focused?: boolean;
onNavigateToCode?: () => void;
onNavigateToOAuth?: () => void;
}
type FocusField = "email" | "password" | "submit" | "code" | "oauth";
export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("email");
const [emailError, setEmailError] = createSignal<string | null>(null);
const [passwordError, setPasswordError] = createSignal<string | null>(null);
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
const validateEmail = (value: string): boolean => {
if (!value) {
setEmailError("Email is required");
return false;
}
if (!AUTH_CONFIG.email.pattern.test(value)) {
setEmailError("Invalid email format");
return false;
}
setEmailError(null);
return true;
};
const validatePassword = (value: string): boolean => {
if (!value) {
setPasswordError("Password is required");
return false;
}
if (value.length < AUTH_CONFIG.password.minLength) {
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
return false;
}
setPasswordError(null);
return true;
};
const handleSubmit = async () => {
const isEmailValid = validateEmail(email());
const isPasswordValid = validatePassword(password());
if (!isEmailValid || !isPasswordValid) {
return;
}
await auth.login({ email: email(), password: password() });
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "submit") {
handleSubmit();
} else if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode();
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
props.onNavigateToOAuth();
}
}
};
return (
<box flexDirection="column" border borderColor={theme.border} padding={2} gap={1}>
<text fg={theme.text}>
<strong>Sign In</strong>
</text>
<box height={1} />
{/* Email field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "email" ? theme.primary : theme.textMuted}>
Email:
</text>
<input
value={email()}
onInput={setEmail}
placeholder="your@email.com"
focused={props.focused && focusField() === "email"}
width={30}
/>
{emailError() && <text fg={theme.error}>{emailError()}</text>}
</box>
{/* Password field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "password" ? theme.primary : theme.textMuted}>
Password:
</text>
<input
value={password()}
onInput={setPassword}
placeholder="********"
focused={props.focused && focusField() === "password"}
width={30}
/>
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
</box>
<box height={1} />
{/* Submit button */}
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={
focusField() === "submit" ? theme.primary : undefined
}
>
<text fg={focusField() === "submit" ? theme.text : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
{/* Alternative auth options */}
<text fg={theme.textMuted}>Or authenticate with:</text>
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={focusField() === "code" ? theme.primary : undefined}
>
<text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
[C] Sync Code
</text>
</box>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
>
<text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
[O] OAuth Info
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box>
);
}

View File

@@ -1,123 +0,0 @@
/**
* OAuth placeholder component for PodTUI
* Displays OAuth limitations and alternative authentication methods
*/
import { createSignal } from "solid-js";
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
import { useTheme } from "@/context/ThemeContext";
interface OAuthPlaceholderProps {
focused?: boolean;
onBack?: () => void;
onNavigateToCode?: () => void;
}
type FocusField = "code" | "back";
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("code");
const fields: FocusField[] = ["code", "back"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode();
} else if (focusField() === "back" && props.onBack) {
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack();
}
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>OAuth Authentication</strong>
</text>
<box height={1} />
{/* OAuth providers list */}
<text fg={theme.primary}>Available OAuth Providers:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
{OAUTH_PROVIDERS.map((provider) => (
<box flexDirection="row" gap={1}>
<text fg={provider.enabled ? theme.success : theme.textMuted}>
{provider.enabled ? "[+]" : "[-]"} {provider.name}
</text>
<text fg={theme.textMuted}>- {provider.description}</text>
</box>
))}
</box>
<box height={1} />
{/* Limitation message */}
<box border padding={1} borderColor={theme.warning}>
<text fg={theme.warning}>Terminal Limitations</text>
</box>
<box paddingLeft={1}>
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
<text fg={theme.textMuted}>{line}</text>
))}
</box>
<box height={1} />
{/* Alternative options */}
<text fg={theme.primary}>Recommended Alternatives:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
<box flexDirection="row" gap={1}>
<text fg={theme.success}>[1]</text>
<text fg={theme.text}>Use a sync code from the web portal</text>
<text fg={theme.success}>[2]</text>
<text fg={theme.text}>Use email/password authentication</text>
<text fg={theme.success}>[3]</text>
<text fg={theme.text}>Use file-based sync (no account needed)</text>
</box>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "code" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "code" ? theme.primary : undefined}>
[C] Enter Sync Code
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
[Esc] Back to Login
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
</box>
);
}

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