122 Commits

Author SHA1 Message Date
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
de01cedee0 add scripts/release-tag.sh: interactive vX.Y.Z bump, tag, dual-remote push
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m26s
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-07 19:36:07 -04:00
2730fa3cae bump VERSION to 0.2.0 2026-08-07 19:32:48 -04:00
91a831c5f9 audio playback fixes 2026-08-07 19:28:57 -04:00
52e9ae0ab7 fix(discover): correct 5 wrong RSS feed URLs in featured.json
The original featured.json had 5 entries pointing at the wrong podcasts
because verification only checked the feed's <title> loosely matched.
Each wrong URL happened to share a slug/name with the intended show:

  - All-In Podcast: feeds.transistor.fm/all-in was a Verizon internal
    comms show (6 eps from 2020), not the Chamath/Sacks/Friedberg/
    Calacanis show. -> allinchamathjason.libsyn.com/rss (405 eps)
  - Code Switch: NPR feed ID 510352 was 'I\'ll Be Seeing You'.
    -> 510312 (correct Code Switch feed, 637 eps)
  - Stuff You Should Know: rss.art19.com/stuff-you-should-know was a
    different show by 'Podgo' (7 eps). -> iHeart omnycontent URL
    (2855 eps, Josh Clark & Chuck Bryant)
  - Philosophize This!: feed.podbean.com/... returned 0 items.
    -> philosophizethis.libsyn.com/rss (189 eps, Stephen West)
  - Very Bad Wizards: feed.podbean.com/... returned 0 items.
    -> feeds.libsyn.com/474285/rss (345 eps, official feed)

Bumps version 1 -> 2 so the discover store re-fetches immediately.
All 5 corrected URLs verified: 200 OK, valid RSS, matching <title>
and <itunes:author>, sensible episode counts, recent pubDates.
2026-08-07 19:12:08 -04:00
64d8b40e61 actual featured page 2026-08-07 18:59:25 -04:00
0cc15c8d90 fix lint: make bun tsc --noEmit actually pass (was a TS crash + latent errors)
tsconfig used jsx:"preserve"+jsxImportSource, which trips a TS 5.9
internal crash ("Expected sourceFile.imports[0] to be the synthesized JSX
runtime import") so tsc could never run clean. Switch to jsx:"react-jsx"
with the package's real jsx-runtime types, and fix the real errors that
surfaced:
- delete dead src/components/Navigation.tsx (imported ./Tab that doesn't
  exist; the component has no importers)
- PlaybackControls: fix relative path to @/utils/audio-player
- SourceBadge: drop dead module-level typeColor (bare 'theme')
- command palette: bind to the real 'command' keybind (:) instead of the
  never-defined 'command_list' action (palette was unreachable)
- yazi-pane-row test: destroy() must return Promise<void> as typed

Also fold in the package.json lint fix (bun tsc --noEmit) and doc polish.
2026-08-07 18:18:25 -04:00
1d3abd53d4 docs: add human-oriented CONTRIBUTING.md (repo map, FFI notes, gotchas, release & tap auto-sync workflow) 2026-08-07 18:09:51 -04:00
592cfd4093 fix search navigation 2026-08-07 17:46:27 -04:00
69e12cf5b9 fix: podtui binary crashed with 'preload not found' when launched from repo root
The embedded runtime reads the CWD bunfig.toml at startup; the repo's
top-level 'preload = ["@opentui/solid/preload"]' made the standalone die
because that module isn't bundled. Removed the ambient preload from
bunfig.toml and moved the solid transform registration to explicit
--preload flags in the start/dev scripts ([test] preload untouched).

Verified: binary boots from repo root, from a clean dir, and dev/start
still work; bun test 54 pass. No new release needed — the binaries were
never config-dependent; this was purely the repo's bunfig trap.
2026-08-07 17:36:11 -04:00
c9e3aa92ec fix release pipeline: vendored cava source, fftw in CI, runner arch, smoke test
- Vendor cava/cavacore.c + header (MIT, from karlstav/cava) — the FFI build
  referenced cava/cavacore.c which was never committed, so every CI runner
  failed at scripts/build-cavacore.sh and no release was possible.
- build-cavacore.sh: discover libfftw3.a across Homebrew and Debian/Ubuntu
  multiarch paths (FFTW_PREFIX override preserved).
- release.yml: install fftw before building cavacore; run the boot smoke test
  from a bunfig-free dir (the embedded runtime reads the CWD bunfig.toml and
  this repo's preload entry breaks it — 'preload not found'); use
  macos-15-intel for darwin-x64 (macos-latest is arm64).
- Makefile/build.ts: drop the no-op BUN_CONFIG=bunfig.standalone.toml compile
  dance (Bun never honored it; compile output is config-independent); delete
  bunfig.standalone.toml.
2026-08-07 14:27:35 -04:00
25fe7f6ac9 theme rendering 2026-08-07 13:42:03 -04:00
85cb9fba26 ui cleanup 2026-08-07 13:38:32 -04:00
c8d29ed59d fix navigation flow 2026-08-07 00:28:24 -04:00
9e7a44309a Merge branch 'verify-remake' 2026-07-31 19:31:11 -04:00
6cd90ad6c0 docs(yazi-remake): mark task 07 blocked, file task 04 blocker for player/search panes
The verification gate found that Task 04's core deliverable was never
implemented: Player and Search pages are not routed through <YaziPaneRow>,
so Player renders a single full-width column and Search's three columns
sit at equal thirds (31/32/31) instead of the target 1:3:3 ratio
(~14/43/43).

- Mark task 07 status BLOCKED with a pointer to the blocker evidence
- Add 07-blocker-task-04-player-search.md documenting the missing
  implementation, evidence (player/search harness frames), failing exit
  criteria, and a fix plan for the task 04 do-over
2026-07-31 19:30:40 -04:00
6c0affc77b Merge branch 'rewire-keybinds' 2026-07-31 19:20:42 -04:00
b24c83711d refactor(keybinds): extract unified action dispatcher
Move the keybind router out of Shell into a reusable createDispatcher
in @/utils/dispatch, covering tabs (digits 1-6, [/]), h/l depth
drill/pop + fixed-pane swipe, modes, audio transport, quit/help, and
command, delegating pane/list actions over the nav.action event bus.

Shell now renders the active page full-width with a bottom status bar
carrying the tab strip; the old sidebar pane and its leftover sidebar
action handling are removed. Adds tests covering the dispatcher's
routing behavior.
2026-07-31 19:19:20 -04:00
866fcd7574 Merge branch 'rebuild-shell-chrome' 2026-07-31 19:06:18 -04:00
7c7d487ca6 refactor: move tab strip from left sidebar to bottom status bar
- Remove the left tab sidebar containing tab labels and now-playing
- Render tabs as compact labels in the command bar row instead
- Move now-playing display next to the selection count in the status bar
- All tabs remain functional; clicking a tab label switches the active tab
2026-07-31 19:05:43 -04:00
798178f21f Merge branch 'convert-list-tabs-to-primitive' 2026-07-31 17:55:37 -04:00
3f61303756 refactor(yazi): render depth-stack list tabs through YaziPaneRow primitive
Convert Discover, Feed, MyShows, and Settings pages from bespoke 3-column
flexbox JSX to render entirely via the shared <YaziPaneRow> parent|current|
preview primitive. Each page now supplies parent/current/preview content
accessors plus labels; the pane row handles layout, borders, focus styling,
and the muted placeholder for the blank parent slot at depth 0.

Replace Solid's children() helper in YaziPaneRow with a local normalizeContent
that hands the raw accessor to a reactive
expression. children() flattens to a stable resolved-nodes array and does not
re-resolve on truthy<->truthy root swaps (e.g. depth switching a pane's root
between a list fragment and an editor), which would freeze the previous
subtree. The insert effect from the reactive expression disposes and
remounts on element-identity change instead.

Add tests/yazi-pages-depth.test.ts covering the nav-store depth-stack
contract the pages depend on (push/pop stack growth, parent-slot data model
across root→child→grandchild→pop transitions).
2026-07-31 17:54:22 -04:00
139a258987 Merge branch 'rearchitect-nav-model'
# Conflicts:
#	src/utils/navigation.ts
2026-07-31 17:12:14 -04:00
cfa4ef0c47 refactor(nav): route tab switches through single switchTab path
Replace reactive / usage in the navigation
store with imperative functions so behavior is identical under both the
client JSX runtime and the server build used by unit tests (where
/ are no-ops).

- Centralize tab-switch side effects (root-frame seed, pane/mode/
  command/visual reset) in , invoked by a single
   helper used by gotoTab/nextTab/prevTab.
- Convert depthStack/currentDepth/topFrame/selectedIds from
  to plain functions that recompute on every read while still tracking
  underlying signals in reactive contexts.
2026-07-31 16:59:20 -04:00
5b667367a5 feat(layout): add YaziPaneRow 3-pane layout primitive
Add the shared parent | current | preview row primitive that implements
yazi's mgr.ratio = [1, 3, 3] contract via Yoga flexGrow, so every list
tab renders an identical, layout-stable shell regardless of terminal
size.

- New YaziPaneRow component: three bordered columns grow at 1/7 : 3/7
  : 3/7 with flexBasis=0 (content can never stretch its slot). The
  current column carries the accent focus ring when `focused` is
  truthy; parent and preview stay muted. The blank parent keeps its
  1/7 slot with a muted placeholder rather than collapsing.
- PANE_RATIO.current 4 -> 3 to match yazi's [1, 3, 3].
- Add render-based tests covering the 1:3:3 ratios (incl. null
  parent/preview) and the focused/unfocused accent ring behavior.
- Configure the bun test preload for the solid JSX transform.
2026-07-31 16:58:33 -04:00
b1bb9d9a1e refactor: rearchitect navigation model with pure store and no sidebar pane
- Extract navigation state and logic into navigation-store.ts for
  testability and separation of concerns
- NavigationContext now only wraps the store as a Solid provider
- Remove SIDEBAR_PANE from the model; depth-tabs have a single
  focusable content pane (center/current column)
- Split LayerGraph and page imports into layer-graph.ts so the
  navigation utils stay free of JSX (unit-testable)
- Update Shell keybind dispatch: remove sidebar pane handling,
  simplify swipe to fixed-pane tabs only, clarify depth-tab h/l
- Add nav-model.test.ts covering depth stack, tab/pane focus, and
  selection semantics
2026-07-31 10:24:45 -04:00
21b088b5a9 redesign tasks 2026-07-31 09:27:57 -04:00
97b2f61e5f pre-ui-rearch 2026-07-31 01:05:32 -04:00
89c5ca2f7e harness improvements 2026-07-30 21:30:37 -04:00
d8f11040bc start revive 2026-07-30 21:25:03 -04:00
b7c4938c54 Auto-commit 2026-03-11 16:27 2026-03-11 16:27:26 -04:00
256f112512 remove unneeded 2026-03-08 23:12:07 -04:00
8196ac8e31 fix: implement page-specific tab depth navigation
- Changed nextPane/prevPane to use current tab's pane count instead of global TabsCount
- Added Page-specific pane counts mapping for accurate depth calculation
- Pages with 1 pane (Feed, Player) now skip depth navigation
- Fixed wrapping logic to respect each page's layout structure
2026-03-08 21:01:33 -04:00
f003377f0d some nav cleanup 2026-03-08 19:25:48 -04:00
1618588a30 cycle 2026-02-22 19:07:07 -05:00
c9a370a424 more keyboard handling 2026-02-21 00:46:36 -05:00
b45e7bf538 temp keyboard handling 2026-02-20 23:42:29 -05:00
1e6618211a more indication 2026-02-20 22:42:15 -05:00
1a5efceebd device switch 2026-02-20 21:58:49 -05:00
0c16353e2e fix nav context 2026-02-20 01:28:46 -05:00
8d350d9eb5 navigation controls + starting indicators 2026-02-20 01:13:32 -05:00
cc09786592 sort fix 2026-02-19 21:15:38 -05:00
cedf099910 working indicator (simpler) 2026-02-19 20:45:48 -05:00
d1e1dd28b4 nonworking indicator, sort broken 2026-02-19 17:52:57 -05:00
1c65c85d02 redoing navigation logic to favor more local 2026-02-19 15:59:50 -05:00
8e0f90f449 nonworking keybinds 2026-02-13 17:25:32 -05:00
91fcaa9b9e getting keybinds going 2026-02-12 17:39:52 -05:00
0bbb327b29 using presets 2026-02-12 09:27:49 -05:00
276732d2a9 continued out the reuse 2026-02-12 00:11:56 -05:00
72000b362d use of selectable 2026-02-11 21:57:17 -05:00
9a2b790897 for consistency 2026-02-11 14:10:35 -05:00
2dfc96321b colors 2026-02-11 11:16:18 -05:00
3d5bc84550 set 2026-02-10 15:30:53 -05:00
f707594d0c more theme color integration 2026-02-10 15:10:07 -05:00
a405474f11 better themeing 2026-02-10 14:28:06 -05:00
ce022dc447 add explicit width 2026-02-10 13:15:25 -05:00
6053d4d02c keybinds 2026-02-10 01:26:18 -05:00
64a2ba2751 simple 2026-02-07 22:30:51 -05:00
bcf248f7dd renders 2026-02-07 19:05:45 -05:00
5bd393c9cd cooking 2026-02-07 18:45:13 -05:00
627fb65547 sketching out layout structure, cleaning Discover 2026-02-07 16:44:49 -05:00
73aa211229 janitorial work 2026-02-07 15:12:34 -05:00
7eb49ac1c7 on left 2026-02-07 14:53:22 -05:00
19a1f1a43b add agents file 2026-02-07 12:01:36 -05:00
2e323d283f needs testing 2026-02-07 00:45:14 -05:00
46f9135776 flatten 2026-02-07 00:44:52 -05:00
db74e20571 better copying 2026-02-06 16:51:00 -05:00
70f50eec2a covert html on fetch instead 2026-02-06 16:45:34 -05:00
1cee931913 understanding 2026-02-06 16:29:09 -05:00
bfea6816ef dead 2026-02-06 15:02:21 -05:00
75f1f7d6af remove migration code 2026-02-06 15:00:21 -05:00
1e3b794b8e file ordering 2026-02-06 14:55:42 -05:00
1293d30225 starting janitorial work 2026-02-06 13:41:44 -05:00
920042ee2a fix stream multiplaction 2026-02-06 11:47:48 -05:00
e1dc242b1d better visualizer 2026-02-06 11:08:41 -05:00
8d6b19582c implementing cava for real time visualization 2026-02-06 10:11:51 -05:00
63ded34a6b basic clean 2026-02-06 09:57:33 -05:00
0e4f47323f mulitmedia pass, downloads 2026-02-06 00:00:15 -05:00
42a1ddf458 meh 2026-02-05 23:43:19 -05:00
168e6d5a61 final feature set 2026-02-05 22:55:24 -05:00
6b00871c32 working playback 2026-02-05 21:18:44 -05:00
e0fa76fb32 slight ui improvement 2026-02-05 19:08:39 -05:00
f3344fbed2 start player 2026-02-05 18:42:56 -05:00
03e69d04dc fix 2026-02-05 18:29:05 -05:00
91de49be0d cleanup 2026-02-05 18:01:26 -05:00
3d156403c7 fixed tmux system theme loading 2026-02-05 14:20:51 -05:00
e239b33042 getting terminal colors working 2026-02-05 13:46:47 -05:00
9fa52d71ca still self referencing 2026-02-05 01:43:10 -05:00
ea9ab4d3f9 theme redux 2026-02-05 01:07:22 -05:00
6950deaa88 it is time 2026-02-05 00:55:10 -05:00
4579659784 will be reworking in just a moment 2026-02-04 23:58:53 -05:00
c26150221a pause 2026-02-04 22:48:54 -05:00
39a4f88496 proper layering work 2026-02-04 16:23:25 -05:00
624a6ba022 pause to fix nav/theme 2026-02-04 12:38:35 -05:00
cdabf2c3e0 checkpoint 2026-02-04 12:10:30 -05:00
b8549777ba missing md 2026-02-04 11:36:47 -05:00
9b1a3585e6 fix 2026-02-04 11:24:19 -05:00
4cee352641 remove dev 2026-02-04 10:23:12 -05:00
72b2870f64 options 2026-02-04 10:02:07 -05:00
f7df578461 broke 2026-02-04 09:39:58 -05:00
bd4747679d fix keyboard, finish 05 2026-02-04 01:18:59 -05:00
d5ce8452e4 4, partial 5 2026-02-04 01:00:57 -05:00
7b5c256e07 start 2026-02-04 00:06:16 -05:00
184 changed files with 19286 additions and 3734 deletions

11
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: {
es2022: true,
node: true,
},
ignorePatterns: ["dist", "node_modules"],
}

102
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,102 @@
name: release
# Builds standalone PodTui binaries for each supported OS/arch and attaches
# them to a GitHub Release. One runner per platform because Bun cannot
# cross-compile — each runner runs `make dist`, which emits a
# podtui-<platform>-<arch>.tar.gz (binary + native libs side by side).
#
# Trigger: push a tag like v0.1.0. Bump VERSION in src/index.tsx in the same
# commit as the tag so the released binary reports the tagged version.
on: # intentional: YAML `on` key
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
name: build (${{ matrix.os }} / ${{ matrix.arch }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
arch: x64
plat: linux
- os: ubuntu-24.04-arm
arch: arm64
plat: linux
- os: macos-15-intel
arch: x64
plat: darwin
- os: macos-14
arch: arm64
plat: darwin
steps:
- name: Check out repo
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install fftw (cavacore build dependency)
run: |
if uname -s | grep -qi darwin; then
brew install fftw
else
sudo apt-get update
sudo apt-get install -y libfftw3-dev
fi
- name: Build native cavacore library
run: scripts/build-cavacore.sh
- name: Build standalone binary + tarball
run: make dist
- name: Smoke-test binary boot
env:
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
run: |
# 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
with:
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
path: dist/podtui-*.tar.gz
upload:
name: Attach to GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all binaries
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Publish release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
artifacts/**/*.tar.gz
LICENSE

34
.gitignore vendored
View File

@@ -1,2 +1,36 @@
.opencode .opencode
# dependencies (bun install)
node_modules node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
*.log
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
*.lock
# Finder (MacOS) folder config
.DS_Store
.harness/
.ralpi

97
AGENTS.md Normal file
View File

@@ -0,0 +1,97 @@
# AGENTS.md
## Build, Lint, and Test Commands
### Development
- `bun start` - Run the application
- `bun run dev` - Run with hot reload (watch mode)
### Build
- `bun run build` - Build JavaScript bundle to `dist/`
- `bun run build:native` - Build native libraries (requires `scripts/build-cavacore.sh`)
### Testing
- `bun test` - Run all tests
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting
- `bun run lint` - Run ESLint with TypeScript rules
## Code Style Guidelines
### TypeScript Configuration
- Target: ESNext with bundler module resolution
- Strict mode enabled
- Path alias: `@/*` maps to `src/*`
- JSX: Use `@opentui/solid` as import source
### Import Organization
1. Third-party framework imports (solid-js, @opentui/solid)
2. Local utility imports
3. Type imports (separate from value imports)
### Naming Conventions
- **Components**: PascalCase (e.g., `FeedPage`, `Player`)
- **Hooks**: `use*` prefix (e.g., `useAudio`, `useAppKeyboard`)
- **Stores**: `create*` factory + `use*` accessor (e.g., `createFeedStore`, `useFeedStore`)
- **Utilities**: camelCase (e.g., `parseRSSFeed`, `detectPlayers`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_EPISODES_REFRESH`)
- **Types/interfaces**: PascalCase (e.g., `Feed`, `AudioBackend`)
- **Enums**: PascalCase (e.g., `FeedVisibility`)
### Code Structure
- **Section Dividers**: Use `// ── Section Name ────────────────────────────────────────────────────────────` format
- **Helper Functions**: Define before main logic
- **Factory Functions**: Use for store creation (return object with state, computed, actions)
- **Singleton Pattern**: Stores use module-level singleton with `use*` accessor
### Type Definitions
- Use `interface` for object shapes
- Use `type` for unions, intersections, and complex types
- Use `enum` for constant sets
- Export types from `src/types/` directory
- Include JSDoc comments for complex types
### Error Handling
- Use `try/catch` for async operations
- For expected failures, use `.catch(() => {})` to suppress errors
- Return default values on failure (e.g., `return []` or `return null`)
- Use `catch` blocks with descriptive comments for unexpected errors
- For UI components, wrap in `ErrorBoundary` with clear fallback
### Async Patterns
- Fire-and-forget async operations: `.catch(() => {})` with comment
- Async store initialization: IIFE `(async () => { ... })()`
- Promise handling: Use `.catch()` to return defaults
### Comments
- **File headers**: Brief description of file purpose
- **Complex functions**: JSDoc-style comments explaining behavior
- **Section dividers**: Visual separators for code organization
- **Inline comments**: Explain non-obvious logic, especially async patterns
### Code Formatting
- 2-space indentation
- No semicolons (Bun style)
- Arrow functions with implicit return where appropriate
- Object shorthand where possible
- Prefer `const` over `let`
### Reactivity (Solid.js)
- Use `createSignal` for primitive state
- Use `createMemo` for computed values
- Use `createEffect` for side effects
- Component functions return JSX
- Store functions return plain objects with state, computed, and actions
### Persistence
- Async persistence operations fire-and-forget
- Use `.catch(() => {})` to suppress errors
- Update state synchronously, persist asynchronously
- Use `setFeeds()` pattern to update state and trigger save
### Testing
- Test files in `tests/` directory
- Use Bun's test framework
- Include JSDoc comments explaining test purpose
- Native library tests use `bun:ffi` for FFI calls

230
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,230 @@
# Contributing to PodTui
This file is written **for humans**. If you're an AI agent or LLM working in
this repo, read [AGENTS.md](AGENTS.md) instead — it has the machine-oriented
build/test/lint contract and code-style rules. Both describe the same project;
CONTRIBUTING.md focuses on *understanding* and *navigating* the codebase.
PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript +
[OpenTUI](https://github.com/opentui/opentui) on top, [Bun](https://bun.sh)
as the runtime and toolchain.
---
## Quick start
```bash
brew install bun # or: curl -fsSL https://bun.sh/install | bash
git clone git@github.com:mikefreno/podtui.git
cd podtui
bun install # install JS dependencies
make native # build libcavacore.dylib from the vendored C source
bun run dev # launch with hot reload (alias: make dev)
```
The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
WezTerm, tmux, …). It will not render in a plain captured `bash` session.
## What each command does
| Command | Purpose |
|--------------------|--------------------------------------------------------------------------|
| `bun install` | Install JS dependencies |
| `make native` | Compile `cava/cavacore.c``src/native/libcavacore.<dylib\|so>` |
| `bun run dev` | Run with hot reload |
| `bun run start` | Run once (no watch) |
| `bun test` | Run the test suite (see [Testing](#testing)) |
| `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
```
src/
api/ Network + XML/RSS — client.ts, rss-parser.ts
components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel…
config/ App config: keybinds.jsonc, shortcuts, auth
constants/ Static tables (sync formats, themes)
context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext
hooks/ useAudio, useMultimediaKeys, useCachedData
native/ FFI glue + the built libcavacore.{dylib,so}
pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings
stores/ Zustand stores — app, feed, audio-nav, search, auth, progress…
styles/ theme.css
themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json
types/ All shared interfaces (podcast, episode, feed, settings…)
ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx
utils/ Parser/persistence/audio helpers (audio-player, config-dir…)
scripts/
build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian
tui-harness.tsx Headless harness for scripted interaction (see below)
cava/ Vendored cavacore C source (MIT, from karlstav/cava)
tests/ bun test suite + cavacore smoke test
dist/ Build output (JS bundle + libs + tarballs)
```
## Native libraries: how the FFI layer works
PodTui loads **two** native libraries at runtime:
1. **libopentui** — the OpenTUI renderer (shipped inside the
`@opentui/core-<platform>-<arch>` npm packages, copied to `dist/` by
`build.ts`).
2. **libcavacore** — the audio spectrum renderer, built from C. The source is
vendored under `cava/` (it must stay committed — every CI runner builds it).
`libfftw3` is needed to build it:
- macOS: `brew install fftw`
- Debian/Ubuntu: `apt-get install libfftw3-dev`
(CI installs it for you; locally run `make native`.)
**Critical sibling rule**: both libraries are loaded *relative to the binary*,
so `podtui`, `libopentui.*` and `libcavacore.*` must sit in the **same
directory**. Never move a single binary out of the tarball. The Homebrew
formula keeps all three in `libexec/` and exposes only a `podtui` symlink.
Cavacore smoke test: `bun tests/cavacore-smoke.ts`
(FFI-calls `cava_init` / `cava_execute` / `cava_destroy` and prints results).
## Gotchas (read before touching anything)
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 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
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
the warning is cosmetic; installs complete and the app boots.
## Testing
```bash
bun test # full suite (54 tests across 6 files today)
```
The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane
logic; plus `tests/cavacore-smoke.ts` asserting the native lib exports.
For scripted end-to-end interaction there's a **headless harness**,
`scripts/tui-harness.tsx`: each invocation snapshot-rebuilds the app state
into a sandboxed `.harness/` config dir, replays the saved action log
(`.harness/actions.json`), executes one more key/action passed on the CLI, and
prints the resulting frame + a style summary — all without a real terminal.
Audio is a no-op during those snapshots. The last frame lands in
`.harness/last-frame.{json,txt}` for inspection.
## Releasing
Releases are built and published from **tags**
### Steps
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
```
3. CI (`.github/workflows/release.yml`) runs four builds in parallel,
each producing `podtui-<platform>-<arch>.tar.gz`:
| Runner | Platform/Arch |
|---------------------|---------------|
| `ubuntu-latest` | linux-x64 |
| `ubuntu-24.04-arm` | linux-arm64 |
| `macos-15-intel` | darwin-x64 |
| `macos-14` | darwin-arm64 |
Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh`
→ `make dist` → smoke-boots the binary from a temp dir → uploads the
tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.)
4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the
`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/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-tap>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
```
### Local release build
```bash
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
- **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
ships from it directly until a full rebuild replaces it. On other hosts the
`make native` build is required — see `scripts/build-cavacore.sh`.

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.

65
Makefile Normal file
View File

@@ -0,0 +1,65 @@
# PodTui — Makefile
#
# Development targets:
# make install install dependencies (bun install) + build native lib
# make dev run with hot reload
# make test run the test suite
# make build produce the JS bundle + native libs in dist/
# make native build the cavacore FFI library from C source
# make lint run typecheck-style checks (lsp), not eslint
#
# Packaging / release targets:
# make dist build a standalone compiled binary + tarball for the
# CURRENT platform (see dist/ for podtui + libs + tarball)
# make dist-mac alias for `dist` targeting macOS (run on macOS)
# make dist-linux alias for `dist` targeting Linux (run on Linux)
# make clean remove dist/ output
#
# Cross-platform binaries are produced by CI (GitHub Actions) with one runner
# per OS/arch — Bun cannot cross-compile, so dist:mac / dist:linux only produce
# the binary for the OS they run on. Each runner runs `make dist` and uploads
# its podtui-<platform>-<arch>.tar.gz artifact.
SHELL := /bin/bash
.PHONY: install dev build native dist dist-mac dist-linux test clean
## Install dependencies and build the native runtime library.
install:
bun install
make native
## Run the dev server with hot reload.
dev:
bun run dev
## Type-check the whole project. (See AGENTS.md: `bun run lint` points at a
## nonexistent lint.ts; LSP diagnostics are the maintained clean bar.)
lint:
bun tsc --noEmit
## Build the JS bundle + native libs into dist/ (the `podtui` npm bin target).
build:
bun run build
## Build the cavacore FFI library from src/native/cavacore.c.
native:
scripts/build-cavacore.sh
## Standalone binary + native-libs tarball for the current platform.
## 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
## macOS build (run on a macOS runner / host).
dist-mac:
bun run build.ts --compile
## Linux build (run on a Linux runner / host).
dist-linux:
bun run build.ts --compile
## Remove build artifacts.
clean:
rm -rf dist

221
README.md Normal file
View File

@@ -0,0 +1,221 @@
# PodTui
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.
## Features
- **Vim/yazi-style navigation** — `j/k` to move, `h/l` to swipe between panes,
`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).
- **Podcast feeds** — add feeds, browse episodes, and manage your library
(My Shows, Discover, Feed tabs).
- **Search** across your subscribed shows.
- **Audio playback** through an external player with full transport control:
play/pause, next/previous, seek, speed, and per-episode resume progress.
- **Themeable** and **remappable keybindings**.
- 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,
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
PodTui distributes as a **self-contained binary** for macOS (arm64/x64) and
Linux (arm64/x64). Pick whichever fits your platform.
### 1. Homebrew (macOS)
```sh
brew install mikefreno/tap/podtui
```
### 2. Standalone tarball (all platforms)
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
[GitHub Release](https://github.com/mikefreno/podtui/releases), unpack it, and
put `podtui` on your `PATH`:
```bash
curl -sS -o /tmp/podtui.tar.gz \
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
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.
### 3. Arch Linux (AUR)
```bash
yay -S podtui-bin # once published
```
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
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`. Press `~` for the in-app help.
### Command-line flags
| Flag | Description |
|------|-------------|
| `-v`, `--version` | Print the version and exit |
| `-q`, `--query <term>` | Query feeds for a show title and print matching shows, without launching the TUI |
| `-p`, `--play <term>` | Play the matching show, without launching the TUI |
### Keybindings
All keys are remappable — edit `keybinds.jsonc` in your config directory
(see [Configuration](#configuration)).
**Movement**
| Keys | Action |
|------|--------|
| `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 |
**Panes**
| Keys | Action |
|------|--------|
| `h` / `l` (or `left` / `right`) | Focus parent pane / preview pane |
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
| `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 |
**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) |
**Audio**
| Keys | Action |
|------|--------|
| `P` | Play / pause |
| `N` / `B` | Next / previous episode |
| `shift-.` / `shift-,` | Seek forward / backward |
## Configuration
Configuration lives under the XDG config directory — `~/.config/podtui` by
default (`$XDG_CONFIG_HOME/podtui` if set).
| File | Purpose |
|------|---------|
| `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 |
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
into `config.json` on first run.
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`.
## Troubleshooting
**`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.
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
**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.
**The app won't start / no spectrum after moving files**`podtui` loads its
two native libraries relative to the binary, so keep `podtui`,
`libopentui.*`, and `libcavacore.*` together in the same directory (the
tarball unpacks them side by side).
## Building from source / contributing
Development setup, the test suite, packaging, and the release process are
documented in [CONTRIBUTING.md](CONTRIBUTING.md).
## License
MIT. See [LICENSE](LICENSE).
## Related
- [OpenTUI](https://github.com/opentui/opentui) — the TUI framework driving the interface

135
build.ts Normal file
View File

@@ -0,0 +1,135 @@
import solidPlugin from "@opentui/solid/bun-plugin";
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { plugin } from "bun";
// Register the solid transform globally (dedup'd by name). This is what makes
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins
// that are registered via `plugin()`, not the `plugins:` array. The transform
// is fully embedded in the compiled binary.
plugin(solidPlugin);
const COMPILE =
process.argv.includes("--compile") || process.env.PODTUI_COMPILE === "1";
const platform = process.platform;
const arch = process.arch;
// Platform/arch → OpenTUI package name
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
};
const libExt =
platform === "win32" ? "dll" : platform === "darwin" ? "dylib" : "so";
// Build the JavaScript bundle
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
sourcemap: "external",
plugins: [solidPlugin],
});
// Copy the opentui native library to dist for distribution.
const platformKey = `${platform}-${arch}`;
const platformPkg = platformMap[platformKey];
if (platformPkg) {
const libName = `libopentui.${libExt}`;
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName);
if (existsSync(srcPath)) {
const destPath = join("dist", libName);
copyFileSync(srcPath, destPath);
console.log(`Copied native library: ${libName}`);
}
}
// Copy cavacore native library to dist
const cavacoreLib = `libcavacore.${libExt}`;
const cavacoreSrc = join("src", "native", cavacoreLib);
if (existsSync(cavacoreSrc)) {
copyFileSync(cavacoreSrc, join("dist", cavacoreLib));
console.log(`Copied cavacore library: ${cavacoreLib}`);
} else {
console.warn(
`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`,
);
}
// ── Standalone compiled binary (dist/podtui + libs beside it) ──────────────
// `bun run build.ts --compile` (or PODTUI_COMPILE=1). Embeds the Bun runtime
// so end users need nothing installed; the two FFI libs are shipped as
// SIBLING FILES next to the binary (both loaders already resolve them that
// way: cavacore checks dirname(process.execPath); opentui embeds via its
// bun-plugin and handles the embedded-file path itself).
if (COMPILE) {
const outfile = join("dist", "podtui");
await Bun.build({
entrypoints: ["./src/index.tsx"],
target: "bun",
minify: true,
sourcemap: "external",
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}`);
// Ensure both native libs sit beside the binary.
const opentuiSrc = join(
"node_modules",
`@opentui/core-${platformPkg}`,
`libopentui.${libExt}`,
);
if (existsSync(opentuiSrc)) {
copyFileSync(opentuiSrc, join("dist", `libopentui.${libExt}`));
}
if (!existsSync(join("dist", cavacoreLib))) {
console.warn(
`Warning: ${cavacoreLib} missing beside the binary — run scripts/build-cavacore.sh`,
);
}
// Tarball: podtui + the two native libs (drop the JS bundle dir)
const tarRoot = join("dist", `podtui-${platform}-${arch}`);
rmSync(tarRoot, { recursive: true, force: true });
mkdirSync(tarRoot, { recursive: true });
copyFileSync(outfile, join(tarRoot, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
}
const tar = Bun.spawnSync([
"tar",
"-czf",
`${tarRoot}.tar.gz`,
"-C",
"dist",
`podtui-${platform}-${arch}`,
]);
if (tar.exitCode !== 0) {
console.error(tar.stderr.toString());
process.exit(1);
}
console.log(`Tarball: ${tarRoot}.tar.gz`);
rmSync(tarRoot, { recursive: true, force: true });
}
console.log("Build complete");

BIN
bun.lockb

Binary file not shown.

2
bunfig.test.toml Normal file
View File

@@ -0,0 +1,2 @@
[test]
preload = ["./tests/preload/solid-test-plugin.ts"]

8
bunfig.toml Normal file
View File

@@ -0,0 +1,8 @@
# 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"

19
cava/LICENSE-cava.txt Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2015 Karl Stavestrand <karl@stavestrand.no>
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.

588
cava/cavacore.c Normal file
View File

@@ -0,0 +1,588 @@
#include "cavacore.h"
#ifndef M_PI
#define M_PI 3.1415926535897932385
#endif
#include <fftw3.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifdef __ANDROID__
#include <jni.h>
struct cava_plan *plan;
double *cava_in;
double *cava_out;
#endif
static double amplitude_to_decibels(double value) {
// Magic number 20 comes from converting amplitude ratios to decibels.
return 20 * log10(value);
}
struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels, int autosens,
double noise_reduction, int low_cut_off, int high_cut_off,
int scaling_mode) {
struct cava_plan *p = malloc(sizeof(struct cava_plan));
p->status = 0;
// sanity checks:
if (channels < 1 || channels > 2) {
snprintf(p->error_message, 1024,
"cava_init called with illegal number of channels: %d, number of channels "
"supported are "
"1 and 2",
channels);
p->status = -1;
return p;
}
if (rate < 1 || rate > 384000) {
snprintf(p->error_message, 1024, "cava_init called with illegal sample rate: %d\n", rate);
p->status = -1;
return p;
}
int fft_buffer_size = 512;
if (rate > 8125 && rate <= 16250)
fft_buffer_size *= 2;
else if (rate > 16250 && rate <= 32500)
fft_buffer_size *= 4;
else if (rate > 32500 && rate <= 75000)
fft_buffer_size *= 8;
else if (rate > 75000 && rate <= 150000)
fft_buffer_size *= 16;
else if (rate > 150000 && rate <= 300000)
fft_buffer_size *= 32;
else if (rate > 300000)
fft_buffer_size *= 64;
if (number_of_bars < 1) {
snprintf(p->error_message, 1024,
"cava_init called with illegal number of bars: %d, number of channels must be "
"positive integer\n",
number_of_bars);
p->status = -1;
return p;
}
if (number_of_bars > fft_buffer_size / 2 + 1) {
snprintf(p->error_message, 1024,
"cava_init called with illegal number of bars: %d, for %d sample rate number of "
"bars can't be more than %d\n",
number_of_bars, rate, fft_buffer_size / 2 + 1);
p->status = -1;
return p;
}
if (low_cut_off < 1 || high_cut_off < 1) {
snprintf(p->error_message, 1024, "low_cut_off must be a positive value\n");
p->status = -1;
return p;
}
if (low_cut_off >= high_cut_off) {
snprintf(p->error_message, 1024, "high_cut_off must be a higher than low_cut_off\n");
p->status = -1;
return p;
}
if ((unsigned int)high_cut_off > rate / 2) {
snprintf(p->error_message, 1024,
"high_cut_off can't be higher than sample rate / 2. (Nyquist Sampling Theorem)\n");
p->status = -1;
return p;
}
if (scaling_mode != CAVA_SCALING_LINEAR && scaling_mode != CAVA_SCALING_DECIBEL) {
snprintf(p->error_message, 1024, "unknown scaling mode: %d\n", scaling_mode);
p->status = -1;
return p;
}
p->number_of_bars = number_of_bars;
p->audio_channels = channels;
p->rate = rate;
p->autosens = 1;
p->sens_init = 1;
p->sens = 1.0;
p->autosens = autosens;
p->framerate = 75;
p->frame_skip = 1;
p->noise_reduction = noise_reduction;
p->scaling_mode = scaling_mode;
int fftw_flag = FFTW_MEASURE;
#ifdef __ANDROID__
fftw_flag = FFTW_ESTIMATE;
#endif
p->FFTbassbufferSize = fft_buffer_size * 2;
p->FFTbufferSize = fft_buffer_size;
p->input_buffer_size = p->FFTbassbufferSize * channels;
p->input_buffer = (double *)malloc(p->input_buffer_size * sizeof(double));
p->FFTbuffer_lower_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
p->FFTbuffer_upper_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
p->eq = (double *)malloc((number_of_bars + 1) * sizeof(double));
p->cut_off_frequency = (float *)malloc((number_of_bars + 1) * sizeof(float));
p->cava_fall = (double *)malloc(number_of_bars * channels * sizeof(double));
p->cava_mem = (double *)malloc(number_of_bars * channels * sizeof(double));
p->cava_peak = (double *)malloc(number_of_bars * channels * sizeof(double));
p->prev_cava_out = (double *)malloc(number_of_bars * channels * sizeof(double));
// Hann Window calculate multipliers
p->bass_multiplier = (double *)malloc(p->FFTbassbufferSize * sizeof(double));
p->multiplier = (double *)malloc(p->FFTbufferSize * sizeof(double));
for (int i = 0; i < p->FFTbassbufferSize; i++) {
p->bass_multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbassbufferSize - 1)));
}
for (int i = 0; i < p->FFTbufferSize; i++) {
p->multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbufferSize - 1)));
}
// BASS
p->in_bass_l = fftw_alloc_real(p->FFTbassbufferSize);
p->in_bass_l_raw = fftw_alloc_real(p->FFTbassbufferSize);
p->out_bass_l = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
p->p_bass_l =
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_l, p->out_bass_l, fftw_flag);
// MID + TREBLE
p->in_l = fftw_alloc_real(p->FFTbufferSize);
p->in_l_raw = fftw_alloc_real(p->FFTbufferSize);
p->out_l = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
p->p_l = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_l, p->out_l, fftw_flag);
memset(p->in_bass_l, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_l, 0, sizeof(double) * p->FFTbufferSize);
memset(p->in_bass_l_raw, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_l_raw, 0, sizeof(double) * p->FFTbufferSize);
memset(p->out_bass_l, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
memset(p->out_l, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
if (p->audio_channels == 2) {
// BASS
p->in_bass_r = fftw_alloc_real(p->FFTbassbufferSize);
p->in_bass_r_raw = fftw_alloc_real(p->FFTbassbufferSize);
p->out_bass_r = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
p->p_bass_r =
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_r, p->out_bass_r, fftw_flag);
// MID + TREBLE
p->in_r = fftw_alloc_real(p->FFTbufferSize);
p->in_r_raw = fftw_alloc_real(p->FFTbufferSize);
p->out_r = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
p->p_r = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_r, p->out_r, fftw_flag);
memset(p->in_bass_r, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_r, 0, sizeof(double) * p->FFTbufferSize);
memset(p->in_bass_r_raw, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_r_raw, 0, sizeof(double) * p->FFTbufferSize);
memset(p->out_bass_r, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
memset(p->out_r, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
}
memset(p->input_buffer, 0, sizeof(double) * p->input_buffer_size);
memset(p->cava_fall, 0, sizeof(double) * number_of_bars * channels);
memset(p->cava_mem, 0, sizeof(double) * number_of_bars * channels);
memset(p->cava_peak, 0, sizeof(double) * number_of_bars * channels);
memset(p->prev_cava_out, 0, sizeof(double) * number_of_bars * channels);
// process: calculate cutoff frequencies and eq
int lower_cut_off = low_cut_off;
int upper_cut_off = high_cut_off;
int bass_cut_off = 100;
// calculate frequency constant (used to distribute bars across the frequency band)
double frequency_constant = log10((float)lower_cut_off / (float)upper_cut_off) /
(1 / ((float)p->number_of_bars + 1) - 1);
float *relative_cut_off = (float *)malloc((p->number_of_bars + 1) * sizeof(float));
p->bass_cut_off_bar = 0;
int first_bar = 1;
float min_bandwidth = p->rate / p->FFTbassbufferSize;
for (int n = 0; n < p->number_of_bars + 1; n++) {
double bar_distribution_coefficient = frequency_constant * (-1);
bar_distribution_coefficient +=
((float)n + 1) / ((float)p->number_of_bars + 1) * frequency_constant;
p->cut_off_frequency[n] = upper_cut_off * pow(10, bar_distribution_coefficient);
if (n > 0) {
if (p->cut_off_frequency[n - 1] >= p->cut_off_frequency[n])
p->cut_off_frequency[n] = p->cut_off_frequency[n - 1] + min_bandwidth;
}
// remember nyquist!
relative_cut_off[n] = p->cut_off_frequency[n] / (p->rate / 2);
if (p->cut_off_frequency[n] < bass_cut_off) {
// BASS
p->FFTbuffer_lower_cut_off[n] = relative_cut_off[n] * (p->FFTbassbufferSize / 2);
p->bass_cut_off_bar++;
if (p->bass_cut_off_bar > 1)
first_bar = 0;
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbassbufferSize / 2) {
p->FFTbuffer_lower_cut_off[n] = p->FFTbassbufferSize / 2;
}
} else {
// MID + TREBLE
p->FFTbuffer_lower_cut_off[n] =
ceil(relative_cut_off[n] * (float)(p->FFTbufferSize / 2));
if (n == p->bass_cut_off_bar) {
first_bar = 1;
if (n > 0) {
p->FFTbuffer_upper_cut_off[n - 1] =
relative_cut_off[n] * (p->FFTbassbufferSize / 2) - 1;
}
} else {
first_bar = 0;
}
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbufferSize / 2) {
p->FFTbuffer_lower_cut_off[n] = p->FFTbufferSize / 2;
}
}
if (n > 0) {
if (!first_bar) {
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
// pushing the spectrum up if the exponential function gets "clumped" in the
// bass and calculating new cut off frequencies
if (p->FFTbuffer_lower_cut_off[n] <= p->FFTbuffer_lower_cut_off[n - 1]) {
// check if there is room for more first
int room_for_more = 0;
if (n < p->bass_cut_off_bar) {
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbassbufferSize / 2 + 1)
room_for_more = 1;
} else {
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbufferSize / 2 + 1)
room_for_more = 1;
}
if (room_for_more) {
// push the spectrum up
p->FFTbuffer_lower_cut_off[n] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
}
}
} else {
if (p->FFTbuffer_upper_cut_off[n - 1] < p->FFTbuffer_lower_cut_off[n - 1])
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
}
}
// calculate actual cut off frequency
if (n < p->bass_cut_off_bar)
relative_cut_off[n] =
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbassbufferSize / 2);
else
relative_cut_off[n] =
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbufferSize / 2);
p->cut_off_frequency[n] = relative_cut_off[n] * ((float)p->rate / 2);
}
// hard coded eq
for (int n = 0; n < p->number_of_bars; n++) {
// the numbers that come out of the FFT are very high
// the EQ is used to "normalize" them by dividing with this very huge number
p->eq[n] = 1 / pow(2, 28);
// need to boost the EQ for higher frequencies
p->eq[n] *= pow(p->cut_off_frequency[n + 1], 0.85);
if (n < p->bass_cut_off_bar) {
p->eq[n] /= log2(p->FFTbassbufferSize);
} else {
p->eq[n] /= log2(p->FFTbufferSize);
}
p->eq[n] /= p->FFTbuffer_upper_cut_off[n] - p->FFTbuffer_lower_cut_off[n] + 1;
}
free(relative_cut_off);
return p;
}
void cava_execute(double *cava_in, int new_samples, double *cava_out, struct cava_plan *p) {
// do not overflow
if (new_samples > p->input_buffer_size) {
new_samples = p->input_buffer_size;
}
int silence = 1;
if (new_samples > 0) {
// process: approximate actual framerate. This will be off by +10% at 60 fps, but should be
// good enough for the autosens and smoothing algorithms to be adjusted accordingly if
// framerate is a lot more or less.
p->framerate -= p->framerate / 64.0;
p->framerate +=
(double)(p->rate * p->frame_skip) / (new_samples / p->audio_channels) / 64.0;
p->frame_skip = 1;
// shifting input buffer
for (int n = p->input_buffer_size - 1; n >= new_samples; n--) {
p->input_buffer[n] = p->input_buffer[n - new_samples];
}
// fill the input buffer
for (int n = 0; n < new_samples; n++) {
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
// Audio signals come in the range [-32768, 32768], normalize to [-1, 1].
p->input_buffer[new_samples - n - 1] = cava_in[n] / 32768.0;
} else {
p->input_buffer[new_samples - n - 1] = cava_in[n];
}
if (cava_in[n]) {
silence = 0;
}
}
} else {
p->frame_skip++;
}
// fill the bass, mid and treble buffers
for (int n = 0; n < p->FFTbassbufferSize; n++) {
if (p->audio_channels == 2) {
p->in_bass_r_raw[n] = p->input_buffer[n * 2];
p->in_bass_l_raw[n] = p->input_buffer[n * 2 + 1];
} else {
p->in_bass_l_raw[n] = p->input_buffer[n];
}
}
for (int n = 0; n < p->FFTbufferSize; n++) {
if (p->audio_channels == 2) {
p->in_r_raw[n] = p->input_buffer[n * 2];
p->in_l_raw[n] = p->input_buffer[n * 2 + 1];
} else {
p->in_l_raw[n] = p->input_buffer[n];
}
}
// Hann Window
for (int i = 0; i < p->FFTbassbufferSize; i++) {
p->in_bass_l[i] = p->bass_multiplier[i] * p->in_bass_l_raw[i];
if (p->audio_channels == 2)
p->in_bass_r[i] = p->bass_multiplier[i] * p->in_bass_r_raw[i];
}
for (int i = 0; i < p->FFTbufferSize; i++) {
p->in_l[i] = p->multiplier[i] * p->in_l_raw[i];
if (p->audio_channels == 2)
p->in_r[i] = p->multiplier[i] * p->in_r_raw[i];
}
// process: execute FFT and sort frequency bands
fftw_execute(p->p_bass_l);
fftw_execute(p->p_l);
if (p->audio_channels == 2) {
fftw_execute(p->p_bass_r);
fftw_execute(p->p_r);
}
// process: separate frequency bands
for (int n = 0; n < p->number_of_bars; n++) {
double temp_l = 0;
double temp_r = 0;
// process: add upp FFT values within bands
for (int i = p->FFTbuffer_lower_cut_off[n]; i <= p->FFTbuffer_upper_cut_off[n]; i++) {
if (n < p->bass_cut_off_bar) {
temp_l += hypot(p->out_bass_l[i][0], p->out_bass_l[i][1]);
if (p->audio_channels == 2)
temp_r += hypot(p->out_bass_r[i][0], p->out_bass_r[i][1]);
} else {
temp_l += hypot(p->out_l[i][0], p->out_l[i][1]);
if (p->audio_channels == 2)
temp_r += hypot(p->out_r[i][0], p->out_r[i][1]);
}
}
// getting average and applying configured scaling
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
const double max_db = 70;
temp_l = amplitude_to_decibels(temp_l) / max_db;
if (!isfinite(temp_l)) {
temp_l = 0;
}
} else {
temp_l *= p->eq[n];
}
cava_out[n] = temp_l;
if (p->audio_channels == 2) {
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
const double max_db = 70;
temp_r = amplitude_to_decibels(temp_r) / max_db;
if (!isfinite(temp_r)) {
temp_r = 0;
}
} else {
temp_r *= p->eq[n];
}
cava_out[n + p->number_of_bars] = temp_r;
}
}
// applying sens or getting max value
if (p->autosens) {
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
cava_out[n] *= p->sens;
}
}
// process [smoothing]
int overshoot = 0;
double framerate_mod = 66 / p->framerate;
double gravity_mod = pow((framerate_mod), 2.5) * 2 / p->noise_reduction;
double integral_mod = pow((framerate_mod), 0.1);
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
// process [smoothing]: falloff
if (cava_out[n] < p->prev_cava_out[n] && p->noise_reduction > 0.1) {
cava_out[n] =
p->cava_peak[n] * (1.0 - (p->cava_fall[n] * p->cava_fall[n] * gravity_mod));
if (cava_out[n] < 0.0)
cava_out[n] = 0.0;
p->cava_fall[n] += 0.028;
} else {
p->cava_peak[n] = cava_out[n];
p->cava_fall[n] = 0.0;
}
p->prev_cava_out[n] = cava_out[n];
// process [smoothing]: integral
cava_out[n] = p->cava_mem[n] * p->noise_reduction / integral_mod + cava_out[n];
p->cava_mem[n] = cava_out[n];
if (p->autosens) {
// check if we overshoot target height
if (cava_out[n] > 1.0) {
overshoot = 1;
cava_out[n] = 1.0;
}
}
}
// calculating automatic sense adjustment
if (p->autosens) {
if (overshoot) {
p->sens = p->sens * (1 - (0.02 * framerate_mod));
p->sens_init = 0;
} else {
if (!silence) {
p->sens = p->sens * (1 + (0.001 * framerate_mod * p->autosens));
if (p->sens_init)
p->sens = p->sens * (1 + (0.1 * framerate_mod));
}
}
}
}
void cava_destroy(struct cava_plan *p) {
free(p->input_buffer);
free(p->bass_multiplier);
free(p->multiplier);
free(p->eq);
free(p->cut_off_frequency);
free(p->FFTbuffer_lower_cut_off);
free(p->FFTbuffer_upper_cut_off);
free(p->cava_fall);
free(p->cava_mem);
free(p->cava_peak);
free(p->prev_cava_out);
fftw_free(p->in_bass_l);
fftw_free(p->in_bass_l_raw);
fftw_free(p->out_bass_l);
fftw_destroy_plan(p->p_bass_l);
fftw_free(p->in_l);
fftw_free(p->in_l_raw);
fftw_free(p->out_l);
fftw_destroy_plan(p->p_l);
if (p->audio_channels == 2) {
fftw_free(p->in_bass_r);
fftw_free(p->in_bass_r_raw);
fftw_free(p->out_bass_r);
fftw_destroy_plan(p->p_bass_r);
fftw_free(p->in_r);
fftw_free(p->out_r);
fftw_free(p->in_r_raw);
fftw_destroy_plan(p->p_r);
}
}
#ifdef __ANDROID__
JNIEXPORT jfloatArray JNICALL Java_com_karlstav_cava_MyGLRenderer_InitCava(
JNIEnv *env, jobject thiz, jint number_of_bars_set, jint refresh_rate, jint lower_cut_off,
jint higher_cut_off) {
jfloatArray cuttOffFreq = (*env)->NewFloatArray(env, number_of_bars_set + 1);
float noise_reduction = pow((float)refresh_rate / 130, 0.75);
plan = cava_init(number_of_bars_set, 44100, 1, 1, noise_reduction, lower_cut_off,
higher_cut_off, CAVA_SCALING_LINEAR);
cava_in = (double *)malloc(plan->FFTbassbufferSize * sizeof(double));
cava_out = (double *)malloc(plan->number_of_bars * sizeof(double));
(*env)->SetFloatArrayRegion(env, cuttOffFreq, 0, plan->number_of_bars + 1,
plan->cut_off_frequency);
return cuttOffFreq;
}
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_MyGLRenderer_ExecCava(JNIEnv *env,
jobject thiz,
jdoubleArray cava_input,
jint new_samples) {
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
cava_execute(cava_in, new_samples, cava_out, plan);
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
return cavaReturn;
}
JNIEXPORT int JNICALL Java_com_karlstav_cava_CavaCoreTest_InitCava(JNIEnv *env, jobject thiz,
jint number_of_bars_set) {
plan = cava_init(number_of_bars_set, 44100, 1, 1, 0.7, 50, 10000, CAVA_SCALING_LINEAR);
return 1;
}
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_CavaCoreTest_ExecCava(JNIEnv *env,
jobject thiz,
jdoubleArray cava_input,
jint new_samples) {
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
cava_execute(cava_in, new_samples, cava_out, plan);
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
return cavaReturn;
}
JNIEXPORT void JNICALL Java_com_karlstav_cava_MyGLRenderer_DestroyCava(JNIEnv *env, jobject thiz) {
cava_destroy(plan);
}
#endif

139
cava/cavacore.h Normal file
View File

@@ -0,0 +1,139 @@
/*
Copyright (c) 2022 Karl Stavestrand <karl@stavestrand.no>
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.
*/
#ifdef __cplusplus
extern "C" {
#endif
#pragma once
#include <stdint.h>
#include <fftw3.h>
#define CAVA_SCALING_LINEAR 0
#define CAVA_SCALING_DECIBEL 1
// cava_plan, parameters used internally by cavacore, do not modify these directly
// only the cut off frequencies is of any potential interest to read out,
// the rest should most likely be hidden somehow
struct cava_plan {
int FFTbassbufferSize;
int FFTbufferSize;
int number_of_bars;
int audio_channels;
int input_buffer_size;
int rate;
int bass_cut_off_bar;
int sens_init;
int autosens;
int frame_skip;
int status;
int scaling_mode;
char error_message[1024];
double sens;
double framerate;
double noise_reduction;
fftw_plan p_bass_l, p_bass_r;
fftw_plan p_l, p_r;
fftw_complex *out_bass_l, *out_bass_r;
fftw_complex *out_l, *out_r;
double *bass_multiplier;
double *multiplier;
double *in_bass_r_raw, *in_bass_l_raw;
double *in_r_raw, *in_l_raw;
double *in_bass_r, *in_bass_l;
double *in_r, *in_l;
double *prev_cava_out, *cava_mem;
double *input_buffer, *cava_peak;
double *eq;
float *cut_off_frequency;
int *FFTbuffer_lower_cut_off;
int *FFTbuffer_upper_cut_off;
double *cava_fall;
};
// cava_init, initialize visualization, takes the following parameters:
// number_of_bars, number of wanted bars per channel
// rate, sample rate of input signal
// channels, number of interleaved channels in input
// autosens, toggle automatic sensitivity adjustment 1 = on, 0 = off
// on, gives a dynamically adjusted output signal from 0 to 1
// the output is continuously adjusted to use the entire range
// off, will pass the raw values from cava directly to the output
// the max values will then be dependent on the input
// noise_reduction, adjust noise reduction filters. 0 - 1, recommended 0.77
// the raw visualization is very noisy, this factor adjusts the integral
// and gravity filters inside cavacore to keep the signal smooth
// 1 will be very slow and smooth, 0 will be fast but noisy.
// low_cut_off, high_cut_off cut off frequencies for visualization in Hz
// recommended: 50, 10000
// scaling_mode, output scaling mode:
// CAVA_SCALING_LINEAR = legacy linear scaling
// CAVA_SCALING_DECIBEL = dB-based logarithmic scaling
// returns a cava_plan to be used by cava_execute. If cava_plan.status is 0 all is OK.
// If cava_plan.status is -1, cava_init was called with an illegal parameter, see error string in
// cava_plan.error_message
extern struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels,
int autosens, double noise_reduction, int low_cut_off,
int high_cut_off, int scaling_mode);
// cava_execute, executes visualization
// cava_in, input buffer can be any size. internal buffers in cavacore is
// 4096 * number of channels at 44100 samples rate, if new_samples is greater
// then samples will be discarded. However it is recommended to use less
// new samples per execution as this determines your framerate.
// 512 samples at 44100 sample rate mono, gives about 86 frames per second.
// new_samples, the number of samples in cava_in to be processed per execution
// in case of async reading of data this number is allowed to vary from execution to execution
// cava_out, output buffer. Size must be number of bars * number of channels. Bars will
// be sorted from lowest to highest frequency. If stereo input channels are configured
// then all left channel bars will be first then the right.
// plan, the cava_plan struct returned from cava_init
// cava_execute assumes cava_in samples to be interleaved if more than one channel
// only up to two channels are supported.
extern void cava_execute(double *cava_in, int new_samples, double *cava_out,
struct cava_plan *plan);
// cava_destroy, destroys the plan, frees up memory
extern void cava_destroy(struct cava_plan *plan);
#ifdef __cplusplus
}
#endif

547
discover/featured.json Normal file
View File

@@ -0,0 +1,547 @@
{
"version": 3,
"podcasts": [
{
"id": "discover-daily",
"title": "The Daily",
"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"
]
},
{
"id": "discover-up-first",
"title": "Up First",
"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 & Politics"
]
},
{
"id": "discover-npr-politics",
"title": "The NPR Politics Podcast",
"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"
]
},
{
"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-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",
"title": "Crime Junkie",
"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"
]
},
{
"id": "discover-serial",
"title": "Serial",
"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"
]
},
{
"id": "discover-intelligence-matters",
"title": "Intelligence Matters",
"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",
"News & Politics"
]
},
{
"id": "discover-smartless",
"title": "SmartLess",
"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"
]
},
{
"id": "discover-this-past-weekend",
"title": "This Past Weekend w/ Theo Von",
"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"
]
},
{
"id": "discover-joe-rogan",
"title": "The Joe Rogan Experience",
"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"
]
},
{
"id": "discover-comedy-bang-bang",
"title": "Comedy Bang Bang: The Podcast",
"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"
]
},
{
"id": "discover-office-ladies",
"title": "Office Ladies",
"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"
]
},
{
"id": "discover-how-did-this-get-made",
"title": "How Did This Get Made?",
"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"
]
},
{
"id": "discover-wait-wait",
"title": "Wait Wait... Don't Tell Me!",
"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 & Politics"
]
},
{
"id": "discover-new-heights",
"title": "New Heights with Jason & Travis Kelce",
"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"
]
},
{
"id": "discover-bill-simmons",
"title": "The Bill Simmons Podcast",
"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"
]
},
{
"id": "discover-acquired",
"title": "Acquired",
"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"
]
},
{
"id": "discover-all-in",
"title": "All-In Podcast",
"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"
]
},
{
"id": "discover-planet-money",
"title": "Planet Money",
"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"
]
},
{
"id": "discover-how-i-built-this",
"title": "How I Built This with Guy Raz",
"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"
]
},
{
"id": "discover-freakonomics",
"title": "Freakonomics Radio",
"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"
]
},
{
"id": "discover-darknet-diaries",
"title": "Darknet Diaries",
"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"
]
},
{
"id": "discover-changelog",
"title": "The Changelog",
"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"
]
},
{
"id": "discover-twit",
"title": "This Week in Tech (TWiT)",
"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"
]
},
{
"id": "discover-radiolab",
"title": "Radiolab",
"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"
]
},
{
"id": "discover-huberman-lab",
"title": "Huberman Lab",
"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"
]
},
{
"id": "discover-skeptics-guide",
"title": "The Skeptics' Guide to the Universe",
"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"
]
},
{
"id": "discover-throughline",
"title": "Throughline",
"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"
]
},
{
"id": "discover-hardcore-history",
"title": "Dan Carlin's Hardcore History",
"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"
]
},
{
"id": "discover-history-of-rome",
"title": "The History of Rome",
"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"
]
},
{
"id": "discover-philosophize-this",
"title": "Philosophize This!",
"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"
]
},
{
"id": "discover-very-bad-wizards",
"title": "Very Bad Wizards",
"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"
]
},
{
"id": "discover-big-picture",
"title": "The Big Picture",
"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"
]
},
{
"id": "discover-all-songs-considered",
"title": "All Songs Considered",
"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"
]
},
{
"id": "discover-switched-on-pop",
"title": "Switched on Pop",
"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"
]
},
{
"id": "discover-hit-parade",
"title": "Hit Parade",
"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"
]
},
{
"id": "discover-song-exploder",
"title": "Song Exploder",
"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"
]
},
{
"id": "discover-blank-check",
"title": "Blank Check with Griffin & David",
"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"
]
},
{
"id": "discover-99-invisible",
"title": "99% Invisible",
"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"
]
},
{
"id": "discover-gastropod",
"title": "Gastropod",
"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"
]
},
{
"id": "discover-this-american-life",
"title": "This American Life",
"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"
]
},
{
"id": "discover-ted-talks-daily",
"title": "TED Talks Daily",
"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"
]
},
{
"id": "discover-tim-ferriss",
"title": "The Tim Ferriss Show",
"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"
]
},
{
"id": "discover-jordan-harbinger",
"title": "The Jordan Harbinger Show",
"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"
]
},
{
"id": "discover-on-purpose",
"title": "On Purpose with Jay Shetty",
"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"
]
},
{
"id": "discover-10-percent-happier",
"title": "10% Happier with Dan Harris",
"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"
]
},
{
"id": "discover-school-of-greatness",
"title": "The School of Greatness",
"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"
]
},
{
"id": "discover-sysk",
"title": "Stuff You Should Know",
"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"
]
},
{
"id": "discover-in-our-time",
"title": "In Our Time",
"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"
]
}
]
}

View File

@@ -1 +1,32 @@
{ "dependencies": { "@opentui/core": "^0.1.77" } } {
"name": "podcast-tui-app",
"version": "0.1.0",
"module": "src/index.tsx",
"type": "module",
"private": true,
"bin": {
"podtui": "./dist/index.js"
},
"scripts": {
"start": "bun --preload @opentui/solid/preload src/index.tsx",
"dev": "bun --preload @opentui/solid/preload --watch src/index.tsx",
"build:native": "bash scripts/build-cavacore.sh",
"build": "bun run build.ts",
"dist": "bun dist/index.js",
"test": "bun test",
"lint": "bun tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77",
"date-fns": "^4.1.0",
"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

55
packaging/aur/PKGBUILD Normal file
View File

@@ -0,0 +1,55 @@
# 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"
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

101
scripts/build-cavacore.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Build cavacore as a shared library with fftw3 statically linked.
#
# Prerequisites:
# macOS: brew install fftw
# Linux: apt install libfftw3-dev (or equivalent)
#
# Output: src/native/libcavacore.{dylib,so}
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/cava/cavacore.c"
OUT_DIR="$ROOT/src/native"
mkdir -p "$OUT_DIR"
OS="$(uname -s)"
ARCH="$(uname -m)"
# Resolve fftw3 paths. The static archive lives in different places per
# platform: Homebrew (/opt/homebrew on arm64, /usr/local on Intel) and, on
# Debian/Ubuntu, the multiarch dir /usr/lib/<triplet> (e.g.
# x86_64-linux-gnu, aarch64-linux-gnu).
if [ "$OS" = "Darwin" ]; then
LIB_EXT="dylib"
SHARED_FLAG="-dynamiclib"
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
if [ "$ARCH" = "arm64" ]; then
FFTW_HINTS="/opt/homebrew /usr/local"
else
FFTW_HINTS="/usr/local /opt/homebrew"
fi
else
LIB_EXT="so"
SHARED_FLAG="-shared"
INSTALL_NAME=""
FFTW_HINTS="/usr /usr/local"
fi
FFTW_PREFIX="${FFTW_PREFIX:-}"
FFTW_STATIC=""
if [ -n "$FFTW_PREFIX" ]; then
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
else
for hint in $FFTW_HINTS; do
for cand in "$hint/lib/libfftw3.a" "$hint/lib/${ARCH}-linux-gnu/libfftw3.a"; do
if [ -f "$cand" ]; then
FFTW_STATIC="$cand"
FFTW_PREFIX="$hint"
break 2
fi
done
done
fi
if [ -z "$FFTW_STATIC" ] || [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found (searched: ${FFTW_HINTS})"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
echo "or point FFTW_PREFIX at a prefix containing lib/libfftw3.a."
exit 1
fi
FFTW_INCLUDE="$FFTW_PREFIX/include"
if [ ! -d "$FFTW_INCLUDE" ]; then
FFTW_INCLUDE="$FFTW_PREFIX/include/$(basename "$(dirname "$FFTW_STATIC")")"
fi
if [ ! -f "$SRC" ]; then
echo "Error: cavacore.c not found at $SRC"
echo "The cava source is vendored under cava/ (from github.com/karlstav/cava, MIT)."
exit 1
fi
OUT="$OUT_DIR/libcavacore.$LIB_EXT"
echo "Building libcavacore.$LIB_EXT ($OS $ARCH)"
echo " Source: $SRC"
echo " FFTW3: $FFTW_STATIC"
echo " Output: $OUT"
cc -O2 \
$SHARED_FLAG \
$INSTALL_NAME \
-fPIC \
-I"$FFTW_INCLUDE" \
-I"$ROOT/cava" \
-o "$OUT" \
"$SRC" \
"$FFTW_STATIC" \
-lm
echo "Built: $OUT"
# Verify exported symbols
if [ "$OS" = "Darwin" ]; then
echo ""
echo "Exported symbols:"
nm -gU "$OUT" | grep "cava_"
fi

240
scripts/release-tag.sh Executable file
View File

@@ -0,0 +1,240 @@
#!/bin/bash
# release-tag.sh — PodTui version bump, commit, tag, and push.
#
# Mirrors the release flow from FlexLove's scripts/make-tag.sh, adapted for
# PodTui's single version source (src/index.tsx) and dual remotes (gh, gt).
#
# Usage:
# scripts/release-tag.sh interactive release
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
#
# Pushing a v* tag to the `gh` remote triggers .github/workflows/release.yml
# (4-platform tarball builds) — the release and the Homebrew tap update then
# happen automatically and need no further local action.
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run | -n) DRY_RUN=1 ;;
--help | -h)
echo "Usage: scripts/release-tag.sh [--dry-run]"
echo " --dry-run, -n show the plan without committing, tagging, or pushing"
exit 0
;;
*)
echo -e "${RED}Unknown option: ${arg}${NC}" >&2
exit 2
;;
esac
done
if [ ! -d .git ] && [ ! -f .git ]; then
echo -e "${RED}Error: Not in a git repository${NC}"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo -e "${YELLOW}You have uncommitted changes:${NC}"
git status --short
echo ""
read -p "Continue anyway? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${RED}Aborted${NC}"
exit 1
fi
echo ""
fi
# Current version from the latest tag; fall back to src/index.tsx.
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
if [ -z "$CURRENT_VERSION" ]; then
CURRENT_VERSION=$(grep -m 1 "^const VERSION" src/index.tsx | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
if [ -z "$CURRENT_VERSION" ]; then
echo -e "${RED}Error: could not extract version from git tags or src/index.tsx${NC}"
exit 1
fi
echo -e "${YELLOW}No tags found; using VERSION from src/index.tsx (${CURRENT_VERSION})${NC}"
fi
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
echo ""
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
echo -e "${CYAN}Select version bump type:${NC}"
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH}$((MAJOR + 1)).0.0"
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.$((MINOR + 1)).0"
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.${MINOR}.$((PATCH + 1))"
echo " 4) Custom version"
echo " 5) Cancel"
echo ""
read -p "Enter choice (1-5): " -n 1 -r CHOICE
echo ""
echo ""
case $CHOICE in
1)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
2)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
3)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
4)
read -p "Enter custom version (e.g., 1.0.0-beta): " -r NEW_VERSION
;;
5)
echo -e "${RED}Cancelled${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
exit 1
;;
esac
# Version sanity check (tags are vMAJOR.MINOR.PATCH).
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo -e "${RED}Error: ${NEW_VERSION} is not a valid X.Y.Z version (v tags only)${NC}"
exit 1
fi
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
echo ""
echo -e "${YELLOW}This will:${NC}"
echo " 1. Set src/index.tsx → VERSION = \"${NEW_VERSION}\""
echo " 2. Commit the bump"
echo " 3. Create annotated tag v${NEW_VERSION}"
echo " 4. Push master and the tag to every remote"
REMOTES=$(git remote)
for r in $REMOTES; do
echo "$r"
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-tap tap update.${NC}"
echo ""
read -p "Proceed? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Aborted — no changes made${NC}"
exit 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo ""
echo -e "${CYAN}[dry-run]${NC} would have:"
echo " sed src/index.tsx: VERSION \"${CURRENT_VERSION}\" → \"${NEW_VERSION}\""
echo " git commit -m \"bump VERSION to ${NEW_VERSION}\""
echo " git tag -a v${NEW_VERSION} -m \"PodTUI v${NEW_VERSION}\""
for r in $REMOTES; do echo " push $r master"; done
for r in $REMOTES; do echo " push $r v${NEW_VERSION}"; done
echo ""
echo -e "${GREEN}Plan only — nothing written${NC}"
exit 0
fi
# ── Apply the bump ───────────────────────────────────────────────────────────
echo ""
echo -e "${CYAN}[1/4]${NC} Updating src/index.tsx..."
sed -i.bak "s/const VERSION = \"[^\"]*\"/const VERSION = \"${NEW_VERSION}\"/" src/index.tsx
rm -f src/index.tsx.bak
echo -e "${GREEN}✓ src/index.tsx updated${NC}"
if git diff --quiet -- src/index.tsx; then
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
echo -e "${YELLOW}Already at ${NEW_VERSION} and tag v${NEW_VERSION} exists — nothing to release.${NC}"
exit 0
fi
echo -e "${YELLOW}VERSION is already ${NEW_VERSION} (bump already committed).${NC}"
echo -e "${YELLOW}Will skip the commit and just create the missing tag + push.${NC}"
read -p "Tag v${NEW_VERSION} on current HEAD and push? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Aborted — no changes made${NC}"
exit 0
fi
else
git add src/index.tsx
echo -e "${GREEN}✓ staged${NC}"
echo -e "${CYAN}[2/4]${NC} Committing..."
DEFAULT_COMMIT_MSG="bump VERSION to ${NEW_VERSION}"
echo -e "Default commit message: ${CYAN}${DEFAULT_COMMIT_MSG}${NC}"
read -p "Use default? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
read -p "Enter commit message: " -r COMMIT_MSG
else
COMMIT_MSG="$DEFAULT_COMMIT_MSG"
fi
git commit -m "$COMMIT_MSG"
echo -e "${GREEN}✓ committed: ${COMMIT_MSG}${NC}"
fi
echo -e "${CYAN}[3/4]${NC} Tagging..."
git tag -a "v${NEW_VERSION}" -m "PodTUI v${NEW_VERSION}"
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
echo ""
echo -e "${CYAN}[4/4]${NC} Pushing..."
FAILED=""
for r in $REMOTES; do
if ! git push "$r" master; then
FAILED="${FAILED}${r} (branch) "
fi
if ! git push "$r" tag "v${NEW_VERSION}"; then
FAILED="${FAILED}${r} (tag) "
fi
done
echo ""
if [ -n "$FAILED" ]; then
echo -e "${RED}═══════════════════════════════════════${NC}"
echo -e "${RED}✗ Push failed for: ${FAILED}${NC}"
echo -e "${RED}═══════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW}The commit and tag exist locally. To retry:${NC}"
for r in $REMOTES; do
echo " git push ${r} master"
echo " git push ${r} v${NEW_VERSION}"
done
echo ""
echo -e "${YELLOW}To undo:${NC}"
echo " git tag -d v${NEW_VERSION}"
echo " git reset --soft HEAD~1"
exit 1
fi
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo -e "${GREEN}✓ PodTui v${NEW_VERSION} released${NC}"
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION}${GREEN}${NEW_VERSION}${NC}"
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
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-tap self-updates within the hour (Formula"
echo " URLs + sha256s); brew upgrade podtui afterwards."

679
scripts/tui-harness.tsx Normal file
View File

@@ -0,0 +1,679 @@
#!/usr/bin/env bun
/**
* PodTUI LLM-interactive harness — stateless per-turn snapshot bridge.
*
* Each invocation:
* 1. Points XDG_CONFIG_HOME / XDG_DATA_HOME / PODTUI_AUDIO_BACKEND at a
* sandbox dir under .harness so your real ~/.config/podtui is never touched.
* 2. Replays the saved action log (.harness/actions.json) from scratch.
* 3. Appends + executes the new action passed on the CLI.
* 4. Renders, captures structured spans, and prints: plain frame + distinct
* style summary (colors/attrs) + selected store state + captured issues
* (stderr / uncaught rejections). Full structured spans are dumped to
* .harness/last-frame.json every turn.
*
* Audio is silent (Noop) by default during the snapshot model so replaying the
* log each turn doesn't re-trigger real playback. Pass --audio (or set
* PODTUI_AUDIO_BACKEND) to flip to a real backend for the new action only.
*
* Import order mirrors src/index.tsx (lazy) to avoid a NavigationContext cycle.
*
* Usage:
* bun scripts/tui-harness.tsx init [--size 100x30] [--seed]
* bun scripts/tui-harness.tsx key <key> [mods...] # mods: ctrl shift meta
* bun scripts/tui-harness.tsx arrow <up|down|left|right> [mods...]
* bun scripts/tui-harness.tsx enter|escape|tab|space|backspace
* bun scripts/tui-harness.tsx type "<text>"
* bun scripts/tui-harness.tsx wait <ms>
* bun scripts/tui-harness.tsx resize <w> <h>
* bun scripts/tui-harness.tsx frame # re-render, no new action
* bun scripts/tui-harness.tsx state [all|nav|audio|feed|app]
* bun scripts/tui-harness.tsx actions # print action log
* bun scripts/tui-harness.tsx reset
* bun scripts/tui-harness.tsx seed [--from ~/.config/podtui]
*
* Flags (after the subcommand):
* --size WxH terminal size (default 100x30)
* --audio enable real audio backend for the new action only
* --no-settle skip the extra render-settle loops
* --styles print the distinct-styles sample block (off by default)
* --verbose restore the original multi-line pretty output
*/
import { testRender } from "@opentui/solid";
import {
existsSync,
mkdirSync,
cpSync,
writeFileSync,
readFileSync,
} from "node:fs";
import { join } from "node:path";
// ── Paths & sandbox ────────────────────────────────────────────────────────
const HANDLES_DIR = ".harness";
const CONFIG_HOME = join(HANDLES_DIR, "config-home");
const DATA_HOME = join(HANDLES_DIR, "data-home");
const ACTIONS_FILE = join(HANDLES_DIR, "actions.json");
const FRAME_JSON = join(HANDLES_DIR, "last-frame.json");
const FRAME_TXT = join(HANDLES_DIR, "last-frame.txt");
const STATE_JSON = join(HANDLES_DIR, "state.json");
// Sandbox must be active BEFORE any app module is imported, so the app's
// config-dir / persistence reads resolve into .harness/*.
function activateSandbox(): void {
mkdirSync(CONFIG_HOME, { recursive: true });
mkdirSync(DATA_HOME, { recursive: true });
process.env.XDG_CONFIG_HOME = join(process.cwd(), CONFIG_HOME);
process.env.XDG_DATA_HOME = join(process.cwd(), DATA_HOME);
// Silent audio during replay by default; --audio flips this after import.
if (!process.env.PODTUI_AUDIO_BACKEND)
process.env.PODTUI_AUDIO_BACKEND = "none";
}
// ── Action log ─────────────────────────────────────────────────────────────
type Mod = "ctrl" | "shift" | "meta" | "super" | "hyper";
type Action =
| { t: "key"; k: string; mods?: Mod[] }
| { t: "arrow"; d: "up" | "down" | "left" | "right"; mods?: Mod[] }
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
| { t: "type"; s: string }
| { t: "wait"; ms: number }
| { t: "resize"; w: number; h: number };
function loadActions(): Action[] {
try {
return JSON.parse(readFileSync(ACTIONS_FILE, "utf8") || "[]");
} catch {
return [];
}
}
function saveActions(a: Action[]): void {
writeFileSync(ACTIONS_FILE, JSON.stringify(a, null, 2));
}
// ── Issue capture ──────────────────────────────────────────────────────────
const issues: string[] = [];
// Captured by the StateProbe component rendered inside the provider tree —
// Solid contexts can only be read from within the tree, not from outside.
let navRef: any = null;
function captureIssues(): void {
const origErr = console.error;
const origWarn = console.warn;
console.error = (...args: unknown[]) => {
issues.push("stderr: " + args.map(String).join(" "));
origErr(...(args as any[]));
};
console.warn = (...args: unknown[]) => {
issues.push("warn: " + args.map(String).join(" "));
origWarn(...(args as any[]));
};
process.on("uncaughtException", (e) =>
issues.push("uncaught: " + ((e as Error)?.stack || String(e))),
);
process.on("unhandledRejection", (e) =>
issues.push("unhandledRejection: " + ((e as Error)?.stack || String(e))),
);
}
// ── Span rendering ─────────────────────────────────────────────────────────
type RGBA = { r: number; g: number; b: number; a: number };
type Span = {
text: string;
fg: RGBA | null;
bg: RGBA | null;
attributes: number;
width: number;
};
const ATTR_NAMES: Record<string, number> = {
BOLD: 1,
DIM: 2,
ITALIC: 4,
UNDERLINE: 8,
BLINK: 16,
INVERSE: 32,
HIDDEN: 64,
STRIKETHROUGH: 128,
};
function hex(c: RGBA | null): string | null {
if (!c) return null;
if (c.a === 0) return null; // transparent → "default"
const [r, g, b] = [c.r, c.g, c.b].map((v) =>
Math.max(0, Math.min(255, Math.round(v))),
);
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
}
function attrLabels(attr: number): string[] {
const out: string[] = [];
for (const [name, bit] of Object.entries(ATTR_NAMES))
if (attr & bit) out.push(name.toLowerCase());
return out;
}
// (Plain frame text comes from captureCharFrame instead of span reconstruction.)
function distinctStyles(spans: {
lines: { spans: Span[] }[];
}): { tag: string; sample: string; n: number }[] {
const map = new Map<string, { tag: string; sample: string; n: number }>();
for (const line of spans.lines) {
for (const s of line.spans) {
if (!s.text || s.text.trim() === "") continue;
const fg = hex(s.fg as any);
const bg = hex(s.bg as any);
if (!fg && !bg && s.attributes === 0) continue; // default — skip
const tags = attrLabels(s.attributes);
const tag = `[fg=${fg ?? "·"} bg=${bg ?? "·"}${tags.length ? " " + tags.join("+") : ""}]`;
const ex = map.get(tag);
const sample = s.text.replace(/\n/g, "\\n").slice(0, 28);
if (ex) {
ex.n++;
if (ex.sample.length < 14 && sample.length > ex.sample.length)
ex.sample = sample;
} else {
map.set(tag, { tag, sample, n: 1 });
}
}
}
return [...map.values()].sort((a, b) => b.n - a.n).slice(0, 20);
}
// ── Arg parsing ────────────────────────────────────────────────────────────
function parseFlags(rest: string[]): {
flags: Record<string, string | boolean>;
positional: string[];
} {
const flags: Record<string, string | boolean> = {};
const positional: string[] = [];
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith("--")) {
if (a === "--audio") flags.audio = true;
else if (a === "--no-settle") flags["no-settle"] = true;
else if (a === "--styles") flags.styles = true;
else if (a === "--verbose") flags.verbose = true;
else if (a === "--size") {
flags.size = rest[++i];
const m = /(\d+)x(\d+)/.exec(String(flags.size));
if (m) {
flags.w = m[1];
flags.h = m[2];
}
} else if (a === "--from") {
flags.from = rest[++i];
} else {
throw new Error(`unknown flag: ${a}`);
}
} else {
positional.push(a);
}
}
return { flags, positional };
}
function parseMods(positional: string[]): Mod[] {
const mods: Mod[] = [];
for (const p of positional)
if (["ctrl", "shift", "meta", "super", "hyper"].includes(p))
mods.push(p as Mod);
return mods;
}
// 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);
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 ──────────────────────────────
function fmtMods(mods?: Mod[]): Record<string, boolean> | undefined {
if (!mods || !mods.length) return undefined;
const o: Record<string, boolean> = {};
for (const m of mods) o[m] = true;
return o;
}
async function execAction(setup: any, a: Action): Promise<void> {
const mi = setup.mockInput;
switch (a.t) {
case "key":
mi.pressKey(a.k, fmtMods(a.mods));
break;
case "arrow":
mi.pressArrow(a.d, fmtMods(a.mods));
break;
case "enter":
mi.pressEnter(fmtMods(a.mods));
break;
case "escape":
mi.pressEscape(fmtMods(a.mods));
break;
case "tab":
mi.pressTab(fmtMods(a.mods));
break;
case "space":
mi.pressKey("space");
break;
case "backspace":
mi.pressBackspace(fmtMods(a.mods));
break;
case "type":
await mi.typeText(a.s, 0);
break;
case "wait":
await new Promise((r) => setTimeout(r, a.ms));
break;
case "resize":
setup.resize(a.w, a.h);
break;
}
await setup.renderOnce();
// tiny settle for reactive updates
await new Promise((r) => setTimeout(r, 40));
}
// ── 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));
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. 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 true;
}
if (cmd === "actions") {
const a = loadActions();
console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2));
return true;
}
if (cmd === "seed") {
const from = String(
flags.from || join(process.env.HOME || "~", ".config", "podtui"),
);
if (!existsSync(from)) {
console.error(`seed source not found: ${from}`);
process.exit(1);
}
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from}${dest}`);
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;
if (flags.w) width = parseInt(String(flags.w), 10);
if (flags.h) height = parseInt(String(flags.h), 10);
let newAction: Action | null = null;
let actions: Action[] = [];
if (cmd !== "init" && cmd !== "frame" && cmd !== "state") {
newAction = buildAction(cmd, positional);
}
if (cmd === "init") {
saveActions([]);
actions = [];
} else {
actions = loadActions();
}
// Mount the real app. Lazy imports (order matters — see NavigationContext cycle).
const { App } = await import("../src/App");
const { ThemeProvider } = await import("../src/context/ThemeContext");
const toast = await import("../src/ui/toast");
const { KeybindProvider } = await import("../src/context/KeybindContext");
const { NavigationProvider, useNavigation } = await import(
"../src/context/NavigationContext"
);
const { DialogProvider } = await import("../src/ui/dialog");
const { CommandProvider } = await import("../src/ui/command");
// Probe rendered inside the provider tree so context hooks resolve.
const StateProbe = () => {
try {
navRef = useNavigation();
} catch (e) {
issues.push("StateProbe: " + String(e));
}
return null;
};
const HarnessRoot = () => (
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<StateProbe />
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
);
const setup = await testRender(() => <HarnessRoot />, {
width,
height,
useThread: false,
});
(setup.renderer as any).disableStdoutInterception?.();
// Wait for providers (keybinds/theme/feeds) to settle.
const settleLoops = flags["no-settle"] ? 2 : 12;
for (let i = 0; i < settleLoops; i++) {
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
}
// Replay history silently (audio already Noop via env).
for (const a of actions) await execAction(setup, a);
// For the *new* action: if --audio, flip to a real backend just for it.
let audioControls: any = null;
try {
const { useAudio } = await import("../src/hooks/useAudio");
audioControls = useAudio();
} catch (e) {
issues.push("useAudio import: " + String(e));
}
if (newAction) {
if (flags.audio && audioControls?.switchBackend) {
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
delete process.env.PODTUI_AUDIO_BACKEND;
await audioControls.switchBackend("mpv").catch(() => {});
}
actions.push(newAction);
saveActions(actions);
await execAction(setup, newAction);
}
// Final settle + capture.
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as FrameCapture;
const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame.
try {
writeFileSync(FRAME_JSON, JSON.stringify(spans));
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 {
const nav = navRef;
if (nav) {
state.nav = {
tab: nav.activeTab?.(),
pane: nav.activePane?.(),
mode: nav.mode?.(),
input: nav.inputFocused?.(),
sel: nav.selectedIds?.()?.length ?? 0,
ready: nav.ready,
};
} else {
state.nav = "NAV_REF not captured (probe did not run)";
}
} catch (e) {
state.nav = "ERR: " + String(e);
}
try {
if (audioControls) {
state.audio = {
backend: audioControls.backendName ? audioControls.backendName() : null,
playing: audioControls.isPlaying ? audioControls.isPlaying() : null,
pos: audioControls.position ? audioControls.position() : null,
dur: audioControls.duration ? audioControls.duration() : null,
vol: audioControls.volume ? audioControls.volume() : null,
err: audioControls.error ? audioControls.error() : null,
ep: audioControls.currentEpisode
? audioControls.currentEpisode()?.title
: null,
};
}
} catch (e) {
state.audio = "ERR: " + String(e);
}
try {
const { useFeedStore } = await import("../src/stores/feed");
const fs_ = useFeedStore();
const feeds = fs_.feeds ? fs_.feeds() : [];
state.feed = {
count: feeds?.length ?? 0,
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
};
} catch (e) {
state.feed = "ERR: " + String(e);
}
try {
writeFileSync(STATE_JSON, JSON.stringify(state));
} catch {}
return state;
}
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 = !!p.flags.verbose;
const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
console.log(
`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(trimFrame(p.plainFrame));
// ── distinct styles: opt-in only (--styles OR --verbose) ──
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}`);
}
}
// ── state: one compact line per requested section ──
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(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 ──
if (issues.length) {
console.log(`issues:${issues.length}`);
for (const i of issues.slice(0, 20)) console.log(" ! " + i);
} else {
console.log("issues:none");
}
// Footer is identical every run — only print on init or --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(() => {});
} catch (e) {
issues.push("teardown audio: " + String(e));
}
try {
setup.renderer.destroy();
} catch (e) {
issues.push("teardown renderer: " + String(e));
}
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);
});

94
src/App.tsx Normal file
View File

@@ -0,0 +1,94 @@
import { ErrorBoundary } from "solid-js";
import { useSelectionHandler, useRenderer } from "@opentui/solid";
import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { Clipboard } from "@/utils/clipboard";
import { useToast } from "@/ui/toast";
import { useTheme, ThemeProvider } from "./context/ThemeContext";
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
import {
NavigationProvider,
useNavigation,
NavMode,
} from "./context/NavigationContext";
import { TABS } from "./utils/navigation";
import { Shell } from "./components/Shell";
const DEBUG = import.meta.env.DEBUG;
export function App() {
const nav = useNavigation();
const audio = useAudio();
const toast = useToast();
const renderer = useRenderer();
const themeContext = useTheme();
const theme = themeContext.theme;
const keybind = useKeybinds();
// Multimedia keys (physical play/seek keys) still feed the audio backend
// regardless of the on-screen yazi keybinds.
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).
useSelectionHandler((selection: any) => {
if (!selection) return;
const text = selection.getSelectedText?.();
if (!text || text.trim().length === 0) return;
Clipboard.copy(text)
.then(() =>
toast.show({ message: "Copied to Clipboard!", variant: "info" }),
)
.catch(toast.error)
.finally(() => renderer.clearSelection());
});
const backgroundColor = () =>
themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface;
return (
<ErrorBoundary
fallback={(err) => (
<box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Error: {err?.message ?? String(err)}
{"\n"}
Press 1-6 to switch tabs, or : to open the command bar.
</text>
</box>
)}
>
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={backgroundColor()}
>
{DEBUG && (
<box flexDirection="row" width="100%" height={1}>
<text fg={theme.primary}></text>
<text fg={theme.secondary}></text>
<text fg={theme.accent}></text>
<text fg={theme.error}></text>
<text fg={theme.warning}></text>
<text fg={theme.success}></text>
<text fg={theme.info}></text>
<text fg={theme.text}></text>
<text fg={theme.textMuted}></text>
<text fg={theme.surface}></text>
</box>
)}
<Shell />
</box>
</ErrorBoundary>
);
}

147
src/api/rss-parser.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { Podcast } from "../types/podcast"
import type { Episode, EpisodeType } from "../types/episode"
import { detectContentType, ContentType } from "../utils/rss-content-detector"
import { htmlToText } from "../utils/html-to-text"
const getTagValue = (xml: string, tag: string): string => {
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"))
return match?.[1]?.trim() ?? ""
}
/** Get an attribute value from a self-closing or open tag */
const getAttr = (xml: string, tag: string, attr: string): string => {
const tagMatch = xml.match(new RegExp(`<${tag}[^>]*>`, "i"))
if (!tagMatch) return ""
const attrMatch = tagMatch[0].match(new RegExp(`${attr}\\s*=\\s*["']([^"']*)["']`, "i"))
return attrMatch?.[1] ?? ""
}
const decodeEntities = (value: string) =>
value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
/**
* Clean a field (description or title): detect HTML vs plain text, and convert
* HTML to readable plain text. Plain text just gets entity decoding.
*/
const cleanField = (raw: string): string => {
if (!raw) return ""
const decoded = decodeEntities(raw)
const type = detectContentType(decoded)
if (type === ContentType.HTML) {
return htmlToText(decoded)
}
return decoded
}
/**
* Parse an itunes:duration value which can be:
* - "HH:MM:SS"
* - "MM:SS"
* - seconds as a plain number string (e.g. "1234")
* Returns duration in seconds, or 0 if unparseable.
*/
const parseDuration = (raw: string): number => {
if (!raw) return 0
const trimmed = raw.trim()
// Pure numeric (seconds)
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10)
}
// HH:MM:SS or MM:SS
const parts = trimmed.split(":").map(Number)
if (parts.some(isNaN)) return 0
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1]
}
return 0
}
const parseEpisodeType = (raw: string): EpisodeType | undefined => {
const lower = raw.trim().toLowerCase()
if (lower === "trailer") return "trailer" as EpisodeType
if (lower === "bonus") return "bonus" as EpisodeType
if (lower === "full") return "full" as EpisodeType
return undefined
}
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
const description = cleanField(getTagValue(channel, "description"))
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, index) => {
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = cleanField(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
const audioUrl = enclosure?.[1] ?? ""
const fileSizeStr = getAttr(item, "enclosure", "length")
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
const mimeType = getAttr(item, "enclosure", "type") || undefined
// Duration from <itunes:duration>
const durationRaw = getTagValue(item, "itunes:duration")
const duration = parseDuration(durationRaw)
// Episode & season numbers
const episodeNumRaw = getTagValue(item, "itunes:episode")
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
const seasonNumRaw = getTagValue(item, "itunes:season")
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
// Episode type & explicit
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
// Episode image (itunes:image has href attribute)
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: `${feedUrl}#${index}`,
podcastId: feedUrl,
title: epTitle,
description: epDescription,
audioUrl,
duration,
pubDate,
}
// Only set optional fields if present
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
if (episodeType) ep.episodeType = episodeType
if (explicit !== undefined) ep.explicit = explicit
if (imageUrl) ep.imageUrl = imageUrl
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
if (mimeType) ep.mimeType = mimeType
return ep
})
return {
id: feedUrl,
title,
description,
author,
feedUrl,
lastUpdated,
isSubscribed: true,
episodes,
}
}

View File

@@ -0,0 +1,30 @@
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/**
* 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);
onCleanup(() => clearInterval(interval));
const currentChar = createMemo(() => spinnerChars[index()]);
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 = [1, 2, 2]` contract: three columns grow at
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
* the ratio is exact regardless of content width — a column's content can
* never stretch its slot.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 1/5 slot when blank (never collapses to width 0).
* 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 (1/5) — 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 (2/5) — 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

@@ -0,0 +1,82 @@
import { useTheme } from "@/context/ThemeContext";
import { children as solidChildren } from "solid-js";
import type { ParentComponent } from "solid-js";
import type { BoxOptions, TextOptions } from "@opentui/core";
export const SelectableBox: ParentComponent<
{
selected: () => boolean;
} & BoxOptions
> = (props) => {
const themeContext = useTheme();
const { theme } = themeContext;
const child = solidChildren(() => props.children);
return (
<box
border={!!props.border}
borderColor={props.selected() ? theme.surface : theme.border}
backgroundColor={
props.selected()
? theme.primary
: themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface
}
{...props}
>
{child()}
</box>
);
};
enum ColorSet {
PRIMARY,
SECONDARY,
TERTIARY,
DEFAULT,
}
function getTextColor(set: ColorSet, selected: () => boolean) {
const { theme } = useTheme();
switch (set) {
case ColorSet.PRIMARY:
return selected() ? theme.textSelectedPrimary : theme.textPrimary;
case ColorSet.SECONDARY:
return selected() ? theme.textSelectedSecondary : theme.textSecondary;
case ColorSet.TERTIARY:
return selected() ? theme.textSelectedTertiary : theme.textTertiary;
default:
return theme.textPrimary;
}
}
export const SelectableText: ParentComponent<
{
selected: () => boolean;
primary?: boolean;
secondary?: boolean;
tertiary?: boolean;
} & TextOptions
> = (props) => {
const child = solidChildren(() => props.children);
return (
<text
fg={getTextColor(
props.primary
? ColorSet.PRIMARY
: props.secondary
? ColorSet.SECONDARY
: props.tertiary
? ColorSet.TERTIARY
: ColorSet.DEFAULT,
props.selected,
)}
{...props}
>
{child()}
</text>
);
};

453
src/components/Shell.tsx Normal file
View File

@@ -0,0 +1,453 @@
/**
* Shell — yazi-style application chrome.
*
* Renders the active page (which owns its own three-column parent | current |
* preview panes) full-width, with a bottom status/command bar that also
* carries the tab strip. A single `useKeyboard` router translates keystrokes
* (via the sequence-aware keybind matcher) into actions: the unified router
* in `@/utils/dispatch` handles tabs (digits `1`-`6`, `[`/`]`), h/l depth
* drill/pop + fixed-pane swipe, modes, audio, quit, help, and command; the
* pane/list ones are dispatched to the active page over the `nav.action`
* event bus. There is no sidebar pane.
*/
import { createSignal, Show, For } from "solid-js";
import { useKeyboard, useRenderer } 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 } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast";
import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph";
import { TABS } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { PaneRow } from "@/components/PaneRow";
export function Shell() {
const theme = useTheme();
const t = theme.theme;
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) {
const cur = audio.currentEpisode();
if (!cur) {
toast.show({ message: "Nothing playing", variant: "warning" });
return;
}
const pid = audioNav.getPodcastId();
const feeds = feedStore.getFilteredFeeds();
const feed =
feeds.find((f) => f.podcast.id === pid) ??
feeds.find((f) => f.episodes.some((e) => e.id === cur.id));
if (!feed) {
toast.show({ message: "Show not found", variant: "warning" });
return;
}
const eps = [...feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
const idx = eps.findIndex((e) => e.id === cur.id);
const next = eps[idx + offset];
if (!next) {
toast.show({
message: offset > 0 ? "No next episode" : "No previous episode",
variant: "warning",
});
return;
}
audio.play(next).catch(() => {});
audioNav.next(eps.length - 1 - (idx + offset) >= 0 ? idx + offset : idx);
toast.show({ message: `${next.title}`.slice(0, 60), variant: "info" });
}
// ── 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();
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 ───────────────────────────────────────────────
function handleCommandKey(evt: any) {
if (k.match("escape", evt) || evt.name === "ctrl-c") {
evt.preventDefault();
nav.exitCommand();
return;
}
if (evt.name === "return" || evt.name === "enter") {
evt.preventDefault();
const cmd = nav.commandBuffer();
runCommand(cmd);
nav.exitCommand();
return;
}
if (evt.name === "backspace") {
evt.preventDefault();
const buf = nav.commandBuffer();
if (buf.length === 0) {
nav.exitCommand();
return;
}
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);
return;
}
}
// ── Unified router (normal + visual) ───────────────────────────────────────
const { dispatch } = createDispatcher({
nav,
audio: {
togglePlayback: audio.togglePlayback,
seekRelative: audio.seekRelative,
},
k,
setShowHelp,
advanceEpisode,
});
useKeyboard(
(evt: any) => {
// Input fields (search boxes, dialogs) own their keys — except Escape,
// which defocuses the input so j/k/h navigation resumes (search: h back
// to the tab root, j/k to move the recent-searches list).
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
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;
}
if (nav.mode() === NavMode.COMMAND) {
handleCommandKey(evt);
return;
}
const action = k.tryMatch(evt);
if (action) dispatch(action, evt);
},
{ release: false },
);
// ── Status bar fragments ──────────────────────────────────────────────────
const nowPlaying = () => {
const ep = audio.currentEpisode();
if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
return `${title}`;
};
const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
const pendingLabel = () =>
k
.pending()
.map((s) => s.key)
.join(" ");
return (
<box
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
when={nav.atRootTab()}
fallback={
<box flexGrow={1} width="100%">
{LayerGraph[nav.activeTab()]()}
</box>
}
>
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
<PaneRow
parent={
<box padding={1}>
<text fg={t.textMuted}></text>
</box>
}
current={<TabListPane />}
preview={
<box padding={1}>
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
</box>
}
currentLabel="Tabs"
focused
/>
</Show>
</box>
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
<box
flexDirection="row"
height={1}
width="100%"
backgroundColor={
theme.transparentBackground()
? "transparent"
: (t.backgroundPanel ?? t.background)
}
>
<Show
when={nav.mode() === NavMode.COMMAND}
fallback={
<>
<text fg={t.accent} paddingLeft={1}>
{modeLabel()}
</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>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>
<text fg={t.textMuted} paddingRight={1}>
~
</text>
</>
}
>
<text fg={t.accent} paddingLeft={1}>
:
</text>
<text fg={t.text}>{nav.commandBuffer()}</text>
<text fg={t.textMuted}></text>
<Show when={nav.commandError()}>
<text fg={t.error} paddingLeft={1}>
{nav.commandError()}
</text>
</Show>
</Show>
</box>
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
<Show when={showHelp()}>
<HelpOverlay
onClose={() => setShowHelp(false)}
sections={helpSections(k)}
theme={t as any}
/>
</Show>
</box>
);
}
function helpSections(k: ReturnType<typeof useKeybinds>) {
const p = (a: KeybindActionName) => k.print(a);
return [
{
group: "Move",
items: [
["j/k", "move"],
["J/K", "5 lines"],
["ctrl-d/u", "half page"],
["gg/G", "top/bottom"],
],
},
{
group: "Panes",
items: [
["j/k", "switch tab (tab panel)"],
["l/enter", "enter tab content"],
["h", "back to tab panel"],
["1-6 / [ ]", "switch tabs"],
[":", "command"],
["~", "help"],
],
},
{
group: "Select",
items: [
["space", "toggle"],
["v", "visual"],
["ctrl-a", "all"],
["esc", "clear"],
],
},
{
group: "Audio",
items: [
[p("audio-toggle"), "play/pause"],
[p("audio-next"), "next"],
[p("audio-seek-forward"), "fwd 10s"],
[p("audio-seek-backward"), "back 10s"],
],
},
{
group: "List",
items: [
["enter", "open"],
["r", "refresh"],
["s", "search"],
["f", "filter"],
[",", "sort"],
[".", "hidden"],
],
},
];
}
function HelpOverlay(props: {
onClose: () => void;
sections: { group: string; items: string[][] }[];
theme: any;
}) {
useKeyboard((evt: any) => {
if (k_match_escape(evt)) {
evt.preventDefault();
props.onClose();
}
});
const th = props.theme;
return (
<box
position="absolute"
top={2}
left={0}
width="100%"
alignItems="center"
backgroundColor="rgba(0,0,0,160)"
onMouseUp={() => props.onClose()}
>
<box
flexDirection="column"
border
borderColor={th.border}
backgroundColor={th.backgroundPanel ?? th.background}
padding={1}
width={60}
onMouseUp={(e: any) => e.stopPropagation()}
>
<text fg={th.accent}>
Yazi-style keybinds press ~ or Esc to close
</text>
<For each={props.sections}>
{(sec) => (
<box flexDirection="column" marginTop={1}>
<text fg={th.textSecondary}>{sec.group}</text>
<For each={sec.items}>
{(it) => (
<box flexDirection="row" gap={2}>
<text fg={th.accent}>{String(it[0]).padEnd(14, " ")}</text>
<text fg={th.textPrimary ?? th.text}>{it[1]}</text>
</box>
)}
</For>
</box>
)}
</For>
<box marginTop={1}>
<text fg={th.textMuted}>
Edit ~/.config/podtui/keybinds.jsonc to remap.
</text>
</box>
</box>
</box>
);
}
function k_match_escape(evt: any): boolean {
return (
evt.name === "escape" || evt.name === "~" || (evt.ctrl && evt.name === "[")
);
}
// Re-export Episode type for callers building pane trees.
export type { Episode } from "@/types/episode";

104
src/components/TabPanel.tsx Normal file
View File

@@ -0,0 +1,104 @@
/**
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
* PREVIEW flow (replaces the old fixed chrome tab column).
*
* Renders one row per tab (digit + label) using the same selection UI every
* other yazi pane uses: the CURSOR row (the one j/k hovers) gets a `` marker
* and the focus background (`theme.primary` when this pane is the CURRENT
* column, `theme.border` when it is the muted UP/parent column). The ACTIVE
* tab (the one whose content is open) always carries a `●` marker in accent so
* it stays readable in both positions.
*
* `muted` marks the parent-column rendering: the highlight is dimmed (border
* bg, text fg) rather than suppressed, so the Up pane still shows the cursor
* and active tab — matching how every other pane's parent column renders its
* focused row.
*/
import { For } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { TABS } from "@/utils/navigation";
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",
};
/** Numeric TABS values, in declaration order (1..TabsCount). */
const TAB_ORDER = Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
) as TABS[];
export function TabListPane(props: { muted?: boolean }) {
const { theme } = useTheme();
const nav = useNavigation();
const cursor = () => nav.tabCursor();
const activeTab = () => nav.activeTab();
/** `active=true` when this pane is the CURRENT column (Shell root);
* `false` when it is the muted UP/parent column (pages' parent pane). */
const active = () => !props.muted;
// Same focus-bg / focus-fg contract every other pane uses.
const focusBg = (t: TABS) =>
t === cursor() && active()
? theme.primary
: t === cursor()
? theme.border
: undefined;
const focusFg = (t: TABS) =>
t === cursor() && active()
? theme.surface
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<For each={TAB_ORDER}>
{(tab) => {
const isCursor = () => cursor() === tab;
const isActive = () => activeTab() === tab;
// The active tab is only accented in the Up/parent position — when this
// pane is CURRENT, the cursor highlight is the only highlight.
const labelFg = () =>
isCursor()
? focusFg(tab)
: isActive() && !active()
? theme.accent
: theme.text;
const ref = useScrollIntoView(isCursor);
return (
<box
ref={ref}
width="100%"
height={1}
flexDirection="row"
paddingRight={1}
backgroundColor={focusBg(tab)}
onMouseDown={() => {
// Click = hover + open, the yazi "open" of the row
// (switches to the tab and enters its content), the same
// as l/Enter. Restores mouse support the tab-strip
// refactor dropped.
nav.setTabCursor(tab);
nav.activateTabCursor();
}}
>
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text>
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
<text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
);
}

77
src/config/keybinds.jsonc Normal file
View File

@@ -0,0 +1,77 @@
{
// ── Yazi-style keybinds for PodTui ──────────────────────────────────────
// Notation:
// "j" single lowercase key
// "G" shift + g (uppercase letter = shift)
// "ctrl-d" ctrl modifier
// "shift-j" shift modifier (equivalent to "J")
// "meta-x" alt/meta modifier (macOS users: map Option to Alt)
// ["g","g"] a two-key sequence (matches only when pressed in order)
// "return" special key (also: escape tab space backspace up down left right)
//
// 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,
// : / 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"],
"move-up": ["k", "up"],
"page-down": ["ctrl-d"],
"page-up": ["ctrl-u"],
"full-down": ["ctrl-f"],
"full-up": ["ctrl-b"],
"jump-down": ["J"], // 5 lines down (shift+j)
"jump-up": ["K"], // 5 lines up (shift+k)
"goto-top": [["g", "g"]],
"goto-bottom": ["G"],
// ── Pane focus / swipe (yazi h/l) ────────────────────────────────────────
"swipe-prev": ["h", "left"], // focus left pane (parent)
"swipe-next": ["l", "right"], // focus right pane (preview)
// ── Open / activate ──────────────────────────────────────────────────────
"open": ["return", "enter"],
"open-interactive": ["shift-return"],
// ── Selection & visual mode ──────────────────────────────────────────────
"toggle-select": ["space"],
"visual-mode": ["v"],
"toggle-all": ["ctrl-a"],
"invert-all": ["ctrl-r"],
"escape": ["escape", "ctrl-["],
// ── Tabs ──────────────────────────────────────────────────────────────────
"tab-prev": ["["],
"tab-next": ["]"],
"tab-goto-1": ["1"],
"tab-goto-2": ["2"],
"tab-goto-3": ["3"],
"tab-goto-4": ["4"],
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// ── 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"],
"sort": [","],
"toggle-hidden": ["."],
"refresh": ["r"],
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
// ── Audio transport (preserved) ──────────────────────────────────────────
// Kept on shifted single keys so they never collide with the yazi core
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
"audio-toggle": ["P"], // play / pause (shift+p)
"audio-next": ["N"], // next episode (shift+n)
"audio-prev": ["B"], // prev episode (shift+b)
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
}

25
src/constants/themes.ts Normal file
View File

@@ -0,0 +1,25 @@
import type {
ThemeColors,
ThemeDefinition,
ThemeName,
} from "../types/settings";
import {
BASE_THEME_COLORS,
BASE_LAYER_BACKGROUND,
} from "../types/desktop-theme";
import catppuccin from "../themes/catppuccin.json" with { type: "json" };
import gruvbox from "../themes/gruvbox.json" with { type: "json" };
import tokyo from "../themes/tokyo.json" with { type: "json" };
import nord from "../themes/nord.json" with { type: "json" };
export const DEFAULT_THEME: ThemeColors = {
...BASE_THEME_COLORS,
layerBackgrounds: BASE_LAYER_BACKGROUND,
};
export const THEME_JSON: Record<string, ThemeDefinition> = {
catppuccin: catppuccin as ThemeDefinition,
gruvbox: gruvbox as ThemeDefinition,
tokyo: tokyo as ThemeDefinition,
nord: nord as ThemeDefinition,
};

View File

@@ -0,0 +1,351 @@
import { createSignal, onMount } from "solid-js";
import { createSimpleContext } from "./helper";
import {
copyKeybindsIfNeeded,
loadKeybindsFromFile,
saveKeybindsToFile,
} from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store";
// ── Keybind model ───────────────────────────────────────────────────────────
// Yazi-style: every binding is one or more "strokes". A stroke is a single
// key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
// sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
// buffers keystrokes, prefers the longest matching sequence, and exposes the
// pending buffer reactively so the status bar can show it (very yazi).
/** A single key press. `key` is the lowercase logical key name
* ("j", "return", "space", "up", "f1", ...). */
export interface Stroke {
key: string;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
}
/** Raw config spec for one action: a list of alternative sequences. Each
* alternative is itself a list of stroke-notation strings. So
* "j" -> [[ {key:"j"} ]]
* ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
* [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
export type KeybindSpec = string | (string | string[])[];
/** Canonical action names. Must match keys in keybinds.jsonc. */
export type KeybindActionName =
| "move-down"
| "move-up"
| "page-down"
| "page-up"
| "full-down"
| "full-up"
| "jump-down"
| "jump-up"
| "goto-top"
| "goto-bottom"
| "swipe-prev"
| "swipe-next"
| "open"
| "open-interactive"
| "toggle-select"
| "visual-mode"
| "toggle-all"
| "invert-all"
| "escape"
| "tab-prev"
| "tab-next"
| "tab-goto-1"
| "tab-goto-2"
| "tab-goto-3"
| "tab-goto-4"
| "tab-goto-5"
| "tab-goto-6"
| "command"
| "quit"
| "help"
| "search"
| "filter"
| "sort"
| "toggle-hidden"
| "refresh"
| "unsubscribe"
| "audio-toggle"
| "audio-next"
| "audio-prev"
| "audio-seek-forward"
| "audio-seek-backward";
/** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
const SEQ_TIMEOUT_MS = 600;
// ── Stroke parsing ───────────────────────────────────────────────────────────
// Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j".
// uppercase letter "G" => {key:"g", shift:true}
// special: return/enter/escape/tab/space/backspace/up/down/left/right
export function parseStroke(notation: string): Stroke {
const raw = notation.trim();
let ctrl = false,
shift = false,
meta = false;
let key = raw;
const parts = raw.split(/[-+]/);
if (parts.length > 1) {
key = parts[parts.length - 1];
for (const mod of parts.slice(0, -1)) {
const m = mod.toLowerCase();
if (m === "ctrl" || m === "c") ctrl = true;
else if (m === "shift" || m === "s") shift = true;
else if (m === "meta" || m === "alt" || m === "m") meta = true;
}
}
// Uppercase single letter w/o modifier => shift+letter (vim convention)
if (
parts.length === 1 &&
key.length === 1 &&
key >= "A" &&
key <= "Z" &&
!ctrl &&
!shift &&
!meta
) {
shift = true;
key = key.toLowerCase();
}
return { key: key.toLowerCase(), ctrl, shift, meta };
}
/** Turn one raw spec into a list of alternative stroke-sequences. */
export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
if (spec == null) return [];
const alts: Stroke[][] = [];
const push = (item: string | string[]) => {
const seq = Array.isArray(item) ? item : [item];
alts.push(seq.map(parseStroke));
};
if (Array.isArray(spec)) {
for (const item of spec) push(item);
} else {
push(spec);
}
return alts;
}
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
function strokeFromEvent(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return {
key: evt.name.toLowerCase(),
ctrl: !!evt.ctrl,
shift: !!evt.shift,
meta: !!evt.meta,
};
}
function strokeEq(a: Stroke, b: Stroke): boolean {
return (
a.key === b.key &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta
);
}
/** A human label for a stroke, for the status bar / help. */
function strokeLabel(s: Stroke): string {
let out = "";
if (s.ctrl) out += "C-";
if (s.meta) out += "M-";
if (s.shift) out += "S-";
out += s.key;
return out;
}
function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" ");
}
export const { use: useKeybinds, provider: KeybindProvider } =
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore<KeybindsResolved>({});
// Resolved sequences per action, recomputed when store changes.
const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
{},
);
const [ready, setReady] = createSignal(false);
const [pending, setPending] = createSignal<Stroke[]>([]);
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
function recompute() {
const out: Record<string, Stroke[][]> = {};
for (const name of Object.keys(store) as string[]) {
out[name] = parseBindingSpec((store as any)[name]);
}
setResolved(out);
}
async function load() {
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
recompute();
setReady(true);
}
async function save() {
saveKeybindsToFile(store as KeybindsResolved);
}
function print(input: KeybindActionName): string {
const alts = resolved()[input] ?? [];
return alts.map(sequenceLabel).join(" / ") || "—";
}
function clearPending() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = undefined;
}
if (pending().length > 0) setPending([]);
}
function armTimer() {
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS);
}
/** Look up every action whose sequence-list the candidate is a prefix of
* (i.e. a longer match is still possible) and every action that the
* candidate exactly equals. */
function classify(candidate: Stroke[]) {
const exact: KeybindActionName[] = [];
const prefix: KeybindActionName[] = [];
const map = resolved();
for (const name of Object.keys(map) as KeybindActionName[]) {
for (const seq of map[name] ?? []) {
if (seq.length < candidate.length) continue;
let isPrefix = true;
for (let i = 0; i < candidate.length; i++) {
if (!strokeEq(seq[i], candidate[i])) {
isPrefix = false;
break;
}
}
if (!isPrefix) continue;
if (seq.length === candidate.length) exact.push(name);
else prefix.push(name);
}
}
return { exact, prefix };
}
/** Legacy single-key matcher (used by command-palette registrations and
* older call sites). Returns true iff `name`'s sequence list contains a
* single-stroke alternative equal to the event. */
function match(
name: KeybindActionName,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const alts = resolved()[name] ?? [];
const s = strokeFromEvent(evt);
// skip in command/input mode unless explicitly handled by caller
for (const seq of alts) {
if (seq.length === 1 && strokeEq(seq[0], s)) return true;
}
return false;
}
/** New sequence-aware matcher. Returns the resolved action or null.
* Callers should invoke this once per keypress in a single router. */
function tryMatch(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): KeybindActionName | null {
const stroke = strokeFromEvent(evt);
const candidate = [...pending(), stroke];
const { exact, prefix } = classify(candidate);
// Still mid-sequence: wait for more keys (unless this stroke also
// exactly matches something AND nothing depends on a longer prefix).
if (prefix.length > 0) {
setPending(candidate);
armTimer();
// If there's also an exact match, we *could* fire now — but yazi
// prefers to wait for the longer sequence within the timeout, then
// falls through. We honor that: only fire exact if no prefix.
if (exact.length > 0) {
// ambiguous prefix+exact: keep waiting (e.g. `g` could be gg)
}
return null;
}
// No longer-match possible: decide on exact.
clearPending();
if (exact.length === 0) {
// The new stroke might itself begin a fresh sequence.
const fresh = classify([stroke]);
if (fresh.prefix.length > 0) {
setPending([stroke]);
armTimer();
return null;
}
if (fresh.exact.length > 0) {
// prefer longest-sequence match among fresh.exact
return pickLongest(fresh.exact);
}
return null;
}
return pickLongest(exact);
}
function pickLongest(names: KeybindActionName[]): KeybindActionName {
const map = resolved();
let best = names[0];
let bestLen = 0;
for (const n of names) {
for (const seq of map[n] ?? []) {
if (seq.length > bestLen) {
bestLen = seq.length;
best = n;
}
}
}
return best;
}
onMount(() => {
load().catch(() => {});
});
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
get resolved() {
return resolved();
},
pending,
match,
tryMatch,
print,
save,
load,
clearPending,
};
},
});

View File

@@ -0,0 +1,22 @@
/**
* NavigationContext — Solid provider wrapper around the pure nav store in
* `./navigation-store`. Re-exports the nav model (`createNavigation`, the
* enums/types, `DEPTH_CENTER_PANE`, etc.) so the rest of the app keeps
* importing everything from `@/context/NavigationContext`, and binds the
* store into a Solid context (`useNavigation` / `NavigationProvider`).
*
* See `./navigation-store` for the model documentation (parent | current |
* preview, depth-stack vs fixed-pane tabs, no sidebar pane).
*/
import { createSimpleContext } from "./helper";
import { createNavigation } from "./navigation-store";
// Re-export the entire nav model surface so existing imports from
// `@/context/NavigationContext` keep resolving.
export * from "./navigation-store";
export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({
name: "Navigation",
init: () => createNavigation(),
});

View File

@@ -0,0 +1,387 @@
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";
import type { ThemeName } from "../types/settings";
import type { ThemeJson } from "../types/theme-schema";
import { useAppStore } from "../stores/app";
import { THEME_JSON } from "../constants/themes";
import {
generateSyntax,
generateSubtleSyntax,
} from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper";
import {
setupThemeSignalHandler,
emitThemeChanged,
emitThemeModeChanged,
} from "../utils/theme-observer";
import {
createTerminalPalette,
type RGBA,
type TerminalColors,
} from "@opentui/core";
export type ThemeResolved = {
primary: RGBA;
secondary: RGBA;
accent: RGBA;
error: RGBA;
warning: RGBA;
success: RGBA;
info: RGBA;
text: RGBA;
textMuted: RGBA;
textPrimary: RGBA;
textSecondary: RGBA;
textTertiary: RGBA;
textSelectedPrimary: RGBA;
textSelectedSecondary: RGBA;
textSelectedTertiary: RGBA;
background: RGBA;
backgroundPanel: RGBA;
backgroundElement: RGBA;
backgroundMenu: RGBA;
border: RGBA;
borderActive: RGBA;
borderSubtle: RGBA;
diffAdded: RGBA;
diffRemoved: RGBA;
diffContext: RGBA;
diffHunkHeader: RGBA;
diffHighlightAdded: RGBA;
diffHighlightRemoved: RGBA;
diffAddedBg: RGBA;
diffRemovedBg: RGBA;
diffContextBg: RGBA;
diffLineNumber: RGBA;
diffAddedLineNumberBg: RGBA;
diffRemovedLineNumberBg: RGBA;
markdownText: RGBA;
markdownHeading: RGBA;
markdownLink: RGBA;
markdownLinkText: RGBA;
markdownCode: RGBA;
markdownBlockQuote: RGBA;
markdownEmph: RGBA;
markdownStrong: RGBA;
markdownHorizontalRule: RGBA;
markdownListItem: RGBA;
markdownListEnumeration: RGBA;
markdownImage: RGBA;
markdownImageText: RGBA;
markdownCodeBlock: RGBA;
syntaxComment: RGBA;
syntaxKeyword: RGBA;
syntaxFunction: RGBA;
syntaxVariable: RGBA;
syntaxString: RGBA;
syntaxNumber: RGBA;
syntaxType: RGBA;
syntaxOperator: RGBA;
syntaxPunctuation: RGBA;
muted?: RGBA;
surface?: RGBA;
selectedListItemText?: RGBA;
/** Theme declares a transparent (terminal-bg-visible) background. */
transparent?: boolean;
layerBackgrounds?: {
layer0: RGBA;
layer1: RGBA;
layer2: RGBA;
layer3: RGBA;
};
_hasSelectedListItemText?: boolean;
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.
*
* This ensures children are NOT rendered until the theme is ready,
* preventing "useTheme must be used within a ThemeProvider" errors.
*
*/
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const appStore = useAppStore();
const renderer = useRenderer();
const [store, setStore] = createStore({
themes: THEME_JSON as Record<string, ThemeJson>,
mode: props.mode,
active: appStore.state().settings.theme as string,
system: undefined as undefined | TerminalColors,
ready: false,
});
function init() {
resolveSystemTheme();
loadThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom);
}),
);
})
.catch(() => {
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
if (store.active !== "system") {
setStore("ready", true);
}
});
}
async function waitForCapabilities(timeoutMs = 300) {
if (renderer.capabilities) return;
await new Promise<void>((resolve) => {
let done = false;
const onCaps = () => {
if (done) return;
done = true;
renderer.off("capabilities", onCaps);
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
if (done) return;
done = true;
renderer.off("capabilities", onCaps);
resolve();
}, timeoutMs);
renderer.on("capabilities", onCaps);
});
}
async function resolveSystemTheme() {
if (process.env.TMUX) {
await waitForCapabilities();
}
let colors: TerminalColors | null = null;
try {
colors = await renderer.getPalette({ size: 16 });
} catch {
colors = null;
}
if (!colors?.palette?.[0] && process.env.TMUX) {
const writeOut = (
renderer as unknown as {
writeOut?: (data: string | Buffer) => boolean;
}
).writeOut;
const writeFn =
typeof writeOut === "function"
? writeOut.bind(renderer)
: process.stdout.write.bind(process.stdout);
const detector = createTerminalPalette(
process.stdin,
process.stdout,
writeFn,
true,
);
try {
const tmuxColors = await detector.detect({ size: 16, timeout: 1200 });
if (tmuxColors?.palette?.[0]) {
colors = tmuxColors;
}
} finally {
detector.cleanup();
}
}
// ── 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)),
);
const hasDefaultColors = Boolean(
colors?.defaultBackground || colors?.defaultForeground,
);
if (!hasPalette && !hasDefaultColors) {
// 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.system = colors ?? EMPTY_TERMINAL_COLORS;
draft.ready = true;
}),
);
}
return;
}
if (colors) {
setStore(
produce((draft) => {
draft.system = colors;
if (store.active === "system") {
draft.ready = true;
}
}),
);
}
}
onMount(init);
// Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>`
const cleanupSignalHandler = setupThemeSignalHandler(() => {
renderer.clearPaletteCache();
init();
});
onCleanup(cleanupSignalHandler);
// Sync active theme with app store settings
createEffect(() => {
const theme = appStore.state().settings.theme;
if (theme) setStore("active", theme);
});
// Emit theme change events for observers
createEffect(() => {
const theme = store.active;
const mode = store.mode;
if (store.ready) {
emitThemeChanged(theme, mode);
}
});
const values = createMemo(() => {
return resolveTerminalTheme(
store.themes,
store.active,
store.mode,
store.system,
);
});
const syntax = createMemo(() =>
generateSyntax(values() as unknown as Record<string, RGBA>),
);
const subtleSyntax = createMemo(() =>
generateSubtleSyntax(
values() as unknown as Record<string, RGBA> & {
thinkingOpacity?: number;
},
),
);
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error - dynamic property access
return values()[prop];
},
}) as ThemeResolved,
get selected() {
return store.active;
},
all() {
return store.themes;
},
syntax,
subtleSyntax,
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);
},
set(theme: string) {
appStore.setTheme(theme as ThemeName);
},
get ready() {
return store.ready;
},
};
},
});

53
src/context/helper.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
/**
* Creates a simple context with automatic ready-state handling.
*
* This pattern ensures that child components are NOT rendered until the
* context's `ready` property is true (or undefined, meaning no ready check needed).
*
* This prevents the "useX must be used within a XProvider" errors that occur
* when child components try to use context values before the provider has
* finished async initialization.
*
* Usage:
* ```tsx
* export const { use: useMyContext, provider: MyProvider } = createSimpleContext({
* name: "MyContext",
* init: (props: { someProp: string }) => {
* const [ready, setReady] = createSignal(false)
* // ... async initialization ...
* return {
* get ready() { return ready() },
* // ... other values
* }
* },
* })
* ```
*/
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
// Use an arrow function accessor for the ready check to maintain reactivity.
// The getter `init.ready` reads from a store, so wrapping it in an
// accessor allows Solid to track changes reactively.
return (
// @ts-expect-error - ready may not exist on all context types
<Show when={init.ready === undefined || init.ready}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

View File

@@ -0,0 +1,513 @@
/**
* navigation-store — the yazi-style navigation model, as a plain Solid store.
*
* This module is deliberately free of JSX and of any `.tsx` page imports so it
* can be exercised directly by unit tests (`bun test`) without the OpenTUI JSX
* runtime (which is supplied only by the build-time @opentui/solid bun-plugin).
* The Solid provider wrapper (`useNavigation` / `NavigationProvider`) and the
* simple-context plumbing live in `NavigationContext.tsx`; the app imports the
* provider from there, tests import `createNavigation` directly from here.
*
* ── Model ────────────────────────────────────────────────────────────────
* The app horizontally lays out three columns per tab:
*
* parent | current | preview
*
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) 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.
*
* The tab list is the app's ROOT and participates in the same pane flow as
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:
*
* • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
* • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
* that tab's content becomes CURRENT and its hovered item PREVIEW
* (`enterTabContent`).
* • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
* own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
* OUT of the 3-pane view.
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
* stays (out of the panes — no-op).
*
* Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
* ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
* the parent/preview are derived. Search drills query→results; Player is a
* single now-playing pane under the tab list (2-pane, no preview). Every
* tab returns to the root via `h` at depth 0 (`backToTabRoot`).
*
* Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
* `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
*/
import { createSignal, batch } from "solid-js";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
export enum NavMode {
NORMAL = "NORMAL",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** The current content pane of the active tab, i.e. the focusable column
* (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
* pane 0. A tab switch made while focused on content resets `activePane` to
* this pane (unless already on the tab list). */
export const DEPTH_CENTER_PANE = 1 as PaneId;
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
* left of the active tab's content (1..n). It is the app's outermost pane:
* starting focus lives here, tab switches made from it keep focus on it, and
* swiping left past the first content pane returns to it. Swiping left again
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
// ── Depth stack ──────────────────────────────────────────────────────────────
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
* focused row index within that list. `ctx` optionally carries an id or
* payload the page needs to derive the list (e.g. a feed id). */
export type DepthFrame = {
kind: string;
ctx?: string;
focus: number;
};
// ── Selection store ───────────────────────────────────────────────────────────
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
// selection anchored at the focused index.
type SelectionMap = Record<string, Set<string>>;
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
/**
* Construct a fresh, self-contained navigation state graph.
*
* Exported (not just inlined into the Solid provider) so unit tests can build
* a nav graph inside a `createRoot` without rendering any provider tree.
*/
export function createNavigation() {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
// The root tab panel's cursor — which tab j/k is currently hovering. It is
// independent of `activeTab` until l/Enter activates it (activateTabCursor)
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
// just like any other yazi list: j/k move the cursor, Enter/l open.
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
// App focus starts on the tab list (the app root). `activePane` is always
// DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
// the `atRootTab` flag describe where focus sits (the tab is the CURRENT
// pane when at the root, and slides into the UP/parent pane once content is
// opened).
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
const [atRootTabSignal, setAtTabRoot] = createSignal(true);
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false);
// per-tab depth stack. Depth-tabs get a root frame on first visit.
const [stacks, setStacks] = createSignal<Partial<Record<TABS, DepthFrame[]>>>(
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
);
// per-pane focused index map (unused by depth-tabs, which read/write the
// top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
// pages). Keyed by `${tab}:${pane}`.
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
{},
);
const [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null);
/** Depth stack for a tab (always non-empty — every tab is a depth-tab). */
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
const ensureStack = (tab: TABS) => {
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
}
};
/** Apply all tab-switch side effects synchronously. Done here in the
* imperative `switchTab` path rather than a reactive `createEffect`
* because this module is exercised by unit tests without the OpenTUI
* JSX runtime, and in that environment Solid's `createEffect` is a
* no-op (server build). Routing every tab change through this helper
* keeps the behavior identical under both runtimes.
*
* - a depth-tab switch from the root keeps the root (the tab list stays
* CURRENT); switches made from inside content drop into the new tab's
* current/center pane.
* - clear mode/command/visual/count state */
const applyTabSwitch = (tab: TABS) => {
ensureStack(tab);
batch(() => {
if (atRootTabSignal()) {
// a depth-tab switch from the root keeps the root (focus stays on
// the tab list); only entering content (enterTabContent) leaves it.
} else {
setActivePane(DEPTH_CENTER_PANE);
}
setMode(NavMode.NORMAL);
setCount(null);
setCommandBuffer("");
setCommandError(null);
setVisualAnchor(null);
});
};
// ── depth stack accessors ──────────────────────────────────────────────
// Plain functions (not createMemo) so they recompute on every read.
// On the client build these are read inside reactive JSX contexts so
// their underlying signal reads are still tracked; on the server build
// (used by unit tests) createMemo is a no-op that freezes at creation,
// so a plain function is the only option that stays correct in tests.
const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
const currentDepth = (): number => Math.max(0, depthStack().length - 1);
const topFrame = (): DepthFrame | undefined =>
depthStack()[depthStack().length - 1];
const isDepthTab = () => DEPTH_TABS.has(activeTab());
/** Focus within a given depth's frame (default = current/top). */
const depthFocus = (d: number = currentDepth()) =>
depthStack()[d]?.focus ?? 0;
const setDepthFocus = (i: number, d: number = currentDepth()) =>
setStacks((s) => {
const st = s[activeTab()];
if (!st || d < 0 || d >= st.length) return s;
const next = st.slice();
next[d] = { ...next[d], focus: i };
return { ...s, [activeTab()]: next };
});
/** Push a child frame (drill in). */
const pushDepth = (frame: DepthFrame) =>
setStacks((s) => {
const st = s[activeTab()] ?? [];
return { ...s, [activeTab()]: [...st, frame] };
});
/** Pop the top frame (go back up a depth). No-op at root. Returns
* true if a frame was popped. */
const popDepth = (): boolean => {
let popped = false;
setStacks((s) => {
const st = s[activeTab()] ?? [];
if (st.length <= 1) return s;
popped = true;
return { ...s, [activeTab()]: st.slice(0, -1) };
});
return popped;
};
// ── tab switching ──────────────────────────────────────────────────────
/** Internal: set activeTab + run all side effects (root-frame seed,
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
* tab change — programmatic or key-driven — goes through one path. */
const switchTab = (tab: TABS) => {
setActiveTab(tab);
// a direct tab switch re-syncs the root panel's cursor so the panel
// reflects what is actually active.
setTabCursor(tab);
applyTabSwitch(tab);
};
const gotoTab = (tab: TABS) => {
if (tab < 1 || tab > TabsCount) return;
switchTab(tab);
};
const nextTab = () => {
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
switchTab(t);
};
const prevTab = () => {
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
switchTab(t);
};
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
// (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
// ── tab root (the app's outermost pane) ──────────────────────────────────
/** True while focus is on the tab list as the CURRENT pane — the app root,
* with nothing above it. Applies to every tab: a depth-tab switch from
* the root keeps it; entering content (`enterTabContent`) clears it; `h`
* at content depth 0 regains it via `backToTabRoot`. */
const atRootTab = (): boolean => atRootTabSignal();
/** Open the active tab's content: the tab slides from CURRENT into the
* UP/parent pane and focus lands on the content's current pane. */
const enterTabContent = () => {
setAtTabRoot(false);
setActivePane(DEPTH_CENTER_PANE);
};
/** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping
* out of content at depth 0. */
const backToTabRoot = () => {
setAtTabRoot(true);
setActivePane(DEPTH_CENTER_PANE);
};
/** 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);
};
/** Open the hovered tab (switch to it and enter its content) from the root.
* The yazi "open" of a tab row. */
const activateTabCursor = () => {
switchTab(tabCursorSignal());
enterTabContent();
};
// ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
/** For depth-tabs (every tab), pane 1 (DEPTH_CENTER_PANE) reads/writes
* the top frame's focus. Other panes fall back to the per-pane index
* map (unused by current pages). */
const focusedIndex = (pane: PaneId = activePane()): number => {
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
return topFrame()?.focus ?? 0;
}
return paneIndices()[paneKey(pane)] ?? 0;
};
const setFocusedIndex = (pane: PaneId, index: number) => {
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
setDepthFocus(index);
return;
}
setPaneIndices((m) => ({
...m,
[`${activeTab()}:${pane}`]: index,
}));
};
/** Apply a clamped relative motion to the active pane's focus. Returns
* the new index so callers can update their own scroll state. */
const move = (
delta: number,
listLen: number,
countOverride?: number,
): number => {
if (listLen <= 0) return 0;
const steps = countOverride ?? count() ?? 1;
const pane = activePane();
const cur = focusedIndex(pane);
let next = cur + delta * steps;
// wrap-around like yazi (arrow wraps top<->bottom)
next = ((next % listLen) + listLen) % listLen;
setFocusedIndex(pane, next);
// visual-mode range selection: add newly-traversed items to selection
if (HAS_VISUAL(mode()) && visualAnchor()) {
growVisualSelection(next);
}
return next;
};
const gotoIndex = (index: number, listLen: number): number => {
if (listLen <= 0) return 0;
const pane = activePane();
const next = Math.max(0, Math.min(listLen - 1, index));
setFocusedIndex(pane, next);
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
return next;
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> => selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
setSelections((m) => {
const set = new Set(m[key] ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
return { ...m, [key]: set };
});
};
const isSelected = (id: string) => selSet(paneKey()).has(id);
const clearSelection = (key?: string) => {
const k = key ?? paneKey();
setSelections((m) => {
if (!(k in m)) return m;
const next = { ...m };
delete next[k];
return next;
});
};
const selectedIds = () => [...selSet(paneKey())];
/** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => {
const pane = activePane();
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
setMode(NavMode.VISUAL);
};
/** Grow selection between the visual anchor and `index` for the active
* pane. Callers pass item ids aligned to indices; we store ids via the
* resolve callback registered per-pane (see registerResolver). */
let resolvers: Record<string, (index: number) => string | undefined> = {};
const registerResolver = (
key: string,
fn: (i: number) => string | undefined,
) => {
resolvers[key] = fn;
};
const growVisualSelection = (index: number) => {
const anchor = visualAnchor();
if (!anchor) return;
const resolve = resolvers[anchor.paneKey];
if (!resolve) return;
const lo = Math.min(anchor.index, index);
const hi = Math.max(anchor.index, index);
const ids: string[] = [];
for (let i = lo; i <= hi; i++) {
const id = resolve(i);
if (id) ids.push(id);
}
const key = anchor.paneKey;
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
};
// ── modes ────────────────────────────────────────────────────────────────
const enterCommand = () => {
setMode(NavMode.COMMAND);
setCommandBuffer("");
setCommandError(null);
};
const enterInput = () => setMode(NavMode.INPUT);
const exitCommand = () => {
batch(() => {
setMode(NavMode.NORMAL);
setCommandBuffer("");
setCommandError(null);
});
};
const exitVisual = () => {
batch(() => {
setMode(NavMode.NORMAL);
setVisualAnchor(null);
});
};
const toNormal = () => {
if (mode() === NavMode.VISUAL) {
clearSelection();
exitVisual();
} else {
setMode(NavMode.NORMAL);
}
};
// ── command buffer ───────────────────────────────────────────────────────
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
const submitCommand = (): string => {
const cmd = commandBuffer().trim();
exitCommand();
return cmd;
};
// ── count register ───────────────────────────────────────────────────────
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
const consumeCount = (): number => {
const c = count();
setCount(null);
return c ?? 1;
};
return {
activeTab,
activePane,
mode,
count,
inputFocused,
commandBuffer,
commandError,
visualAnchor,
selections,
selectedIds,
// depth stack
depthStack,
currentDepth,
topFrame,
depthFocus,
setDepthFocus,
pushDepth,
popDepth,
isDepthTab,
// tab
setActiveTab: gotoTab,
nextTab,
prevTab,
// tab root (app's outermost pane)
atRootTab,
enterTabContent,
backToTabRoot,
tabCursor,
setTabCursor: setTabCursorTo,
moveTabCursor,
activateTabCursor,
// pane focus
setActivePane: setPane,
// focus index
focusedIndex,
setFocusedIndex,
move,
gotoIndex,
// selection
isSelected,
toggleSelected,
clearSelection,
selectedIdsFor: (key: string) => [...selSet(key)],
registerResolver,
enterVisual,
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setInputFocused,
setMode,
enterCommand,
enterInput,
exitCommand,
toNormal,
// command buffer
setCommandBuffer,
appendCommand,
backspaceCommand,
submitCommand,
setCommandError,
// count
pushCountDigit,
consumeCount,
};
}
export type NavigationState = ReturnType<typeof createNavigation>;

560
src/hooks/useAudio.ts Normal file
View File

@@ -0,0 +1,560 @@
/**
* Reactive SolidJS hook wrapping the AudioBackend.
*
* Provides signals for playback state and methods for controlling
* audio. Integrates with the event bus and app store.
*
* Usage:
* ```tsx
* const audio = useAudio()
* audio.play(episode)
* <text>{audio.isPlaying() ? "Playing" : "Paused"}</text>
* ```
*/
import { createSignal, onCleanup } from "solid-js";
import {
createAudioBackend,
detectPlayers,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useFeedStore } from "../stores/feed";
export interface AudioControls {
// Signals (reactive getters)
isPlaying: () => boolean;
position: () => number;
duration: () => number;
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
availablePlayers: () => DetectedPlayer[];
// Actions
play: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
switchBackend: (name: BackendName) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
}
// Singleton state — shared across all components that call useAudio()
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(0.7);
const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null);
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
registerExitTeardown();
}
return backend;
}
// ── Process-exit teardown ─────────────────────────────────────────────
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
// player (mpv). Without this hook those child processes
// survive the host and keep playing audio after the TUI has quit. The
// `exit` event fires synchronously on `process.exit(N)`; the signal
// handlers cover Ctrl-C / kill, which otherwise terminate without running
// `exit` listeners.
let exitTeardownRegistered = false;
function registerExitTeardown(): void {
if (exitTeardownRegistered) return;
exitTeardownRegistered = true;
const teardown = (): void => {
stopPolling();
try {
backend?.dispose();
} catch {
/* best-effort at exit */
}
try {
useMediaRegistry().clearNowPlaying();
} catch {
/* best-effort at exit */
}
};
process.on("exit", teardown);
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(sig, () => {
teardown();
process.exit(0);
});
}
}
function startPolling(): void {
stopPolling();
pollCount = 0;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return;
try {
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms)
pollCount++;
if (pollCount % 10 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
// Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false);
stopPolling();
// Save final position on track end
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
}
}
} catch {
// Backend may have been disposed
}
}, 500);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
if (!episode.audioUrl) {
setError("No audio URL for this episode");
return;
}
try {
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
await b.play(episode.audioUrl, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
});
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
// Register with platform media controls
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed");
}
}
async function resume(): Promise<void> {
if (!backend) return;
try {
await backend.resume();
setIsPlaying(true);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Resume failed");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
await resume();
}
}
async function stop(): Promise<void> {
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
emit("player.stop", {});
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped);
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped);
// Sync back to app store
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
});
setIsPlaying(true);
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
/**
* Reactive audio controls hook.
*
* Returns a singleton — all components share the same playback state.
* Registers event bus listeners and cleans them up with onCleanup.
*/
export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend();
// Sync initial speed from app store
if (refCount === 0) {
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed);
}
}
refCount++;
// Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
});
const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
});
// Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback();
});
const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
const 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);
});
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const currentPos = position();
const currentDur = duration();
const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
} else {
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.prev(newIndex);
}
}
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.next(newIndex);
}
}
onCleanup(() => {
refCount--;
unsubPlay();
unsubStop();
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed();
if (refCount <= 0) {
stopPolling();
if (backend) {
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
const media = useMediaRegistry();
media.clearNowPlaying();
refCount = 0;
}
});
return {
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
availablePlayers,
play,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
switchBackend,
prev,
next,
};
}

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

@@ -0,0 +1,81 @@
/**
* Global multimedia key handler hook.
*
* Captures media-related key events (play/pause, volume, seek, 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.
*/
import { useKeyboard } from "@opentui/solid";
import { emit } from "../utils/event-bus";
export type MediaKeyAction =
| "media.toggle"
| "media.volumeUp"
| "media.volumeDown"
| "media.seekForward"
| "media.seekBackward"
| "media.speedCycle";
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;
}
/**
* Registers a global keyboard listener that emits media events on the
* event bus. Call once at the app level (e.g. in App.tsx).
*/
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
useKeyboard((key) => {
// Don't intercept when a text input owns the keyboard
if (options.inputFocused?.()) return;
// Don't intercept when Player component handles its own keys
if (options.playerFocused?.()) return;
// Ctrl/Meta combos are app-level shortcuts, not media keys
if (key.ctrl || key.meta) return;
switch (key.name) {
case "space":
// Toggle play/pause — always valid (may start a loaded episode)
emit("media.toggle", {});
break;
case "up":
emit("media.volumeUp", {});
break;
case "down":
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;
default:
// Not a media key — do nothing
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;
}

254
src/index.tsx Normal file
View File

@@ -0,0 +1,254 @@
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.3.1";
interface CliArgs {
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,
};
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;
}
const cliArgs = parseArgs();
if (cliArgs.version) {
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 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)
}
}
if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence")
.then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) {
handleQuery(feeds, cliArgs.query)
}
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");
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 },
);
});
}

BIN
src/native/libcavacore.dylib Executable file

Binary file not shown.

27
src/opentui-jsx.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
declare namespace JSX {
type Element = any
interface IntrinsicElements {
box: any
text: any
span: any
input: any
select: any
textarea: any
scrollbox: any
tab_select: any
ascii_font: any
code: any
diff: any
line_number: any
markdown: any
b: any
strong: any
i: any
em: any
u: any
br: any
a: any
}
}
export {}

View File

@@ -0,0 +1,398 @@
/**
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
*
* depth 0 (current) — category list. Parent pane shows the muted
* 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 `<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
* category so the preview follows.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const DiscoverPaneCount = 1;
function DiscoverPage() {
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
const categories = () => DISCOVER_CATEGORIES;
const podcasts = () => discoverStore.filteredPodcasts();
const focusedCatIdx = () =>
categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1);
const focusedCategory = createMemo(() => categories()[focusedCatIdx()]);
const focusedPodIdx = () =>
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
const curLen = () =>
depth() === 0 ? categories().length : podcasts().length;
const ensureFocus = () => {
if (categories().length > 0 && focus(0) >= categories().length)
nav.setDepthFocus(categories().length - 1, 0);
if (podcasts().length > 0 && focus(1) >= podcasts().length)
nav.setDepthFocus(podcasts().length - 1, 1);
};
onMount(ensureFocus);
// Auto-fetch the featured-shows manifest on first mount (network failure is
// non-fatal — the list stays empty until the user hits refresh).
onMount(() => {
discoverStore.refresh().catch(() => {});
});
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id;
});
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
const c = focusedCategory();
if (!c) return;
discoverStore.setSelectedCategory(c.id);
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 1) {
const pod = focusedPodcast();
if (pod) discoverStore.toggleSubscription(pod.id);
}
}
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
},
refresh: () => {
discoverStore.refresh().catch(() => {});
},
};
function step(delta: number) {
nav.move(delta, curLen());
// keep the store's selected category synced with the focused row at depth 0
if (depth() === 0) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
}
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () =>
depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane ───────────────────────────────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: categories */}
<Show when={depth() === 0}>
<For each={categories()}>
{(cat, index) => {
const lf = () => focusedCatIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
</box>
);
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<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={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text
fg={index() === lf() ? theme.surface : theme.success}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf() ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
);
}}
</For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
</Show>
</Show>
</>
);
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: shows for the hovered category
<Show
when={focusedCategory()}
fallback={
<box padding={1}>
<text fg={muted()}>No category focused</text>
</box>
}
>
{(cat) => (
<box flexDirection="column" gap={0} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{cat().name}</strong>
</text>
<Show when={(cat() as any).description}>
<text fg={theme.textSecondary}>{(cat() as any).description}</text>
</Show>
<box height={1} />
<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>
) : (
// depth ≥1 preview: hovered podcast + subscribe
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ?? "No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
</box>
)}
</Show>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}
export { DiscoverPage };

400
src/pages/Feed/FeedPage.tsx Normal file
View File

@@ -0,0 +1,400 @@
/**
* FeedPage — flat chronological list of episodes across all subscribed feeds.
*
* depth 0 (current) — every episode from every feed, newest-first (the
* combined view the old "All Feeds" virtual row used to
* drill into). Parent pane shows the muted tab list.
* preview — detail of the hovered episode.
*
* This page does NOT drill: the previous depth-1 "episodes of one feed" panel
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
* shows the full flat episodes list immediately.
*
* 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, 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";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const FeedPaneCount = 1;
type EpItem = { episode: Episode; feed: Feed };
function FeedPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
// ── 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 = () =>
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 (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}`,
(i) => episodes()[i]?.episode.id,
);
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (item: EpItem | undefined) => {
if (!item) return;
audio.play(item.episode).catch(() => {});
audioNav.setSource(AudioSource.FEED);
};
// ── open ───────────────────────────────────────────────────────────────────
function open() {
if (focusedOnMore()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
playEpisode(focusedItem());
}
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id);
},
refresh: () => {
feedStore.refreshAllFeeds().catch(() => {});
},
};
function step(delta: number) {
nav.move(delta, curLen());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
const parentContent = () => <TabListPane muted />;
// ── current pane: the flat episodes list (the only focusable column) ──────
const currentContent = () => (
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<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={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi() ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingLeft={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() ? "" : " "}
</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 label="Refreshing…" />
</box>
</Show>
</Show>
);
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const previewContent = () => (
<>
<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 across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
</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 · space: select · h back</text>
</box>
)}
</Show>
</Show>
</>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}
export { FeedPage };

View File

@@ -0,0 +1,439 @@
/**
* MyShowsPage — yazi depth-stack view of subscribed shows.
*
* depth 0 (current) — subscribed shows. Parent pane shows the muted
* 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 `<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 { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
const shows = () => feedStore.getFilteredFeeds();
const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
// depth-1 frame ctx = the drilled feed id
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
const episodes = createMemo<Episode[]>(() => {
if (depth() < 1) return [];
const id = drilledShowId();
const show = shows().find((s) => s.id === id);
if (!show) return [];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
const focusedEpisode = () => episodes()[focusedEpIdx()];
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
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);
};
onMount(ensureFocus);
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return shows()[i]?.id;
return episodes()[i]?.id;
});
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
const show = selectedShow();
if (!show) return;
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
return;
}
if (depth() >= 1) {
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
}
// ── nav.action ──────────────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
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());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
depth() === 0
? `Shows (${shows().length})`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<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() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane: the current-depth list ───────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<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={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf() ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Loading more…" />
</box>
</Show>
</Show>
</Show>
</>
);
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered show
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
</box>
)}
</Show>
) : (
// depth ≥1 preview: hovered episode
<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}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
</box>
)}
</Show>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}

View File

@@ -0,0 +1,85 @@
import type { BackendName } from "@/utils/audio-player";
import { useTheme } from "@/context/ThemeContext";
type PlaybackControlsProps = {
isPlaying: boolean;
volume: number;
speed: number;
backendName?: BackendName;
hasAudioUrl?: boolean;
onToggle: () => void;
onPrev: () => void;
onNext: () => void;
onVolumeChange: (value: number) => void;
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"
gap={1}
alignItems="center"
border
padding={1}
borderColor={theme.border}
>
<box
border
padding={0}
onMouseDown={props.onPrev}
borderColor={theme.border}
>
<text fg={theme.primary}>[Prev]</text>
</box>
<box
border
padding={0}
onMouseDown={props.onToggle}
borderColor={theme.border}
>
<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>
</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>
)}
</box>
);
}

View File

@@ -0,0 +1,127 @@
/**
* PlayerPage — 2-pane yazi depth view of the now-playing episode.
*
* depth 0 (parent) — tab list (muted, read-only).
* depth 0 (current) — the single now-playing pane (rich view + controls).
*
* 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.
*/
import { Show } from "solid-js";
import { PlaybackControls } from "./PlaybackControls";
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 { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
export const PlayerPaneCount = 1;
export function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const muted = () => theme.muted || theme.text;
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const progressPercent = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
// ── parent pane: the tab list (muted) ──────────────────────────────────────
const parentContent = () => <TabListPane muted />;
// ── current pane: now playing ───────────────────────────────────────────────
const currentContent = () => (
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={muted()}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box>
<Show when={audio.error()}>
{(err) => <text fg={theme.error}>{err()}</text>}
</Show>
<Show
when={audio.currentEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode loaded.</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
// bars is width-derived in RealtimeWaveform; pass only the
// audio-processing params here.
return {
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
)}
</Show>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
onToggle={audio.togglePlayback}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<box height={1} />
<text fg={muted()}>
{"P play/pause N next B prev ◀▶ seek h back"}
</text>
</box>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
currentLabel="Player"
panes={2}
focused={isActive}
/>
);
}

View File

@@ -0,0 +1,272 @@
/**
* RealtimeWaveform — live audio frequency visualization using cavacore.
*
* Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps.
*/
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
const { theme } = useTheme();
const audio = useAudio();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
// Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
// chrome (scrollbox border + box padding + waveform border + padding).
// Falls back to 64 before the renderer reports a real size.
const dimensions = useTerminalDimensions();
const numBars = () => {
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
const width = dimensions().width;
if (!width) return 64;
return Math.max(
8,
Math.min(256, Math.floor((width * current) / total) - 8),
);
};
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
return false;
}
return true;
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides.
// bars is width-derived (see numBars); visualizerConfig supplies the
// audio-processing params (noise reduction, cutoffs, etc.).
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
};
cava.init(config);
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy();
}
sampleBuffer = null;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
const count = reader.read(sampleBuffer);
if (count === 0) return;
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
// Copy bar values to a new array for the signal
setBarData(Array.from(output as Float64Array));
};
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "",
audio.speed,
numBars,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(audio.position);
startVisualization(url, pos, speed);
} else {
stopVisualization();
}
},
),
);
// ── Seek detection: lightweight effect for position jumps ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0;
createEffect(
on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1);
}
}),
);
onCleanup(() => {
stopVisualization();
if (reader) {
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy();
}
});
// ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
const renderLine = () => {
const bars = barData();
const count = numBars();
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{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("");
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</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}
>
{renderLine()}
</box>
);
}

View File

@@ -0,0 +1,448 @@
/**
* SearchPage — yazi depth-stack view of podcast search.
*
* depth 0 (current) — query input row + recent-searches list (navigable
* with j/k when the input is defocused). Parent pane
* shows the tab list (muted); preview shows a hint.
* depth 1 (current) — search results list. Parent pane shows the submitted
* query (muted, read-only); preview shows the detail of
* the focused result.
*
* 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
* input (or on a focused recent at depth 0) submits the query and pushes to
* depth 1 (results). `h` pops: results→query, query→tab root.
*/
import {
createSignal,
createMemo,
createEffect,
For,
Show,
onMount,
onCleanup,
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const SearchPaneCount = 1;
function SearchPage() {
const searchStore = useSearchStore();
const feedStore = useFeedStore();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
// depth 1's ctx carries the submitted query string.
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell
// 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 effect only re-runs on a depth transition, so Escape (defocus) and
// `s` (refocus) at the same depth are not clobbered.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
createEffect(() => {
nav.setInputFocused(depth() === 0);
});
// ── results (depth 1) ─────────────────────────────────────────────────────
const results = () => searchStore.results();
const focusedResultIdx = () =>
results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
const focusedResult = createMemo(() => {
const list = results();
if (list.length === 0) return undefined;
return list[focusedResultIdx()];
});
// ── recents (depth 0) ────────────────────────────────────────────────────
const recents = () => searchStore.history();
const curLen = () => (depth() === 0 ? recents().length : results().length);
const ensureFocus = () => {
if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
nav.setDepthFocus(results().length - 1, 1);
};
onMount(ensureFocus);
// 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);
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const runSearch = (query: string) => {
const q = query.trim();
if (!q) return;
searchStore.search(q).catch(() => {});
nav.pushDepth({
kind: "search:results",
ctx: q,
focus: 0,
} as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
};
const handleSubmit = () => runSearch(inputValue());
const selectRecent = (query: string) => {
setInputValue(query);
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);
};
// ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() === 1) {
const r = focusedResult();
if (r) nav.toggleSelected(r.podcast.id);
}
},
search: () => {
// `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true);
},
refresh: () => {
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
},
};
function step(delta: number) {
nav.move(delta, curLen());
}
function open() {
if (depth() === 0) {
// Enter/l on a focused recent search → submit it and drill to results.
const list = recents();
const idx = Math.min(focus(0), list.length - 1);
const q = list[idx];
if (q) selectRecent(q);
return;
}
if (depth() === 1) {
const r = focusedResult();
if (r) handleSubscribe(r);
}
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const inputActive = () => nav.inputFocused() && depth() === 0;
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
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>
);
// ── current pane ────────────────────────────────────────────────────────────
const currentContent = () => (
<>
<Show when={depth() === 0}>
{/* query input row + recent searches */}
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text>
<input
value={inputValue()}
onInput={setInputValue}
onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..."
focused={inputActive()}
width={28}
/>
</box>
<Show when={searchStore.isSearching()}>
<LoadingIndicator label="Searching…" />
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>Recent</text>
<Show
when={recents().length > 0}
fallback={
<text fg={muted()}>
{inputActive()
? "Enter to search"
: "s to type · Enter to search"}
</text>
}
>
<For each={recents()}>
{(query, index) => {
const lf = () => focus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
</box>
);
}}
</For>
</Show>
<box height={1} />
<text fg={muted()}>
{inputActive()
? "Enter to search · Esc to defocus"
: "j/k recents · s to type · h back"}
</text>
</box>
</Show>
<Show when={depth() >= 1}>
{/* results list */}
<Show
when={results().length > 0}
fallback={
<box padding={1}>
<Show
when={searchStore.isSearching()}
fallback={
<text fg={muted()}>
{searchStore.query()
? "No results found"
: "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={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title}
</text>
<Show when={result.podcast.isSubscribed}>
<text
fg={index() === fi() ? theme.surface : theme.success}
>
[+]
</text>
</Show>
</box>
<Show when={result.podcast.author}>
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
</box>
);
}}
</For>
</Show>
</Show>
</>
);
// ── preview pane ────────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>Search</strong>
</text>
<text fg={muted()}>Type a query, press Enter to search.</text>
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
<box height={1} />
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
<For each={recents().slice(0, 6)}>
{(q) => <text fg={muted()}> {q}</text>}
</For>
</box>
) : (
<Show
when={focusedResult()}
fallback={
<box padding={1}>
<text fg={muted()}>No result focused</text>
</box>
}
>
{(result) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<Show when={result().podcast.author}>
<text fg={muted()}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400)}
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</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>
)}
</Show>
);
const currentLabel = () =>
depth() === 0
? `Search · ${recents().length} recent`
: `Results · ${results().length}`;
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}
export { SearchPage };

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

@@ -0,0 +1,55 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value;
return [
() => current,
(next) => {
current = next;
},
];
};
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");
// 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
ref={filenameRef}
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>
);
}

View File

@@ -0,0 +1,28 @@
import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = {
value: string;
onChange: (value: string) => void;
};
export function FilePicker(props: FilePickerProps) {
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
ref={inputRef}
value={props.value}
onInput={props.onChange}
placeholder="/path/to/sync-file.json"
style={{ width: 40 }}
/>
<text fg={theme.text}>Format: {format}</text>
</box>
);
}

View File

@@ -0,0 +1,23 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
import { FilePicker } from "./FilePicker"
import { useTheme } from "@/context/ThemeContext"
export function ImportDialog() {
const { theme } = useTheme();
const filePath = createSignal("")
return (
<box border title="Import" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<FilePicker value={filePath[0]()} onChange={filePath[1]} />
<box border borderColor={theme.border}>
<text fg={theme.text}>Import selected file</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,133 @@
/**
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
* driven by the Shell router via nav.action.
*/
import { useAppStore } from "@/stores/app";
import type { ThemeName } from "@/types/settings";
import type { SettingItem } from "./types";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "system", label: "System" },
{ value: "catppuccin", label: "Catppuccin" },
{ value: "gruvbox", label: "Gruvbox" },
{ value: "tokyo", label: "Tokyo" },
{ value: "nord", label: "Nord" },
{ value: "custom", label: "Custom" },
];
export function usePreferencesItems(): SettingItem[] {
const app = useAppStore();
const settings = () => app.state().settings;
const prefs = () => app.state().preferences;
return [
{
id: "theme",
label: "Theme",
kind: "select",
display: () =>
THEME_LABELS.find((t) => t.value === settings().theme)?.label ??
settings().theme,
help: () =>
`Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length;
app.setTheme(THEME_LABELS[next].value);
},
},
{
id: "transparentBackground",
label: "Transparent Background",
kind: "toggle",
display: () =>
settings().transparentBackground ? "On" : "Off",
help: () =>
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateSettings({
transparentBackground: !settings().transparentBackground,
}),
},
{
id: "fontSize",
label: "Font Size",
kind: "number",
display: () => `${settings().fontSize}px`,
help: () =>
`Terminal font size in pixels.\nType: number (1020)\nDefault: 14\nCurrent: ${settings().fontSize}\nj/k to /+1px.`,
cycle: (dir) => {
const next = Math.min(20, Math.max(10, settings().fontSize + dir));
app.updateSettings({ fontSize: next });
},
},
{
id: "playbackSpeed",
label: "Playback Speed",
kind: "number",
display: () => `${settings().playbackSpeed}x`,
help: () =>
`Default audio playback speed.\nType: number (0.52.0)\nDefault: 1.0\nCurrent: ${settings().playbackSpeed}\nj/k to /+0.1.`,
cycle: (dir) => {
const next = Math.min(
2,
Math.max(0.5, settings().playbackSpeed + dir * 0.1),
);
app.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
},
},
{
id: "showExplicit",
label: "Show Explicit",
kind: "toggle",
display: () => (prefs().showExplicit ? "On" : "Off"),
help: () =>
`Whether to list explicit episodes.\nType: toggle\nDefault: true\nCurrent: ${prefs().showExplicit}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
showExplicit: !prefs().showExplicit,
}),
},
{
id: "autoDownload",
label: "Auto Download",
kind: "toggle",
display: () => (prefs().autoDownload ? "On" : "Off"),
help: () =>
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoDownload: !prefs().autoDownload,
}),
},
{
id: "autoJumpToPlayer",
label: "Auto Jump to Player",
kind: "toggle",
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
help: () =>
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
{
id: "fetchMore",
label: "Fetch More",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
];
}

View File

@@ -0,0 +1,534 @@
/**
* SettingsPage — yazi depth-stack settings.
*
* depth 0 — sections list (Sync / Sources / Preferences / Visualizer / ...)
* depth 1 — the focused section's items as a navigable list
* depth 2 — per-item editor (for editor-kind items) or value adjuster
*
* Renders entirely through `<PaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2);
* blank placeholder at depth 0 (1/5 slot kept).
* current = the current-depth list (or editor at depth 2); the only
* focusable column.
* preview = help/preview text for the hovered item in current.
*
* All movement comes from the Shell router over `nav.action` (j/k move,
* Enter/l drill, h back). Panels no longer register their own useKeyboard —
* that was the root cause of the old right-pane key conflicts.
*/
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
import { rgbToHex, type RGBA } from "@opentui/core";
import { useTheme, type ThemeResolved } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel";
import { useVisualizerItems } from "./VisualizerSettings";
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
import { useSourceItems } from "./SourceManager";
import { useDownloadItems } from "./DownloadManager";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const SettingsPaneCount = 1;
const SECTIONS: SettingsSectionDef[] = [
{
id: 0,
label: "Sync",
description: "Import/export subscriptions and sync status.",
},
{
id: 1,
label: "Sources",
description: "Podcast search/RSS sources — add, enable, remove.",
},
{
id: 2,
label: "Preferences",
description: "Theme, font, playback speed, explicit/auto-download.",
},
{
id: 3,
label: "Visualizer",
description: "Audio visualizer: bars, sensitivity, cutoffs.",
},
{
id: 4,
label: "Downloads",
description: "Manage downloaded episodes — delete by show or individually.",
},
];
/** Resolve the items for a section id at render time. */
function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) {
case 0:
return useSyncItems();
case 1:
return useSourceItems();
case 2:
return usePreferencesItems();
case 3:
return useVisualizerItems();
case 4:
return useDownloadItems();
default:
return [];
}
}
export function SettingsPage() {
const { theme } = useTheme();
const nav = useNavigation();
const stack = nav.depthStack;
const depth = nav.currentDepth;
// ── depth 0: sections ────────────────────────────────────────────────────
const focusedSectionIdx = () =>
Math.min(nav.depthFocus(0), SECTIONS.length - 1);
const focusedSection = () => SECTIONS[focusedSectionIdx()] ?? SECTIONS[0];
// ── depth ≥1: section items (resolved from the section id stored in the
// depth-0 frame's ctx). The depth-1 frame kind is "settings:<id>". ────
const sectionForDepth1 = (): SettingsSectionDef | undefined => {
const f = stack()[1];
if (!f) return undefined;
const id = Number(f.ctx ?? "0");
return SECTIONS[id];
};
const items = createMemo<SettingItem[]>(() => {
const sec = sectionForDepth1();
if (!sec) return [];
return sectionItems(sec.id);
});
const focusedItemIdx = () =>
items().length === 0 ? 0 : Math.min(nav.depthFocus(1), items().length - 1);
const focusedItem = (): SettingItem | undefined => items()[focusedItemIdx()];
// ── depth 2: the editor item (resolved from depth-1 frame ctx + item id) ─
const editorItem = (): SettingItem | undefined => {
const f1 = stack()[1];
const f2 = stack()[2];
if (!f1 || !f2) return undefined;
const secId = Number(f1.ctx ?? "0");
const list = sectionItems(secId);
return list.find((it) => it.id === f2.ctx);
};
// ── drill / open dispatch ───────────────────────────────────────────────
function open() {
const d = depth();
if (d === 0) {
// drill into the focused section's items
const id = focusedSection().id;
nav.pushDepth({
kind: `settings:${id}`,
ctx: String(id),
focus: 0,
});
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (d === 1) {
const it = focusedItem();
if (!it) return;
switch (it.kind) {
case "toggle":
it.toggle?.();
return;
case "action":
it.run?.();
return;
case "info":
return;
case "editor":
case "number":
case "select":
nav.pushDepth({
kind: `settings:item:${it.id}`,
ctx: it.id,
focus: 0,
});
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
}
if (d === 2) {
// in an editor: Enter adjusts/cycles a number/select forward, toggles
const it = editorItem();
if (!it) return;
if (it.kind === "number" || it.kind === "select") it.cycle?.(1);
else if (it.kind === "toggle") it.toggle?.();
return;
}
}
// ── movement (j/k etc.) routed by the Shell over nav.action ───────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, len()),
"goto-bottom": () => nav.gotoIndex(len() - 1, len()),
open: () => open(),
};
function len(): number {
const d = depth();
if (d === 0) return SECTIONS.length;
if (d === 1) return items().length;
return 0; // depth 2 editor: no list length; j/k cycles instead
}
function step(delta: number) {
const d = depth();
if (d === 2) {
// editor: j/k nudges the value
const it = editorItem();
if (it?.kind === "number" || it?.kind === "select")
it.cycle?.(delta as -1 | 1);
return;
}
nav.move(delta, len());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
// ignore actions meant for non-center panes
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
const handler = PAGE_ACTIONS[data.action];
if (handler) handler();
};
onMount(() => {
on("nav.action", onAction);
// keep a resolver so visual-mode range selection grows by section/item id
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
const d = depth();
if (d === 0) return SECTIONS[i]?.id.toString();
if (d === 1) return items()[i]?.id;
return undefined;
});
});
onCleanup(() => off("nav.action", onAction));
// when leaving a sync editor (h to pop), close any open dialog overlay
onCleanup(() => closeSyncEditor());
// ── render helpers ───────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Whether the currently-focused settings row is the Theme select — the
// only item whose Detail pane carries a color breakdown below the help text.
const isThemeItem = () => {
const d = depth();
if (d === 1) return focusedItem()?.id === "theme";
if (d === 2) return editorItem()?.id === "theme";
return false;
};
// preview text for the right column
const previewText = createMemo<string>(() => {
const d = depth();
if (d === 0) {
return `${focusedSection().label}\n\n${focusedSection().description}\n\nDrill in (Enter/l) to open this section's settings.`;
}
if (d === 1) {
const it = focusedItem();
return it?.help() ?? "No item.";
}
// editor: same help, plus note
const it = editorItem();
return it
? `${it.help()}\n\n— Editor —\nj/k adjust · h back`
: "No editor.";
});
// ── column label ───────────────────────────────────────────────────────────
const currentLabel = () => {
const d = depth();
if (d === 0) return "Settings";
if (d === 1) return sectionForDepth1()?.label ?? "Items";
return editorItem()?.label ?? "Editor";
};
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
// mounts every branch once and toggles children on depth change — the
// known-good opentui disposal pattern. A ternary returning different
// roots leaves subtree orphaned on swap; the trick is a STABLE fragment
// root whose inner <Show> children swap instead.
const parentContent = () => (
<>
<Show when={depth() === 0}>
{/* app root: the tab list as the parent (muted) at the lowest depth */}
<TabListPane muted />
</Show>
<Show when={depth() === 1}>
{/* previous depth = sections list (read-only) */}
<For each={SECTIONS}>
{(section, index) => (
<Row
label={section.label}
focused={index() === focusedSectionIdx()}
active={false}
/>
)}
</For>
</Show>
<Show when={depth() === 2}>
{/* previous depth = items list (read-only) */}
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label} ${it.display()}`}
focused={index() === focusedItemIdx()}
active={false}
/>
)}
</For>
</Show>
</>
);
// ── current pane: current-depth list (or editor at depth 2) ───────────────
const currentContent = () => (
<>
<Show when={depth() === 0}>
<For each={SECTIONS}>
{(section, index) => (
<Row
label={section.label}
focused={index() === focusedSectionIdx()}
active={isActive()}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
</Show>
<Show when={depth() === 1}>
<box flexDirection="column">
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label}`}
value={it.display()}
focused={index() === focusedItemIdx()}
active={isActive()}
hint={hintFor(it)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
/>
)}
</For>
<Show when={items().length === 0}>
<box padding={1}>
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
</box>
</Show>
</box>
</Show>
<Show when={depth() === 2}>
{/* depth 2: editor */}
<Show
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
>
{editorItem()!.renderEditor!()}
</Show>
</Show>
</>
);
// ── preview pane ──────────────────────────────────────────────────────────
const previewContent = () => (
<box padding={1} flexDirection="column">
{/* Keep everything on a stable root so Solid re-resolves the swap
between plain help text and the theme breakdown on focus move. */}
<Show when={isThemeItem()} fallback={<MultiLine text={previewText()} />}>
<MultiLine text={previewText()} />
<ThemeBreakdown />
</Show>
</box>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}
/** Per-kind hint glyph shown at the right of an item row. */
function hintFor(it: SettingItem): string {
switch (it.kind) {
case "toggle":
return "⏻";
case "number":
case "select":
return "±";
case "action":
return "↵";
case "editor":
return "→";
case "info":
return "·";
}
}
function Row(props: {
label: string;
value?: string;
focused: boolean;
active: boolean;
hint?: string;
onMouseDown?: () => void;
}) {
const { theme } = useTheme();
const bg = () =>
props.focused && props.active
? theme.primary
: props.focused
? theme.border
: undefined;
const fg = () =>
props.focused && props.active
? theme.surface
: props.focused
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.focused ? "" : " "}</text>
<text fg={fg()}>{props.label}</text>
<Show when={props.value}>
<box flexGrow={1} />
<text fg={props.focused ? fg() : theme.textMuted}>{props.value}</text>
</Show>
<Show when={props.hint}>
<text fg={theme.textMuted}>{props.hint}</text>
</Show>
</box>
);
}
/** Center editor for number/select/toggle items without a bespoke renderer. */
function GenericEditor(props: { item: SettingItem }) {
const { theme } = useTheme();
const it = props.item;
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>{it.label}</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Value:</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{it.display()}</text>
</box>
</box>
<Show when={it.kind === "number" || it.kind === "select"}>
<text fg={theme.muted ?? theme.textMuted}>
j/k to adjust · Enter to nudge forward · h to go back
</text>
</Show>
<Show when={it.kind === "toggle"}>
<text fg={theme.muted ?? theme.textMuted}>
Enter/Space to toggle · h to go back
</text>
</Show>
</box>
);
}
/** Curated theme color roles shown in the Theme breakdown. */
const THEME_ROLES: Array<{ key: keyof ThemeResolved; label: string }> = [
{ key: "primary", label: "Primary" },
{ key: "secondary", label: "Secondary" },
{ key: "accent", label: "Accent" },
{ key: "text", label: "Text" },
{ key: "textMuted", label: "Muted" },
{ key: "background", label: "Background" },
{ key: "surface", label: "Surface" },
{ key: "border", label: "Border" },
{ key: "error", label: "Error" },
{ key: "warning", label: "Warning" },
{ key: "success", label: "Success" },
{ key: "info", label: "Info" },
];
/** Color swatch breakdown (block <Label> (<HEX>)) of the resolved theme. */
function ThemeBreakdown() {
const { theme, selected } = useTheme();
return (
<box flexDirection="column" paddingTop={1} gap={1}>
<text fg={theme.accent}>Theme · {selected}</text>
<For each={THEME_ROLES}>
{(role) => {
const color = theme[role.key] as RGBA | undefined;
return (
<box flexDirection="row" gap={1} alignItems="center">
<box backgroundColor={color}>
<text>{" "}</text>
</box>
<text fg={theme.text}>{role.label}</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>
{color ? rgbToHex(color).toUpperCase() : "n/a"}
</text>
</box>
);
}}
</For>
</box>
);
}
/** Renders a string with `\n` newlines as stacked <text> lines. */
function MultiLine(props: { text: string }) {
const lines = () => props.text.split("\n");
const { theme } = useTheme();
return (
<For each={lines()}>
{(line, i) => (
<text fg={i() === 0 ? theme.accent : theme.textMuted}>
{line || " "}
</text>
)}
</For>
);
}

View File

@@ -0,0 +1,147 @@
/**
* SourceManager — exposes podcast sources as SettingItems for the depth-stack.
*
* • "Add Source" — an editor item; drilling in shows a name/URL add form.
* • Each source — a toggle item (Space toggles enabled) whose display shows
* the source type and on/off state.
*
* Advanced per-API-source options (country/language/explicit) are flattened to
* simple toggles/cycles reachable by drilling into the source's editor.
* Movement flows through nav.action — no own useKeyboard (avoids the old
* right-pane key conflicts).
*/
import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types";
export function useSourceItems(): SettingItem[] {
const feedStore = useFeedStore();
const typeBadge = (s: PodcastSource) =>
s.type === SourceType.API
? "[API]"
: s.type === SourceType.RSS
? "[RSS]"
: "[?]";
const items: SettingItem[] = [
{
id: "add",
label: "Add Source",
kind: "editor",
display: () => "+",
help: () =>
`Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`,
renderEditor: () => <AddSourceForm />,
},
];
for (const s of feedStore.sources()) {
items.push({
id: `src:${s.id}`,
label: s.name,
kind: "toggle",
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
help: () =>
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
toggle: () => feedStore.toggleSource(s.id),
});
}
return items;
}
function AddSourceForm() {
const feedStore = useFeedStore();
const { theme } = useTheme();
const [name, setName] = createSignal("");
const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
// Yield navigation keybinds to the Shell router while either input is focused.
const nameRef = useInputFocusNav();
const urlRef = useInputFocusNav();
const submit = () => {
const u = url().trim();
if (!u) {
setError("URL is required");
return;
}
try {
new URL(u);
} catch {
setError("Invalid URL format");
return;
}
feedStore.addSource({
name: name().trim() || "Custom Source",
type: SourceType.RSS,
baseUrl: u,
enabled: true,
description: `Custom RSS feed: ${u}`,
});
setName("");
setUrl("");
setError(null);
};
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>Add Source</strong>
</text>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Name:</text>
<input
ref={nameRef}
value={name()}
onInput={setName}
placeholder="My Custom Feed"
width={25}
/>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>URL:</text>
<input
ref={urlRef}
value={url()}
onInput={(v) => {
setUrl(v);
setError(null);
}}
placeholder="https://example.com/feed.rss"
width={35}
/>
</box>
<box
border
borderColor={theme.border}
padding={0}
width={15}
onMouseDown={submit}
>
<text fg={theme.primary}>[+] Add</text>
</box>
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
<Show when={feedStore.sources().length > 0}>
<box flexDirection="column" marginTop={1}>
<text fg={theme.textMuted}>
Current sources ({feedStore.sources().length}):
</text>
<For each={feedStore.sources()}>
{(s) => (
<text fg={theme.textMuted}>
{s.enabled ? "●" : "○"} {s.name}
</text>
)}
</For>
</box>
</Show>
</box>
);
}

View File

@@ -0,0 +1,18 @@
import { useTheme } from "@/context/ThemeContext"
type SyncErrorProps = {
message: string
onRetry: () => void
}
export function SyncError(props: SyncErrorProps) {
const { theme } = useTheme();
return (
<box border title="Error" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<text fg={theme.text}>{props.message}</text>
<box border borderColor={theme.border} onMouseDown={props.onRetry}>
<text fg={theme.text}>Retry</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,43 @@
/**
* SyncPanel — exposes Import / Export / status as SettingItems. The Import and
* Export dialogs render as depth-2 editors. No own useKeyboard.
*/
import { ImportDialog } from "./ImportDialog";
import { ExportDialog } from "./ExportDialog";
import type { SettingItem } from "./types";
// closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
// (the syncEditor signal) was removed as dead — nothing ever read it.
export function closeSyncEditor() {}
export function useSyncItems(): SettingItem[] {
return [
{
id: "import",
label: "Import",
kind: "editor",
display: () => "→",
help: () =>
`Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`,
renderEditor: () => <ImportDialog />,
},
{
id: "export",
label: "Export",
kind: "editor",
display: () => "→",
help: () =>
`Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`,
renderEditor: () => <ExportDialog />,
},
{
id: "status",
label: "Status",
kind: "info",
display: () => "Idle",
help: () =>
`Last sync status. (Sync is run from the import/export dialogs.)\nType: info`,
},
];
}

View File

@@ -0,0 +1,28 @@
import { useTheme } from "@/context/ThemeContext"
type SyncProgressProps = {
value: number
}
export function SyncProgress(props: SyncProgressProps) {
const { theme } = useTheme();
const width = 30
let filled = (props.value / 100) * width
filled = filled >= 0 ? filled : 0
filled = filled <= width ? filled : width
filled = filled | 0
if (filled < 0) filled = 0
if (filled > width) filled = width
let bar = ""
for (let i = 0; i < width; i += 1) {
bar += i < filled ? "#" : "-"
}
return (
<box style={{ flexDirection: "column" }}>
<text fg={theme.text}>{bar}</text>
<text fg={theme.text}>{props.value}%</text>
</box>
)
}

View File

@@ -0,0 +1,52 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
import { SyncProgress } from "./SyncProgress"
import { SyncError } from "./SyncError"
import { useTheme } from "@/context/ThemeContext"
type SyncState = "idle" | "syncing" | "complete" | "error"
export function SyncStatus() {
const { theme } = useTheme();
const state = createSignal<SyncState>("idle")
const message = createSignal("Idle")
const progress = createSignal(0)
const toggle = () => {
if (state[0]() === "idle") {
state[1]("syncing")
message[1]("Syncing...")
progress[1](40)
} else if (state[0]() === "syncing") {
state[1]("complete")
message[1]("Sync complete")
progress[1](100)
} else if (state[0]() === "complete") {
state[1]("error")
message[1]("Sync failed")
} else {
state[1]("idle")
message[1]("Idle")
progress[1](0)
}
}
return (
<box border title="Sync Status" borderColor={theme.border} style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Status:</text>
<text fg={theme.text}>{message[0]()}</text>
</box>
<SyncProgress value={progress[0]()} />
{state[0]() === "error" ? <SyncError message={message[0]()} onRetry={() => toggle()} /> : null}
<box border borderColor={theme.border} onMouseDown={toggle}>
<text fg={theme.text}>Cycle Status</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,81 @@
/**
* VisualizerSettings — exposes bars/sensitivity/noise/lowCut/highCut as
* SettingItems for the yazi depth-stack. No own useKeyboard.
*/
import { useAppStore } from "@/stores/app";
import type { SettingItem } from "./types";
export function useVisualizerItems(): SettingItem[] {
const app = useAppStore();
const viz = () => app.state().settings.visualizer;
return [
{
id: "bars",
label: "Bars",
kind: "number",
display: () => String(viz().bars),
help: () =>
`Number of visualizer bars.\nType: number (8128, step 8)\nDefault: 64\nCurrent: ${viz().bars}\nj/k to /+8.`,
cycle: (dir) =>
app.updateVisualizer({
bars: Math.min(128, Math.max(8, viz().bars + dir * 8)),
}),
},
{
id: "sensitivity",
label: "Auto Sensitivity",
kind: "toggle",
display: () => (viz().sensitivity === 1 ? "On" : "Off"),
help: () =>
`Automatic gain sensitivity.\nType: toggle\nDefault: on\nCurrent: ${viz().sensitivity === 1 ? "on" : "off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateVisualizer({
sensitivity: viz().sensitivity === 1 ? 0 : 1,
}),
},
{
id: "noiseReduction",
label: "Noise Reduction",
kind: "number",
display: () => viz().noiseReduction.toFixed(2),
help: () =>
`FFT noise reduction factor.\nType: number (0.001.00, step 0.05)\nDefault: 0.20\nCurrent: ${viz().noiseReduction.toFixed(2)}\nj/k to /+0.05.`,
cycle: (dir) =>
app.updateVisualizer({
noiseReduction: Math.min(
1,
Math.max(0, Number((viz().noiseReduction + dir * 0.05).toFixed(2))),
),
}),
},
{
id: "lowCutOff",
label: "Low Cutoff",
kind: "number",
display: () => `${viz().lowCutOff} Hz`,
help: () =>
`Lower frequency cutoff.\nType: number (20500 Hz, step 10)\nDefault: 20\nCurrent: ${viz().lowCutOff}\nj/k to /+10.`,
cycle: (dir) =>
app.updateVisualizer({
lowCutOff: Math.min(500, Math.max(20, viz().lowCutOff + dir * 10)),
}),
},
{
id: "highCutOff",
label: "High Cutoff",
kind: "number",
display: () => `${viz().highCutOff} Hz`,
help: () =>
`Upper frequency cutoff.\nType: number (100020000 Hz, step 500)\nDefault: 20000\nCurrent: ${viz().highCutOff}\nj/k to /+500.`,
cycle: (dir) =>
app.updateVisualizer({
highCutOff: Math.min(
20000,
Math.max(1000, viz().highCutOff + dir * 500),
),
}),
},
];
}

View File

@@ -0,0 +1,45 @@
/**
* Settings item model — each settings section exposes a list of items that the
* SettingsPage renders through the yazi depth-stack (sections → items → editor).
*
* All movement flows through the Shell's nav.action router (j/k move, Enter/l
* drill, h back), so panels no longer register their own useKeyboard — that was
* the root cause of the "right pane ignores keys / double-handled input" bugs.
*/
import type { JSX } from "solid-js";
export type SettingItemKind =
| "toggle"
| "number"
| "select"
| "action"
| "editor"
| "info";
export interface SettingItem {
/** Stable id within its section. */
id: string;
/** One-line label shown in the items list. */
label: string;
/** Category — decides how the item is interacted with. */
kind: SettingItemKind;
/** Current value as a short string (shown to the right of the label). */
display: () => string;
/** Help text for the preview pane: description, type, default, current. */
help: () => string;
/** For number/select: nudge the value by -1 or +1 (j/k at depth 2). */
cycle?: (dir: -1 | 1) => void;
/** For toggle: flip the value (Space/Enter at depth 1). */
toggle?: () => void;
/** For action: run immediately (Enter at depth 1). */
run?: () => void;
/** For editor: a bespoke depth-2 editor component. */
renderEditor?: () => JSX.Element;
}
export interface SettingsSectionDef {
id: number;
label: string;
description: string;
items?: () => SettingItem[];
}

133
src/stores/app.ts Normal file
View File

@@ -0,0 +1,133 @@
import { createSignal } from "solid-js";
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
import type {
AppSettings,
AppState,
ThemeColors,
ThemeName,
ThemeMode,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { resolveTheme } from "../utils/theme-resolver";
import type { ThemeJson } from "../types/theme-schema";
import {
loadAppStateFromFile,
saveAppStateToFile,
} from "../utils/app-persistence";
const defaultVisualizerSettings: VisualizerSettings = {
bars: 64,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
};
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
fetchMoreMode: "manual",
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
};
function createAppStore() {
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation
const init = async () => {
const loaded = await loadAppStateFromFile();
setState(loaded);
};
init();
const saveState = (next: AppState) => {
saveAppStateToFile(next);
};
const updateState = (next: AppState) => {
setState(next);
saveState(next);
};
const updateSettings = (updates: Partial<AppSettings>) => {
const next = {
...state(),
settings: { ...state().settings, ...updates },
};
updateState(next);
};
const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = {
...state(),
preferences: { ...state().preferences, ...updates },
};
updateState(next);
};
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = {
...state(),
customTheme: { ...state().customTheme, ...updates },
};
updateState(next);
};
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({
visualizer: { ...state().settings.visualizer, ...updates },
});
};
const setTheme = (theme: ThemeName) => {
updateSettings({ theme });
};
const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme;
if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME;
return resolveTheme(
json as ThemeJson,
"dark" as ThemeMode,
) as unknown as ThemeColors;
};
return {
state,
updateSettings,
updatePreferences,
updateCustomTheme,
updateVisualizer,
setTheme,
resolveTheme: resolveThemeColors,
};
}
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
export function useAppStore() {
if (!appStoreInstance) {
appStoreInstance = createAppStore();
}
return appStoreInstance;
}

126
src/stores/audio-nav.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Audio navigation store for tracking episode order and position
* Persists the current episode context (source type, index, and podcastId)
*/
import { createSignal } from "solid-js";
import {
loadAudioNavFromFile,
saveAudioNavToFile,
} from "../utils/app-persistence";
/** Source type for audio navigation */
export enum AudioSource {
FEED = "feed",
MY_SHOWS = "my_shows",
SEARCH = "search",
}
/** Audio navigation state */
export interface AudioNavState {
/** Current source type */
source: AudioSource;
/** Index of current episode in the ordered list */
currentIndex: number;
/** Podcast ID for My Shows source */
podcastId?: string;
/** Timestamp when navigation state was last saved */
lastUpdated: Date;
}
/** Default navigation state */
const defaultNavState: AudioNavState = {
source: AudioSource.FEED,
currentIndex: 0,
lastUpdated: new Date(),
};
/** Create audio navigation store */
function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
/** Persist current navigation state to file (fire-and-forget) */
function persist(): void {
saveAudioNavToFile(navState());
}
/** Load navigation state from file */
async function init(): Promise<void> {
const loaded = await loadAudioNavFromFile<AudioNavState>();
if (loaded) {
setNavState(loaded);
}
}
/** Fire-and-forget initialization */
init();
return {
/** Get current navigation state */
get state(): AudioNavState {
return navState();
},
/** Update source type */
setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({
...prev,
source,
podcastId,
lastUpdated: new Date(),
}));
persist();
},
/** Move to next episode */
next: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
currentIndex,
lastUpdated: new Date(),
}));
persist();
},
/** Move to previous episode */
prev: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
currentIndex,
lastUpdated: new Date(),
}));
persist();
},
/** Reset to default state */
reset: () => {
setNavState(defaultNavState);
persist();
},
/** Get current index */
getCurrentIndex: (): number => {
return navState().currentIndex;
},
/** Get current source */
getSource: (): AudioSource => {
return navState().source;
},
/** Get current podcast ID */
getPodcastId: (): string | undefined => {
return navState().podcastId;
},
};
}
/** Singleton instance */
let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null;
export function useAudioNavStore() {
if (!audioNavInstance) {
audioNavInstance = createAudioNavStore();
}
return audioNavInstance;
}

218
src/stores/discover.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Discover store for PodTUI
* Manages trending/popular podcasts and category filtering.
*
* The featured-shows list is fetched at runtime from a JSON file hosted in the
* GitHub repo (discover/featured.json on the `master` branch), so the list
* can be updated without shipping a new release. The feed URL, de-duped set,
* and version field act as the cache key — a fresh fetch only happens when the
* version bumps or the cache window (24h) expires.
*/
import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast";
import { useFeedStore } from "./feed";
export interface DiscoverCategory {
id: string;
name: string;
icon: string;
}
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" },
{ id: "technology", name: "Technology", icon: ">" },
{ id: "science", name: "Science", icon: "~" },
{ id: "comedy", name: "Comedy", icon: ")" },
{ id: "news", name: "News", icon: "!" },
{ id: "business", name: "Business", icon: "$" },
{ id: "health", name: "Health", icon: "+" },
{ id: "education", name: "Education", icon: "?" },
{ id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" },
];
// ── Remote featured-shows manifest ───────────────────────────────────────────
// The raw GitHub URL serving discover/featured.json from the master branch.
// Update this file in the repo (no release needed) to refresh the list.
const FEATURED_JSON_URL =
"https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
/** Cache window for the remote featured list (24 hours) */
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** Shape of a single entry in the remote JSON */
interface FeaturedEntry {
id: string;
title: string;
description: string;
feedUrl: string;
author?: string;
categories?: string[];
}
/** Shape of the remote JSON manifest */
interface FeaturedManifest {
version: number;
podcasts: FeaturedEntry[];
}
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
function entryToPodcast(entry: FeaturedEntry): Podcast {
return {
id: entry.id,
title: entry.title,
description: entry.description,
feedUrl: entry.feedUrl,
author: entry.author,
categories: entry.categories ?? [],
coverUrl: undefined,
lastUpdated: new Date(),
isSubscribed: false,
};
}
/** Reconcile isSubscribed state across the discover list against the feed store */
function syncSubscriptionState(
podcasts: Podcast[],
subscribedUrls: Set<string>,
subscribedIds: Set<string>,
): Podcast[] {
return podcasts.map((p) => ({
...p,
isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
}));
}
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0;
/** Reconcile local isSubscribed flags with the feed store */
const syncSubscriptions = () => {
const feedStore = useFeedStore();
const feeds = feedStore.feeds();
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
const ids = new Set(feeds.map((f) => f.podcast.id));
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
};
/** Fetch the featured-shows manifest from GitHub if stale */
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
return;
}
const resp = await fetch(FEATURED_JSON_URL, {
headers: { "User-Agent": "PodTUI/1.0" },
});
if (!resp.ok) {
syncSubscriptions();
return;
}
const manifest = (await resp.json()) as FeaturedManifest;
if (!manifest?.podcasts?.length) {
syncSubscriptions();
return;
}
const fetched = manifest.podcasts.map(entryToPodcast);
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
} finally {
setIsLoading(false);
}
};
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
const category = selectedCategory();
if (category === "all") {
return podcasts();
}
return podcasts().filter((p) => {
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
return cats.some((c) =>
c.includes(category.toLowerCase().replace("-", " ")),
);
});
};
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
// Actually add the feed to the feed store
const feedStore = useFeedStore();
feedStore.addFeed(podcast, "discover").catch(() => {});
}
setPodcasts((prev) =>
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)),
);
};
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
const feedStore = useFeedStore();
feedStore.removeFeedByUrl(podcast.feedUrl);
}
setPodcasts((prev) =>
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
);
};
/** Toggle subscription */
const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) {
unsubscribe(podcastId);
} else {
subscribe(podcastId);
}
};
return {
// State
selectedCategory,
isLoading,
podcasts,
filteredPodcasts,
categories: DISCOVER_CATEGORIES,
// Actions
setSelectedCategory,
subscribe,
unsubscribe,
toggleSubscription,
refresh,
};
}
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() {
if (!discoverStoreInstance) {
discoverStoreInstance = createDiscoverStore();
}
return discoverStoreInstance;
}

376
src/stores/download.ts Normal file
View File

@@ -0,0 +1,376 @@
/**
* Download store for PodTUI
*
* Manages per-episode download state with SolidJS signals, persists download
* metadata to downloads.json in XDG_CONFIG_HOME, and provides a sequential
* download queue (max 2 concurrent).
*/
import { createSignal } from "solid-js";
import { DownloadStatus } from "../types/episode";
import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode";
import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2;
/** Serializable download record for persistence */
interface DownloadRecord {
episodeId: string;
feedId: string;
status: DownloadStatus;
filePath: string | null;
downloadedAt: string | null;
fileSize: number;
error: string | null;
audioUrl: string;
episodeTitle: string;
}
/** Queue item for pending downloads */
interface QueueItem {
episodeId: string;
feedId: string;
audioUrl: string;
episodeTitle: string;
}
/** Create download store */
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
>(new Map());
const [queue, setQueue] = createSignal<QueueItem[]>([]);
const [activeCount, setActiveCount] = createSignal(0);
/** Active AbortControllers keyed by episodeId */
const abortControllers = new Map<string, AbortController>();
(async () => {
const loaded = await loadDownloads();
if (loaded.size > 0) setDownloads(loaded);
// Resume any queued downloads from previous session
resumeIncomplete();
})();
/** Load downloads from JSON file */
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
try {
const filePath = getConfigFilePath(DOWNLOADS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return new Map();
const raw: DownloadRecord[] = await file.json();
if (!Array.isArray(raw)) return new Map();
const map = new Map<string, DownloadedEpisode>();
for (const rec of raw) {
map.set(rec.episodeId, {
episodeId: rec.episodeId,
feedId: rec.feedId,
status:
rec.status === DownloadStatus.DOWNLOADING
? DownloadStatus.QUEUED
: rec.status,
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
filePath: rec.filePath,
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
speed: 0,
fileSize: rec.fileSize,
error: rec.error,
});
}
return map;
} catch {
return new Map();
}
}
/** Persist downloads to JSON file */
async function saveDownloads(): Promise<void> {
try {
await ensureConfigDir();
const map = downloads();
const records: DownloadRecord[] = [];
for (const [, dl] of map) {
// Find the audioUrl from queue or use empty string
const qItem = queue().find((q) => q.episodeId === dl.episodeId);
records.push({
episodeId: dl.episodeId,
feedId: dl.feedId,
status: dl.status,
filePath: dl.filePath,
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
fileSize: dl.fileSize,
error: dl.error,
audioUrl: qItem?.audioUrl ?? "",
episodeTitle: qItem?.episodeTitle ?? "",
});
}
const filePath = getConfigFilePath(DOWNLOADS_FILE);
await Bun.write(filePath, JSON.stringify(records, null, 2));
} catch {
// Silently ignore write errors
}
}
/** Resume incomplete downloads from a previous session */
function resumeIncomplete(): void {
const map = downloads();
for (const [, dl] of map) {
if (dl.status === DownloadStatus.QUEUED) {
// Re-queue — but we lack audioUrl from persistence alone.
// These will sit as QUEUED until the user re-triggers them.
}
}
}
/** Update a single download entry and trigger reactivity */
function updateDownload(
episodeId: string,
updates: Partial<DownloadedEpisode>,
): void {
setDownloads((prev) => {
const next = new Map(prev);
const existing = next.get(episodeId);
if (existing) {
next.set(episodeId, { ...existing, ...updates });
}
return next;
});
}
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
function processQueue(): void {
const current = activeCount();
const q = queue();
if (current >= MAX_CONCURRENT || q.length === 0) return;
const slotsAvailable = MAX_CONCURRENT - current;
const toStart = q.slice(0, slotsAvailable);
if (toStart.length > 0) {
setQueue((prev) => prev.slice(toStart.length));
}
for (const item of toStart) {
executeDownload(item);
}
}
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController();
abortControllers.set(item.episodeId, controller);
setActiveCount((c) => c + 1);
updateDownload(item.episodeId, {
status: DownloadStatus.DOWNLOADING,
progress: 0,
speed: 0,
error: null,
});
const result = await downloadEpisode(
item.audioUrl,
item.episodeTitle,
item.feedId,
(progress) => {
updateDownload(item.episodeId, {
progress: progress.percent >= 0 ? progress.percent : 0,
speed: progress.speed,
fileSize: progress.totalBytes,
});
},
controller.signal,
);
abortControllers.delete(item.episodeId);
setActiveCount((c) => Math.max(0, c - 1));
if (result.success) {
updateDownload(item.episodeId, {
status: DownloadStatus.COMPLETED,
progress: 100,
filePath: result.filePath,
fileSize: result.fileSize,
downloadedAt: new Date(),
speed: 0,
error: null,
});
} else {
updateDownload(item.episodeId, {
status: DownloadStatus.FAILED,
speed: 0,
error: result.error ?? "Unknown error",
});
}
saveDownloads().catch(() => {});
// Process next items in queue
processQueue();
}
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
};
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0;
};
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId);
};
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
return dl.filePath;
}
return null;
};
/** Start downloading an episode */
const startDownload = (episode: Episode, feedId: string): void => {
const existing = downloads().get(episode.id);
if (
existing?.status === DownloadStatus.DOWNLOADING ||
existing?.status === DownloadStatus.QUEUED
) {
return; // Already downloading or queued
}
const entry: DownloadedEpisode = {
episodeId: episode.id,
feedId,
status: DownloadStatus.QUEUED,
progress: 0,
filePath: null,
downloadedAt: null,
speed: 0,
fileSize: episode.fileSize ?? 0,
error: null,
};
setDownloads((prev) => {
const next = new Map(prev);
next.set(episode.id, entry);
return next;
});
const queueItem: QueueItem = {
episodeId: episode.id,
feedId,
audioUrl: episode.audioUrl,
episodeTitle: episode.title,
};
setQueue((prev) => [...prev, queueItem]);
saveDownloads().catch(() => {});
processQueue();
};
/** Cancel a download */
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId);
if (controller) {
controller.abort();
abortControllers.delete(episodeId);
}
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
updateDownload(episodeId, {
status: DownloadStatus.NONE,
progress: 0,
speed: 0,
error: null,
});
saveDownloads().catch(() => {});
};
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId);
if (dl?.filePath) {
try {
const { unlink } = await import("fs/promises");
await unlink(dl.filePath);
} catch {
// File may already be gone
}
}
setDownloads((prev) => {
const next = new Map(prev);
next.delete(episodeId);
return next;
});
saveDownloads().catch(() => {});
};
/** Remove every download (active/queued/completed) belonging to a feed —
* abort in-flight transfers, drop queued items, delete files + metadata. */
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
const eps = Array.from(downloads().values()).filter(
(d) => d.feedId === feedId,
);
for (const d of eps) {
cancelDownload(d.episodeId);
await removeDownload(d.episodeId);
}
};
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values());
};
/** Get the current queue */
const getQueue = (): QueueItem[] => {
return queue();
};
/** Get count of active downloads */
const getActiveCount = (): number => {
return activeCount();
};
return {
// Getters
getDownloadStatus,
getDownloadProgress,
getDownload,
getDownloadedFilePath,
getAllDownloads,
getQueue,
getActiveCount,
// Actions
startDownload,
cancelDownload,
removeDownload,
removeDownloadsForFeed,
};
}
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() {
if (!downloadStoreInstance) {
downloadStoreInstance = createDownloadStore();
}
return downloadStoreInstance;
}

535
src/stores/feed.ts Normal file
View File

@@ -0,0 +1,535 @@
/**
* Feed store for PodTUI
* Manages feed data, sources, and filtering
*/
import { createSignal } from "solid-js";
import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser";
import {
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile,
} from "../utils/feeds-persistence";
import { useDownloadStore } from "./download";
import { DownloadStatus } from "../types/episode";
/** Max episodes to load per page/chunk */
const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
const fullEpisodeCache = new Map<string, Episode[]>();
/** Track how many episodes are currently loaded per feed */
const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds);
}
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources);
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES,
]);
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,
sortDirection: "desc",
});
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility);
}
if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId);
}
if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned);
}
if (f.searchQuery) {
const query = f.searchQuery.toLowerCase();
result = result.filter(
(feed) =>
feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query),
);
}
const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => {
switch (f.sortBy) {
case "title":
return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title,
)
);
case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest);
case "updated":
default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
}
});
result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return 0;
});
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
}> => {
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
for (const feed of feeds()) {
for (const episode of feed.episodes) {
allEpisodes.push({ episode, feed });
}
}
// Sort by publication date (newest first)
allEpisodes.sort(
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
);
return allEpisodes;
};
/** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return [];
const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
// Cache all parsed episodes for pagination
if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
}
return allEpisodes.slice(0, limit);
} catch {
return [];
}
};
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
/** Add a new feed and auto-fetch latest 20 episodes */
const addFeed = async (
podcast: Podcast,
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
): Promise<Feed | null> => {
// Guard: don't add a feed we already have (matched by feedUrl)
if (hasFeedByUrl(podcast.feedUrl)) {
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
}
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
const newFeed: Feed = {
id: feedId,
podcast,
episodes,
visibility,
sourceId,
lastUpdated: new Date(),
isPinned: false,
};
setFeeds((prev) => {
const updated = [...prev, newFeed];
saveFeeds(updated);
return updated;
});
return newFeed;
};
/** Auto-download newest episodes for a feed */
const autoDownloadEpisodes = (
feedId: string,
newEpisodes: Episode[],
count: number,
) => {
const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
}
}
};
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
// Auto-download new episodes if enabled for this feed
if (feed.autoDownload) {
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
if (newEpisodes.length > 0) {
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
}
}
};
/** Refresh all feeds */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
const currentFeeds = feeds();
for (const feed of currentFeeds) {
await refreshFeed(feed.id);
}
} finally {
setIsLoadingFeeds(false);
}
};
(async () => {
const loadedFeeds = await loadFeedsFromFile();
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
const loadedSources = await loadSourcesFromFile<PodcastSource>();
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
await refreshAllFeeds();
})();
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId);
setFeeds((prev) => {
const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated);
return updated;
});
};
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
const removeFeedByUrl = (feedUrl: string) => {
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
if (feed) {
fullEpisodeCache.delete(feed.id);
episodeLoadCount.delete(feed.id);
setFeeds((prev) => {
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
saveFeeds(updated);
return updated;
});
}
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Toggle feed pinned status */
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSources(updated);
return updated;
});
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSources(updated);
return updated;
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (sourceId === "itunes" || sourceId === "rss") return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);
saveSources(updated);
return updated;
});
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSources(updated);
return updated;
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
};
/** Check if a feed has more episodes available beyond what's currently loaded */
const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId);
if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0;
return loaded < cached.length;
};
/** Load the next chunk of episodes for one feed from the cache.
* No global guard — callers own the `isLoadingMore` flag so batches
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
const loadMoreEpisodesForFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed
if (!cached) {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
);
if (newCount <= currentCount) return; // nothing more to load
episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Load the next chunk of episodes for a feed from the cache.
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
const loadMoreEpisodes = async (feedId: string) => {
if (isLoadingMore()) return;
setIsLoadingMore(true);
try {
await loadMoreEpisodesForFeed(feedId);
} finally {
setIsLoadingMore(false);
}
};
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
* still has cached episodes — powers the Feed page's "[Fetch More]". */
const loadMoreAllFeeds = async () => {
if (isLoadingMore()) return;
setIsLoadingMore(true);
try {
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
for (const feed of pending) {
await loadMoreEpisodesForFeed(feed.id);
}
} finally {
setIsLoadingMore(false);
}
};
/** Set auto-download settings for a feed */
const setAutoDownload = (
feedId: string,
enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
};
return {
// State
feeds,
sources,
filter,
selectedFeedId,
isLoadingMore,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
getSelectedFeed,
hasMoreEpisodes,
isLoadingFeeds,
// Actions
setFilter,
setSelectedFeedId,
addFeed,
hasFeedByUrl,
removeFeed,
removeFeedByUrl,
updateFeed,
togglePinned,
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
loadMoreAllFeeds,
hasMoreAcrossAll,
addSource,
removeSource,
toggleSource,
updateSource,
setAutoDownload,
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() {
if (!feedStoreInstance) {
feedStoreInstance = createFeedStore();
}
return feedStoreInstance;
}

166
src/stores/progress.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* Episode progress store for PodTUI
*
* Persists per-episode playback progress to a JSON file in XDG_CONFIG_HOME.
* Tracks position, duration, completion, and last-played timestamp.
*/
import { createSignal } from "solid-js";
import type { Progress } from "../types/episode";
import {
loadProgressFromFile,
saveProgressToFile,
} from "../utils/app-persistence";
/** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95;
/** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5;
// --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
{},
);
/** Persist current progress map to file (fire-and-forget) */
function persist(): void {
saveProgressToFile(progressMap());
}
/** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries(
raw: Record<string, unknown>,
): Record<string, Progress> {
const result: Record<string, Progress> = {};
for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown>;
result[key] = {
episodeId: p.episodeId as string,
position: p.position as number,
duration: p.duration as number,
timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined,
};
}
return result;
}
async function initProgress(): Promise<void> {
const raw = await loadProgressFromFile();
const parsed = parseProgressEntries(raw as Record<string, unknown>);
setProgressMap(parsed);
}
// Fire-and-forget init
initProgress();
function createProgressStore() {
return {
/**
* Get progress for a specific episode.
*/
get(episodeId: string): Progress | undefined {
return progressMap()[episodeId];
},
/**
* Get all progress entries.
*/
all(): Record<string, Progress> {
return progressMap();
},
/**
* Update progress for an episode. Only persists if position is meaningful.
*/
update(
episodeId: string,
position: number,
duration: number,
playbackSpeed?: number,
): void {
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
setProgressMap((prev) => ({
...prev,
[episodeId]: {
episodeId,
position,
duration,
timestamp: new Date(),
playbackSpeed,
},
}));
persist();
},
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD;
},
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0;
return Math.min(100, Math.round((p.position / p.duration) * 100));
},
/**
* Mark an episode as completed (set position to duration).
*/
markCompleted(episodeId: string): void {
const p = progressMap()[episodeId];
const duration = p?.duration ?? 0;
setProgressMap((prev) => ({
...prev,
[episodeId]: {
episodeId,
position: duration,
duration,
timestamp: new Date(),
playbackSpeed: p?.playbackSpeed,
},
}));
persist();
},
/**
* Remove progress for an episode (e.g. "mark as new").
*/
remove(episodeId: string): void {
setProgressMap((prev) => {
const next = { ...prev };
delete next[episodeId];
return next;
});
persist();
},
/**
* Clear all progress data.
*/
clear(): void {
setProgressMap({});
persist();
},
};
}
// Singleton instance
let instance: ReturnType<typeof createProgressStore> | null = null;
export function useProgressStore() {
if (!instance) {
instance = createProgressStore();
}
return instance;
}

210
src/stores/search.ts Normal file
View File

@@ -0,0 +1,210 @@
/**
* Search store for PodTUI
* Manages search state, history, and results
*/
import { createSignal } from "solid-js";
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
const STORAGE_KEY = "podtui_search_history";
const MAX_HISTORY = 20;
export interface SearchState {
query: string;
isSearching: boolean;
results: SearchResult[];
error: string | null;
}
const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */
function loadHistory(): string[] {
if (typeof localStorage === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/** Save search history to localStorage */
function saveHistory(history: string[]): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// Ignore errors
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds();
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
return items.map((item) => ({
...item,
podcast: {
...item.podcast,
isSubscribed:
item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id),
},
}));
};
/** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim();
if (!q) {
setResults([]);
return;
}
setQuery(q);
setIsSearching(true);
setError(null);
addToHistory(q);
try {
// A query that is a direct RSS feed URL (e.g. a private feed that
// isn't in any public directory) resolves to that feed directly,
// independent of enabled search sources.
const urlResults = await searchByFeedUrl(q);
if (urlResults.length > 0) {
setResults(applySubscribedStatus(urlResults));
return;
}
const sources = feedStore.sources();
const enabledSourceIds = sources
.filter((s) => s.enabled)
.map((s) => s.id);
const sourceIds =
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
// Empty query guard already returned above; if there are no enabled
// sources, tell the user instead of returning an empty list that looks
// like a network outage.
if (enabledSourceIds.length === 0) {
setError(
"No search sources are enabled. Enable one in Settings → Sources.",
);
setResults([]);
return;
}
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
setResults(applySubscribedStatus(searchResults));
} catch (e) {
setError(
e instanceof Error && e.message
? e.message
: "Search failed. Please try again.",
);
setResults([]);
} finally {
setIsSearching(false);
}
};
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveHistory([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
saveHistory(updated);
return updated;
});
};
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId;
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
};
}
return result;
}),
);
};
return {
// State
query,
isSearching,
results,
error,
history,
selectedSources,
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {
if (!searchStoreInstance) {
searchStoreInstance = createSearchStore();
}
return searchStoreInstance;
}

138
src/styles/theme.css Normal file
View File

@@ -0,0 +1,138 @@
/* Theme CSS Variables */
:root {
/* Base Colors */
--color-background: transparent;
--color-surface: #1b1f27;
--color-primary: #6fa8ff;
--color-secondary: #a9b1d6;
--color-accent: #f6c177;
--color-text: #e6edf3;
--color-muted: #7d8590;
--color-warning: #f0b429;
--color-error: #f47067;
--color-success: #3fb950;
/* Layer Backgrounds */
--color-layer0: transparent;
--color-layer1: #1e222e;
--color-layer2: #161b22;
--color-layer3: #0d1117;
}
/* Dark Theme (Catppuccin default) */
[data-theme="dark"] {
--color-background: transparent;
--color-surface: #1e1e2e;
--color-primary: #89b4fa;
--color-secondary: #cba6f7;
--color-accent: #f9e2af;
--color-text: #cdd6f4;
--color-muted: #7f849c;
--color-warning: #fab387;
--color-error: #f38ba8;
--color-success: #a6e3a1;
--color-layer0: transparent;
--color-layer1: #181825;
--color-layer2: #11111b;
--color-layer3: #0a0a0f;
}
/* Light Theme (Gruvbox) */
[data-theme="light"] {
--color-background: transparent;
--color-surface: #282828;
--color-primary: #fabd2f;
--color-secondary: #83a598;
--color-accent: #fe8019;
--color-text: #ebdbb2;
--color-muted: #928374;
--color-warning: #fabd2f;
--color-error: #fb4934;
--color-success: #b8bb26;
--color-layer0: transparent;
--color-layer1: #32302a;
--color-layer2: #1d2021;
--color-layer3: #0d0c0c;
}
/* Tokyo Theme */
[data-theme="tokyo"] {
--color-background: transparent;
--color-surface: #1a1b26;
--color-primary: #7aa2f7;
--color-secondary: #bb9af7;
--color-accent: #e0af68;
--color-text: #c0caf5;
--color-muted: #565f89;
--color-warning: #e0af68;
--color-error: #f7768e;
--color-success: #9ece6a;
--color-layer0: transparent;
--color-layer1: #16161e;
--color-layer2: #0f0f15;
--color-layer3: #08080b;
}
/* Nord Theme */
[data-theme="nord"] {
--color-background: transparent;
--color-surface: #2e3440;
--color-primary: #88c0d0;
--color-secondary: #81a1c1;
--color-accent: #ebcb8b;
--color-text: #eceff4;
--color-muted: #4c566a;
--color-warning: #ebcb8b;
--color-error: #bf616a;
--color-success: #a3be8c;
--color-layer0: transparent;
--color-layer1: #3b4252;
--color-layer2: #242933;
--color-layer3: #1a1c23;
}
/* System Theme */
@media (prefers-color-scheme: dark) {
[data-theme="system"] {
--color-background: transparent;
--color-surface: #1e1e2e;
--color-primary: #89b4fa;
--color-secondary: #cba6f7;
--color-accent: #f9e2af;
--color-text: #cdd6f4;
--color-muted: #7f849c;
--color-warning: #fab387;
--color-error: #f38ba8;
--color-success: #a6e3a1;
--color-layer0: transparent;
--color-layer1: #181825;
--color-layer2: #11111b;
--color-layer3: #0a0a0f;
}
}
@media (prefers-color-scheme: light) {
[data-theme="system"] {
--color-background: transparent;
--color-surface: #282828;
--color-primary: #fabd2f;
--color-secondary: #83a598;
--color-accent: #fe8019;
--color-text: #ebdbb2;
--color-muted: #928374;
--color-warning: #fabd2f;
--color-error: #fb4934;
--color-success: #b8bb26;
--color-layer0: transparent;
--color-layer1: #32302a;
--color-layer2: #1d2021;
--color-layer3: #0d0c0c;
}
}

View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#181825",
"surface": "#1e1e2e",
"primary": "#89b4fa",
"secondary": "#cba6f7",
"accent": "#f9e2af",
"text": "#cdd6f4",
"muted": "#7f849c",
"warning": "#fab387",
"error": "#f38ba8",
"success": "#a6e3a1",
"layer0": "#181825",
"layer1": "#181825",
"layer2": "#11111b",
"layer3": "#0a0a0f"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

73
src/themes/gruvbox.json Normal file
View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#282828",
"surface": "#282828",
"primary": "#fabd2f",
"secondary": "#83a598",
"accent": "#fe8019",
"text": "#ebdbb2",
"muted": "#928374",
"warning": "#fabd2f",
"error": "#fb4934",
"success": "#b8bb26",
"layer0": "#282828",
"layer1": "#32302a",
"layer2": "#1d2021",
"layer3": "#0d0c0c"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

73
src/themes/nord.json Normal file
View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#2e3440",
"surface": "#2e3440",
"primary": "#88c0d0",
"secondary": "#81a1c1",
"accent": "#ebcb8b",
"text": "#eceff4",
"muted": "#4c566a",
"warning": "#ebcb8b",
"error": "#bf616a",
"success": "#a3be8c",
"layer0": "#2e3440",
"layer1": "#3b4252",
"layer2": "#242933",
"layer3": "#1a1c23"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

58
src/themes/schema.json Normal file
View File

@@ -0,0 +1,58 @@
{
"defs": {},
"theme": {
"primary": "#000000",
"secondary": "#000000",
"accent": "#000000",
"error": "#000000",
"warning": "#000000",
"success": "#000000",
"info": "#000000",
"text": "#000000",
"textMuted": "#000000",
"selectedListItemText": "#000000",
"background": "#000000",
"backgroundPanel": "#000000",
"backgroundElement": "#000000",
"backgroundMenu": "#000000",
"border": "#000000",
"borderActive": "#000000",
"borderSubtle": "#000000",
"diffAdded": "#000000",
"diffRemoved": "#000000",
"diffContext": "#000000",
"diffHunkHeader": "#000000",
"diffHighlightAdded": "#000000",
"diffHighlightRemoved": "#000000",
"diffAddedBg": "#000000",
"diffRemovedBg": "#000000",
"diffContextBg": "#000000",
"diffLineNumber": "#000000",
"diffAddedLineNumberBg": "#000000",
"diffRemovedLineNumberBg": "#000000",
"markdownText": "#000000",
"markdownHeading": "#000000",
"markdownLink": "#000000",
"markdownLinkText": "#000000",
"markdownCode": "#000000",
"markdownBlockQuote": "#000000",
"markdownEmph": "#000000",
"markdownStrong": "#000000",
"markdownHorizontalRule": "#000000",
"markdownListItem": "#000000",
"markdownListEnumeration": "#000000",
"markdownImage": "#000000",
"markdownImageText": "#000000",
"markdownCodeBlock": "#000000",
"syntaxComment": "#000000",
"syntaxKeyword": "#000000",
"syntaxFunction": "#000000",
"syntaxVariable": "#000000",
"syntaxString": "#000000",
"syntaxNumber": "#000000",
"syntaxType": "#000000",
"syntaxOperator": "#000000",
"syntaxPunctuation": "#000000",
"thinkingOpacity": 0.6
}
}

73
src/themes/tokyo.json Normal file
View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#0f0f15",
"surface": "#1a1b26",
"primary": "#7aa2f7",
"secondary": "#bb9af7",
"accent": "#e0af68",
"text": "#c0caf5",
"muted": "#565f89",
"warning": "#e0af68",
"error": "#f7768e",
"success": "#9ece6a",
"layer0": "#0f0f15",
"layer1": "#16161e",
"layer2": "#0f0f15",
"layer3": "#08080b"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

View File

@@ -0,0 +1,31 @@
import type { ThemeColors } from "../types/settings"
// Base theme colors
export const BASE_THEME_COLORS: ThemeColors = {
background: "transparent",
surface: "#1b1f27",
primary: "#6fa8ff",
secondary: "#a9b1d6",
accent: "#f6c177",
text: "#e6edf3",
textPrimary: "#e6edf3",
textSecondary: "#a9b1d6",
textTertiary: "#7d8590",
textSelectedPrimary: "#1b1f27",
textSelectedSecondary: "#e6edf3",
textSelectedTertiary: "#a9b1d6",
muted: "#7d8590",
warning: "#f0b429",
error: "#f47067",
success: "#3fb950",
_hasSelectedListItemText: true,
thinkingOpacity: 0.5,
}
// Base layer backgrounds
export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
layer0: "transparent",
layer1: "#1e222e",
layer2: "#161b22",
layer3: "#0d1117",
}

117
src/types/episode.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Episode type definitions for PodTUI
*/
/** Episode playback status */
export enum EpisodeStatus {
NOT_STARTED = "not_started",
PLAYING = "playing",
PAUSED = "paused",
COMPLETED = "completed",
}
/** Core episode information */
export interface Episode {
/** Unique identifier */
id: string
/** Parent podcast ID */
podcastId: string
/** Episode title */
title: string
/** Episode description/show notes */
description: string
/** Audio file URL */
audioUrl: string
/** Duration in seconds */
duration: number
/** Publication date */
pubDate: Date
/** Episode number (if available) */
episodeNumber?: number
/** Season number (if available) */
seasonNumber?: number
/** Episode type (full, trailer, bonus) */
episodeType?: EpisodeType
/** Whether episode is explicit */
explicit?: boolean
/** Episode image URL (if different from podcast) */
imageUrl?: string
/** File size in bytes */
fileSize?: number
/** MIME type */
mimeType?: string
}
/** Episode type enumeration */
export enum EpisodeType {
FULL = "full",
TRAILER = "trailer",
BONUS = "bonus",
}
/** Episode playback progress */
export interface Progress {
/** Episode ID */
episodeId: string
/** Current position in seconds */
position: number
/** Total duration in seconds */
duration: number
/** Last played timestamp */
timestamp: Date
/** Playback speed (1.0 = normal) */
playbackSpeed?: number
}
/** Episode with playback state */
export interface EpisodeWithProgress extends Episode {
/** Current playback status */
status: EpisodeStatus
/** Playback progress */
progress?: Progress
}
/** Episode list item for display */
export interface EpisodeListItem {
/** Episode data */
episode: Episode
/** Podcast title (for display in feeds) */
podcastTitle: string
/** Podcast cover URL */
podcastCoverUrl?: string
/** Current status */
status: EpisodeStatus
/** Progress percentage (0-100) */
progressPercent: number
}
/** Download status for an episode */
export enum DownloadStatus {
NONE = "none",
QUEUED = "queued",
DOWNLOADING = "downloading",
COMPLETED = "completed",
FAILED = "failed",
}
/** Metadata for a downloaded episode */
export interface DownloadedEpisode {
/** Episode ID */
episodeId: string
/** Feed ID the episode belongs to */
feedId: string
/** Current download status */
status: DownloadStatus
/** Download progress 0-100 */
progress: number
/** Absolute path to the downloaded file */
filePath: string | null
/** When the download completed */
downloadedAt: Date | null
/** Download speed in bytes/sec (while downloading) */
speed: number
/** File size in bytes */
fileSize: number
/** Error message if failed */
error: string | null
}

114
src/types/feed.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Feed type definitions for PodTUI
*/
import type { Podcast } from "./podcast"
import type { Episode, EpisodeStatus } from "./episode"
/** Feed visibility */
export enum FeedVisibility {
PUBLIC = "public",
PRIVATE = "private",
}
/** Feed information */
export interface Feed {
/** Unique identifier */
id: string
/** Associated podcast */
podcast: Podcast
/** Episodes in this feed */
episodes: Episode[]
/** Whether feed is public or private */
visibility: FeedVisibility
/** Source ID that provided this feed */
sourceId: string
/** Last updated timestamp */
lastUpdated: Date
/** Custom feed name (user-defined) */
customName?: string
/** User notes about this feed */
notes?: string
/** Whether feed is pinned/favorited */
isPinned: boolean
/** Feed color for UI */
color?: string
/** Whether auto-download is enabled for this feed */
autoDownload?: boolean
/** Number of newest episodes to auto-download (0 = all new) */
autoDownloadCount?: number
}
/** Feed item for display in lists */
export interface FeedItem {
/** Episode data */
episode: Episode
/** Parent podcast */
podcast: Podcast
/** Feed ID */
feedId: string
/** Episode status */
status: EpisodeStatus
/** Progress percentage (0-100) */
progressPercent: number
/** Whether this item is new (unplayed) */
isNew: boolean
}
/** Feed filter options */
export interface FeedFilter {
/** Filter by visibility */
visibility?: FeedVisibility | "all"
/** Filter by source ID */
sourceId?: string
/** Filter by pinned status */
pinnedOnly?: boolean
/** Search query for filtering */
searchQuery?: string
/** Sort field */
sortBy?: FeedSortField
/** Sort direction */
sortDirection?: "asc" | "desc"
/** Show private feeds */
showPrivate?: boolean
}
/** Feed sort fields */
export enum FeedSortField {
/** Sort by last updated */
UPDATED = "updated",
/** Sort by title */
TITLE = "title",
/** Sort by episode count */
EPISODE_COUNT = "episodeCount",
/** Sort by most recent episode */
LATEST_EPISODE = "latestEpisode",
}
/** Feed list display options */
export interface FeedListOptions {
/** Show episode count */
showEpisodeCount: boolean
/** Show last updated */
showLastUpdated: boolean
/** Show source indicator */
showSource: boolean
/** Compact mode */
compact: boolean
}
/** Feed statistics */
export interface FeedStats {
/** Total feed count */
totalFeeds: number
/** Public feed count */
publicFeeds: number
/** Private feed count */
privateFeeds: number
/** Total episode count across all feeds */
totalEpisodes: number
/** Unplayed episode count */
unplayedEpisodes: number
/** In-progress episode count */
inProgressEpisodes: number
}

42
src/types/podcast.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Podcast type definitions for PodTUI
*/
/** Core podcast information */
export interface Podcast {
/** Unique identifier */
id: string
/** Podcast title */
title: string
/** Podcast description/summary */
description: string
/** Cover image URL */
coverUrl?: string
/** RSS feed URL */
feedUrl: string
/** Author/creator name */
author?: string
/** Podcast categories */
categories?: string[]
/** Language code (e.g., 'en', 'es') */
language?: string
/** Website URL */
websiteUrl?: string
/** Last updated timestamp */
lastUpdated: Date
/** Whether the podcast is currently subscribed */
isSubscribed: boolean
/** Callback to toggle feed visibility */
onToggleVisibility?: (feedId: string) => void
}
/** Podcast with episodes included */
export interface PodcastWithEpisodes extends Podcast {
/** List of episodes */
episodes: Episode[]
/** Total episode count */
totalEpisodes: number
}
/** Episode import - needed for PodcastWithEpisodes */
import type { Episode } from "./episode"

107
src/types/settings.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { RGBA } from "@opentui/core";
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
export type ThemeName =
| "system"
| "catppuccin"
| "gruvbox"
| "tokyo"
| "nord"
| "custom";
export type LayerBackgrounds = {
layer0: ColorValue;
layer1: ColorValue;
layer2: ColorValue;
layer3: ColorValue;
};
export type ThemeColors = {
background: ColorValue;
surface: ColorValue;
primary: ColorValue;
secondary: ColorValue;
accent: ColorValue;
text: ColorValue;
textPrimary?: ColorValue;
textSecondary?: ColorValue;
textTertiary?: ColorValue;
textSelectedPrimary?: ColorValue;
textSelectedSecondary?: ColorValue;
textSelectedTertiary?: ColorValue;
muted: ColorValue;
warning: ColorValue;
error: ColorValue;
success: ColorValue;
layerBackgrounds?: LayerBackgrounds;
_hasSelectedListItemText?: boolean;
thinkingOpacity?: number;
selectedListItemText?: ColorValue;
};
export type ThemeVariant = {
name: string;
colors: ThemeColors;
};
export type ThemeToken = {
[key: string]: string;
};
export type ResolvedTheme = Record<string, RGBA> & {
layerBackgrounds: Record<string, RGBA>;
_hasSelectedListItemText: boolean;
thinkingOpacity: number;
};
export type DesktopTheme = {
name: string;
variants: ThemeVariant[];
defaultVariant: string;
tokens: ThemeToken;
};
export type VisualizerSettings = {
/** Number of frequency bars (8128, default: 64) */
bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
sensitivity: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction: number;
/** Low frequency cutoff in Hz (default: 50) */
lowCutOff: number;
/** High frequency cutoff in Hz (default: 10000) */
highCutOff: number;
};
export type AppSettings = {
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
visualizer: VisualizerSettings;
};
/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
fetchMoreMode: FetchMoreMode;
};
export type AppState = {
settings: AppSettings;
preferences: UserPreferences;
customTheme: ThemeColors;
};
export type ThemeMode = "dark" | "light";
export type ThemeVariantValue = Variant;
export type ThemeDefinition = ThemeJson;

116
src/types/source.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Podcast source type definitions for PodTUI
*/
/** Source type enumeration */
export enum SourceType {
/** RSS feed URL */
RSS = "rss",
/** API-based source (iTunes, Spotify, etc.) */
API = "api",
/** Custom/user-defined source */
CUSTOM = "custom",
}
/** Podcast source configuration */
export interface PodcastSource {
/** Unique identifier */
id: string
/** Source display name */
name: string
/** Source type */
type: SourceType
/** Base URL for the source */
baseUrl: string
/** API key (if required) */
apiKey?: string
/** Whether source is enabled */
enabled: boolean
/** Source icon/logo URL */
iconUrl?: string
/** Source description */
description?: string
/** Default country for source searches */
country?: string
/** Default language for search results */
language?: string
/** Include explicit results */
allowExplicit?: boolean
/** Rate limit (requests per minute) */
rateLimit?: number
/** Last successful fetch */
lastFetch?: Date
}
/** Search query configuration */
export interface SearchQuery {
/** Search query text */
query: string
/** Source IDs to search (empty = all enabled sources) */
sourceIds: string[]
/** Optional filters */
filters?: SearchFilters
}
/** Search filters */
export interface SearchFilters {
/** Filter by language */
language?: string
/** Filter by category */
category?: string
/** Filter by explicit content */
explicit?: boolean
/** Sort by field */
sortBy?: SearchSortField
/** Sort direction */
sortDirection?: "asc" | "desc"
/** Results limit */
limit?: number
/** Results offset for pagination */
offset?: number
}
/** Search sort fields */
export enum SearchSortField {
RELEVANCE = "relevance",
DATE = "date",
TITLE = "title",
POPULARITY = "popularity",
}
/** Search result */
export interface SearchResult {
/** Source that returned this result */
sourceId: string
/** Source display name */
sourceName?: string
/** Source type */
sourceType?: SourceType
/** Podcast data */
podcast: import("./podcast").Podcast
/** Relevance score (0-1) */
score?: number
}
/** Default podcast sources */
export const DEFAULT_SOURCES: PodcastSource[] = [
{
id: "itunes",
name: "Apple Podcasts",
type: SourceType.API,
baseUrl: "https://itunes.apple.com/search",
enabled: true,
description: "Search the Apple Podcasts directory",
country: "US",
language: "en_us",
allowExplicit: true,
},
{
id: "rss",
name: "RSS Feed",
type: SourceType.RSS,
baseUrl: "",
enabled: true,
description: "Add podcasts via RSS feed URL",
},
]

34
src/types/theme-schema.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { RGBA } from "@opentui/core"
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Record<string, ColorValue | boolean> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
/** Render the app background transparent (let the terminal's own bg show). */
transparent?: boolean
}
}
export type ThemeColors = Record<string, RGBA> & {
_hasSelectedListItemText: boolean
thinkingOpacity: number
textPrimary?: ColorValue
textSecondary?: ColorValue
textTertiary?: ColorValue
textSelectedPrimary?: ColorValue
textSelectedSecondary?: ColorValue
textSelectedTertiary?: ColorValue
}

336
src/ui/command.tsx Normal file
View File

@@ -0,0 +1,336 @@
import {
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
For,
Show,
} from "solid-js";
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
import { useDialog } from "./dialog";
import { useTheme } from "../context/ThemeContext";
import { TextAttributes } from "@opentui/core";
import { emit } from "../utils/event-bus";
import { SelectableBox, SelectableText } from "@/components/Selectable";
/**
* Command option for the command palette.
*/
export type CommandOption = {
/** Display title */
title: string;
/** Unique identifier */
value: string;
/** Description shown below title */
description?: string;
/** Category for grouping */
category?: string;
/** Keybind reference */
keybind?: keyof KeybindsResolved;
/** Whether this command is suggested */
suggested?: boolean;
/** Slash command configuration */
slash?: {
name: string;
aliases?: string[];
};
/** Whether to hide from command list */
hidden?: boolean;
/** Whether command is enabled */
enabled?: boolean;
/** Footer text (usually keybind display) */
footer?: string;
/** Handler when command is selected */
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
};
type CommandContext = ReturnType<typeof init>;
const ctx = createContext<CommandContext>();
function init() {
const [registrations, setRegistrations] = createSignal<
Accessor<CommandOption[]>[]
>([]);
const [suspendCount, setSuspendCount] = createSignal(0);
const dialog = useDialog();
const keybind = useKeybinds();
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x());
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}));
});
const isEnabled = (option: CommandOption) => option.enabled !== false;
const isVisible = (option: CommandOption) =>
isEnabled(option) && !option.hidden;
const visibleOptions = createMemo(() =>
entries().filter((option) => isVisible(option)),
);
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
);
const suspended = () => suspendCount() > 0;
useKeyboard((evt) => {
if (suspended()) return;
if (dialog.isOpen) return;
for (const option of entries()) {
if (!isEnabled(option)) continue;
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault();
option.onSelect?.(dialog);
emit("command.execute", { command: option.value });
return;
}
}
});
const result = {
/**
* Trigger a command by its value.
*/
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return;
option.onSelect?.(dialog);
emit("command.execute", { command: name });
return;
}
}
},
/**
* Get all slash commands.
*/
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash;
if (!slash) return [];
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
};
});
},
/**
* Enable/disable keybinds temporarily.
*/
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1));
},
suspended,
/**
* Show the command palette dialog.
*/
show() {
dialog.replace(() => (
<CommandDialog
options={visibleOptions()}
suggestedOptions={suggestedOptions()}
/>
));
},
/**
* Register commands. Returns cleanup function.
*/
register(cb: () => CommandOption[]) {
const results = createMemo(cb);
setRegistrations((arr) => [results, ...arr]);
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results));
});
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions();
},
};
return result;
}
export function useCommandDialog() {
const value = useContext(ctx);
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider");
}
return value;
}
export function CommandProvider(props: ParentProps) {
const value = init();
const dialog = useDialog();
const keybind = useKeybinds();
// Open the command palette via the `command` keybind (bound to `:` or `q`
// in keybinds.jsonc; the Shell router owns the action and runs it first).
useKeyboard((evt) => {
if (value.suspended()) return;
if (dialog.isOpen) return;
if (evt.defaultPrevented) return;
if (keybind.match("command", evt)) {
evt.preventDefault();
value.show();
return;
}
});
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: {
options: CommandOption[];
suggestedOptions: CommandOption[];
}) {
const { theme } = useTheme();
const dialog = useDialog();
const dimensions = useTerminalDimensions();
const [filter, setFilter] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredOptions = createMemo(() => {
const query = filter().toLowerCase();
if (!query) {
return [...props.suggestedOptions, ...props.options];
}
return props.options.filter(
(option) =>
option.title.toLowerCase().includes(query) ||
option.description?.toLowerCase().includes(query) ||
option.category?.toLowerCase().includes(query),
);
});
// Reset selection when filter changes
createMemo(() => {
filter();
setSelectedIndex(0);
});
useKeyboard((evt) => {
if (evt.name === "escape") {
dialog.clear();
evt.preventDefault();
return;
}
if (evt.name === "return" || evt.name === "enter") {
const option = filteredOptions()[selectedIndex()];
if (option) {
option.onSelect?.(dialog);
dialog.clear();
}
evt.preventDefault();
return;
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
setSelectedIndex((i) => Math.max(0, i - 1));
evt.preventDefault();
return;
}
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
evt.preventDefault();
return;
}
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
setFilter((f) => f + evt.name);
return;
}
if (evt.name === "backspace") {
setFilter((f) => f.slice(0, -1));
return;
}
});
const maxHeight = Math.floor(dimensions().height * 0.6);
return (
<box flexDirection="column" padding={1} borderColor={theme.border}>
{/* Search input */}
<box marginBottom={1}>
<text fg={theme.textMuted}>{"> "}</text>
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
</box>
{/* Command list */}
<box
flexDirection="column"
maxHeight={maxHeight}
borderColor={theme.border}
>
<For each={filteredOptions().slice(0, 10)}>
{(option, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
const selectedOption = filteredOptions()[index()];
if (selectedOption) {
selectedOption.onSelect?.(dialog);
dialog.clear();
}
}}
>
<box flexDirection="column" flexGrow={1}>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{option.title}
</SelectableText>
<Show when={option.footer}>
<SelectableText
selected={() => index() === selectedIndex()}
tertiary
>
{option.footer}
</SelectableText>
</Show>
<Show when={option.description}>
<SelectableText
selected={() => index() === selectedIndex()}
tertiary
>
{option.description}
</SelectableText>
</Show>
</box>
</SelectableBox>
)}
</For>
<Show when={filteredOptions().length === 0}>
<text fg={theme.textMuted} style={{ padding: 1 }}>
No commands found
</text>
</Show>
</box>
</box>
);
}

225
src/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,225 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "../context/ThemeContext"
import { RGBA, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "../utils/clipboard"
import { useToast } from "./toast"
import { emit } from "../utils/event-bus"
export type DialogSize = "medium" | "large"
/**
* Dialog component that renders a modal overlay with content.
*/
function Dialog(
props: ParentProps<{
size?: DialogSize
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const renderer = useRenderer()
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={Math.floor(dimensions().height / 4)}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
borderColor={theme.border}
>
{props.children}
</box>
</box>
)
}
type DialogStackItem = {
element: JSX.Element
onClose?: () => void
}
function init() {
const [store, setStore] = createStore({
stack: [] as DialogStackItem[],
size: "medium" as DialogSize,
})
const renderer = useRenderer()
let focus: Renderable | null = null
function refocus() {
setTimeout(() => {
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable): boolean {
for (const child of item.getChildren()) {
if (child === focus) return true
if (find(child)) return true
}
return false
}
const found = find(renderer.root)
if (!found) return
focus.focus()
}, 1)
}
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
emit("dialog.close", {})
}
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
batch(() => {
setStore("size", "medium")
setStore("stack", [])
})
refocus()
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
const element = typeof input === "function" ? input() : input
setStore("size", "medium")
setStore("stack", [{ element, onClose }])
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
const element = typeof input === "function" ? input() : input
setStore("stack", [...store.stack, { element, onClose }])
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
if (store.stack.length === 0) {
refocus()
}
emit("dialog.close", {})
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: DialogSize) {
setStore("size", size)
},
get isOpen() {
return store.stack.length > 0
},
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
/**
* DialogProvider wraps the application and provides dialog functionality.
* Also handles clipboard copy on text selection within dialogs.
*/
export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
}}
>
<Show when={value.stack.length > 0}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>
</box>
</ctx.Provider>
)
}
/**
* Hook to access the dialog context.
*/
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}

153
src/ui/toast.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/ThemeContext"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { emit } from "../utils/event-bus"
export type ToastVariant = "info" | "success" | "warning" | "error"
export type ToastOptions = {
title?: string
message: string
variant: ToastVariant
duration?: number
}
const DEFAULT_DURATION = 5000
/**
* Toast component that displays at the top-right of the screen.
* NOTE: This component must be rendered INSIDE ThemeProvider since it uses useTheme().
* The ToastProvider itself can be placed outside ThemeProvider if needed.
*/
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const getVariantColor = (variant: ToastVariant) => {
switch (variant) {
case "success":
return theme.success
case "warning":
return theme.warning
case "error":
return theme.error
case "info":
default:
return theme.info
}
}
return (
<Show when={toast.currentToast}>
{(current) => (
<box
position="absolute"
justifyContent="center"
alignItems="flex-start"
top={2}
right={2}
maxWidth={Math.min(60, dimensions().width - 6)}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundPanel}
borderColor={getVariantColor(current().variant)}
border={["left", "right"]}
>
<box flexDirection="column">
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} style={{ marginBottom: 1 }} fg={theme.text}>
{current().title}
</text>
</Show>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box>
</box>
)}
</Show>
)
}
function init() {
const [store, setStore] = createStore({
currentToast: null as ToastOptions | null,
})
let timeoutHandle: NodeJS.Timeout | null = null
const toast = {
show(options: ToastOptions) {
const duration = options.duration ?? DEFAULT_DURATION
setStore("currentToast", {
title: options.title,
message: options.message,
variant: options.variant,
})
// Emit event for other listeners
emit("toast.show", options)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration)
},
error: (err: unknown) => {
if (err instanceof Error) {
return toast.show({
variant: "error",
message: err.message,
})
}
toast.show({
variant: "error",
message: "An unknown error has occurred",
})
},
info: (message: string, title?: string) => {
toast.show({ variant: "info", message, title })
},
success: (message: string, title?: string) => {
toast.show({ variant: "success", message, title })
},
warning: (message: string, title?: string) => {
toast.show({ variant: "warning", message, title })
},
clear: () => {
if (timeoutHandle) clearTimeout(timeoutHandle)
setStore("currentToast", null)
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
return toast
}
export type ToastContext = ReturnType<typeof init>
const ctx = createContext<ToastContext>()
/**
* ToastProvider provides toast functionality.
* NOTE: The Toast UI component is NOT rendered here - you must render <Toast />
* separately inside your component tree, after ThemeProvider.
*/
export function ToastProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useToast() {
const value = useContext(ctx)
if (!value) {
throw new Error("useToast must be used within a ToastProvider")
}
return value
}

41
src/utils/ansi-to-rgba.ts Normal file
View File

@@ -0,0 +1,41 @@
import { RGBA } from "@opentui/core"
export function ansiToRgba(code: number) {
if (code < 16) {
const ansi = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(ansi[code] ?? "#000000")
}
if (code < 232) {
const index = code - 16
const b = index % 6
const g = Math.floor(index / 6) % 6
const r = Math.floor(index / 36)
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(value(r), value(g), value(b))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}

View File

@@ -0,0 +1,154 @@
/**
* App state persistence — settings, preferences, and custom theme are stored
* in the centralized `config.json` (see utils/config.ts). Playback progress
* and audio-nav state stay in separate files (they change on every seek and
* would thrash config.json).
*
* No backups — writes always overwrite.
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { loadConfig, updateConfig } from "./config";
import type {
AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes";
// --- Defaults ---
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
};
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
fetchMoreMode: "manual",
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
};
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const cfg = await loadConfig();
if (!cfg || typeof cfg !== "object") return defaultState;
return {
settings: { ...defaultSettings, ...cfg.settings },
preferences: { ...defaultPreferences, ...cfg.preferences },
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
};
} catch {
return defaultState;
}
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
preferences: state.preferences,
customTheme: state.customTheme,
});
}
// ── Playback Progress (separate file — changes on every seek) ───────────────
const PROGRESS_FILE = "progress.json";
interface ProgressEntry {
episodeId: string;
position: number;
duration: number;
timestamp: string | Date;
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return {};
const raw = await file.json();
if (!raw || typeof raw !== "object") return {};
return raw as Record<string, ProgressEntry>;
} catch {
return {};
}
}
/** Save progress map to JSON file (overwrite, no backup) */
export function saveProgressToFile(data: Record<string, unknown>): void {
(async () => {
try {
await ensureConfigDir();
await Bun.write(
getConfigFilePath(PROGRESS_FILE),
JSON.stringify(data, null, 2),
);
} catch {
// Silently ignore write errors
}
})();
}
// ── Audio Nav State (separate file — changes on every track change) ──────────
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
if (!(await file.exists())) return null;
const raw = await file.json();
if (!raw || typeof raw !== "object") return null;
return raw as T;
} catch {
return null;
}
}
/** Save audio navigation state to JSON file (overwrite, no backup) */
export function saveAudioNavToFile<T>(data: T): void {
(async () => {
try {
await ensureConfigDir();
await Bun.write(
getConfigFilePath(AUDIO_NAV_FILE),
JSON.stringify(data, null, 2),
);
} catch {
// Silently ignore write errors
}
})();
}

465
src/utils/audio-player.ts Normal file
View File

@@ -0,0 +1,465 @@
/**
* Audio playback engine for PodTUI.
*
* Single backend: mpv — full IPC control (seek, volume, speed, position
* tracking), so speed/volume/seek changes apply instantly with no process
* restart. When mpv isn't installed there is no fallback: the no-op backend
* surfaces "No audio player found" honestly rather than degrading through
* players that can't change speed/volume without restarting.
*/
import { platform } from "os";
import { existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// ── Types ────────────────────────────────────────────────────────────
export type BackendName = "mpv" | "none";
export interface AudioState {
playing: boolean;
position: number;
duration: number;
volume: number;
speed: number;
backend: BackendName;
error: string | null;
}
export interface AudioBackend {
readonly name: BackendName;
play(url: string, opts?: PlayOptions): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
seek(seconds: number): Promise<void>;
setVolume(volume: number): Promise<void>;
setSpeed(speed: number): Promise<void>;
getPosition(): Promise<number>;
getDuration(): Promise<number>;
isPlaying(): boolean;
dispose(): void;
}
export interface PlayOptions {
startPosition?: number;
volume?: number;
speed?: number;
}
// ── Utilities ────────────────────────────────────────────────────────
function which(cmd: string): string | null {
const resolved = Bun.which(cmd);
if (resolved) return resolved;
if (platform() === "darwin") {
const candidates = [
`/opt/homebrew/bin/${cmd}`,
`/usr/local/bin/${cmd}`,
`/usr/bin/${cmd}`,
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
}
return null;
}
function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
}
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
export class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv";
private proc: ReturnType<typeof Bun.spawn> | null = null;
private socketPath = mpvSocketPath();
private _playing = false;
private _position = 0;
private _duration = 0;
private _volume = 100;
private _speed = 1;
private pollTimer: ReturnType<typeof setInterval> | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
// Clean up stale socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs");
unlinkSync(this.socketPath);
}
} catch {
/* ignore */
}
const args = [
"mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
`--input-ipc-server=${this.socketPath}`,
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
`--speed=${opts?.speed ?? 1}`,
];
if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`);
}
args.push(url);
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
this._playing = true;
this._position = opts?.startPosition ?? 0;
this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1;
// Wait for socket to appear (mpv creates it async)
await this.waitForSocket(2000);
// Start polling position
this.startPolling();
// Detect process exit
this.proc.exited
.then(() => {
this._playing = false;
this.stopPolling();
})
.catch(() => {});
}
private async waitForSocket(timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (existsSync(this.socketPath)) return;
await new Promise((r) => setTimeout(r, 50));
}
}
private async ipc(command: unknown[]): Promise<unknown> {
try {
const socket = await Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
// Response handling is done by reading below
},
error(_socket, err) {},
close() {},
open() {},
},
});
const payload = JSON.stringify({ command }) + "\n";
socket.write(payload);
// Read response with timeout
const response = await new Promise<string>((resolve) => {
let buf = "";
const reader = setInterval(() => {
// Check if we got a response already
if (buf.includes("\n")) {
clearInterval(reader);
resolve(buf);
}
}, 10);
setTimeout(() => {
clearInterval(reader);
resolve(buf);
}, 200);
});
socket.end();
if (response) {
try {
return JSON.parse(response.split("\n")[0]);
} catch {
return null;
}
}
return null;
} catch {
return null;
}
}
/** Send a command over mpv's IPC and get the parsed response data. */
private async ipcCommand(command: unknown[]): Promise<unknown> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
});
const payload = JSON.stringify({ command }) + "\n";
conn.write(payload);
// Give mpv a moment to process, then read via a fresh connection
await new Promise((r) => setTimeout(r, 30));
conn.end();
return null;
} catch {
return null;
}
}
/** Send a fire-and-forget command (no response needed) */
private async send(command: unknown[]): Promise<void> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
});
conn.write(JSON.stringify({ command }) + "\n");
// Don't wait, just schedule a close
setTimeout(() => {
try {
conn.end();
} catch {}
}, 50);
} catch {
/* ignore */
}
}
/** Get a property value from mpv via IPC */
private async getProperty(name: string): Promise<number> {
try {
return await new Promise<number>((resolve) => {
let result = 0;
const timeout = setTimeout(() => resolve(result), 300);
Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
try {
const text = Buffer.from(data).toString();
const parsed = JSON.parse(text.split("\n")[0]);
if (parsed?.data !== undefined) {
result = Number(parsed.data) || 0;
}
} catch {
/* ignore parse errors */
}
clearTimeout(timeout);
resolve(result);
},
error() {
clearTimeout(timeout);
resolve(0);
},
close() {},
open(socket) {
socket.write(
JSON.stringify({ command: ["get_property", name] }) + "\n",
);
},
},
}).catch(() => {
clearTimeout(timeout);
resolve(0);
});
});
} catch {
return 0;
}
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(async () => {
if (!this._playing || !this.proc) return;
this._position = await this.getProperty("time-pos");
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
}
}, 500);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
async pause(): Promise<void> {
await this.send(["set_property", "pause", true]);
this._playing = false;
}
async resume(): Promise<void> {
await this.send(["set_property", "pause", false]);
this._playing = true;
}
async stop(): Promise<void> {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
this._playing = false;
this._position = 0;
// Clean up socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs");
unlinkSync(this.socketPath);
}
} catch {
/* ignore */
}
}
async seek(seconds: number): Promise<void> {
await this.send(["set_property", "time-pos", seconds]);
this._position = seconds;
}
async setVolume(volume: number): Promise<void> {
const v = Math.round(volume * 100);
await this.send(["set_property", "volume", v]);
this._volume = v;
}
async setSpeed(speed: number): Promise<void> {
await this.send(["set_property", "speed", speed]);
this._speed = speed;
}
async getPosition(): Promise<number> {
return this._position;
}
async getDuration(): Promise<number> {
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
}
return this._duration;
}
isPlaying(): boolean {
return this._playing;
}
dispose(): void {
this.stop();
}
}
// ── No-op Backend ────────────────────────────────────────────────────
class NoopBackend implements AudioBackend {
readonly name: BackendName = "none";
async play(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async stop(): Promise<void> {}
async seek(): Promise<void> {}
async setVolume(): Promise<void> {}
async setSpeed(): Promise<void> {}
async getPosition(): Promise<number> {
return 0;
}
async getDuration(): Promise<number> {
return 0;
}
isPlaying(): boolean {
return false;
}
dispose(): void {}
}
// ── Detection & Factory ──────────────────────────────────────────────
export interface DetectedPlayer {
name: BackendName;
path: string | null;
capabilities: {
seek: boolean;
volume: boolean;
speed: boolean;
positionTracking: boolean;
};
}
/** Detect all available audio players on this system. */
export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = [];
const mpvPath = which("mpv");
if (mpvPath) {
players.push({
name: "mpv",
path: mpvPath,
capabilities: {
seek: true,
volume: true,
speed: true,
positionTracking: true,
},
});
}
return players;
}
/** Create the best available audio backend. */
export function createAudioBackend(preferred?: BackendName): AudioBackend {
// Testability hook: allow forcing a backend (incl. "none") via env so the
// harness can run silently during replay and opt into real playback per turn.
// An explicit `preferred` argument still wins.
if (!preferred) {
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
if (envPref && (envPref === "mpv" || envPref === "none")) {
preferred = envPref;
}
}
if (preferred) {
const backend = createBackendByName(preferred);
if (backend) return backend;
}
return which("mpv") ? new MpvBackend() : new NoopBackend();
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv":
return which("mpv") ? new MpvBackend() : null;
case "none":
return new NoopBackend();
}
}

View File

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

239
src/utils/cavacore.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* TypeScript FFI bindings for libcavacore.
*
* Wraps cava's frequency-analysis engine (cavacore) via Bun's dlopen.
* The precompiled shared library ships in src/native/ (dev) and dist/ (prod)
* with fftw3 statically linked — zero native dependencies for end users.
*
* Usage:
* ```ts
* const cava = loadCavaCore()
* if (cava) {
* cava.init({ bars: 32, sampleRate: 44100 })
* const freqs = cava.execute(pcmSamples)
* cava.destroy()
* }
* ```
*/
import { dlopen, FFIType, ptr } from "bun:ffi";
import { existsSync } from "fs";
import { join, dirname } from "path";
// ── Types ────────────────────────────────────────────────────────────
export interface CavaCoreConfig {
/** Number of frequency bars (default: 32) */
bars?: number;
/** Audio sample rate in Hz (default: 44100) */
sampleRate?: number;
/** Number of audio channels (default: 1 = mono) */
channels?: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
autosens?: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction?: number;
/** Low frequency cutoff in Hz (default: 50) */
lowCutOff?: number;
/** High frequency cutoff in Hz (default: 10000) */
highCutOff?: number;
/** Output scaling mode: 0 = linear (default), 1 = decibel */
scalingMode?: number;
}
const DEFAULTS: Required<CavaCoreConfig> = {
bars: 32,
sampleRate: 44100,
channels: 1,
autosens: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
};
// ── Library resolution ───────────────────────────────────────────────
function findLibrary(): string | null {
const platform = process.platform;
const libName =
platform === "darwin"
? "libcavacore.dylib"
: platform === "win32"
? "cavacore.dll"
: "libcavacore.so";
// Candidate paths, in priority order:
// 1. src/native/ (development)
// 2. Same directory as the running executable (dist bundle)
// 3. dist/ relative to cwd
const candidates = [
join(import.meta.dir, "..", "native", libName),
join(dirname(process.execPath), libName),
join(process.cwd(), "dist", libName),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
// ── CavaCore class ───────────────────────────────────────────────────
export class CavaCore {
private lib: CavaLib;
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null;
private inputBuffer: Float64Array | null = null;
private outputBuffer: Float64Array | null = null;
private _bars = 0;
private _channels = 1;
private _destroyed = false;
/** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}
/** Whether this instance has been initialized (and not yet destroyed). */
get isReady(): boolean {
return this.plan !== null && !this._destroyed;
}
/**
* Initialize the cavacore engine with the given configuration.
* Must be called before execute(). Can be called again after destroy()
* to reinitialize with different parameters.
*/
init(config: CavaCoreConfig = {}): void {
if (this.plan) {
this.destroy();
}
const cfg = { ...DEFAULTS, ...config };
this._bars = cfg.bars;
this._channels = cfg.channels;
this.plan = this.lib.symbols.cava_init(
cfg.bars,
cfg.sampleRate,
cfg.channels,
cfg.autosens,
cfg.noiseReduction,
cfg.lowCutOff,
cfg.highCutOff,
cfg.scalingMode,
);
if (!this.plan) {
throw new Error("cava_init returned null — initialization failed");
}
// Pre-allocate output buffer (bars * channels)
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels);
this._destroyed = false;
}
/**
* Feed PCM samples into cavacore and get frequency bar values back.
*
* @param samples - Float64Array of PCM samples (scaled ~±32768).
* The array length determines the number of samples processed.
* @returns Float64Array of bar values (0.01.0 range, length = bars * channels).
* Returns the same buffer reference each call (overwritten in place).
*/
execute(samples: Float64Array): Float64Array {
if (!this.plan || !this.outputBuffer) {
throw new Error("CavaCore not initialized — call init() first");
}
// Reuse input buffer if same size, otherwise allocate new
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
this.inputBuffer = new Float64Array(samples.length);
}
this.inputBuffer.set(samples);
this.lib.symbols.cava_execute(
ptr(this.inputBuffer),
samples.length,
ptr(this.outputBuffer),
this.plan,
);
return this.outputBuffer;
}
/**
* Release all native resources. Safe to call multiple times.
* After calling destroy(), init() can be called again to reuse the instance.
*/
destroy(): void {
if (this.plan && !this._destroyed) {
this.lib.symbols.cava_destroy(this.plan);
this.plan = null;
this._destroyed = true;
}
this.inputBuffer = null;
this.outputBuffer = null;
}
}
// ── Factory ──────────────────────────────────────────────────────────
/**
* Attempt to load the cavacore shared library and return a CavaCore instance.
* Returns null if the library cannot be found — callers should fall back
* to the static waveform display.
*/
export function loadCavaCore(): CavaCore | null {
try {
const libPath = findLibrary();
if (!libPath) return null;
const lib = dlopen(libPath, {
cava_init: {
args: [
FFIType.i32, // bars
FFIType.u32, // rate
FFIType.i32, // channels
FFIType.i32, // autosens
FFIType.double, // noise_reduction
FFIType.i32, // low_cut_off
FFIType.i32, // high_cut_off
FFIType.i32, // scaling_mode
],
returns: FFIType.ptr,
},
cava_execute: {
args: [
FFIType.ptr, // cava_in (double*)
FFIType.i32, // samples
FFIType.ptr, // cava_out (double*)
FFIType.ptr, // plan
],
returns: FFIType.void,
},
cava_destroy: {
args: [FFIType.ptr], // plan
returns: FFIType.void,
},
});
return new CavaCore(lib as CavaLib);
} catch {
// Library load failed — missing dylib, wrong arch, etc.
return null;
}
}

221
src/utils/clipboard.ts Normal file
View File

@@ -0,0 +1,221 @@
import { $ } from "bun"
import { platform, release } from "os"
import { tmpdir } from "os"
import path from "path"
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
/**
* Lazy initialization for clipboard copy method.
* Detects the best clipboard method for the current platform.
*/
function createLazy<T>(factory: () => T): () => T {
let value: T | undefined
return () => {
if (value === undefined) {
value = factory()
}
return value
}
}
export namespace Clipboard {
export interface Content {
data: string
mime: string
}
/**
* Read content from the clipboard.
* Supports text and image (PNG) content on macOS, Windows, and Linux.
*/
export async function read(): Promise<Content | undefined> {
const os = platform()
// macOS: Try to read PNG image first
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "podtui-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
if (buffer.byteLength > 0) {
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
}
} catch {
// Ignore errors, fall through to text
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
}
}
// Windows/WSL: Try to read PNG image
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
// Linux: Try Wayland or X11
if (os === "linux") {
// Try Wayland first
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
}
// Try X11
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
}
}
// Fall back to reading text
try {
const text = await readText()
if (text) {
return { data: text, mime: "text/plain" }
}
} catch {
// Ignore errors
}
return undefined
}
/**
* Read text from the clipboard.
*/
async function readText(): Promise<string | undefined> {
const os = platform()
if (os === "darwin") {
const result = await $`pbpaste`.nothrow().text()
return result || undefined
}
if (os === "linux") {
// Try Wayland first
if (process.env["WAYLAND_DISPLAY"]) {
const result = await $`wl-paste`.nothrow().text()
if (result) return result
}
// Try X11
const result = await $`xclip -selection clipboard -o`.nothrow().text()
return result || undefined
}
if (os === "win32" || release().includes("WSL")) {
const result = await $`powershell.exe -NonInteractive -NoProfile -command "Get-Clipboard"`.nothrow().text()
return result?.trim() || undefined
}
return undefined
}
const getCopyMethod = createLazy(() => {
const os = platform()
if (os === "darwin" && Bun.which("osascript")) {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (Bun.which("xclip")) {
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (Bun.which("xsel")) {
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
}
if (os === "win32") {
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
// Fallback: No native clipboard support
return async (_text: string) => {
console.warn("No clipboard support available on this platform")
}
})
/**
* Copy text to the clipboard.
* Uses OSC 52 for SSH/tmux support and native clipboard for local.
*/
export async function copy(text: string): Promise<void> {
// Always try OSC 52 first for SSH/tmux support
writeOsc52(text)
// Then use native clipboard
await getCopyMethod()(text)
}
}

View File

@@ -0,0 +1,67 @@
import { RGBA } from "@opentui/core"
export function tint(base: RGBA, overlay: RGBA, alpha: number) {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
export function generateGrayScale(bg: RGBA, isDark: boolean) {
const grays: Record<number, RGBA> = {}
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
for (let i = 1; i <= 12; i++) {
const factor = i / 12.0
if (isDark) {
if (luminance < 10) {
const gray = Math.floor(factor * 0.4 * 255)
grays[i] = RGBA.fromInts(gray, gray, gray)
} else {
const newLum = luminance + (255 - luminance) * factor * 0.4
const ratio = newLum / luminance
grays[i] = RGBA.fromInts(
Math.min(bgR * ratio, 255),
Math.min(bgG * ratio, 255),
Math.min(bgB * ratio, 255)
)
}
} else {
if (luminance > 245) {
const gray = Math.floor(255 - factor * 0.4 * 255)
grays[i] = RGBA.fromInts(gray, gray, gray)
} else {
const newLum = luminance * (1 - factor * 0.4)
const ratio = newLum / luminance
grays[i] = RGBA.fromInts(
Math.max(bgR * ratio, 0),
Math.max(bgG * ratio, 0),
Math.max(bgB * ratio, 0)
)
}
}
}
return grays
}
export function generateMutedTextColor(bg: RGBA, isDark: boolean) {
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
if (isDark) {
if (bgLum < 10) return RGBA.fromInts(180, 180, 180)
const gray = Math.min(Math.floor(160 + bgLum * 0.3), 200)
return RGBA.fromInts(gray, gray, gray)
}
if (bgLum > 245) return RGBA.fromInts(75, 75, 75)
const gray = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
return RGBA.fromInts(gray, gray, gray)
}

72
src/utils/config-dir.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* XDG_CONFIG_HOME directory setup for PodTUI
*
* Handles config directory detection and creation following the XDG Base
* Directory Specification. Falls back to ~/.config when XDG_CONFIG_HOME
* is not set.
*/
import { mkdir } from "fs/promises"
import path from "path"
/** Application config directory name */
const APP_DIR_NAME = "podtui"
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
function getXdgConfigHome(): string {
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return xdg
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
if (!home) throw new Error("Cannot determine home directory")
return path.join(home, ".config")
}
/** Get the application-specific config directory path */
export function getConfigDir(): string {
return path.join(getXdgConfigHome(), APP_DIR_NAME)
}
/** Get the path for a specific config file */
export function getConfigFilePath(filename: string): string {
return path.join(getConfigDir(), filename)
}
/**
* Ensure the application config directory exists.
* Creates it recursively if needed.
*/
export async function ensureConfigDir(): Promise<string> {
const dir = getConfigDir()
await mkdir(dir, { recursive: true })
return dir
}
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
function getXdgDataHome(): string {
const xdg = process.env.XDG_DATA_HOME
if (xdg) return xdg
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
if (!home) throw new Error("Cannot determine home directory")
return path.join(home, ".local", "share")
}
/** Get the application-specific data directory path */
function getDataDir(): string {
return path.join(getXdgDataHome(), APP_DIR_NAME)
}
/** Get the downloads directory path */
function getDownloadsDir(): string {
return path.join(getDataDir(), "downloads")
}
/** Ensure the downloads directory exists */
export async function ensureDownloadsDir(): Promise<string> {
const dir = getDownloadsDir()
await mkdir(dir, { recursive: true })
return dir
}

182
src/utils/config.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Centralized PodTui configuration — a single `config.json` holding every
* user-facing bit needed to migrate to a new machine by copying one file.
*
* Contains: settings, preferences, custom theme, feeds (subscriptions), and
* sources (podcast search/RSS sources).
*
* Runtime state that changes on every playback action (progress, downloads,
* audio-nav) stays in separate files to avoid rewriting this file on every
* seek. Keybinds remain in `keybinds.jsonc` (user-editable JSONC).
*
* Writes are serialized to avoid concurrent read-modify-write races, and
* always overwrite — no backup files are created.
*/
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
import type {
AppSettings,
UserPreferences,
ThemeColors,
} from "../types/settings";
import type { Feed } from "../types/feed";
import type { PodcastSource } from "../types/source";
/** Everything a user needs to migrate, in one file. */
export interface PodTuiConfig {
settings?: AppSettings;
preferences?: UserPreferences;
customTheme?: ThemeColors;
feeds?: Feed[];
sources?: PodcastSource[];
}
const CONFIG_FILE = "config.json";
/** Legacy per-section files, migrated into config.json on first load. */
const LEGACY_FILES = ["app-state.json", "feeds.json", "sources.json"] as const;
/** Load the full config from disk. Returns {} if missing or corrupt.
* Runs one-time legacy migration on first call. */
export async function loadConfig(): Promise<PodTuiConfig> {
await migrateOnce();
try {
const file = Bun.file(getConfigFilePath(CONFIG_FILE));
if (!(await file.exists())) return {};
const raw = await file.json();
if (!raw || typeof raw !== "object") return {};
return raw as PodTuiConfig;
} catch {
return {};
}
}
// ── Write serialization ────────────────────────────────────────────────────
// A simple promise chain ensures reads-modify-writes execute sequentially so
// two concurrent saves can't clobber each other's sections.
let writeChain: Promise<void> = Promise.resolve();
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
export function updateConfig(patch: Partial<PodTuiConfig>): void {
writeChain = writeChain.then(async () => {
try {
await ensureConfigDir();
const current = await loadConfig();
const next = { ...current, ...patch };
await Bun.write(
getConfigFilePath(CONFIG_FILE),
JSON.stringify(next, null, 2),
);
} catch {
// Fire-and-forget persistence — silently ignore write errors.
}
});
}
/** Guards so migration runs exactly once per process. */
let migrationDone = false;
let migrationPromise: Promise<void> | null = null;
/** Run legacy migration + backup cleanup once, before the first config read. */
async function migrateOnce(): Promise<void> {
if (migrationDone) return;
if (!migrationPromise) migrationPromise = migrateLegacyConfig();
await migrationPromise;
migrationDone = true;
}
/**
* One-time migration: if config.json doesn't exist but legacy per-section
* files do, merge them into a single config.json. Also cleans up any stale
* backup files (`.backup` suffix) left by the old config-backup module.
*
* Safe to call on every startup — no-op once config.json exists (except for
* backup cleanup, which runs unconditionally since those files are now dead).
*/
async function migrateLegacyConfig(): Promise<void> {
try {
await ensureConfigDir();
const dir = getConfigDir();
const configExists = await Bun.file(
getConfigFilePath(CONFIG_FILE),
).exists();
if (!configExists) {
const merged: PodTuiConfig = {};
// app-state.json → settings, preferences, customTheme
const appStateFile = Bun.file(getConfigFilePath("app-state.json"));
if (await appStateFile.exists()) {
try {
const raw = await appStateFile.json();
if (raw && typeof raw === "object") {
merged.settings = raw.settings;
merged.preferences = raw.preferences;
merged.customTheme = raw.customTheme;
}
} catch {
// ignore corrupt legacy file
}
}
// feeds.json → feeds
const feedsFile = Bun.file(getConfigFilePath("feeds.json"));
if (await feedsFile.exists()) {
try {
const raw = await feedsFile.json();
if (Array.isArray(raw)) merged.feeds = raw;
} catch {
// ignore
}
}
// sources.json → sources
const sourcesFile = Bun.file(getConfigFilePath("sources.json"));
if (await sourcesFile.exists()) {
try {
const raw = await sourcesFile.json();
if (Array.isArray(raw)) merged.sources = raw;
} catch {
// ignore
}
}
if (Object.keys(merged).length > 0) {
await Bun.write(
getConfigFilePath(CONFIG_FILE),
JSON.stringify(merged, null, 2),
);
// Remove migrated legacy files
for (const name of LEGACY_FILES) {
await Bun.file(getConfigFilePath(name))
.exists()
.then(async (exists) => {
if (exists)
await import("fs/promises").then((fs) =>
fs.unlink(getConfigFilePath(name)).catch(() => {}),
);
});
}
}
}
// Clean up stale backup files (no longer created, remove old ones)
await cleanBackups(dir);
} catch {
// Migration is best-effort — never block startup.
}
}
/** Remove all `.backup` files from the config directory. */
async function cleanBackups(dir: string): Promise<void> {
try {
const { readdir, unlink } = await import("fs/promises");
const entries = await readdir(dir);
const backups = entries.filter((e) => e.endsWith(".backup"));
for (const name of backups) {
await unlink(`${dir}/${name}`).catch(() => {});
}
} catch {
// ignore
}
}

View File

@@ -0,0 +1,37 @@
import path from "path"
import { mkdir } from "fs/promises"
import type { ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
import { validateTheme } from "./theme-loader"
// Files to exclude from theme loading (not actual themes)
const EXCLUDED_FILES = new Set(["schema", "schema.json"])
export async function getCustomThemes() {
const home = process.env.HOME ?? ""
if (!home) return {}
const dir = path.join(home, ".config/podtui/themes")
await mkdir(dir, { recursive: true })
for (const [name, theme] of Object.entries(THEME_JSON)) {
const file = path.join(dir, `${name}.json`)
const exists = await Bun.file(file).exists()
if (exists) continue
await Bun.write(file, JSON.stringify(theme, null, 2))
}
const result: Record<string, ThemeJson> = {}
const glob = new Bun.Glob("*.json")
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
const name = path.basename(item, ".json")
// Skip non-theme files
if (EXCLUDED_FILES.has(name) || EXCLUDED_FILES.has(path.basename(item))) {
continue
}
const json = (await Bun.file(item).json()) as ThemeJson
validateTheme(json, item)
result[name] = json
}
return result
}

252
src/utils/dispatch.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* dispatch — the yazi-style unified keybind router.
*
* Extracted from `src/components/Shell.tsx` into this pure (no-JSX, no
* @opentui/solid) module so `dispatch()` is unit-testable with `bun test`
* directly — mirroring how task 01 split `navigation-store` out of the
* NavigationContext so the nav model could be exercised without the OpenTUI
* JSX runtime (supplied only by the build-time bun-plugin).
*
* The Shell builds a `DispatcherDeps` from its live hooks + the audio-side
* `advanceEpisode` helper, then forwards every matched keystroke action to
* `dispatch`. Behavioural rules (tab root + task 06):
*
* • The tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is
* the CURRENT pane with nothing above it:
* `k`/`j` (`move-down`/`move-up`) move a cursor through the tabs
* (highlight follows `tabCursor`; the active tab is untouched),
* `l`/Enter (`swipe-next`/`open`) open the hovered tab (`activateTabCursor`)
* — the tab slides into UP and its content becomes CURRENT; `h`
* (`swipe-prev`) at the root stays (out of the panes); `1-6` / `[`/`]`
* switch tabs directly (re-syncing the cursor).
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
* switch tabs; focus keeps its context (root iff already at the root,
* otherwise the content `DEPTH_CENTER_PANE`).
* • `h`/`l` are `swipe-prev`/`swipe-next` in content (every tab is a
* depth-tab): `l` at the current pane drills in (emits `open`); `h` pops
* a depth when depth > 0; at depth 0 `h` returns to the tab root
* (`backToTabRoot`), where the tab becomes CURRENT again.
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
* are unchanged from the pre-rewrite Shell. */
import type { KeybindActionName } from "@/context/KeybindContext";
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
import { TABS, TabsCount } from "@/utils/navigation";
import { emit } from "@/utils/event-bus";
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
export { DEPTH_CENTER_PANE, NavMode };
/** The payload carried on the `nav.action` event bus. Mirrors the typed event
* in utils/event-bus.ts but duplicated here so this module stays dep-light. */
export type NavActionEvent = {
action: KeybindActionName;
tab: TABS;
pane: number;
mode: NavMode;
};
/** Actions the active page is responsible for (pane/list-local). These flow
* to the current pane only via `emit("nav.action", …)`. There is no
* SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */
export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
new Set<KeybindActionName>([
"move-down",
"move-up",
"page-down",
"page-up",
"full-down",
"full-up",
"jump-down",
"jump-up",
"goto-top",
"goto-bottom",
"toggle-select",
"visual-mode",
"toggle-all",
"invert-all",
"open",
"open-interactive",
"search",
"filter",
"sort",
"toggle-hidden",
"refresh",
"unsubscribe",
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
}
return null;
}
/** Dependencies the unified keybind dispatcher closes over. `advanceEpisode`
* is passed in (it lives over the full audio/feed/toast surface in Shell) so
* the dispatcher only needs the subset it touches directly. */
export type DispatcherDeps = {
nav: NavigationState;
audio: {
togglePlayback: () => Promise<void>;
seekRelative: (n: number) => Promise<void>;
};
k: { clearPending: () => void };
setShowHelp: (fn: (v: boolean) => boolean) => void;
advanceEpisode: (offset: number) => void;
};
/** Build the unified router (normal + visual modes). Returns `dispatch` —
* the closure Shell's `useKeyboard` calls with each matched action. */
export function createDispatcher(deps: DispatcherDeps) {
const { nav, audio, k, setShowHelp, advanceEpisode } = deps;
function dispatch(
action: KeybindActionName,
evt: { preventDefault: () => void },
) {
const tab = nav.activeTab();
const pane = nav.activePane();
switch (action) {
// ── modes ──
case "escape":
evt.preventDefault();
if (nav.mode() === NavMode.VISUAL) {
nav.toNormal();
break;
}
k.clearPending();
break;
case "command":
evt.preventDefault();
nav.enterCommand();
break;
case "visual-mode":
evt.preventDefault();
nav.enterVisual();
break;
case "toggle-select":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
case "toggle-all":
case "invert-all":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
// ── tabs (the only tab switchers) ──
case "tab-next":
evt.preventDefault();
nav.nextTab();
break;
case "tab-prev":
evt.preventDefault();
nav.prevTab();
break;
default: {
const dt = tabByDigit(action);
if (dt) {
evt.preventDefault();
nav.setActiveTab(dt);
break;
}
// ── tab root focus ──
// At the app root the tab list is the CURRENT pane: j/k move the tab
// cursor (active tab untouched), l/Enter open the hovered tab (switch
// to it + enter its content), h stays inert (out of the panes).
if (nav.atRootTab()) {
evt.preventDefault();
if (action === "move-down") {
nav.moveTabCursor(1);
break;
}
if (action === "move-up") {
nav.moveTabCursor(-1);
break;
}
if (action === "open") {
nav.activateTabCursor();
break;
}
if (action === "swipe-next") {
nav.activateTabCursor();
break;
}
if (action === "swipe-prev") break;
}
// ── pane swipe / depth nav ──
// Every tab is a depth-tab: `l` at the center drills in (emits `open`);
// `h` at the center pops a depth when depth > 0, and at depth 0 returns
// to the tab root (the tab becomes CURRENT again).
if (action === "swipe-prev") {
evt.preventDefault();
if (nav.currentDepth() > 0) nav.popDepth();
else nav.backToTabRoot(); // depth 0 → tab root
break;
}
if (action === "swipe-next") {
evt.preventDefault();
emit("nav.action", {
action: "open",
tab,
pane: DEPTH_CENTER_PANE,
mode: nav.mode(),
});
break;
}
// ── audio transport (global) ──
if (action === "audio-toggle") {
evt.preventDefault();
audio.togglePlayback().catch(() => {});
break;
}
if (action === "audio-seek-forward") {
evt.preventDefault();
audio.seekRelative(10).catch(() => {});
break;
}
if (action === "audio-seek-backward") {
evt.preventDefault();
audio.seekRelative(-10).catch(() => {});
break;
}
if (action === "audio-next") {
evt.preventDefault();
advanceEpisode(1);
break;
}
if (action === "audio-prev") {
evt.preventDefault();
advanceEpisode(-1);
break;
}
// ── global app ──
if (action === "quit") {
evt.preventDefault();
process.exit(0);
}
if (action === "help") {
evt.preventDefault();
setShowHelp((v) => !v);
}
// ── page-local list/pane actions ──
if (PAGE_ACTIONS.has(action)) {
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
}
}
}
}
return { dispatch };
}
// Re-export the depth-frame type for convenience.
export type { DepthFrame };

View File

@@ -0,0 +1,199 @@
/**
* Episode download utility for PodTUI
*
* Streams audio files from episode URLs to the local downloads directory
* using fetch() + ReadableStream. Supports progress tracking and cancellation
* via AbortController.
*/
import path from "path"
import { ensureDownloadsDir } from "./config-dir"
/** Progress callback info */
export interface DownloadProgress {
/** Bytes downloaded so far */
bytesDownloaded: number
/** Total file size in bytes (0 if unknown) */
totalBytes: number
/** Progress percentage 0-100 (or -1 if total unknown) */
percent: number
/** Download speed in bytes/sec */
speed: number
}
/** Download result */
export interface DownloadResult {
/** Whether the download succeeded */
success: boolean
/** Absolute path to the downloaded file */
filePath: string
/** File size in bytes */
fileSize: number
/** Error message if failed */
error?: string
}
/**
* Sanitize a string for use as a filename.
* Removes or replaces characters that are invalid in file paths.
*/
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, "-")
.replace(/\s+/g, "_")
.replace(/-+/g, "-")
.replace(/^[-_.]+/, "")
.slice(0, 200)
}
/**
* Derive a filename from the episode URL or title.
*/
function deriveFilename(audioUrl: string, episodeTitle: string): string {
// Try to extract filename from URL
try {
const url = new URL(audioUrl)
const urlFilename = path.basename(url.pathname)
if (urlFilename && urlFilename.includes(".")) {
return sanitizeFilename(decodeURIComponent(urlFilename))
}
} catch {
// Fall through to title-based name
}
// Fall back to sanitized title + .mp3
const ext = ".mp3"
return sanitizeFilename(episodeTitle) + ext
}
/**
* Download an episode audio file with progress tracking and cancellation support.
*
* @param audioUrl - URL of the audio file to download
* @param episodeTitle - Episode title (used for filename fallback)
* @param feedId - Feed ID (used to organize downloads into subdirectories)
* @param onProgress - Optional callback invoked periodically with download progress
* @param abortSignal - Optional AbortSignal for cancellation
* @returns DownloadResult with file path and size info
*/
export async function downloadEpisode(
audioUrl: string,
episodeTitle: string,
feedId: string,
onProgress?: (progress: DownloadProgress) => void,
abortSignal?: AbortSignal,
): Promise<DownloadResult> {
const downloadsDir = await ensureDownloadsDir()
const feedDir = path.join(downloadsDir, feedId)
await Bun.write(path.join(feedDir, ".keep"), "") // ensures dir exists
const { unlink } = await import("fs/promises")
await unlink(path.join(feedDir, ".keep")).catch(() => {})
const { mkdir } = await import("fs/promises")
await mkdir(feedDir, { recursive: true })
const filename = deriveFilename(audioUrl, episodeTitle)
const filePath = path.join(feedDir, filename)
try {
const response = await fetch(audioUrl, {
signal: abortSignal,
headers: {
"Accept": "audio/*, */*",
"Accept-Encoding": "identity",
},
})
if (!response.ok) {
return {
success: false,
filePath,
fileSize: 0,
error: `HTTP ${response.status}: ${response.statusText}`,
}
}
const contentLength = parseInt(response.headers.get("content-length") ?? "0", 10)
const body = response.body
if (!body) {
return {
success: false,
filePath,
fileSize: 0,
error: "No response body",
}
}
const reader = body.getReader()
const chunks: Uint8Array[] = []
let bytesDownloaded = 0
let lastProgressTime = Date.now()
let lastProgressBytes = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
bytesDownloaded += value.length
// Report progress roughly every 250ms
const now = Date.now()
if (onProgress && now - lastProgressTime >= 250) {
const elapsed = (now - lastProgressTime) / 1000
const speed = elapsed > 0 ? (bytesDownloaded - lastProgressBytes) / elapsed : 0
const percent = contentLength > 0
? Math.round((bytesDownloaded / contentLength) * 100)
: -1
onProgress({ bytesDownloaded, totalBytes: contentLength, percent, speed })
lastProgressTime = now
lastProgressBytes = bytesDownloaded
}
}
// Concatenate chunks and write to file
const totalSize = bytesDownloaded
const buffer = new Uint8Array(totalSize)
let offset = 0
for (const chunk of chunks) {
buffer.set(chunk, offset)
offset += chunk.length
}
await Bun.write(filePath, buffer)
// Final progress report
if (onProgress) {
onProgress({
bytesDownloaded: totalSize,
totalBytes: contentLength || totalSize,
percent: 100,
speed: 0,
})
}
return {
success: true,
filePath,
fileSize: totalSize,
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
return {
success: false,
filePath,
fileSize: 0,
error: "Download cancelled",
}
}
const message = err instanceof Error ? err.message : "Unknown download error"
return {
success: false,
filePath,
fileSize: 0,
error: message,
}
}
}

167
src/utils/event-bus.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* Simple event bus for inter-component communication.
*
* This provides a decoupled way for components to communicate without
* direct dependencies. Components can publish events and subscribe to
* events they're interested in.
*
* Usage:
* ```tsx
* // Subscribe to events
* const unsub = EventBus.on("theme.changed", (data) => {
* console.log("Theme changed to:", data.theme)
* })
*
* // Publish events
* EventBus.emit("theme.changed", { theme: "dark" })
*
* // Cleanup
* unsub()
* ```
*/
type EventHandler<T = unknown> = (data: T) => void;
// Export EventHandler type for external use
export type { EventHandler };
interface EventBusInstance {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
off<T = unknown>(event: string, handler: EventHandler<T>): void;
emit<T = unknown>(event: string, data: T): void;
clear(): void;
}
function createEventBus(): EventBusInstance {
const handlers = new Map<string, Set<EventHandler>>();
return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) {
handlers.set(event, new Set());
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler);
};
},
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler);
handler(data);
};
return this.on(event, wrappedHandler);
},
off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler as EventHandler);
if (eventHandlers.size === 0) {
handlers.delete(event);
}
}
},
emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(data);
} catch (error) {
console.error(`Error in event handler for "${event}":`, error);
}
}
}
},
clear(): void {
handlers.clear();
},
};
}
// Singleton event bus instance
const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation";
import type { PaneId, NavMode } from "@/context/NavigationContext";
// Common event types for the application
export type AppEvents = {
"theme.changed": { theme: string; mode: "dark" | "light" };
"theme.mode.changed": { mode: "dark" | "light" };
"theme.reload": {};
"navigation.tab.changed": { tab: string; previousTab?: string };
"navigation.layer.changed": { depth: number; previousDepth: number };
"feed.subscribed": { feedId: string; feedUrl: string };
"feed.unsubscribed": { feedId: string };
"player.play": { episodeId: string };
"player.pause": { episodeId: string };
"player.stop": {};
// Emitted when a NEW episode begins playback (not on resume).
"player.started": { episodeId: string };
"toast.show": {
message: string;
variant: "info" | "success" | "warning" | "error";
title?: string;
duration?: number;
};
"dialog.open": { dialogId: string };
"dialog.close": { dialogId?: string };
"command.execute": { command: string; args?: unknown };
// Yazi-style unified router → active page dispatch. The Shell router
// emits these; each page subscribes to the subset it implements.
"nav.action": {
action: KeybindActionName;
tab: TABS;
pane: PaneId;
mode: NavMode;
};
"clipboard.copied": { text: string };
"selection.start": { x: number; y: number };
"selection.end": { text: string };
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
"media.toggle": {};
"media.volumeUp": {};
"media.volumeDown": {};
"media.seekForward": {};
"media.seekBackward": {};
"media.speedCycle": {};
};
// Type-safe emit and on functions
export function emit<K extends keyof AppEvents>(
event: K,
data: AppEvents[K],
): void {
EventBus.emit(event, data);
}
export function on<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>,
): () => void {
return EventBus.on(event, handler);
}
export function once<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>,
): () => void {
return EventBus.once(event, handler);
}
export function off<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>,
): void {
EventBus.off(event, handler);
}

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