Compare commits
222 Commits
f08afb2ed1
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 48076fcef5 | |||
| 06c5cc9184 | |||
| a3641d100e | |||
| 8a173a5180 | |||
| 132d2079f7 | |||
| b53d4add29 | |||
| 6c3ad5d925 | |||
| 09d5732b55 | |||
| 9e2f232d27 | |||
| 9143078b12 | |||
| ca46de4d70 | |||
| 3e90f9e783 | |||
| c2ec356a5f | |||
| 6aac138629 | |||
| 3f0001b0d5 | |||
| 74158d75d4 | |||
| 4ff8aabb51 | |||
| 895138357f | |||
| 02c957f584 | |||
| d7ceb9d045 | |||
| 0677f82c44 | |||
| 4990eae60f | |||
| 9ddfd21685 | |||
| 22059c24ca | |||
| 0aac0a157f | |||
| df4701957b | |||
| 4b44623891 | |||
| 4ef9ab7e59 | |||
| 9df8eebf6c | |||
| badbc6a037 | |||
| 878d1e01ab | |||
| 42c48e59fb | |||
| 91d4acca90 | |||
| 20d5b57cb6 | |||
| d7aec4e810 | |||
| 77531ce41d | |||
| 26729fa5e6 | |||
| 4127fd1181 | |||
| 6c99b96b12 | |||
| acbaf2ed1c | |||
| e7ed89056e | |||
| b5432e3e5f | |||
| 13664c3cec | |||
| 0b4a551744 | |||
| ed75c2fff7 | |||
| bd7d988741 | |||
| deac6081ca | |||
| e09ae15e32 | |||
| 33af131b77 | |||
| 3d6d4918bc | |||
| 35ad858d0d | |||
| af827a9a96 | |||
| 2cf9559b0b | |||
| 20336ea716 | |||
| 8b7b38276e | |||
| 8496922aaf | |||
| 5e3ad48a2d | |||
| 005ac8fde3 | |||
| 1f0b9de456 | |||
| 3388757185 | |||
| 8049d02457 | |||
| 1b55b7117c | |||
| 15f8a098b5 | |||
| 2d7d49b91c | |||
| df9c519439 | |||
| 2bf1c229c7 | |||
| b2e9e5c16c | |||
| 41c0002090 | |||
| c0252fc9b8 | |||
| 0c3506beb5 | |||
| ef9fc13aaa | |||
| 0b0637b9dc | |||
| e73e608b9f | |||
| ebed49237c | |||
| dc2b22eaa5 | |||
| 2bb612ee07 | |||
| dc855ab8a0 | |||
| f976bdc2b7 | |||
| 1cf3361e59 | |||
| ada441300a | |||
| 6134dea044 | |||
| 93d5925dfd | |||
| de6d0ccbf6 | |||
| cda29bcb95 | |||
| 3775a9801d | |||
| a9589e7686 | |||
| c52fa14e42 | |||
| 116d095ad5 | |||
| b280af484c | |||
| 5dce21c038 | |||
| d2c46631ef | |||
| 67032460ff | |||
| 0f3ffcf934 | |||
| c813949d48 | |||
| eb220386ce | |||
| 19eae4fd5a | |||
| e70469b1ec | |||
| 30b7ff57d3 | |||
| fd689aba04 | |||
| 8eaca82ce9 | |||
| 8ac1ec1162 | |||
| 4a94ff5910 | |||
| 2e69868ffc | |||
| 491a736c32 | |||
| 12bd6be4bc | |||
| d2f6c5c525 | |||
| f758b53336 | |||
| b0bfa41028 | |||
| 25307f83e9 | |||
| db285530b6 | |||
| e1cdd6b2a5 | |||
| 2abdbaa4e9 | |||
| 1d06156b8b | |||
| c63e9e1b9c | |||
| 0facfff51b | |||
| 3ef19f80b8 | |||
| 13a31aabdc | |||
| 8dbdebfd30 | |||
| 529817323d | |||
| ace883b505 | |||
| de01cedee0 | |||
| 2730fa3cae | |||
| 91a831c5f9 | |||
| 52e9ae0ab7 | |||
| 64d8b40e61 | |||
| 0cc15c8d90 | |||
| 1d3abd53d4 | |||
| 592cfd4093 | |||
| 69e12cf5b9 | |||
| c9e3aa92ec | |||
| 25fe7f6ac9 | |||
| 85cb9fba26 | |||
| c8d29ed59d | |||
| 9e7a44309a | |||
| 6cd90ad6c0 | |||
| 6c0affc77b | |||
| b24c83711d | |||
| 866fcd7574 | |||
| 7c7d487ca6 | |||
| 798178f21f | |||
| 3f61303756 | |||
| 139a258987 | |||
| cfa4ef0c47 | |||
| 5b667367a5 | |||
| b1bb9d9a1e | |||
| 21b088b5a9 | |||
| 97b2f61e5f | |||
| 89c5ca2f7e | |||
| d8f11040bc | |||
| b7c4938c54 | |||
| 256f112512 | |||
| 8196ac8e31 | |||
| f003377f0d | |||
| 1618588a30 | |||
| c9a370a424 | |||
| b45e7bf538 | |||
| 1e6618211a | |||
| 1a5efceebd | |||
| 0c16353e2e | |||
| 8d350d9eb5 | |||
| cc09786592 | |||
| cedf099910 | |||
| d1e1dd28b4 | |||
| 1c65c85d02 | |||
| 8e0f90f449 | |||
| 91fcaa9b9e | |||
| 0bbb327b29 | |||
| 276732d2a9 | |||
| 72000b362d | |||
| 9a2b790897 | |||
| 2dfc96321b | |||
| 3d5bc84550 | |||
| f707594d0c | |||
| a405474f11 | |||
| ce022dc447 | |||
| 6053d4d02c | |||
| 64a2ba2751 | |||
| bcf248f7dd | |||
| 5bd393c9cd | |||
| 627fb65547 | |||
| 73aa211229 | |||
| 7eb49ac1c7 | |||
| 19a1f1a43b | |||
| 2e323d283f | |||
| 46f9135776 | |||
| db74e20571 | |||
| 70f50eec2a | |||
| 1cee931913 | |||
| bfea6816ef | |||
| 75f1f7d6af | |||
| 1e3b794b8e | |||
| 1293d30225 | |||
| 920042ee2a | |||
| e1dc242b1d | |||
| 8d6b19582c | |||
| 63ded34a6b | |||
| 0e4f47323f | |||
| 42a1ddf458 | |||
| 168e6d5a61 | |||
| 6b00871c32 | |||
| e0fa76fb32 | |||
| f3344fbed2 | |||
| 03e69d04dc | |||
| 91de49be0d | |||
| 3d156403c7 | |||
| e239b33042 | |||
| 9fa52d71ca | |||
| ea9ab4d3f9 | |||
| 6950deaa88 | |||
| 4579659784 | |||
| c26150221a | |||
| 39a4f88496 | |||
| 624a6ba022 | |||
| cdabf2c3e0 | |||
| b8549777ba | |||
| 9b1a3585e6 | |||
| 4cee352641 | |||
| 72b2870f64 | |||
| f7df578461 | |||
| bd4747679d | |||
| d5ce8452e4 | |||
| 7b5c256e07 |
102
.github/workflows/release.yml
vendored
Normal 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@v5
|
||||
|
||||
- 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@v6
|
||||
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@v7
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*.tar.gz
|
||||
LICENSE
|
||||
37
.gitignore
vendored
@@ -1,2 +1,39 @@
|
||||
.opencode
|
||||
# dependencies (bun install)
|
||||
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
|
||||
notes.md
|
||||
# pygienium run-state and check artifacts
|
||||
.pygienium/
|
||||
|
||||
97
AGENTS.md
Normal 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 the TypeScript typecheck (`bun tsc --noEmit`)
|
||||
|
||||
## 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
@@ -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
@@ -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
@@ -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
|
||||
237
README.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# 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, `1–6` / `[` `]` 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.
|
||||
When an episode finishes, the next one plays automatically, continuing
|
||||
down the list you started it from (search results, a show, or the Feed).
|
||||
- **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 -sSL -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 1–6 (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) |
|
||||
| `d` | Download the focused episode (Feed / My Shows detail pane) |
|
||||
| `D` | Delete the focused episode's download (if one exists) |
|
||||
| `w` | Toggle the focused show in/out of the auto-download whitelist (My Shows, whitelist scope) |
|
||||
|
||||
**Audio**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `P` | Play / pause |
|
||||
| `N` / `B` | Next / previous episode |
|
||||
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||
|
||||
## 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.
|
||||
|
||||
**Auto-download** — in Settings → Preferences: `Auto Download` (master
|
||||
toggle) downloads the `Auto Download Count` most recent episodes (default 2,
|
||||
any positive integer — type it in the editor) of every show in the `Auto
|
||||
Download Scope` (all / none / whitelist, default all). With the whitelist
|
||||
scope, a search field appears under the setting to pick shows (Space toggles
|
||||
a suggestion in/out), and `w` in My Shows adds/removes the focused show.
|
||||
|
||||
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
|
||||
|
||||
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed and per-show "Fetch More" rows). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
|
||||
|
||||
## 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`. Homebrew and AUR installs pull it in automatically; if you used the
|
||||
standalone tarball, install it yourself (`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
|
||||
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="400" xmlns="http://www.w3.org/2000/svg" height="125.714" id="screenshot-993fe4cb-279d-80e0-8008-769a7b1374b8" viewBox="0 0 400 125.714" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-769a7b1374b8" rx="0" ry="0"><g id="shape-993fe4cb-279d-80e0-8008-769a7a62c77d"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a62c77d"><rect rx="6.857142857142833" ry="6.857142857142833" x="0" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a89c956"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a89c956"><rect rx="15" ry="15" x="45.71428571428572" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><rect rx="15" ry="15" x="91.42857142857144" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ab46172"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ab46172"><rect rx="15" ry="15" x="137.1428571428571" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ac82b71"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ac82b71"><rect rx="15" ry="15" x="182.85714285714283" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="125.71428571428578" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><rect rx="15" ry="15" x="228.57142857142856" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7aea70cd"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7aea70cd"><rect rx="15" ry="15" x="274.28571428571433" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7af77904"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7af77904"><rect rx="15" ry="15" x="320" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7b04705e"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7b04705e"><rect rx="6.857142857142833" ry="6.857142857142833" x="365.7142857142858" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
1
assets/App Icon/App Icon.icon/Assets/Terminal Cursor.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="80" xmlns="http://www.w3.org/2000/svg" height="256" id="screenshot-993fe4cb-279d-80e0-8008-76a1e45d35f1" viewBox="0 0 80 256" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-76a1e45d35f1"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-76a1e45d35f1"><rect rx="10" ry="10" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="80" height="256" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 526 B |
51
assets/App Icon/App Icon.icon/icon.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"fill" : {
|
||||
"solid" : "display-p3:0.18481,0.20325,0.24683,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "Podcast Waveform.svg",
|
||||
"name" : "Podcast Waveform",
|
||||
"position" : {
|
||||
"scale" : 2,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"fill" : "automatic",
|
||||
"glass" : false,
|
||||
"image-name" : "Terminal Cursor.svg",
|
||||
"name" : "Terminal Cursor",
|
||||
"position" : {
|
||||
"scale" : 2,
|
||||
"translation-in-points" : [
|
||||
320.00000000000006,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
BIN
assets/App Icon/App Icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
assets/App Icon/App Icon.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/App Icon/App Icon_2x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/App Icon/App Icon_4x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
assets/App Icon/App Icon_6x.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
150
build.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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));
|
||||
}
|
||||
|
||||
// App icon: bundled into every platform tarball; Linux also gets the
|
||||
// desktop entry so the AUR package can install both system-wide
|
||||
// (icon to hicolor, entry to applications/).
|
||||
const iconSrc = join("assets", "App Icon", "App Icon.png");
|
||||
if (existsSync(iconSrc)) {
|
||||
copyFileSync(iconSrc, join(tarRoot, "podtui.png"));
|
||||
}
|
||||
if (platform === "linux") {
|
||||
const desktopSrc = join("packaging", "podtui.desktop");
|
||||
if (existsSync(desktopSrc)) {
|
||||
copyFileSync(desktopSrc, join(tarRoot, "podtui.desktop"));
|
||||
}
|
||||
}
|
||||
|
||||
const tar = Bun.spawnSync([
|
||||
"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");
|
||||
2
bunfig.test.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/preload/solid-test-plugin.ts"]
|
||||
8
bunfig.toml
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
31
package.json
@@ -1 +1,30 @@
|
||||
{ "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": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.1.77",
|
||||
"@opentui/solid": "^0.1.77",
|
||||
"date-fns": "^4.1.0",
|
||||
"effect": "^3",
|
||||
"solid-js": "^1.9.9"
|
||||
}
|
||||
}
|
||||
|
||||
41
packaging/aur/.SRCINFO
Normal file
@@ -0,0 +1,41 @@
|
||||
pkgbase = podtui-bin
|
||||
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||
pkgver = 0.2.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/mikefreno/podtui
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
depends = mpv
|
||||
provides = podtui
|
||||
conflicts = podtui
|
||||
options = !strip
|
||||
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||
|
||||
pkgname = podtui-bin
|
||||
pkgver = 0.2.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/mikefreno/podtui
|
||||
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
depends = mpv
|
||||
provides = podtui
|
||||
conflicts = podtui
|
||||
options = !strip
|
||||
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||
63
packaging/aur/PKGBUILD
Normal file
@@ -0,0 +1,63 @@
|
||||
# Maintainer: Michael Freno <michael.freno@gmail.com>
|
||||
# Contributor: Michael Freno <michael.freno@gmail.com>
|
||||
# podtui-bin — TUI podcast/audiobook player with synchronized audio-waveform
|
||||
# visualization. Serves the official standalone release binary and its two FFI
|
||||
# sibling libraries (libcavacore.so + libopentui.so) from GitHub Releases.
|
||||
#
|
||||
# The embedded Bun runtime is statically linked into the binary — no Bun, no
|
||||
# fftw needed at runtime (fftw3 is linked statically into libcavacore.so).
|
||||
|
||||
pkgname=podtui-bin
|
||||
_pkgname=podtui
|
||||
pkgver=0.2.0
|
||||
pkgrel=1
|
||||
pkgdesc="Terminal podcast and audio player with synchronized audio-waveform visualization"
|
||||
url="https://github.com/mikefreno/podtui"
|
||||
arch=('x86_64' 'aarch64')
|
||||
license=('MIT')
|
||||
depends=('mpv') # sole audio backend; no-op without it
|
||||
provides=("${_pkgname}")
|
||||
conflicts=("${_pkgname}")
|
||||
options=('!strip') # standalone binary, pre-minified
|
||||
source_x86_64=(
|
||||
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-x64.tar.gz"
|
||||
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
|
||||
)
|
||||
source_aarch64=(
|
||||
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-arm64.tar.gz"
|
||||
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
|
||||
)
|
||||
sha256sums_x86_64=(
|
||||
'5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc'
|
||||
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
|
||||
)
|
||||
sha256sums_aarch64=(
|
||||
'c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5'
|
||||
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
|
||||
)
|
||||
|
||||
package() {
|
||||
local libdir
|
||||
|
||||
case "$CARCH" in
|
||||
x86_64) libdir="podtui-linux-x64" ;;
|
||||
aarch64) libdir="podtui-linux-arm64" ;;
|
||||
esac
|
||||
|
||||
# Binary + native FFI libs must stay side by side in /usr/lib/podtui/;
|
||||
# a /usr/bin symlink works because the embedded Bun runtime resolves
|
||||
# process.execPath through symlinks (verified against the compiled binary).
|
||||
install -Dm755 "${srcdir}/${libdir}/podtui" "${pkgdir}/usr/lib/podtui/podtui"
|
||||
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
||||
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
||||
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
|
||||
|
||||
# App icon + desktop entry ship inside the release tarball; Terminal=true
|
||||
# makes launchers drop the TUI into a terminal window.
|
||||
install -Dm644 "${srcdir}/${libdir}/podtui.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/podtui.png"
|
||||
install -Dm644 "${srcdir}/${libdir}/podtui.desktop" \
|
||||
"${pkgdir}/usr/share/applications/podtui.desktop"
|
||||
|
||||
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
60
packaging/aur/gen-srcinfo.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# gen-srcinfo.sh — emit .SRCINFO for the podtui-bin PKGBUILD without makepkg.
|
||||
# Emits the same field set/ordering makepkg --printsrcinfo produces for this
|
||||
# PKGBUILD shape (single package, per-arch source + sha256sums arrays).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. ./PKGBUILD
|
||||
|
||||
emit() { printf '\t%s = %s\n' "$1" "$2"; }
|
||||
emit_multi() { # $1 field, rest values
|
||||
local f="$1"
|
||||
shift
|
||||
for v in "$@"; do emit "$f" "$v"; done
|
||||
}
|
||||
|
||||
pkgbase_section() {
|
||||
echo "pkgbase = ${pkgname}"
|
||||
for f in pkgdesc pkgver pkgrel url; do
|
||||
v="${!f}"
|
||||
[ -n "${v:-}" ] && emit "$f" "$v"
|
||||
done
|
||||
[ -n "${install:-}" ] && emit install "$install"
|
||||
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||
emit_arch_arrays
|
||||
}
|
||||
|
||||
emit_arch_arrays() {
|
||||
for a in "${arch[@]}"; do
|
||||
src_name="source_${a}"
|
||||
sha_name="sha256sums_${a}"
|
||||
src_val="${src_name}[@]"
|
||||
sha_val="${sha_name}[@]"
|
||||
[ "${#src_name}" -gt 0 ] && emit_multi "source_${a}" "${!src_val}"
|
||||
emit_multi "sha256sums_${a}" "${!sha_val}"
|
||||
done
|
||||
}
|
||||
|
||||
pkgbase_section
|
||||
echo ""
|
||||
echo "pkgname = ${pkgname}"
|
||||
for v in pkgver pkgrel url; do
|
||||
val="${!v}"
|
||||
[ -n "${val:-}" ] && emit "$v" "$val"
|
||||
done
|
||||
emit pkgdesc "$pkgdesc"
|
||||
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||
emit_arch_arrays
|
||||
11
packaging/podtui.desktop
Normal file
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=PodTui
|
||||
GenericName=Podcast Player
|
||||
Comment=Terminal podcast and audio player with waveform visualization
|
||||
Exec=podtui
|
||||
Icon=podtui
|
||||
Terminal=true
|
||||
Categories=Audio;AudioVideo;Player;
|
||||
Keywords=podcast;audio;player;terminal;
|
||||
StartupNotify=false
|
||||
101
scripts/build-cavacore.sh
Executable 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
@@ -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."
|
||||
706
scripts/tui-harness.tsx
Normal file
@@ -0,0 +1,706 @@
|
||||
#!/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 mouse <x> <y> <down|up|click>
|
||||
* bun scripts/tui-harness.tsx mdrag <x1> <y1> <x2> <y2> # border/seek drag
|
||||
* 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 }
|
||||
| { t: "mouse"; kind: "down" | "up" | "click"; x: number; y: number }
|
||||
| { t: "mdrag"; x1: number; y1: number; x2: number; y2: 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) || 0,
|
||||
h: parseInt(p[1], 10) || 0,
|
||||
};
|
||||
},
|
||||
mouse: (p) => {
|
||||
if (!p[0] || !p[1] || !p[2]) throw new Error("mouse requires <x> <y> <down|up|click>");
|
||||
return {
|
||||
t: "mouse",
|
||||
kind: p[2] as "down" | "up" | "click",
|
||||
x: parseInt(p[0], 10),
|
||||
y: parseInt(p[1], 10),
|
||||
};
|
||||
},
|
||||
mdrag: (p) => {
|
||||
if (p.length < 4) throw new Error("mdrag requires <x1> <y1> <x2> <y2>");
|
||||
return {
|
||||
t: "mdrag",
|
||||
x1: parseInt(p[0], 10),
|
||||
y1: parseInt(p[1], 10),
|
||||
x2: parseInt(p[2], 10),
|
||||
y2: parseInt(p[3], 10),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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 "mouse":
|
||||
if (a.kind === "click") await setup.mockMouse.click(a.x, a.y);
|
||||
else if (a.kind === "down") await setup.mockMouse.pressDown(a.x, a.y);
|
||||
else await setup.mockMouse.release(a.x, a.y);
|
||||
break;
|
||||
case "mdrag":
|
||||
await setup.mockMouse.drag(a.x1, a.y1, a.x2, a.y2);
|
||||
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,
|
||||
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
|
||||
};
|
||||
} 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);
|
||||
});
|
||||
93
src/App.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
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 toast = useToast();
|
||||
const renderer = useRenderer();
|
||||
const themeContext = useTheme();
|
||||
const theme = themeContext.theme;
|
||||
const keybind = useKeybinds();
|
||||
|
||||
// Multimedia keys (physical play/volume/speed keys) still feed the audio
|
||||
// backend regardless of the on-screen yazi keybinds. Seek lives on the
|
||||
// keybind router (< / > = shift+, / shift+.), so arrows stay on navigation.
|
||||
useMultimediaKeys({
|
||||
playerFocused: () =>
|
||||
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
||||
? true
|
||||
: false,
|
||||
inputFocused: () => nav.inputFocused(),
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
206
src/api/rss-parser.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
|
||||
}
|
||||
|
||||
/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions
|
||||
* (unlike Bun.hash) — used to derive stable episode ids from audio URLs so
|
||||
* a feed's episode ids never change between refreshes. */
|
||||
const fnv1a = (input: string): number => {
|
||||
let hash = 0x811c9dc5
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-episode identity. The old positional id (`feedUrl#index`) was
|
||||
* invalidated by ANY feed change: a new episode or a pruned one shifted
|
||||
* every episode's index, so progress/downloads saved under `feedUrl#5`
|
||||
* attached to whatever episode now sat at index 5 — new episodes resumed
|
||||
* minutes in. Identity derives from stable content instead:
|
||||
* 1. `<guid>` — the canonical per-episode identifier (required by Apple
|
||||
* Podcasts; nearly universal).
|
||||
* 2. The enclosure URL, hashed to keep the id compact (hosts serve
|
||||
* permanent per-episode URLs; guids can be absent in hand-rolled feeds).
|
||||
* 3. Positional index as a last resort: no guid AND no audio URL means
|
||||
* the episode cannot be played, so nothing persistent keys off it.
|
||||
*/
|
||||
const stableEpisodeId = (
|
||||
feedUrl: string,
|
||||
item: string,
|
||||
audioUrl: string,
|
||||
index: number,
|
||||
): string => {
|
||||
const guid = getTagValue(item, "guid")
|
||||
if (guid) return `${feedUrl}#guid:${guid}`
|
||||
if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}`
|
||||
return `${feedUrl}#${index}`
|
||||
}
|
||||
|
||||
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
||||
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||
* RSS has no items outside <channel>). */
|
||||
export const getRSSItems = (xml: string): string[] => {
|
||||
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||
}
|
||||
|
||||
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
|
||||
* `<image><url>`. Works on the full XML — channel-level tags precede
|
||||
* <item> blocks in RSS, so the first match is the channel image. */
|
||||
export const parseChannelCoverUrl = (xml: string): string | undefined => {
|
||||
const itunesHref = getAttr(xml, "itunes:image", "href")
|
||||
if (itunesHref) return itunesHref
|
||||
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
|
||||
return url?.trim() || undefined
|
||||
}
|
||||
|
||||
/** Parse a single `<item>` into an Episode. Exported so the feed store can
|
||||
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||
* chunks) instead of one synchronous block. */
|
||||
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
|
||||
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||
const epDescription = cleanField(getTagValue(item, "description"))
|
||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||
|
||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||
const audioUrl = enclosure?.[1] ?? ""
|
||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||
|
||||
const durationRaw = getTagValue(item, "itunes:duration")
|
||||
const duration = parseDuration(durationRaw)
|
||||
|
||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||
|
||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||
|
||||
// Episode image (itunes:image has href attribute)
|
||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||
|
||||
const ep: Episode = {
|
||||
id: stableEpisodeId(feedUrl, item, audioUrl, index),
|
||||
podcastId: feedUrl,
|
||||
title: epTitle,
|
||||
description: epDescription,
|
||||
audioUrl,
|
||||
duration,
|
||||
pubDate,
|
||||
}
|
||||
|
||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||
if (episodeType) ep.episodeType = episodeType
|
||||
if (explicit !== undefined) ep.explicit = explicit
|
||||
if (imageUrl) ep.imageUrl = imageUrl
|
||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||
if (mimeType) ep.mimeType = mimeType
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
/** Parse a full RSS document (channel metadata + all episodes). The sync
|
||||
* whole-feed variant — callers that parse potentially huge feeds on a UI
|
||||
* thread should prefer the store's chunked incremental parse instead. */
|
||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||
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 = getRSSItems(xml)
|
||||
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
|
||||
|
||||
return {
|
||||
id: feedUrl,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
feedUrl,
|
||||
lastUpdated,
|
||||
isSubscribed: true,
|
||||
coverUrl: parseChannelCoverUrl(channel),
|
||||
episodes,
|
||||
}
|
||||
}
|
||||
249
src/components/EpisodeList.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Shared list-row and preview components for the Feed and My Shows pages.
|
||||
*
|
||||
* Both pages render the same episode rows (marker + title, optional subtitle
|
||||
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
|
||||
* hovered-episode / fetch-more preview panes; the pages differ only in the
|
||||
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
|
||||
* so the previously 3-4-level-nested render blocks run as flat named
|
||||
* components.
|
||||
*
|
||||
* Anything that can change at runtime arrives as a signal getter: Solid
|
||||
* components do not re-render, so only props that are called inside the
|
||||
* component's own JSX stay reactive (focus, selection, download state).
|
||||
*/
|
||||
|
||||
import { Show } from "solid-js";
|
||||
import { format } from "date-fns";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { NF_ICONS } from "@/utils/nerd-fonts";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import type { Episode } from "@/types/episode";
|
||||
|
||||
// ── formatting helpers ──────────────────────────────────────────────────────
|
||||
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
export const formatDuration = (s: number) => {
|
||||
const mins = Math.floor(s / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
// ── EpisodeRow ──────────────────────────────────────────────────────────────
|
||||
export function EpisodeRow(props: {
|
||||
/** The episode this row renders. */
|
||||
episode: Episode;
|
||||
/** Optional second line under the title (podcast/show name). */
|
||||
subtitle?: () => string | undefined;
|
||||
/** For index signal (row position). */
|
||||
index: () => number;
|
||||
/** Focused row index in this list (-1 while the Fetch More row is
|
||||
* focused, so no episode row draws the cursor). */
|
||||
focused: () => number;
|
||||
/** Whether the current pane has keyboard focus. */
|
||||
active: () => boolean;
|
||||
/** Whether this episode is selection-marked. */
|
||||
selected: () => boolean;
|
||||
downloadLabel: () => string;
|
||||
downloadColor: () => RGBA;
|
||||
marker: () => string;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||
const isFocused = () => props.index() === props.focused();
|
||||
const bg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.primary
|
||||
: isFocused()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.surface
|
||||
: isFocused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{isFocused() ? props.marker() : " "}
|
||||
</text>
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
|
||||
{props.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
{/* podcast name on its own row — readable at a glance; the 50%
|
||||
current pane fits it in full for typical names, and truncate
|
||||
keeps the row one line tall either way */}
|
||||
<Show when={props.subtitle?.()}>
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||
>
|
||||
{props.subtitle?.()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
|
||||
{formatDate(props.episode.pubDate)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||
{formatDuration(props.episode.duration)}
|
||||
</text>
|
||||
<Show when={props.selected()}>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={props.downloadLabel()}>
|
||||
<text flexShrink={0} fg={props.downloadColor()}>
|
||||
{props.downloadLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FetchMoreRow ────────────────────────────────────────────────────────────
|
||||
export function FetchMoreRow(props: {
|
||||
/** Row index of the Fetch More button within the list. */
|
||||
index: () => number;
|
||||
/** Focused row index. */
|
||||
focused: () => number;
|
||||
/** True while the Fetch More row itself is focused. */
|
||||
onMore: () => boolean;
|
||||
/** Whether the current pane has keyboard focus. */
|
||||
active: () => boolean;
|
||||
isLoadingMore: () => boolean;
|
||||
nerd: boolean;
|
||||
marker: () => string;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const ref = useScrollIntoView(props.onMore);
|
||||
const bg = () =>
|
||||
props.index() === props.focused() && props.active()
|
||||
? theme.primary
|
||||
: props.index() === props.focused()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () =>
|
||||
props.index() === props.focused() && props.active()
|
||||
? theme.surface
|
||||
: props.index() === props.focused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
|
||||
{props.nerd && (
|
||||
<text fg={fg()}>{NF_ICONS.more}</text>
|
||||
)}
|
||||
<Show
|
||||
when={!props.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={fg()}>[Fetch More]</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EpisodePreview ──────────────────────────────────────────────────────────
|
||||
export function EpisodePreview(props: {
|
||||
episode: () => Episode;
|
||||
/** Optional line under the meta row (podcast/show name). */
|
||||
subtitle?: () => string | undefined;
|
||||
author: () => string | undefined;
|
||||
downloadLabel: () => string;
|
||||
downloadColor: () => RGBA;
|
||||
/** Page-specific action-hint line. */
|
||||
hint: () => string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const dims = useTerminalDimensions();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
|
||||
{props.episode().title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
|
||||
<Show when={props.downloadLabel()}>
|
||||
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={props.subtitle?.()}>
|
||||
<text fg={muted()}>{props.subtitle?.()}</text>
|
||||
</Show>
|
||||
<Show when={props.author()}>
|
||||
<text fg={muted()}>by {props.author()}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show
|
||||
when={props.episode().description}
|
||||
fallback={<text fg={theme.textSecondary}>No description available.</text>}
|
||||
>
|
||||
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
|
||||
<text fg={theme.textSecondary}>{props.episode().description}</text>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>{props.hint()}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FetchMorePreview ────────────────────────────────────────────────────────
|
||||
export function FetchMorePreview(props: {
|
||||
isLoadingMore: () => boolean;
|
||||
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
|
||||
manualText: () => string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{props.isLoadingMore()
|
||||
? "Loading the next batch of episodes…"
|
||||
: props.manualText()}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: load more · h back</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
35
src/components/GlobalActivityIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Show } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useActivityStore } from "@/stores/activity";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
|
||||
/**
|
||||
* GlobalActivityIndicator — one global top-right signal that ANY feed
|
||||
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
|
||||
* Per-page spinners are unchanged; this overlays the content row and status
|
||||
* bar as a single app-wide "something is happening" indicator.
|
||||
*/
|
||||
export function GlobalActivityIndicator() {
|
||||
const feedStore = useFeedStore();
|
||||
const searchStore = useSearchStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const activity = useActivityStore();
|
||||
|
||||
/** True while any tracked activity is in flight */
|
||||
const isActive = () =>
|
||||
feedStore.isLoadingFeeds() ||
|
||||
feedStore.isLoadingMore() ||
|
||||
searchStore.isSearching() ||
|
||||
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||
activity.isActive();
|
||||
|
||||
return (
|
||||
<Show when={isActive()}>
|
||||
<box position="absolute" top={0} right={0} paddingRight={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
30
src/components/LoadingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
src/components/PaneRow.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
*
|
||||
* Implements yazi's resizable `mgr.ratio` contract: the two borders of the
|
||||
* CENTER (current) column are draggable and resize the neighboring panes.
|
||||
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
|
||||
* as fractions of the row width; this component resolves them to pixel
|
||||
* columns, gives each column an explicit width (so the grab zones sit
|
||||
* exactly on the drawn borders), and renders two 3-column invisible grab
|
||||
* zones over the borders.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* keeps a minimum 15-col slot. Borderless.
|
||||
* current — the current-depth list. The only focusable content column; the
|
||||
* ONLY bordered column — left/right edges only, always muted.
|
||||
* preview — detail of the hovered item in `current`. Borderless.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Example:
|
||||
* <PaneRow
|
||||
* parent={parentList}
|
||||
* current={currentList}
|
||||
* preview={detail}
|
||||
* currentLabel="List · 42"
|
||||
* focused={isActive}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { createMemo, createSignal, Show } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import type { RGBA, BorderSides } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
MIN_PANE_WIDTH,
|
||||
splitPixels,
|
||||
usePaneLayout,
|
||||
} from "@/stores/pane-layout";
|
||||
|
||||
// ── 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 — a minimum 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;
|
||||
/** Which sides of the current column's border render. Defaults to
|
||||
* `["left", "right"]` (the standard focused-list frame). */
|
||||
currentBorder?: boolean | BorderSides[];
|
||||
};
|
||||
|
||||
// ── 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: {
|
||||
width: 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"
|
||||
width={props.width}
|
||||
flexShrink={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>
|
||||
);
|
||||
}
|
||||
|
||||
/** A 3-column invisible grab zone centered on one border of the current
|
||||
* pane: the border column plus one column of help padding on each side,
|
||||
* so the thin border is easy to target with a mouse. `onBegin` is called
|
||||
* on mousedown with the cursor's x; the row records that grab offset so
|
||||
* the border stays glued to the cursor while dragging. On hover or while
|
||||
* dragging it overdraws just the border column with a full-height accent
|
||||
* `│` line (a bordered box would render as a blocky rectangle instead).
|
||||
* The two padding columns are transparent; the hit grid is rect-based, so
|
||||
* they capture clicks too — they must never overlap interactive content. */
|
||||
function Splitter(props: {
|
||||
/** Column of the border itself. The strip spans `left - 1` .. `left + 1`
|
||||
* (the border plus one help-padded column each side); the highlight
|
||||
* renders at `left`. */
|
||||
left: number;
|
||||
active: boolean;
|
||||
onBegin: (x: number) => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const dims = useTerminalDimensions();
|
||||
const [hovered, setHovered] = createSignal(false);
|
||||
const highlighted = () => props.active || hovered();
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={props.left - 1}
|
||||
top={0}
|
||||
width={3}
|
||||
height="100%"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault?.();
|
||||
props.onBegin(e.x);
|
||||
}}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
>
|
||||
<Show when={highlighted()}>
|
||||
{/* Draw the accent edge down the full pane height; the box clips
|
||||
* any excess rows below the row's bottom edge. */}
|
||||
<text fg={theme.primary} selectable={false}>
|
||||
{" │\n".repeat(dims().height)}
|
||||
</text>
|
||||
</Show>
|
||||
</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 currentBorder = createMemo<boolean | BorderSides[]>(
|
||||
() => props.currentBorder ?? ["left", "right"],
|
||||
);
|
||||
|
||||
// Shared split state + terminal width drive explicit column widths so the
|
||||
// drag strips sit exactly on the drawn borders.
|
||||
const layout = usePaneLayout();
|
||||
const dims = useTerminalDimensions();
|
||||
const width = () => dims().width;
|
||||
const pixels = createMemo(() => splitPixels(width(), layout.splits()));
|
||||
const hasRoom = () =>
|
||||
width() >=
|
||||
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
|
||||
|
||||
// Column widths in pixels (sum to the row width).
|
||||
const parentWidth = () => pixels().leftPx;
|
||||
const currentWidth = () =>
|
||||
panes() === 2
|
||||
? width() - pixels().leftPx
|
||||
: pixels().rightPx - pixels().leftPx;
|
||||
const previewWidth = () => width() - pixels().rightPx;
|
||||
|
||||
// ── Drag state ──────────────────────────────────────────────────────────
|
||||
// onMouseDown on a Splitter records which border is being dragged and
|
||||
// the cursor's grab offset from that border's column; the row then
|
||||
// lives-updates the split from the drag x (minus the offset, so the
|
||||
// border stays glued to the cursor) and commits on release.
|
||||
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
|
||||
null,
|
||||
);
|
||||
// Column of the border a strip centers on (the current pane's edge).
|
||||
const borderCol = (which: "left" | "right") =>
|
||||
which === "left" ? pixels().leftPx : pixels().rightPx - 1;
|
||||
// Cursor x relative to the grabbed border column. Set on mousedown and
|
||||
// subtracted from every drag x so the border tracks the cursor rather
|
||||
// than jumping to it.
|
||||
let grabOffset = 0;
|
||||
const beginDrag = (which: "left" | "right") => (x: number) => {
|
||||
grabOffset = x - borderCol(which);
|
||||
setActiveSplit(which);
|
||||
};
|
||||
const handleDrag = (e: { x: number }) => {
|
||||
const which = activeSplit();
|
||||
if (!which) return;
|
||||
if (which === "left") layout.setLeft(e.x - grabOffset, width());
|
||||
else layout.setRight(e.x - grabOffset, width());
|
||||
};
|
||||
const handleDragEnd = () => {
|
||||
if (activeSplit()) layout.commit();
|
||||
setActiveSplit(null);
|
||||
grabOffset = 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexGrow={1}
|
||||
onMouseDrag={handleDrag}
|
||||
onMouseDragEnd={handleDragEnd}
|
||||
onMouseUp={handleDragEnd}
|
||||
>
|
||||
{/* ── parent — previous-depth list; title row top-left ─────────────── */}
|
||||
<Pane
|
||||
width={parentWidth()}
|
||||
label={currentLabel}
|
||||
content={parentContent}
|
||||
border={false}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current — the focused list; left/right borders only ─────────── */}
|
||||
<Pane
|
||||
width={currentWidth()}
|
||||
label={() => ""}
|
||||
content={currentContent}
|
||||
border={currentBorder()}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (optional) — hovered-item detail; no border ─────────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<Pane
|
||||
width={previewWidth()}
|
||||
label={() => ""}
|
||||
content={previewContent}
|
||||
border={false}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</Show>
|
||||
{/* ── drag handles over the current pane's borders ───────────────── */}
|
||||
<Show when={hasRoom()}>
|
||||
<Splitter
|
||||
left={borderCol("left")}
|
||||
active={activeSplit() === "left"}
|
||||
onBegin={beginDrag("left")}
|
||||
/>
|
||||
<Show when={panes() === 3}>
|
||||
<Splitter
|
||||
left={borderCol("right")}
|
||||
active={activeSplit() === "right"}
|
||||
onBegin={beginDrag("right")}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
82
src/components/Selectable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
526
src/components/Shell.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* 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 { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore } 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 { feedForEpisode } from "@/utils/feed-resolve";
|
||||
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";
|
||||
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
|
||||
|
||||
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;
|
||||
}
|
||||
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 ──────────────────────────────────────────────────
|
||||
// Now-playing text carries the podcast name (custom name when set) when
|
||||
// the episode's feed is resolvable, mirroring advanceEpisode's lookup.
|
||||
const nowPlayingText = () => {
|
||||
const ep = audio.currentEpisode();
|
||||
if (!ep) return null;
|
||||
const feeds = feedStore.getFilteredFeeds();
|
||||
const feed = feedForEpisode(feeds, ep);
|
||||
return feed
|
||||
? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}`
|
||||
: `♪ ${ep.title}`;
|
||||
};
|
||||
const modeLabel = () =>
|
||||
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
||||
const pendingLabel = () =>
|
||||
k
|
||||
.pending()
|
||||
.map((s) => s.key)
|
||||
.join(" ");
|
||||
|
||||
// ── Now-playing marquee ────────────────────────────────────────────────────
|
||||
// The now-playing segment takes the full remaining status-bar width and
|
||||
// marquee-scrolls when its text overflows; when it fits (or the bar is too
|
||||
// narrow to show anything) it renders statically. Each pass scrolls at
|
||||
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
||||
// before scrolling again.
|
||||
const dims = useTerminalDimensions();
|
||||
const GAP = 3;
|
||||
const SCROLL_STEP_MS = 150;
|
||||
const SCROLL_HOLD_MS = 10_000;
|
||||
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||
const leftFixed = () =>
|
||||
modeLabel().length +
|
||||
(nav.selectedIds().length > 0
|
||||
? 4 + String(nav.selectedIds().length).length
|
||||
: 0);
|
||||
const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3;
|
||||
const availableWidth = () =>
|
||||
Math.max(0, dims().width - leftFixed() - rightFixed() - 2);
|
||||
const visible = () => {
|
||||
const text = nowPlayingText();
|
||||
const avail = availableWidth();
|
||||
if (!text || avail <= 0) return "";
|
||||
if (text.length <= avail) return text;
|
||||
// Double the text with a gap so the wrap is seamless: the window
|
||||
// slides over text + gap + text without ever hitting the tail.
|
||||
return (text + " ".repeat(GAP) + text).slice(
|
||||
scrollOffset(),
|
||||
scrollOffset() + avail,
|
||||
);
|
||||
};
|
||||
createEffect(() => {
|
||||
const text = nowPlayingText();
|
||||
const avail = availableWidth();
|
||||
setScrollOffset(0);
|
||||
if (!text || avail <= 0 || text.length <= avail) return;
|
||||
const cycle = text.length + GAP - avail;
|
||||
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
||||
// then hold again before the next pass.
|
||||
let holdId: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollId: ReturnType<typeof setInterval> | null = null;
|
||||
const startHold = () => {
|
||||
setScrollOffset(0);
|
||||
holdId = setTimeout(() => {
|
||||
scrollId = setInterval(() => {
|
||||
const next = scrollOffset() + 1;
|
||||
if (next >= cycle) {
|
||||
clearInterval(scrollId!);
|
||||
startHold();
|
||||
} else {
|
||||
setScrollOffset(next);
|
||||
}
|
||||
}, SCROLL_STEP_MS);
|
||||
}, SCROLL_HOLD_MS);
|
||||
};
|
||||
startHold();
|
||||
onCleanup(() => {
|
||||
if (holdId) clearTimeout(holdId);
|
||||
if (scrollId) clearInterval(scrollId);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
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={nowPlayingText()}>
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||
* transform HTML-escapes static string children (`<` → `<`),
|
||||
* which opentui renders verbatim; content bypasses that. */}
|
||||
<text fg={t.primary} content={visible()} />
|
||||
</box>
|
||||
</Show>
|
||||
<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>
|
||||
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
|
||||
<GlobalActivityIndicator />
|
||||
</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"],
|
||||
[p("search-scope-toggle"), "shows/episodes"],
|
||||
["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";
|
||||
123
src/components/TabPanel.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
|
||||
const TAB_ICON: Record<TABS, string> = {
|
||||
[TABS.FEED]: NF_ICONS.feed,
|
||||
[TABS.MYSHOWS]: NF_ICONS.shows,
|
||||
[TABS.DISCOVER]: NF_ICONS.discover,
|
||||
[TABS.SEARCH]: NF_ICONS.search,
|
||||
[TABS.PLAYER]: NF_ICONS.player,
|
||||
[TABS.SETTINGS]: NF_ICONS.settings,
|
||||
};
|
||||
|
||||
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||
const TAB_ORDER = Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
) as TABS[];
|
||||
|
||||
export function TabListPane(props: { muted?: boolean }) {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const cursor = () => nav.tabCursor();
|
||||
const activeTab = () => nav.activeTab();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
|
||||
// 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() ? marker() : " "}</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(tab)} paddingRight={1}>
|
||||
{TAB_ICON[tab]}
|
||||
</text>
|
||||
)}
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
86
src/config/keybinds.jsonc
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
// ── 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"],
|
||||
// tab toggles the Search page between show and episode scope (re-runs the
|
||||
// current query when viewing results)
|
||||
"search-scope-toggle": ["tab"],
|
||||
"filter": ["f"],
|
||||
"sort": [","],
|
||||
"toggle-hidden": ["."],
|
||||
"refresh": ["r"],
|
||||
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
|
||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||
|
||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||
"download": ["d"], // download the focused episode (detail pane)
|
||||
"delete-download": ["D"], // delete the focused episode's download (if any)
|
||||
"whitelist-toggle": ["w"], // add/remove the focused show from the auto-download whitelist (My Shows)
|
||||
|
||||
// ── Audio transport (preserved) ──────────────────────────────────────────
|
||||
// Kept on shifted single keys so they never collide with the yazi core
|
||||
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
|
||||
"audio-toggle": ["P"], // play / pause (shift+p)
|
||||
"audio-next": ["N"], // next episode (shift+n)
|
||||
"audio-prev": ["B"], // prev episode (shift+b)
|
||||
"audio-seek-forward": ["shift-."], // seek forward (> = shift+.)
|
||||
"audio-seek-backward": ["shift-,"] // seek backward (< = shift+,)
|
||||
}
|
||||
25
src/constants/themes.ts
Normal 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,
|
||||
};
|
||||
356
src/context/KeybindContext.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
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"
|
||||
| "search-scope-toggle"
|
||||
| "filter"
|
||||
| "sort"
|
||||
| "toggle-hidden"
|
||||
| "refresh"
|
||||
| "subscribe"
|
||||
| "unsubscribe"
|
||||
| "download"
|
||||
| "delete-download"
|
||||
| "whitelist-toggle"
|
||||
| "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,
|
||||
};
|
||||
},
|
||||
});
|
||||
22
src/context/NavigationContext.tsx
Normal 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(),
|
||||
});
|
||||
453
src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
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 } from "../utils/theme";
|
||||
import { getCustomThemes } from "../utils/custom-themes";
|
||||
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;
|
||||
|
||||
/**
|
||||
* How often to re-query the terminal for theme changes (OSC 10/11/12).
|
||||
* Terminals only answer these queries — they never push a color change —
|
||||
* so detection is a slow poll. 60 s keeps CPU cost unmeasurable while
|
||||
* still tracking theme flips within a reasonable delay.
|
||||
*/
|
||||
const SYSTEM_THEME_POLL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom);
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "catppuccin");
|
||||
})
|
||||
.finally(() => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the terminal's colors via OSC (palette + default fg/bg), with a
|
||||
* legacy-tmux fallback for servers < 3.6 that don't forward OSC replies.
|
||||
* Returns null when the terminal cannot answer.
|
||||
*/
|
||||
async function queryTerminalColors(): Promise<TerminalColors | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
async function resolveSystemTheme() {
|
||||
const colors = await queryTerminalColors();
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for terminal theme changes: re-query OSC colors, update the
|
||||
* system palette when it differs, and re-detect dark/light mode.
|
||||
* Runs on a slow timer (see SYSTEM_THEME_POLL_MS); most polls change
|
||||
* nothing and only pay the idle query round-trip.
|
||||
*/
|
||||
async function pollSystemTheme() {
|
||||
if (!store.ready) return;
|
||||
const colors = await queryTerminalColors();
|
||||
if (!colors) return;
|
||||
|
||||
const current = store.system;
|
||||
const changed =
|
||||
!current ||
|
||||
current.defaultBackground !== colors.defaultBackground ||
|
||||
current.defaultForeground !== colors.defaultForeground ||
|
||||
current.palette.join(",") !== colors.palette.join(",");
|
||||
|
||||
if (changed) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.system = colors;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh the OS-appearance fallback only when the terminal cannot
|
||||
// report a background (e.g. tmux without OSC forwarding), so the
|
||||
// common path never spawns a subprocess.
|
||||
if (process.platform === "darwin" && !colors.defaultBackground) {
|
||||
cachedOsMode = null;
|
||||
}
|
||||
const detectedMode = detectSystemMode(colors);
|
||||
if (detectedMode && detectedMode !== store.mode) {
|
||||
setStore("mode", detectedMode);
|
||||
emitThemeModeChanged(detectedMode);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(init);
|
||||
|
||||
// Poll the terminal for theme changes (see pollSystemTheme). Registered
|
||||
// once per provider init — SIGUSR2 re-runs the inner `init`, not this
|
||||
// closure, so the timer cannot stack.
|
||||
const pollTimer = setInterval(() => {
|
||||
void pollSystemTheme();
|
||||
}, SYSTEM_THEME_POLL_MS);
|
||||
onCleanup(() => clearInterval(pollTimer));
|
||||
|
||||
// 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
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
513
src/context/navigation-store.ts
Normal 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
|
||||
*
|
||||
* Pane sizes are user-resizable (draggable borders in `PaneRow`).
|
||||
* 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>;
|
||||
85
src/effects/feed-refresh.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Feed-refresh batch as an Effect program.
|
||||
*
|
||||
* Replaces the hand-rolled worker pool (mapWithConcurrency) + per-feed
|
||||
* fetch/apply plumbing in stores/feed.ts with Effect's structured
|
||||
* concurrency:
|
||||
* - `Effect.forEach(..., { concurrency })` bounds in-flight fetches to
|
||||
* `concurrency` (starts exactly that many fibers; each completion pulls
|
||||
* the next feed — identical semantics to the old shared-counter pool).
|
||||
* - `Effect.timeout` bounds each feed's fetch to `timeoutMs`. It runs
|
||||
* through the `Clock` service, so under `TestContext` the TestClock
|
||||
* drives it deterministically (no real 20s wait in tests).
|
||||
* - Failures are folded to a null result: a failed or timed-out feed is
|
||||
* left untouched instead of failing the batch.
|
||||
* - The apply callback runs inside each feed's own fiber, so a feed's
|
||||
* refreshed episodes land AS ITS OWN FETCH COMPLETES — the
|
||||
* per-feed-apply-as-it-lands contract, no Promise.all barrier.
|
||||
*
|
||||
* The store boundary (stores/feed.ts) supplies the real fetch and apply
|
||||
* closures and runs the program with Effect.runPromise.
|
||||
*/
|
||||
|
||||
import { Duration, Effect } from "effect"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Feed } from "../types/feed"
|
||||
|
||||
/** Result of fetching one feed's RSS. `episodes: null` means the fetch
|
||||
* failed or timed out — callers must leave that feed untouched. */
|
||||
export interface RefreshFetchResult {
|
||||
episodes: Episode[] | null
|
||||
coverUrl: string | undefined
|
||||
}
|
||||
|
||||
/** Result guaranteed to have parsed episodes (the apply path only). */
|
||||
export interface RefreshSuccess {
|
||||
episodes: Episode[]
|
||||
coverUrl: string | undefined
|
||||
}
|
||||
|
||||
export interface RefreshBatchOptions {
|
||||
/** Max simultaneous in-flight fetches. */
|
||||
concurrency: number
|
||||
/** Per-feed fetch timeout in milliseconds. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** Fold any failure (network error, timeout, rejection) to a null result so
|
||||
* one bad feed can never fail the batch. */
|
||||
const failedResult: RefreshFetchResult = { episodes: null, coverUrl: undefined }
|
||||
|
||||
/** Fetch one feed with a timeout, applying its result as its own fetch
|
||||
* lands. A failed or timed-out fetch yields null — the feed is untouched. */
|
||||
const refreshOne = (
|
||||
feed: Feed,
|
||||
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||
timeoutMs: number,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.tryPromise(() => fetchOne(feed)).pipe(
|
||||
Effect.timeout(Duration.millis(timeoutMs)),
|
||||
Effect.catchAll(() => Effect.succeed(failedResult)),
|
||||
Effect.flatMap((result) => {
|
||||
if (result.episodes === null) return Effect.void
|
||||
// Capture the narrowed array before the closure — TS drops the
|
||||
// `episodes !== null` narrowing inside Effect.sync's callback.
|
||||
const episodes = result.episodes
|
||||
return Effect.sync(() => applyOne(feed, { episodes, coverUrl: result.coverUrl }))
|
||||
}),
|
||||
)
|
||||
|
||||
/** Refresh every feed with bounded concurrency. Each feed's refreshed
|
||||
* episodes are applied as its own fetch lands (no barrier); a failed or
|
||||
* timed-out feed is left untouched. The program never fails — failures
|
||||
* are folded to per-feed no-ops. */
|
||||
export const refreshFeedsBatch = (
|
||||
feeds: readonly Feed[],
|
||||
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||
options: RefreshBatchOptions,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.forEach(
|
||||
feeds,
|
||||
(feed) => refreshOne(feed, fetchOne, applyOne, options.timeoutMs),
|
||||
{ concurrency: options.concurrency, discard: true },
|
||||
)
|
||||
155
src/hooks/useAudio.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Reactive SolidJS hook over the module-level audio engine.
|
||||
*
|
||||
* Wraps utils/audio-engine: every useAudio() call shares ONE engine (all
|
||||
* playback logic, the 150ms poll, session restore, and the event-bus
|
||||
* commands live there). This hook keeps only what is tied to the Solid
|
||||
* lifecycle — the ref-counted last-owner dispose and the process-exit
|
||||
* teardown — and re-exposes the two controls the engine deliberately omits
|
||||
* (availablePlayers, switchBackend).
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const audio = useAudio()
|
||||
* audio.play(episode)
|
||||
* <text>{audio.isPlaying() ? "Playing" : "Paused"}</text>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { onCleanup } from "solid-js";
|
||||
import {
|
||||
availablePlayers,
|
||||
currentEpisode,
|
||||
speed,
|
||||
setSpeed,
|
||||
volume,
|
||||
setVolume,
|
||||
} from "../utils/audio-signals";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useMediaRegistry } from "../utils/media-registry";
|
||||
import { saveLastPlayerSync } from "../utils/app-persistence";
|
||||
import type { BackendName, DetectedPlayer } from "../utils/audio-player";
|
||||
import {
|
||||
createAudioEngine,
|
||||
ensureEngineBackend,
|
||||
disposeEngineBackend,
|
||||
stopEnginePolling,
|
||||
getEngineBackend,
|
||||
switchBackend,
|
||||
restoreLastSession,
|
||||
type AudioEngine,
|
||||
} from "../utils/audio-engine";
|
||||
|
||||
// Re-exported so the session-restore test can pull it from this module.
|
||||
export { restoreLastSession };
|
||||
|
||||
// useAudio() surface: the engine plus the two controls it doesn't expose.
|
||||
export type AudioControls = AudioEngine & {
|
||||
availablePlayers: () => DetectedPlayer[];
|
||||
switchBackend: (name: BackendName) => Promise<void>;
|
||||
};
|
||||
|
||||
const engine = createAudioEngine();
|
||||
|
||||
// Singleton ref count — how many live useAudio() owners there are. The engine
|
||||
// is shared; the last owner to unmount disposes the backend.
|
||||
let refCount = 0;
|
||||
|
||||
// ── 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 => {
|
||||
stopEnginePolling();
|
||||
// Persist "what's loaded in the player right now" synchronously —
|
||||
// process.exit(0) runs this handler synchronously and an async write
|
||||
// would never land. The next launch restores this episode paused.
|
||||
try {
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
|
||||
}
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
try {
|
||||
getEngineBackend()?.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive audio controls hook.
|
||||
*
|
||||
* Returns the shared audio engine wrapped with the two extra controls, so
|
||||
* all components observe the same playback state. The first useAudio()
|
||||
* owner creates the backend, runs the one-time boot (volume/speed sync +
|
||||
* session restore) and registers the process-exit teardown; the last
|
||||
* owner disposes the backend.
|
||||
*/
|
||||
export function useAudio(): AudioControls {
|
||||
const engine = createAudioEngine();
|
||||
ensureEngineBackend();
|
||||
registerExitTeardown();
|
||||
|
||||
// First owner: sync speed/volume from the persisted settings and restore
|
||||
// the last player session once (loaded, not playing). Raw signal
|
||||
// accessors are used here on purpose — this is boot-only, not a user
|
||||
// volume/speed change, so it must not re-persist to the app store.
|
||||
if (refCount === 0) {
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
|
||||
// Volume re-syncs once settings finish loading (async config read)
|
||||
// so a level persisted last session is applied at boot.
|
||||
appStore
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
const storeVolume = appStore.state().settings.volume;
|
||||
if (storeVolume !== undefined && storeVolume !== volume()) {
|
||||
setVolume(storeVolume);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Restore the last player session once at boot (loaded, not playing).
|
||||
restoreLastSession().catch(() => {});
|
||||
}
|
||||
|
||||
refCount++;
|
||||
|
||||
onCleanup(() => {
|
||||
refCount--;
|
||||
if (refCount <= 0) {
|
||||
disposeEngineBackend();
|
||||
refCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return { ...engine, availablePlayers, switchBackend };
|
||||
}
|
||||
65
src/hooks/useInputFocusNav.ts
Normal 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;
|
||||
}
|
||||
70
src/hooks/useMultimediaKeys.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Global multimedia key handler hook.
|
||||
*
|
||||
* Captures media-related key events (play/pause, volume, speed)
|
||||
* regardless of which component is focused. Uses the event bus to
|
||||
* decouple key detection from audio control logic.
|
||||
*
|
||||
* Volume and speed are app-level settings — adjustable with or without
|
||||
* an episode loaded (they apply to the next playback and persist). Seek
|
||||
* is NOT handled here: it lives on the yazi keybind router (`<` / `>` =
|
||||
* shift+, / shift+.), so the arrow keys stay free for navigation.
|
||||
*/
|
||||
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { emit } from "../utils/event-bus";
|
||||
|
||||
export type MediaKeyAction =
|
||||
| "media.toggle"
|
||||
| "media.volumeUp"
|
||||
| "media.volumeDown"
|
||||
| "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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "s":
|
||||
// Speed is shift+s (S) so plain `s` stays free for search.
|
||||
if (!key.shift) return;
|
||||
emit("media.speedCycle", {});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not a media key — do nothing
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
115
src/hooks/useScrollIntoView.ts
Normal 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;
|
||||
}
|
||||
16
src/hooks/useSelectionMarker.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* useSelectionMarker — reactive accessor for the row-selection marker glyph.
|
||||
*
|
||||
* When the `showSelectionMarker` setting is on, the focused row of every list
|
||||
* renders `❯`; when off (the default), it renders a space so column alignment
|
||||
* is preserved. Every list pane in the app reads the marker through this hook
|
||||
* so the setting applies consistently everywhere.
|
||||
*/
|
||||
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
export function useSelectionMarker(): () => string {
|
||||
const app = useAppStore();
|
||||
return () =>
|
||||
app.state().settings.showSelectionMarker ? "❯" : " ";
|
||||
}
|
||||
272
src/index.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { onCleanup } from "solid-js";
|
||||
import { setupTerminalRecovery } from "./utils/terminal-recovery";
|
||||
import { installNestedScrollBehavior } from "./utils/nested-scroll";
|
||||
import type { Feed } from "./types/feed"
|
||||
import type { Episode } from "./types/episode"
|
||||
|
||||
const VERSION = "0.9.0";
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
function findLatestEpisode(
|
||||
feeds: Feed[],
|
||||
): { feed: Feed; episode: Episode } | null {
|
||||
let latest: { feed: Feed; episode: Episode } | null = null
|
||||
let latestDate = 0
|
||||
|
||||
for (const feed of feeds) {
|
||||
if (feed.episodes.length === 0) continue
|
||||
const ep = feed.episodes[0]
|
||||
const epDate =
|
||||
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
|
||||
if (epDate > latestDate) {
|
||||
latestDate = epDate
|
||||
latest = { feed, episode: ep }
|
||||
}
|
||||
}
|
||||
|
||||
return latest
|
||||
}
|
||||
|
||||
/** Search feeds by title and print matching shows */
|
||||
function handleQuery(feeds: Feed[], query: string): void {
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
|
||||
const matches = feeds.filter((feed) => {
|
||||
const title = feed.podcast.title.toLowerCase()
|
||||
return title.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log(`No shows found matching: ${query}`)
|
||||
if (feeds.length > 0) {
|
||||
console.log("\nAvailable shows:")
|
||||
feeds.slice(0, 5).forEach((feed) => {
|
||||
console.log(` - ${feed.podcast.title}`)
|
||||
})
|
||||
if (feeds.length > 5) {
|
||||
console.log(` ... and ${feeds.length - 5} more`)
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
const feed = matches[0]
|
||||
console.log(`\n${feed.podcast.title}`)
|
||||
if (feed.podcast.description) {
|
||||
console.log(
|
||||
feed.podcast.description.substring(0, 200) +
|
||||
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||
)
|
||||
}
|
||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
const date =
|
||||
ep.pubDate instanceof Date
|
||||
? ep.pubDate.toLocaleDateString()
|
||||
: String(ep.pubDate)
|
||||
console.log(` ${idx + 1}. ${ep.title} (${date})`)
|
||||
})
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`\nClosest matches for "${query}":`)
|
||||
matches.slice(0, 5).forEach((feed, idx) => {
|
||||
console.log(` ${idx + 1}. ${feed.podcast.title}`)
|
||||
})
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
/** Resolve and play an episode from `arg` (title path or "latest") */
|
||||
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||
const normalizedArg = arg.toLowerCase()
|
||||
|
||||
let feedResult: Feed | null = null
|
||||
let episodeResult: Episode | null = null
|
||||
|
||||
if (normalizedArg === "latest") {
|
||||
const latest = findLatestEpisode(feeds)
|
||||
if (latest) {
|
||||
feedResult = latest.feed
|
||||
episodeResult = latest.episode
|
||||
}
|
||||
} else {
|
||||
const parts = normalizedArg.split("/")
|
||||
const showQuery = parts[0]
|
||||
const episodeQuery = parts[1]
|
||||
|
||||
const matchingFeeds = feeds.filter((feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||
)
|
||||
|
||||
if (matchingFeeds.length === 0) {
|
||||
console.log(`No show found matching: ${showQuery}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const feed = matchingFeeds[0]
|
||||
|
||||
if (!episodeQuery) {
|
||||
if (feed.episodes.length > 0) {
|
||||
feedResult = feed
|
||||
episodeResult = feed.episodes[0]
|
||||
} else {
|
||||
console.log(`No episodes available for: ${feed.podcast.title}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (episodeQuery === "latest") {
|
||||
feedResult = feed
|
||||
episodeResult = feed.episodes[0]
|
||||
} else {
|
||||
const matchingEpisode = feed.episodes.find((ep) =>
|
||||
ep.title.toLowerCase().includes(episodeQuery),
|
||||
)
|
||||
|
||||
if (matchingEpisode) {
|
||||
feedResult = feed
|
||||
episodeResult = matchingEpisode
|
||||
} else {
|
||||
console.log(`Episode not found: ${episodeQuery}`)
|
||||
console.log(`Available episodes for ${feed.podcast.title}:`)
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
console.log(` ${idx + 1}. ${ep.title}`)
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!feedResult || !episodeResult) {
|
||||
console.log("Could not find episode to play")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\nPlaying: ${episodeResult.title}`)
|
||||
console.log(`Show: ${feedResult.podcast.title}`)
|
||||
|
||||
try {
|
||||
const { createAudioBackend } = await import("./utils/audio-player")
|
||||
const { fetchCoverArt } = await import("./utils/cover-art")
|
||||
const backend = createAudioBackend()
|
||||
if (episodeResult.audioUrl) {
|
||||
// Stage the podcast cover so the system Now Playing shows
|
||||
// artwork (mpv --cover-art-files), like the UI path does. Falls
|
||||
// back to the episode's own image when the feed has no channel
|
||||
// cover (URL-added feeds).
|
||||
const coverUrl =
|
||||
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
|
||||
const coverArtPath = coverUrl
|
||||
? await fetchCoverArt(coverUrl)
|
||||
: null
|
||||
await backend.play(episodeResult.audioUrl, {
|
||||
mediaTitle: episodeResult.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
})
|
||||
console.log("Playback started (use the UI to control)")
|
||||
} else {
|
||||
console.log("No audio URL available for this episode")
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Playback error:", err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||
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");
|
||||
// Nested scroll sections favor the innermost one under the cursor.
|
||||
installNestedScrollBehavior();
|
||||
|
||||
function RendererSetup(props: { children: unknown }) {
|
||||
const renderer = useRenderer();
|
||||
renderer.disableStdoutInterception();
|
||||
onCleanup(setupTerminalRecovery(renderer));
|
||||
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
27
src/opentui-jsx.d.ts
vendored
Normal 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 {}
|
||||
691
src/pages/Discover/DiscoverPage.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* 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.
|
||||
* depth 2 (current) — episodes of the drilled show, fetched on demand
|
||||
* WITHOUT subscribing. Parent pane = the results list.
|
||||
* preview — detail of the hovered item (category summary,
|
||||
* podcast detail, or episode detail).
|
||||
*
|
||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (category → results → episodes); `a`
|
||||
* subscribes the focused show (enter/l never subscribe — they open the
|
||||
* episode list); `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 { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
import { format } from "date-fns";
|
||||
import { 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 { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
function DiscoverPage() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const discoverStore = useDiscoverStore();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audio = useAudio();
|
||||
const audioNav = useAudioNavStore();
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
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()]);
|
||||
|
||||
// depth-2 frame ctx = the drilled podcast id (episode preview, no
|
||||
// subscription). Episodes come from the discover store's session cache.
|
||||
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
|
||||
const drilledPodcast = (): Podcast | undefined =>
|
||||
podcasts().find((p) => p.id === drilledPodcastId());
|
||||
const episodes = createMemo<Episode[]>(() => {
|
||||
if (depth() < 2) return [];
|
||||
return discoverStore.episodesForPodcast(drilledPodcastId());
|
||||
});
|
||||
const episodesLoading = () =>
|
||||
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
|
||||
const episodesError = () =>
|
||||
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
|
||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||
|
||||
const curLen = () =>
|
||||
depth() === 0
|
||||
? categories().length
|
||||
: depth() === 1
|
||||
? podcasts().length
|
||||
: episodes().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);
|
||||
if (episodes().length > 0 && focus(2) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 2);
|
||||
};
|
||||
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;
|
||||
if (depth() === 1) return podcasts()[i]?.id;
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
/** The subscribed feed backing a podcast, if any (matched by directory id
|
||||
* or feed URL — a Discover show may already be subscribed). */
|
||||
const feedForPodcast = (p: Podcast) =>
|
||||
feedStore.feeds().find(
|
||||
(f) =>
|
||||
f.podcast.id === p.id ||
|
||||
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
|
||||
);
|
||||
|
||||
const downloadLabel = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "[Q]";
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "[DL]";
|
||||
case DownloadStatus.FAILED:
|
||||
return "[ERR]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const downloadColor = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return theme.warning;
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return theme.primary;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return theme.success;
|
||||
case DownloadStatus.FAILED:
|
||||
return theme.error;
|
||||
default:
|
||||
return muted();
|
||||
}
|
||||
};
|
||||
const playEpisode = (ep: Episode) => {
|
||||
audio.play(ep).catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
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) return;
|
||||
// Drill into the show's episode list WITHOUT subscribing — `l`,
|
||||
// right, and Enter open the episodes; `a` is the subscribe key.
|
||||
discoverStore.openEpisodes(pod).catch(() => {});
|
||||
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 2) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) playEpisode(ep);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
if (depth() >= 2) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) nav.toggleSelected(ep.id);
|
||||
}
|
||||
},
|
||||
download: () => {
|
||||
if (depth() !== 2) return;
|
||||
const pod = drilledPodcast();
|
||||
const ep = focusedEpisode();
|
||||
if (!pod || !ep) return;
|
||||
// Under its subscribed feed when already subscribed, otherwise as
|
||||
// an "unsubscribed show" download (mirrors Search).
|
||||
const feed = feedForPodcast(pod);
|
||||
if (feed) downloadStore.startDownload(ep, feed.id);
|
||||
else downloadStore.startUnsubscribedDownload(ep, pod);
|
||||
},
|
||||
"delete-download": () => {
|
||||
if (depth() !== 2) return;
|
||||
const ep = focusedEpisode();
|
||||
if (!ep) return;
|
||||
const id = ep.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
|
||||
// the episode list, so subscribing moved off open).
|
||||
subscribe: () => {
|
||||
if (depth() === 1) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 2) {
|
||||
const pod = drilledPodcast();
|
||||
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||
}
|
||||
},
|
||||
unsubscribe: () => {
|
||||
if (depth() !== 1) return;
|
||||
const pod = focusedPodcast();
|
||||
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
|
||||
},
|
||||
refresh: () => {
|
||||
if (depth() >= 2) {
|
||||
const pod = drilledPodcast();
|
||||
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
|
||||
return;
|
||||
}
|
||||
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"
|
||||
: depth() === 1
|
||||
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
|
||||
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
|
||||
// children toggle on depth change, so the old subtree is disposed instead
|
||||
// of left orphaned next to the new one (single <Show with fallback> and
|
||||
// ternary root swaps both leak the previous root).
|
||||
const parentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
<TabListPane muted />
|
||||
</Show>
|
||||
<Show when={depth() === 1}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? marker() : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.icon}
|
||||
</text>
|
||||
)}
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.name}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={depth() >= 2}>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = () => nav.depthFocus(1);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), lf(), false)}>
|
||||
{index() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text flexShrink={0} fg={muted()}>[+]</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── current pane ───────────────────────────────────────────────────────────
|
||||
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}
|
||||
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() ? marker() : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{cat.icon}
|
||||
</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}
|
||||
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() ? marker() : " "}
|
||||
</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 />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
|
||||
<Show when={depth() >= 2}>
|
||||
<Show when={episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<LoadingIndicator label="Loading episodes…" />
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={episodesError() && !episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<text fg={theme.error}>{episodesError()}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>r: retry · h: back</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||
}
|
||||
>
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes found. :refresh</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() && !episodesError() && episodes().length > 0
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => (
|
||||
<EpisodeRow
|
||||
episode={ep}
|
||||
index={index}
|
||||
focused={focusedEpIdx}
|
||||
active={isActive}
|
||||
selected={() => nav.isSelected(ep.id)}
|
||||
downloadLabel={() => downloadLabel(ep.id)}
|
||||
downloadColor={() => downloadColor(ep.id)}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 2);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── 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 ? (
|
||||
// depth 1 preview: hovered podcast + episode-list hint
|
||||
<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 · x: unsubscribe</text>
|
||||
</Show>
|
||||
<Show when={!pod().isSubscribed}>
|
||||
<text fg={theme.primary}>a: subscribe</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/l: episodes · h: back · r: refresh</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
) : (
|
||||
// depth ≥2 preview: hovered episode (or loading/error/empty)
|
||||
<>
|
||||
<Show when={episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<LoadingIndicator label="Loading episodes…" />
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={episodesError() && !episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<text fg={theme.error}>{episodesError()}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>r: retry · h: back</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||
}
|
||||
>
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes found.</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() &&
|
||||
!episodesError() &&
|
||||
episodes().length > 0 &&
|
||||
focusedEpisode()
|
||||
}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<EpisodePreview
|
||||
episode={() => ep()}
|
||||
author={() => drilledPodcast()?.author}
|
||||
downloadLabel={() => downloadLabel(ep().id)}
|
||||
downloadColor={() => downloadColor(ep().id)}
|
||||
hint={() =>
|
||||
`enter: play · d: download${
|
||||
downloadStore.getDownloadStatus(ep().id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""
|
||||
}${
|
||||
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
|
||||
} · h: back`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
currentLabel={currentLabel}
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { DiscoverPage };
|
||||
368
src/pages/Feed/FeedPage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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 { prefetchCoverArt } from "@/utils/cover-art";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
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 { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import {
|
||||
EpisodeRow,
|
||||
FetchMoreRow,
|
||||
EpisodePreview,
|
||||
FetchMorePreview,
|
||||
} from "@/components/EpisodeList";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
const audio = useAudio();
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||
const episodes = createMemo<EpItem[]>(
|
||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||
);
|
||||
|
||||
// ── Cover warm-up ────────────────────────────────────────────────────────
|
||||
// Prefetch covers for episodes around the focus (plus the top of the
|
||||
// list) so plays land on a warm cache: cover-art-files only applies at
|
||||
// file load, and there is no working runtime fallback. Single-flight +
|
||||
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
|
||||
createEffect(() => {
|
||||
const list = episodes();
|
||||
const focusIdx = focusedEpIdx();
|
||||
const start = Math.max(0, focusIdx - 10);
|
||||
const end = Math.min(list.length, focusIdx + 11);
|
||||
for (let i = start; i < end; i++) {
|
||||
const item = list[i];
|
||||
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||
// loaded window by 50 episodes. Enter on the row to load the next batch.
|
||||
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||
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();
|
||||
|
||||
// ── Render window ────────────────────────────────────────────────────────
|
||||
// The union grows to thousands of episodes after repeated fetch-more
|
||||
// presses; rendering every row per frame froze the UI. Render only a
|
||||
// bounded slice around the focus (real indexes preserved) — the scrollbox
|
||||
// still keeps the focused row in view. Spacers above/below the window
|
||||
// restore the full content height so the scrollbar tracks the real list.
|
||||
// Each EpisodeRow is 3 lines tall (title, subtitle, date).
|
||||
const LIST_WINDOW = 30;
|
||||
const ROW_HEIGHT = 3;
|
||||
const listWindow = createMemo<[number, number]>(() => {
|
||||
const len = episodes().length;
|
||||
// Focusing the Fetch More button keeps the window anchored at the
|
||||
// last episode — no jump when the focus crosses onto the button.
|
||||
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||
return [
|
||||
Math.max(0, f - LIST_WINDOW),
|
||||
Math.min(len, f + LIST_WINDOW + 1),
|
||||
];
|
||||
});
|
||||
const visibleEpisodes = createMemo(() => {
|
||||
const [start, end] = listWindow();
|
||||
return episodes().slice(start, end);
|
||||
});
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (rowCount() > 0 && focus() >= rowCount())
|
||||
nav.setDepthFocus(rowCount() - 1, 0);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
},
|
||||
download: () => {
|
||||
const item = focusedItem();
|
||||
if (item) downloadStore.startDownload(item.episode, item.feed.id);
|
||||
},
|
||||
"delete-download": () => {
|
||||
const item = focusedItem();
|
||||
if (!item) return;
|
||||
const id = item.episode.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
refresh: () => {
|
||||
feedStore.refreshAllFeeds().catch(() => {});
|
||||
},
|
||||
};
|
||||
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 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} alignItems="center">
|
||||
<Show
|
||||
when={feedStore.isLoadingFeeds()}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{/* Spacers keep the scrollbox content at the FULL list height so
|
||||
the scrollbar reflects the real list, not the render window. */}
|
||||
<Show when={listWindow()[0] > 0}>
|
||||
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<For each={visibleEpisodes()}>
|
||||
{(item, index) => (
|
||||
<EpisodeRow
|
||||
episode={item.episode}
|
||||
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||
index={() => listWindow()[0] + index()}
|
||||
focused={focusedEpIdx}
|
||||
active={isActive}
|
||||
selected={() => nav.isSelected(item.episode.id)}
|
||||
downloadLabel={() => downloadLabel(item.episode.id)}
|
||||
downloadColor={() => downloadColor(item.episode.id)}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(listWindow()[0] + index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<Show when={showFetchMore()}>
|
||||
<FetchMoreRow
|
||||
index={() => episodes().length}
|
||||
focused={focusedRow}
|
||||
onMore={focusedOnMore}
|
||||
active={isActive}
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
nerd={nerd}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 0);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box alignItems="center" paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||
const episodeHint = (item: EpItem) =>
|
||||
`enter: play · d: download${
|
||||
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""
|
||||
} · space: select · h back`;
|
||||
|
||||
const previewContent = () => (
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<FetchMorePreview
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
manualText={() =>
|
||||
"Load the next batch of older episodes across all feeds (Enter)."
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<EpisodePreview
|
||||
episode={() => item().episode}
|
||||
subtitle={() =>
|
||||
item().feed.customName || item().feed.podcast.title
|
||||
}
|
||||
author={() => item().feed.podcast.author}
|
||||
downloadLabel={() => downloadLabel(item().episode.id)}
|
||||
downloadColor={() => downloadColor(item().episode.id)}
|
||||
hint={() => episodeHint(item())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
currentLabel={currentLabel}
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FeedPage };
|
||||
848
src/pages/MyShows/MyShowsPage.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Depth 0's shows list and depth 1's episode list both end with a
|
||||
* "[Fetch More]" row that loads the next batch of episodes — depth 0 for
|
||||
* every subscribed show, depth 1 for the drilled show.
|
||||
*
|
||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||
* 0). j/k move only within the current column.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
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 { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import {
|
||||
EpisodeRow,
|
||||
FetchMoreRow,
|
||||
EpisodePreview,
|
||||
FetchMorePreview,
|
||||
formatDate,
|
||||
} from "@/components/EpisodeList";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
|
||||
// ── render components ────────────────────────────────────────────────────────
|
||||
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
|
||||
// preview panes are My Shows-specific; episode rows/previews are shared with
|
||||
// the Feed page (see EpisodeList.tsx).
|
||||
|
||||
/** A subscribed-show row (depth 0). */
|
||||
function ShowRow(props: {
|
||||
feed: Feed;
|
||||
title: string;
|
||||
index: () => number;
|
||||
focused: () => number;
|
||||
active: () => boolean;
|
||||
marker: () => string;
|
||||
wlScope: () => boolean;
|
||||
wlInList: () => boolean;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||
const isFocused = () => props.index() === props.focused();
|
||||
const bg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.primary
|
||||
: isFocused()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.surface
|
||||
: isFocused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{isFocused() ? props.marker() : " "}
|
||||
</text>
|
||||
{/* Long titles truncate with middle-ellipsis instead of wrapping —
|
||||
a wrapped title grows the row to 2+ lines and shifts every row
|
||||
below (see EpisodeList for the same guard). The episode-count
|
||||
and watchlist cells are flexShrink=0 so they never shrink or
|
||||
wrap; the flexible title takes the remaining width. */}
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||
({props.feed.episodes.length})
|
||||
</text>
|
||||
<Show when={props.wlScope()}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={
|
||||
isFocused()
|
||||
? theme.surface
|
||||
: props.wlInList()
|
||||
? theme.warning
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{props.wlInList() ? "●" : "○"}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** An unsubscribed-show download row (depth 0, below the shows list). */
|
||||
function UnsubscribedRow(props: {
|
||||
d: DownloadedEpisode;
|
||||
index: () => number;
|
||||
focused: () => number;
|
||||
active: () => boolean;
|
||||
marker: () => string;
|
||||
downloadLabel: () => string;
|
||||
downloadColor: () => RGBA;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||
const isFocused = () => props.index() === props.focused();
|
||||
const bg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.primary
|
||||
: isFocused()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.surface
|
||||
: isFocused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{isFocused() ? props.marker() : " "}
|
||||
</text>
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{props.d.episodeTitle ?? props.d.episodeId}
|
||||
</text>
|
||||
<Show when={props.downloadLabel()}>
|
||||
<text flexShrink={0} fg={props.downloadColor()}>
|
||||
{props.downloadLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||
>
|
||||
{props.d.podcastTitle ?? props.d.feedId}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Depth-0 preview: the hovered subscribed show. */
|
||||
function ShowPreview(props: {
|
||||
show: () => Feed;
|
||||
title: () => string;
|
||||
hint: () => string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const show = props.show;
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{props.title()}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>{props.hint()}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Depth-0 preview: the hovered unsubscribed-show download. */
|
||||
function UnsubscribedPreview(props: {
|
||||
d: () => DownloadedEpisode;
|
||||
downloadLabel: () => string;
|
||||
downloadColor: () => RGBA;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const d = props.d;
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={d().pubDate}>
|
||||
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
|
||||
</Show>
|
||||
<Show when={props.downloadLabel()}>
|
||||
<text fg={props.downloadColor()}>
|
||||
{props.downloadLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
Downloaded from episode search — the show is not subscribed.
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · D: delete download · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
export function MyShowsPage() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const app = useAppStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
const audio = useAudio();
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds();
|
||||
|
||||
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||
// listed as their own section under the shows list. Reads feeds() so an
|
||||
// entry drops out the moment the user subscribes to its show.
|
||||
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||
|
||||
/** True while some subscribed show has more episodes to load — shows the
|
||||
* depth-0 "[Fetch More]" row. */
|
||||
const showLoadMore = () => feedStore.hasMoreAcrossAll();
|
||||
const depth0Count = () =>
|
||||
shows().length + unsubs().length + (showLoadMore() ? 1 : 0);
|
||||
const focusedRow0 = () =>
|
||||
depth0Count() === 0 ? 0 : Math.min(focus(0), depth0Count() - 1);
|
||||
/** True while the depth-0 cursor sits on the "[Fetch More]" row. */
|
||||
const focusedOnMore0 = () =>
|
||||
showLoadMore() && focusedRow0() === shows().length + unsubs().length;
|
||||
const focusedShowIdx = () =>
|
||||
focusedOnMore0()
|
||||
? -1
|
||||
: Math.min(focusedRow0(), Math.max(shows().length - 1, 0));
|
||||
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||
* row (past the shows list). */
|
||||
const focusedOnUnsub = () =>
|
||||
!focusedOnMore0() &&
|
||||
depth() === 0 &&
|
||||
focusedRow0() >= shows().length &&
|
||||
unsubs().length > 0;
|
||||
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||
if (!focusedOnUnsub()) return undefined;
|
||||
return unsubs()[
|
||||
Math.min(focusedRow0() - shows().length, unsubs().length - 1)
|
||||
];
|
||||
};
|
||||
const selectedShow = (): Feed | undefined => {
|
||||
if (focusedOnUnsub() || focusedOnMore0()) return undefined;
|
||||
return 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(),
|
||||
);
|
||||
});
|
||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||
// A "[Fetch More]" row at the bottom of a drilled show's episode list
|
||||
// advances that show's loaded window by 50 episodes — the per-show
|
||||
// counterpart to the Feed page's row (which loads every feed).
|
||||
const showFetchMore = () =>
|
||||
depth() >= 1 &&
|
||||
!!drilledShowId() &&
|
||||
feedStore.hasMoreEpisodes(drilledShowId());
|
||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||
const focusedRow = () =>
|
||||
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
||||
const focusedOnMore = () =>
|
||||
showFetchMore() && focusedRow() === episodes().length;
|
||||
// -1 while the Fetch More row is focused so no episode row renders the
|
||||
// cursor/highlight (the button is the focused row, not the last episode).
|
||||
const focusedEpIdx = () =>
|
||||
focusedOnMore()
|
||||
? -1
|
||||
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||
const focusedEpisode = () =>
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
|
||||
// ── Render window ────────────────────────────────────────────────────────
|
||||
// The drilled show's list grows deep after repeated fetch-more presses;
|
||||
// rendering every row per frame froze the UI. Render only a bounded slice
|
||||
// around the focus (real indexes preserved) — the scrollbox still keeps
|
||||
// the focused row in view. Spacers above/below the window restore the
|
||||
// full content height so the scrollbar tracks the real list.
|
||||
// Each episode row is 2 lines tall (title, date) — no subtitle here.
|
||||
const LIST_WINDOW = 30;
|
||||
const ROW_HEIGHT = 2;
|
||||
const listWindow = createMemo<[number, number]>(() => {
|
||||
const len = episodes().length;
|
||||
// Focusing the Fetch More button keeps the window anchored at the
|
||||
// last episode — no jump when the focus crosses onto the button.
|
||||
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||
return [
|
||||
Math.max(0, f - LIST_WINDOW),
|
||||
Math.min(len, f + LIST_WINDOW + 1),
|
||||
];
|
||||
});
|
||||
const visibleEpisodes = createMemo(() => {
|
||||
const [start, end] = listWindow();
|
||||
return episodes().slice(start, end);
|
||||
});
|
||||
|
||||
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||
nav.setDepthFocus(rowCount() - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) {
|
||||
if (i < shows().length) return shows()[i]?.id;
|
||||
return unsubs()[i - shows().length]?.episodeId;
|
||||
}
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
};
|
||||
|
||||
/** Stream an unsubscribed-show download. The record carries only what was
|
||||
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||
audio
|
||||
.play({
|
||||
id: d.episodeId,
|
||||
podcastId: d.feedId,
|
||||
title: d.episodeTitle ?? d.episodeId,
|
||||
description: "",
|
||||
audioUrl: d.audioUrl ?? "",
|
||||
duration: 0,
|
||||
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||
})
|
||||
.catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
if (focusedOnMore0()) {
|
||||
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const d = focusedUnsub();
|
||||
if (d) {
|
||||
playUnsubscribedDownload(d);
|
||||
return;
|
||||
}
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
if (focusedOnMore()) {
|
||||
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
download: () => {
|
||||
if (depth() < 1) return;
|
||||
const ep = focusedEpisode();
|
||||
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||
},
|
||||
"delete-download": () => {
|
||||
if (depth() === 0) {
|
||||
const d = focusedUnsub();
|
||||
if (d) {
|
||||
downloadStore.cancelDownload(d.episodeId);
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (depth() < 1) return;
|
||||
const ep = focusedEpisode();
|
||||
if (!ep) return;
|
||||
const id = ep.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
"whitelist-toggle": () => {
|
||||
const prefs = app.state().preferences;
|
||||
if (prefs.autoDownloadScope !== "whitelist") return;
|
||||
// depth 0: the focused show; depth ≥1: the drilled show.
|
||||
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
|
||||
if (!id) return;
|
||||
const cur = prefs.autoDownloadWhitelist ?? [];
|
||||
const next = cur.includes(id)
|
||||
? cur.filter((x) => x !== id)
|
||||
: [...cur, id];
|
||||
app.updatePreferences({ autoDownloadWhitelist: next });
|
||||
feedStore.runAutoDownload();
|
||||
},
|
||||
refresh: () => {
|
||||
const show = selectedShow();
|
||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||
},
|
||||
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 showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Shows (${shows().length})${
|
||||
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||
}`
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// 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());
|
||||
const focused = () => index() === lf();
|
||||
const fg = () =>
|
||||
focused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focused() ? theme.border : undefined}
|
||||
>
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{focused() ? marker() : " "}
|
||||
</text>
|
||||
{/* 20%-wide parent pane truncates hard — same
|
||||
middle-ellipsis guard as the depth-0 rows. */}
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</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={depth0Count() > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No shows. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => (
|
||||
<ShowRow
|
||||
feed={feed}
|
||||
title={showTitle(feed)}
|
||||
index={index}
|
||||
focused={focusedShowIdx}
|
||||
active={isActive}
|
||||
marker={marker}
|
||||
wlScope={() =>
|
||||
app.state().preferences.autoDownloadScope === "whitelist"
|
||||
}
|
||||
wlInList={() =>
|
||||
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||
feed.id,
|
||||
)
|
||||
}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={unsubs().length > 0}>
|
||||
<box paddingLeft={1} paddingTop={1}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Unsubscribed Show Downloads
|
||||
</text>
|
||||
</box>
|
||||
<For each={unsubs()}>
|
||||
{(d, index) => (
|
||||
<UnsubscribedRow
|
||||
d={d}
|
||||
index={() => shows().length + index()}
|
||||
focused={focusedRow0}
|
||||
active={isActive}
|
||||
marker={marker}
|
||||
downloadLabel={() => downloadLabel(d.episodeId)}
|
||||
downloadColor={() => downloadColor(d.episodeId)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(shows().length + index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={showLoadMore()}>
|
||||
<FetchMoreRow
|
||||
index={() => shows().length + unsubs().length}
|
||||
focused={focusedRow0}
|
||||
onMore={focusedOnMore0}
|
||||
active={isActive}
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
nerd={nerd}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(shows().length + unsubs().length, 0);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</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>
|
||||
}
|
||||
>
|
||||
{/* Spacers keep the scrollbox content at the FULL list
|
||||
height so the scrollbar reflects the real list, not the
|
||||
render window. */}
|
||||
<Show when={listWindow()[0] > 0}>
|
||||
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<For each={visibleEpisodes()}>
|
||||
{(ep, index) => (
|
||||
<EpisodeRow
|
||||
episode={ep}
|
||||
index={() => listWindow()[0] + index()}
|
||||
focused={focusedEpIdx}
|
||||
active={isActive}
|
||||
selected={() => nav.isSelected(ep.id)}
|
||||
downloadLabel={() => downloadLabel(ep.id)}
|
||||
downloadColor={() => downloadColor(ep.id)}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(listWindow()[0] + index(), 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<Show when={showFetchMore()}>
|
||||
<FetchMoreRow
|
||||
index={() => episodes().length}
|
||||
focused={focusedRow}
|
||||
onMore={focusedOnMore}
|
||||
active={isActive}
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
nerd={nerd}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 1);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── preview pane ───────────────────────────────────────────────────────────
|
||||
const episodeHint = (epId: string) =>
|
||||
`enter: play · d: download${
|
||||
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""
|
||||
}${
|
||||
app.state().preferences.autoDownloadScope === "whitelist"
|
||||
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||
drilledShowId(),
|
||||
)
|
||||
? " · w: un-whitelist"
|
||||
: " · w: whitelist"
|
||||
: ""
|
||||
} · space: select · h: back`;
|
||||
|
||||
const showHint = (show: Feed) =>
|
||||
`enter/l: open · h: back · x: unsubscribe${
|
||||
app.state().preferences.autoDownloadScope === "whitelist"
|
||||
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
|
||||
? " · w: un-whitelist"
|
||||
: " · w: whitelist"
|
||||
: ""
|
||||
}`;
|
||||
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered "[Fetch More]" row, else the
|
||||
// unsubscribed-show download, else the hovered show.
|
||||
<>
|
||||
<Show when={focusedOnMore0()}>
|
||||
<FetchMorePreview
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
manualText={() =>
|
||||
"Load the next batch of older episodes across all subscribed shows (Enter)."
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore0()}>
|
||||
<Show
|
||||
when={focusedUnsub()}
|
||||
fallback={
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No show focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
<ShowPreview
|
||||
show={() => show()}
|
||||
title={() => showTitle(show())}
|
||||
hint={() => showHint(show())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(d) => (
|
||||
<UnsubscribedPreview
|
||||
d={() => d()}
|
||||
downloadLabel={() => downloadLabel(d().episodeId)}
|
||||
downloadColor={() => downloadColor(d().episodeId)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<FetchMorePreview
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
manualText={() =>
|
||||
"Load the next batch of older episodes for this show (Enter)."
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<EpisodePreview
|
||||
episode={() => ep()}
|
||||
author={() => selectedShow()?.podcast.author}
|
||||
downloadLabel={() => downloadLabel(ep().id)}
|
||||
downloadColor={() => downloadColor(ep().id)}
|
||||
hint={() => episodeHint(ep().id)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
currentLabel={currentLabel}
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
src/pages/Player/PlaybackControls.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
border
|
||||
padding={1}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
{/* transport buttons — wrap as a unit, centered on their own line */}
|
||||
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onPrev}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary} wrapMode="none">[Prev]</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onToggle}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary} wrapMode="none">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onNext}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary} wrapMode="none">[Next]</text>
|
||||
</box>
|
||||
</box>
|
||||
{/* status group — always follows the buttons; wrap point is here */}
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
marginLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.textMuted}>Vol</text>
|
||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||
<text fg={theme.textMuted}>↑↓</text>
|
||||
<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>
|
||||
</box>
|
||||
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||
{(props.backendName === "none" || props.hasAudioUrl === false) && (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.backendName === "none" && (
|
||||
<box marginLeft={2}>
|
||||
<text fg={theme.warning}>No audio player found</text>
|
||||
</box>
|
||||
)}
|
||||
{props.hasAudioUrl === false && (
|
||||
<box marginLeft={2}>
|
||||
<text fg={theme.warning}>No audio URL</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
146
src/pages/Player/PlayerPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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, onMount, onCleanup } from "solid-js";
|
||||
import { PlaybackControls } from "./PlaybackControls";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useVisualizer } from "@/stores/visualizer";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
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 viz = useVisualizer();
|
||||
const dims = useTerminalDimensions();
|
||||
const app = useAppStore();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
// Settings master switch: off hides the waveform entirely (the store
|
||||
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
||||
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
||||
|
||||
// The page is mounted exactly while the Player tab is in focus (Shell
|
||||
// renders only the active tab), so mount ⇔ focused. Report it to the
|
||||
// visualizer store: losing focus starts the unload grace timer instead
|
||||
// of killing the pipeline with the page; regaining focus restarts it.
|
||||
onMount(() => viz.setFocused(true));
|
||||
onCleanup(() => viz.setFocused(false));
|
||||
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
|
||||
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>
|
||||
<Show
|
||||
when={ep().description}
|
||||
fallback={<text fg={muted()}>No description available.</text>}
|
||||
>
|
||||
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
|
||||
<text fg={muted()}>{ep().description}</text>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
<ProgressBar />
|
||||
|
||||
<Show when={vizEnabled()}>
|
||||
<RealtimeWaveform />
|
||||
</Show>
|
||||
</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} />
|
||||
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||
* transform HTML-escapes static string children (`<` → `<`),
|
||||
* which opentui renders verbatim; content bypasses that. */}
|
||||
<text
|
||||
fg={muted()}
|
||||
content={"P play/pause N next B prev < > seek h back"}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
currentLabel="Player"
|
||||
panes={2}
|
||||
focused={isActive}
|
||||
currentBorder={["left"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
src/pages/Player/ProgressBar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* ProgressBar — one-row, click-to-seek playback progress bar for the
|
||||
* player pane. Played portion renders as full blocks (█) in the theme's
|
||||
* primary color, the remainder as light shade blocks (░) in the muted
|
||||
* color. The header time/percent text lives in PlayerPage — this is only
|
||||
* the bar itself.
|
||||
*/
|
||||
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import type { Renderable } from "@opentui/core";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { usePaneLayout } from "@/stores/pane-layout";
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function ProgressBar() {
|
||||
const audio = useAudio();
|
||||
const { theme } = useTheme();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const layout = usePaneLayout();
|
||||
|
||||
// The bar's renderable, captured for its absolute left edge: MouseEvent.x
|
||||
// is terminal-absolute (not bar-relative), so local x needs the offset
|
||||
// of the bar inside the 2-pane row (parent pane = left split of the width).
|
||||
let bar: Renderable | undefined;
|
||||
|
||||
// Full content width of the player pane: the player is a 2-pane row
|
||||
// (parent = left split, current = the rest of the terminal width). Track
|
||||
// the live split so a dragged border re-sizes the bar. Subtract ~8 chars
|
||||
// of border/padding chrome (same math as RealtimeWaveform's numBars).
|
||||
const width = () =>
|
||||
Math.max(8, Math.floor(dimensions().width * (1 - layout.splits().left)) - 8);
|
||||
|
||||
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
|
||||
|
||||
const playedChars = () => {
|
||||
const duration = audio.duration();
|
||||
if (duration <= 0) return 0;
|
||||
return Math.round(clamp01(audio.position() / duration) * width());
|
||||
};
|
||||
|
||||
const remainingColor = theme.muted || theme.text;
|
||||
|
||||
return (
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
flexDirection="row"
|
||||
gap={0}
|
||||
// The bar's block-char texts are non-selectable below: a drag
|
||||
// over the bar is a seek gesture, not a text selection — otherwise
|
||||
// mouse-up would copy █/░ to the clipboard via the global
|
||||
// selection handler.
|
||||
ref={(el) => {
|
||||
bar = el;
|
||||
}}
|
||||
onMouseDown={(e: { x: number }) => {
|
||||
const duration = audio.duration();
|
||||
if (duration <= 0 || !bar) return;
|
||||
// localX = 0 is the box border; content starts at localX = 1.
|
||||
const localX = e.x - bar.x;
|
||||
const ratio = Math.max(0, Math.min(1, (localX - 1) / width()));
|
||||
void audio.seek(ratio * duration);
|
||||
}}
|
||||
>
|
||||
{playedChars() > 0 && (
|
||||
<text fg={theme.primary} selectable={false}>
|
||||
{"\u2588".repeat(playedChars())}
|
||||
</text>
|
||||
)}
|
||||
<text fg={remainingColor} selectable={false}>
|
||||
{"\u2591".repeat(width() - playedChars())}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
90
src/pages/Player/RealtimeWaveform.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||
*
|
||||
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
|
||||
*
|
||||
* This component only subscribes to store state, reports the width-derived
|
||||
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||
* a braille spinner while the pipeline is loading its first frames or the
|
||||
* player is stalled (re-buffering), the frequency bars once frames arrive,
|
||||
* and a dotted placeholder when idle.
|
||||
*/
|
||||
|
||||
import { createEffect, on } from "solid-js";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import { useVisualizer } from "@/stores/visualizer";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||
import { usePaneLayout } from "@/stores/pane-layout";
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RealtimeWaveform() {
|
||||
const { theme } = useTheme();
|
||||
const viz = useVisualizer();
|
||||
|
||||
const dimensions = useTerminalDimensions();
|
||||
const layout = usePaneLayout();
|
||||
const numBars = () => {
|
||||
const width = dimensions().width;
|
||||
if (!width) return 64;
|
||||
// The player is a 2-pane row: the current column = whole width minus
|
||||
// the parent (left split). Subtract ~8 chars of chrome.
|
||||
return Math.max(
|
||||
8,
|
||||
Math.min(256, Math.floor(width * (1 - layout.splits().left)) - 8),
|
||||
);
|
||||
};
|
||||
|
||||
// Keep the store's bar count in sync with the terminal width; the store
|
||||
// re-inits the running pipeline when it changes (terminal resize).
|
||||
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
const renderLine = () => {
|
||||
const bars = viz.barData();
|
||||
const count = numBars();
|
||||
|
||||
// Loading state: the braille spinner shows while the pipeline is
|
||||
// warming up — cold start (first play / after an unload), resume
|
||||
// into undecoded audio, or a stalled position clock (mpv
|
||||
// re-buffering after a long pause on a network stream). The store
|
||||
// clears it the moment the first fresh frame renders, so stale
|
||||
// bars never masquerade as live data while the pipeline re-arms.
|
||||
if (viz.isLoading() || viz.isStalled()) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(count);
|
||||
return (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg={theme.primary}>{placeholder}</text>
|
||||
<text fg={theme.primary}>{placeholder}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
|
||||
const top = pairs.map((pair) => pair.top).join("");
|
||||
const bottom = pairs.map((pair) => pair.bottom).join("");
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg={theme.primary}>{top}</text>
|
||||
<text fg={theme.primary}>{bottom}</text>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<box border borderColor={theme.border} padding={1}>
|
||||
{renderLine()}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
804
src/pages/Search/SearchPage.tsx
Normal file
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Search scope: `tab` (search-scope-toggle) flips between shows and episodes
|
||||
* (clickable pills on the query depth too); toggling while viewing results
|
||||
* re-runs the current query in the new scope.
|
||||
*
|
||||
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||||
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||
* 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 { useDownloadStore } from "@/stores/download";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
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, SearchScope } from "@/types/source";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
|
||||
export const SearchPaneCount = 1;
|
||||
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audio = useAudio();
|
||||
const audioNav = useAudioNavStore();
|
||||
const toast = useToast();
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
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` tells the Shell router to yield keys to the query input.
|
||||
// The input's REAL focus is the source of truth: useInputFocusNav flips
|
||||
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
|
||||
// input drops it and the router resumes j/k/h — no stuck "typing" state.
|
||||
// The depth stack only SEEDS it on transitions (re-entering depth 0
|
||||
// focuses the input; mounting at depth 1 stays list-nav), gated on the
|
||||
// depth VALUE via a memo because setDepthFocus also writes the stack
|
||||
// signal — without the memo every j/k at query depth re-focuses the input
|
||||
// and strands the recents list.
|
||||
onMount(() => nav.setInputFocused(depth() === 0));
|
||||
onCleanup(() => nav.setInputFocused(false));
|
||||
const focusNavRef = useInputFocusNav();
|
||||
const isQueryDepth = createMemo(() => depth() === 0);
|
||||
createEffect(() => {
|
||||
nav.setInputFocused(isQueryDepth());
|
||||
});
|
||||
|
||||
// ── 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) => {
|
||||
const r = results()[i];
|
||||
return r?.kind === "episode" ? r.episode.id : r?.podcast.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
const downloadLabel = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "[Q]";
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "[DL]";
|
||||
case DownloadStatus.FAILED:
|
||||
return "[ERR]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const downloadColor = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return theme.warning;
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return theme.primary;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return theme.success;
|
||||
case DownloadStatus.FAILED:
|
||||
return theme.error;
|
||||
default:
|
||||
return muted();
|
||||
}
|
||||
};
|
||||
|
||||
const runSearch = (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
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);
|
||||
};
|
||||
|
||||
/** Set show/episode scope; when viewing results, re-run the current query
|
||||
* so the list switches immediately (the toggle is otherwise invisible on
|
||||
* a list of results). */
|
||||
const applyScope = (next: SearchScope) => {
|
||||
searchStore.setScope(next);
|
||||
if (depth() >= 1) {
|
||||
const q = submittedQuery() || inputValue().trim();
|
||||
if (q) searchStore.search(q).catch(() => {});
|
||||
}
|
||||
};
|
||||
const toggleScope = () =>
|
||||
applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast");
|
||||
|
||||
const handleSubscribe = async (result: SearchResult) => {
|
||||
// Actually add the feed to the feed store, then mark the result
|
||||
// subscribed. addFeed returns null when a feedless directory stub
|
||||
// (delisted show) can't be resolved — tell the user why.
|
||||
const feed = await feedStore
|
||||
.addFeed(result.podcast, result.sourceId)
|
||||
.catch(() => null);
|
||||
if (!feed && !result.podcast.feedUrl) {
|
||||
toast.show({
|
||||
title: "Can't subscribe",
|
||||
message:
|
||||
"No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.",
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (feed) searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
/** The subscribed feed backing a search result, if any (matched by
|
||||
* directory id or feed URL). */
|
||||
const feedForResult = (r: SearchResult) =>
|
||||
feedStore.feeds().find(
|
||||
(f) =>
|
||||
f.podcast.id === r.podcast.id ||
|
||||
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
|
||||
);
|
||||
|
||||
/** Download the focused episode: under its subscribed feed when the show
|
||||
* is subscribed, otherwise as an "unsubscribed show" download (listed
|
||||
* under Unsubscribed Show Downloads in My Shows / the download manager). */
|
||||
const downloadFocusedEpisode = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
const feed = feedForResult(r);
|
||||
if (feed) downloadStore.startDownload(r.episode, feed.id);
|
||||
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
|
||||
};
|
||||
|
||||
const playFocusedEpisode = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
audio.play(r.episode).catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
|
||||
};
|
||||
|
||||
const unsubscribeFocused = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || !r.podcast.isSubscribed) return;
|
||||
const feed = feedForResult(r);
|
||||
if (feed) {
|
||||
feedStore.removeFeed(feed.id);
|
||||
downloadStore
|
||||
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
|
||||
.catch(() => {});
|
||||
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe the focused result's show in place (episode or podcast
|
||||
* result). `enter` plays episodes regardless of subscription, so an
|
||||
* unsubscribed show's episode needs this explicit path. */
|
||||
const subscribeFocused = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.podcast.isSubscribed) return;
|
||||
handleSubscribe(r);
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
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.kind === "episode" ? r.episode.id : r.podcast.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
download: () => downloadFocusedEpisode(),
|
||||
"delete-download": () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
const id = r.episode.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
unsubscribe: () => unsubscribeFocused(),
|
||||
subscribe: () => subscribeFocused(),
|
||||
search: () => {
|
||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||
if (depth() === 0) nav.setInputFocused(true);
|
||||
},
|
||||
"search-scope-toggle": () => toggleScope(),
|
||||
refresh: () => {
|
||||
const q = submittedQuery() || inputValue().trim();
|
||||
if (q) searchStore.search(q).catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
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) return;
|
||||
if (r.kind === "episode") {
|
||||
// Any episode result streams directly — subscribed or not
|
||||
// (matches Feed/My Shows). `a` subscribes an unsubscribed
|
||||
// show's episode in place.
|
||||
playFocusedEpisode();
|
||||
return;
|
||||
}
|
||||
handleSubscribe(r);
|
||||
}
|
||||
}
|
||||
|
||||
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) ──────────────
|
||||
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
|
||||
// children toggle on depth change, so the old subtree is disposed instead
|
||||
// of left orphaned next to the new one (single <Show with fallback> and
|
||||
// ternary root swaps both leak the previous root).
|
||||
const parentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
<TabListPane muted />
|
||||
</Show>
|
||||
<Show when={depth() >= 1}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textSecondary}>Query</text>
|
||||
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"}
|
||||
</text>
|
||||
<text fg={muted()}>h: back to query</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── current pane ────────────────────────────────────────────────────────────
|
||||
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
|
||||
ref={focusNavRef}
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onSubmit={() => handleSubmit()}
|
||||
onMouseDown={(evt) => {
|
||||
// Clicking the input must focus it (typing mode).
|
||||
// preventDefault stops opentui's click auto-focus from
|
||||
// grabbing the pane scrollbox instead; setting the flag
|
||||
// drives the `focused` prop → renderable focus → the
|
||||
// useInputFocusNav FOCUSED handler.
|
||||
evt.preventDefault();
|
||||
nav.setInputFocused(true);
|
||||
}}
|
||||
onKeyDown={(evt) => {
|
||||
// While the input owns keys the Shell router never sees
|
||||
// Tab, so the scope toggle must be handled here (the
|
||||
// pills and the tab keybind cover the defocused cases).
|
||||
if (evt.name === "tab") {
|
||||
evt.preventDefault();
|
||||
toggleScope();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
searchStore.scope() === "episode"
|
||||
? "Enter episode, guest, topic..."
|
||||
: "Enter podcast name..."
|
||||
}
|
||||
focused={inputActive()}
|
||||
width={28}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={theme.textSecondary}>Scope:</text>
|
||||
<box
|
||||
backgroundColor={
|
||||
searchStore.scope() === "podcast" ? theme.primary : undefined
|
||||
}
|
||||
onMouseDown={() => applyScope("podcast")}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
searchStore.scope() === "podcast"
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
Shows{" "}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={
|
||||
searchStore.scope() === "episode" ? theme.primary : undefined
|
||||
}
|
||||
onMouseDown={() => applyScope("episode")}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
searchStore.scope() === "episode"
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
Episodes{" "}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={muted()}>tab to toggle</text>
|
||||
</box>
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<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());
|
||||
// While the input is focused (typing), the list is not
|
||||
// in focus: no bg, no accent fg, no `❯` on any entry.
|
||||
const typing = () => inputActive();
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
typing()
|
||||
? undefined
|
||||
: focusBg(index(), lf(), isActive())
|
||||
}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
// A recent is an action, not an item: clicking
|
||||
// it re-runs that search (focus-only would be
|
||||
// invisible — the input still owns the keys).
|
||||
selectRecent(query);
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
typing()
|
||||
? theme.text
|
||||
: focusFg(index(), lf(), isActive())
|
||||
}
|
||||
>
|
||||
{index() === lf() && !typing() ? marker() : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
typing()
|
||||
? theme.text
|
||||
: 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 · tab scope · 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"
|
||||
: searchStore.scope() === "episode"
|
||||
? "Enter a search term to find episodes"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<LoadingIndicator label="Searching…" />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={results()}>
|
||||
{(result, index) => {
|
||||
const fi = () => focusedResultIdx();
|
||||
const ref = useScrollIntoView(() => index() === fi());
|
||||
// Episode download status badge ("" when absent).
|
||||
const dlLabel = () =>
|
||||
result.kind === "episode"
|
||||
? downloadLabel(result.episode.id)
|
||||
: "";
|
||||
const dlEpId = () =>
|
||||
result.kind === "episode" ? result.episode.id : "";
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
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() ? marker() : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{result.kind === "episode"
|
||||
? result.episode.title
|
||||
: result.podcast.title}
|
||||
</text>
|
||||
<Show when={dlLabel()}>
|
||||
<text fg={downloadColor(dlEpId())}>
|
||||
{dlLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={result.podcast.isSubscribed}>
|
||||
<text
|
||||
fg={index() === fi() ? theme.surface : theme.success}
|
||||
>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
{result.kind === "episode" ? (
|
||||
<text
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
{result.podcast.title} ·{" "}
|
||||
{formatDate(result.episode.pubDate)}
|
||||
</text>
|
||||
) : (
|
||||
<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()}>
|
||||
Tab toggles Shows ↔ Episodes (episode search finds guests
|
||||
and topics).
|
||||
</text>
|
||||
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||
<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) => {
|
||||
const r = result();
|
||||
if (r.kind === "episode") {
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{r.episode.title}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>{r.podcast.title}</text>
|
||||
<Show when={r.podcast.author}>
|
||||
<text fg={muted()}>by {r.podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={r.episode.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{r.episode.description!.slice(0, 400)}
|
||||
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={muted()}>
|
||||
Published: {formatDate(r.episode.pubDate)}
|
||||
</text>
|
||||
<Show when={downloadLabel(r.episode.id)}>
|
||||
<text fg={downloadColor(r.episode.id)}>
|
||||
{downloadLabel(r.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={r.sourceName}>
|
||||
<text fg={muted()}>Source: {r.sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!r.podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (a)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>
|
||||
Subscribed · x: unsubscribe
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show
|
||||
when={r.podcast.isSubscribed}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
enter: play · a: subscribe · d: download · h: back to query
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={muted()}>
|
||||
enter: play · d: download · x: unsubscribe
|
||||
{downloadStore.getDownloadStatus(r.episode.id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""}{" "}
|
||||
· h: back to query
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{r.podcast.title}</strong>
|
||||
</text>
|
||||
<Show when={r.podcast.author}>
|
||||
<text fg={muted()}>by {r.podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{r.podcast.description!.slice(0, 400)}
|
||||
{(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Feed:{" "}
|
||||
{r.podcast.feedUrl ||
|
||||
"not listed by source — resolves on subscribe"}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(r.podcast.lastUpdated)}
|
||||
</text>
|
||||
<Show when={r.sourceName}>
|
||||
<text fg={muted()}>Source: {r.sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!r.podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>
|
||||
Subscribed · x: unsubscribe
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: subscribe
|
||||
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
|
||||
· h: back to query
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Search · ${recents().length} recent`
|
||||
: `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
currentLabel={currentLabel}
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { SearchPage };
|
||||
167
src/pages/Settings/DownloadManager.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||
*
|
||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||
* • one item per subscribed show — action item; Enter deletes all that
|
||||
* show's downloads (file + metadata, aborts
|
||||
* in-flight).
|
||||
* • "Unsubscribed Show Downloads" — downloads made from episode search for
|
||||
* shows that aren't subscribed, grouped
|
||||
* under their own header.
|
||||
* • one item per episode — action item; Enter deletes a single download.
|
||||
*
|
||||
* Titles resolve from the feed store at render time (reactive), falling back
|
||||
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||
* panels).
|
||||
*/
|
||||
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
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);
|
||||
* falls back to the persisted title (kept for unsubscribed-show downloads). */
|
||||
function episodeTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
d: DownloadedEpisode,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(d.feedId);
|
||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||
return ep?.title ?? d.episodeTitle ?? d.episodeId;
|
||||
}
|
||||
|
||||
/** Show title for a download's feed id; falls back to the persisted show
|
||||
* title (unsubscribed-show downloads have no feed to resolve from). */
|
||||
function feedTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
d: DownloadedEpisode,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(d.feedId);
|
||||
if (feed) return feed.customName || feed.podcast.title;
|
||||
return d.podcastTitle ?? d.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 subscribed show gets a delete-by-show
|
||||
// item. Unsubscribed-show downloads (search downloads, synthetic feed
|
||||
// ids) are kept out of these groups and listed under their own section
|
||||
// below.
|
||||
const unsubscribed = downloadStore.getUnsubscribedDownloads();
|
||||
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
|
||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||
for (const d of downloads()) {
|
||||
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||
const arr = byFeed.get(d.feedId) ?? [];
|
||||
arr.push(d);
|
||||
byFeed.set(d.feedId, arr);
|
||||
}
|
||||
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, eps[0])}`,
|
||||
kind: "action",
|
||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||
help: () =>
|
||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore
|
||||
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Unsubscribed-show downloads: a section header + one item per episode.
|
||||
if (unsubscribed.length > 0) {
|
||||
items.push({
|
||||
id: "unsubscribed-header",
|
||||
label: "Unsubscribed Show Downloads",
|
||||
kind: "info",
|
||||
display: () => `${unsubscribed.length} files`,
|
||||
help: () =>
|
||||
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
|
||||
});
|
||||
}
|
||||
for (const d of unsubscribed) {
|
||||
items.push({
|
||||
id: `unsub:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One item per individual (subscribed-show) episode download.
|
||||
for (const d of downloads()) {
|
||||
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||
items.push({
|
||||
id: `ep:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
58
src/pages/Settings/ExportDialog.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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 }}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text fg={theme.text}>Format:</text>
|
||||
<tab_select
|
||||
options={[
|
||||
{ name: "JSON", description: "Portable" },
|
||||
{ name: "XML", description: "Structured" },
|
||||
]}
|
||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||
/>
|
||||
</box>
|
||||
<box border borderColor={theme.border}>
|
||||
<text fg={theme.text}>
|
||||
Export {format[0]()} to {filename[0]()}
|
||||
</text>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
31
src/pages/Settings/FilePicker.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { detectFormat } from "@/utils/file-detector";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
|
||||
type FilePickerProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
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 }}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
<text fg={theme.text}>Format: {format}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
23
src/pages/Settings/ImportDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
571
src/pages/Settings/PreferencesPanel.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
||||
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
||||
* driven by the Shell router via nav.action.
|
||||
*
|
||||
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
||||
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
||||
* field over subscribed shows; suggestions toggle
|
||||
* in/out with Space (j/k to move, Esc to browse).
|
||||
* • Episode Cache Mode — date or count bound for the episode list
|
||||
* (default: date)
|
||||
* • Episode Cache Count — N most recent episodes when mode is count
|
||||
* (default: 25)
|
||||
* • Episode Cache Days — rolling N-day window when mode is date
|
||||
* (default: 60)
|
||||
*/
|
||||
|
||||
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||
import { RenderableEvents, type InputRenderable } from "@opentui/core";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
import {
|
||||
NavMode,
|
||||
useNavigation,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "whitelist", label: "Whitelist" },
|
||||
];
|
||||
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "count", label: "Count" },
|
||||
];
|
||||
|
||||
function cacheModeLabel(mode: EpisodeCacheMode): string {
|
||||
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
|
||||
}
|
||||
|
||||
function scopeLabel(scope: AutoDownloadScope): string {
|
||||
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
||||
}
|
||||
|
||||
export function usePreferencesItems(): SettingItem[] {
|
||||
const app = useAppStore();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
const settings = () => app.state().settings;
|
||||
const prefs = () => app.state().preferences;
|
||||
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
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: "showSelectionMarker",
|
||||
label: "Selection Marker",
|
||||
kind: "toggle",
|
||||
display: () => (settings().showSelectionMarker ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Show the ❯ marker on the focused row of every list (tabs, shows, episodes, results).\nType: toggle\nDefault: off\nCurrent: ${settings().showSelectionMarker ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updateSettings({
|
||||
showSelectionMarker: !settings().showSelectionMarker,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font Size",
|
||||
kind: "number",
|
||||
display: () => `${settings().fontSize}px`,
|
||||
help: () =>
|
||||
`Terminal font size in pixels.\nType: number (10–20)\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.5–2.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 the ${prefs().autoDownloadCount} most recent episodes of your shows automatically (see Count/Scope below).\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => {
|
||||
app.updatePreferences({ autoDownload: !prefs().autoDownload });
|
||||
feedStore.runAutoDownload();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "autoDownloadCount",
|
||||
label: "Auto Download Count",
|
||||
kind: "number",
|
||||
display: () => `${prefs().autoDownloadCount} per show`,
|
||||
help: () =>
|
||||
`How many of the most recent episodes to auto-download per in-scope show.\nType: number (any positive integer)\nDefault: 2\nCurrent: ${prefs().autoDownloadCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.max(1, prefs().autoDownloadCount + dir);
|
||||
app.updatePreferences({ autoDownloadCount: next });
|
||||
feedStore.runAutoDownload();
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Auto Download Count"
|
||||
value={() => prefs().autoDownloadCount}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({ autoDownloadCount: n });
|
||||
feedStore.runAutoDownload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "autoDownloadScope",
|
||||
label: "Auto Download Scope",
|
||||
kind: "select",
|
||||
display: () => scopeLabel(prefs().autoDownloadScope),
|
||||
help: () =>
|
||||
`Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on the focused show; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const idx = SCOPE_LABELS.findIndex(
|
||||
(s) => s.value === prefs().autoDownloadScope,
|
||||
);
|
||||
const next =
|
||||
SCOPE_LABELS[(idx + dir + SCOPE_LABELS.length) % SCOPE_LABELS.length]
|
||||
.value;
|
||||
app.updatePreferences({ autoDownloadScope: next });
|
||||
feedStore.runAutoDownload();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "autoJumpToPlayer",
|
||||
label: "Auto Jump to Player",
|
||||
kind: "toggle",
|
||||
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updatePreferences({
|
||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "episodeCacheMode",
|
||||
label: "Episode Cache Mode",
|
||||
kind: "select",
|
||||
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||
help: () =>
|
||||
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const idx = CACHE_MODE_LABELS.findIndex(
|
||||
(s) => s.value === prefs().episodeCacheMode,
|
||||
);
|
||||
const next =
|
||||
CACHE_MODE_LABELS[
|
||||
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
|
||||
].value;
|
||||
app.updatePreferences({ episodeCacheMode: next });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "episodeCacheCount",
|
||||
label: "Episode Cache Count",
|
||||
kind: "number",
|
||||
display: () =>
|
||||
prefs().episodeCacheMode === "count"
|
||||
? `${prefs().episodeCacheCount} eps`
|
||||
: "(date mode)",
|
||||
help: () =>
|
||||
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.max(1, prefs().episodeCacheCount + dir);
|
||||
app.updatePreferences({ episodeCacheCount: next });
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Episode Cache Count"
|
||||
value={() => prefs().episodeCacheCount}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({
|
||||
episodeCacheCount: Math.max(1, n),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "episodeCacheDays",
|
||||
label: "Episode Cache Days",
|
||||
kind: "number",
|
||||
display: () =>
|
||||
prefs().episodeCacheMode === "date"
|
||||
? `${prefs().episodeCacheDays} days`
|
||||
: "(count mode)",
|
||||
help: () =>
|
||||
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.min(
|
||||
365,
|
||||
Math.max(1, prefs().episodeCacheDays + dir * 5),
|
||||
);
|
||||
app.updatePreferences({ episodeCacheDays: next });
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Episode Cache Days"
|
||||
value={() => prefs().episodeCacheDays}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({
|
||||
episodeCacheDays: Math.min(365, Math.max(1, n)),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "refreshInterval",
|
||||
label: "Feed Refresh Interval",
|
||||
kind: "number",
|
||||
display: () => `${prefs().refreshIntervalMinutes} min`,
|
||||
help: () =>
|
||||
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.min(
|
||||
120,
|
||||
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
|
||||
);
|
||||
app.updatePreferences({ refreshIntervalMinutes: next });
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Feed Refresh Interval (minutes)"
|
||||
value={() => prefs().refreshIntervalMinutes}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({
|
||||
refreshIntervalMinutes: Math.min(120, n),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Whitelist management only appears while scope is set to "whitelist".
|
||||
if (prefs().autoDownloadScope === "whitelist") {
|
||||
items.push({
|
||||
id: "autoDownloadWhitelist",
|
||||
label: "Auto Download Whitelist",
|
||||
kind: "editor",
|
||||
display: () => `${prefs().autoDownloadWhitelist.length} shows`,
|
||||
help: () =>
|
||||
`Shows included in auto-download (scope: whitelist).\nSearch your subscribed shows; suggestions toggle in/out with Space.\nType: editor\nCurrent: ${prefs().autoDownloadWhitelist.length} shows`,
|
||||
renderEditor: () => <WhitelistEditor />,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── Number editor ────────────────────────────────────────────────────────────
|
||||
// Lets the user type any positive integer (Enter commits; Esc defocuses and
|
||||
// j/k ±1 cycling takes over — SettingsPage's depth-2 step handler).
|
||||
|
||||
function NumberInputEditor(props: {
|
||||
label: string;
|
||||
value: () => number;
|
||||
commit: (n: number) => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const ref = useInputFocusNav();
|
||||
const [draft, setDraft] = createSignal(String(props.value()));
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const submit = () => {
|
||||
const n = Number(draft().trim());
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
setError("Enter a whole number ≥ 1");
|
||||
return;
|
||||
}
|
||||
props.commit(n);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" padding={1} gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{props.label}</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={theme.textMuted}>Episodes per show:</text>
|
||||
<input
|
||||
ref={ref}
|
||||
value={draft()}
|
||||
onInput={(v) => {
|
||||
setDraft(v);
|
||||
setError(null);
|
||||
}}
|
||||
onSubmit={submit}
|
||||
focused
|
||||
width={8}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</Show>
|
||||
<text fg={theme.muted ?? theme.textMuted}>
|
||||
Type a number, Enter to apply · Esc to browse (j/k ±1) · h back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Whitelist editor ─────────────────────────────────────────────────────────
|
||||
// Search field over subscribed shows + a navigable suggestion list. Space
|
||||
// (toggle-select) toggles the focused show in/out of the whitelist; Enter
|
||||
// does the same. While the input is focused, keys type; Esc (handled in the
|
||||
// Shell) defocuses so j/k move the list.
|
||||
//
|
||||
// Transient UI state lives at module level so preference updates (which
|
||||
// rebuild the item list) never reset the search or yank focus back into the
|
||||
// input mid-browse.
|
||||
//
|
||||
// The nav.action listener is registered ONCE at module level, not per
|
||||
// component instance: toggling a show updates preferences, which remounts
|
||||
// the editor (SettingsPage re-resolves the item's renderEditor), and
|
||||
// re-registering the listener via onMount/onCleanup during a bus emit
|
||||
// mutates the handler set mid-iteration — the event bus then re-delivers to
|
||||
// the fresh listener forever. A single stable listener guarded by an active
|
||||
// flag sidesteps that entirely.
|
||||
|
||||
const [wlQuery, setWlQuery] = createSignal("");
|
||||
const [wlCursor, setWlCursor] = createSignal(0);
|
||||
const [wlTyping, setWlTyping] = createSignal(true);
|
||||
let wlEditorActive = false;
|
||||
// Indirection for refocusing the search input from the module-level nav.action
|
||||
// listener (which cannot call useNavigation — that needs the provider).
|
||||
let wlFocusInput: (() => void) | null = null;
|
||||
|
||||
function wlSuggestions(): Feed[] {
|
||||
const q = wlQuery().trim().toLowerCase();
|
||||
const all = useFeedStore().getFilteredFeeds();
|
||||
if (!q) return all;
|
||||
return all.filter((f) =>
|
||||
(f.customName || f.podcast.title).toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep the cursor inside the (possibly shrinking) suggestion list. */
|
||||
function wlCursorClamped(): number {
|
||||
return Math.min(wlCursor(), Math.max(wlSuggestions().length - 1, 0));
|
||||
}
|
||||
|
||||
function wlToggle(feedId: string): void {
|
||||
const app = useAppStore();
|
||||
const cur = app.state().preferences.autoDownloadWhitelist ?? [];
|
||||
const next = cur.includes(feedId)
|
||||
? cur.filter((id) => id !== feedId)
|
||||
: [...cur, feedId];
|
||||
app.updatePreferences({ autoDownloadWhitelist: next });
|
||||
useFeedStore().runAutoDownload();
|
||||
}
|
||||
|
||||
const wlOnAction = (data: {
|
||||
action: KeybindActionName;
|
||||
tab: TABS;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
// Fire at most once per dispatch: the editor is only ever open inside the
|
||||
// Settings tab's depth-2 pane, so scope on tab + pane and gate on the
|
||||
// mount flag (which flips during remounts without re-registering).
|
||||
if (!wlEditorActive) return;
|
||||
if (data.tab !== TABS.SETTINGS) return;
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
const list = wlSuggestions();
|
||||
if (list.length === 0) return;
|
||||
switch (data.action) {
|
||||
case "move-down":
|
||||
setWlCursor((c) => Math.min(c + 1, list.length - 1));
|
||||
break;
|
||||
case "move-up":
|
||||
setWlCursor((c) => Math.max(c - 1, 0));
|
||||
break;
|
||||
case "toggle-select":
|
||||
case "open":
|
||||
wlToggle(list[wlCursorClamped()].id);
|
||||
break;
|
||||
case "search":
|
||||
// `s` while browsing re-enters typing mode (mirrors SearchPage).
|
||||
wlFocusInput?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
on("nav.action", wlOnAction);
|
||||
|
||||
function WhitelistEditor() {
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
const feedStore = useFeedStore();
|
||||
const app = useAppStore();
|
||||
|
||||
const whitelist = () => app.state().preferences.autoDownloadWhitelist ?? [];
|
||||
const inList = (feedId: string) => whitelist().includes(feedId);
|
||||
|
||||
onMount(() => {
|
||||
wlEditorActive = true;
|
||||
// Restore the last typing/browsing mode across the remounts that
|
||||
// preference updates trigger. nav.inputFocused() drives the input's
|
||||
// focused prop (deterministic Esc-to-blur, same as SearchPage), so
|
||||
// keep the store in sync with the persisted module mode.
|
||||
nav.setInputFocused(wlTyping());
|
||||
wlFocusInput = () => nav.setInputFocused(true);
|
||||
onCleanup(() => {
|
||||
wlEditorActive = false;
|
||||
nav.setInputFocused(false);
|
||||
wlFocusInput = null;
|
||||
});
|
||||
});
|
||||
|
||||
const focusNavRef = useInputFocusNav();
|
||||
const inputRef = (el: InputRenderable | null | undefined) => {
|
||||
focusNavRef(el);
|
||||
if (el) {
|
||||
// Sync the persisted mode with real focus changes so remounts
|
||||
// (e.g. after a toggle) restore the right state.
|
||||
el.on(RenderableEvents.FOCUSED, () => setWlTyping(true));
|
||||
el.on(RenderableEvents.BLURRED, () => setWlTyping(false));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" padding={1} gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>Auto Download Whitelist</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={theme.textMuted}>Search:</text>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={wlQuery()}
|
||||
onInput={setWlQuery}
|
||||
focused={nav.inputFocused()}
|
||||
placeholder="Type to filter shows…"
|
||||
width={30}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<Show when={wlSuggestions().length === 0}>
|
||||
<text fg={theme.muted ?? theme.textMuted}>
|
||||
No subscribed shows match.
|
||||
</text>
|
||||
</Show>
|
||||
<For each={wlSuggestions()}>
|
||||
{(feed, index) => {
|
||||
// While the input is focused (typing), no row shows the
|
||||
// accent highlight or `❯` — only the input is "in focus".
|
||||
const focused = () =>
|
||||
!nav.inputFocused() && index() === wlCursorClamped();
|
||||
const ref = useScrollIntoView(focused);
|
||||
const marker = useSelectionMarker();
|
||||
const bg = () => (focused() ? theme.primary : undefined);
|
||||
const fg = () => (focused() ? theme.surface : theme.text);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
setWlCursor(index());
|
||||
// Click toggles membership directly (works even
|
||||
// while typing, where Space is input text).
|
||||
wlToggle(feed.id);
|
||||
}}
|
||||
>
|
||||
<text fg={fg()}>{focused() ? marker() : " "}</text>
|
||||
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
|
||||
<text fg={fg()}>
|
||||
{feed.customName || feed.podcast.title}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<text fg={theme.muted ?? theme.textMuted}>
|
||||
Type to search · Esc to browse · j/k move · Space toggles · s to
|
||||
type · h back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
546
src/pages/Settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 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 { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
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";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
|
||||
export const SettingsPaneCount = 1;
|
||||
|
||||
const SECTIONS: SettingsSectionDef[] = [
|
||||
{
|
||||
id: 0,
|
||||
label: "Sync",
|
||||
description: "Import/export subscriptions and sync status.",
|
||||
icon: NF_ICONS.sync,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: "Sources",
|
||||
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||
icon: NF_ICONS.sources,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: "Preferences",
|
||||
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||
icon: NF_ICONS.preferences,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "Visualizer",
|
||||
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
|
||||
icon: NF_ICONS.visualizer,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: "Downloads",
|
||||
description: "Manage downloaded episodes — delete by show or individually.",
|
||||
icon: NF_ICONS.downloads,
|
||||
},
|
||||
];
|
||||
|
||||
// Static: detection never changes mid-session. Module-level because the Row
|
||||
// component below (a sibling module function) needs it too.
|
||||
const nerd = supportsNerdFonts();
|
||||
|
||||
/** Resolve the items for a section id at render time. */
|
||||
function sectionItems(sectionId: number): SettingItem[] {
|
||||
switch (sectionId) {
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
}) => {
|
||||
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}
|
||||
icon={section.icon}
|
||||
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}
|
||||
icon={section.icon}
|
||||
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;
|
||||
icon?: 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);
|
||||
const marker = useSelectionMarker();
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text fg={fg()}>{props.focused ? marker() : " "}</text>
|
||||
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
|
||||
<text fg={fg()}>{props.label}</text>
|
||||
<Show when={props.value}>
|
||||
<box flexGrow={1} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
322
src/pages/Settings/SourceManager.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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, onMount } from "solid-js";
|
||||
import { Renderable } from "@opentui/core";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
import { useDialog } from "@/ui/dialog";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import {
|
||||
resolveSourceCredentials,
|
||||
savePodcastIndexCredentials,
|
||||
} from "@/utils/source-credentials";
|
||||
import { SourceType } from "@/types/source";
|
||||
import type { PodcastSource } from "@/types/source";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
export function useSourceItems(): SettingItem[] {
|
||||
const feedStore = useFeedStore();
|
||||
const dialog = useDialog();
|
||||
|
||||
const typeBadge = (s: PodcastSource) =>
|
||||
s.type === SourceType.API
|
||||
? "[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: () =>
|
||||
s.id === "podcastindex"
|
||||
? `Source: ${s.name} (open podcast directory)\nEnabled: ${s.enabled}\nSpace to ${s.enabled ? "disable" : "enable"}: enabling asks for API keys.\nKeys are masked in the UI and stored in the macOS keychain\n(encrypted at rest), falling back to config.json when the\nkeychain is unavailable; they are kept when disabled.`
|
||||
: `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => {
|
||||
// Enabling Podcast Index requires credentials: ask first
|
||||
// (prefilled with the stored key, masked) instead of flipping
|
||||
// the source into a key-less "on" state. Disabling never
|
||||
// clears the stored credentials.
|
||||
if (s.id === "podcastindex" && !s.enabled) {
|
||||
dialog.push(() => <PodcastIndexCredentialsDialog />);
|
||||
return;
|
||||
}
|
||||
feedStore.toggleSource(s.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</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}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mask a stored credential for prefill: first 3 chars then "...". */
|
||||
const maskCredential = (value: string): string => `${value.slice(0, 3)}...`;
|
||||
|
||||
/** Credentials popup shown when enabling the Podcast Index source. Prefilled
|
||||
* (masked) with stored credentials so re-enabling just needs Enter; leaving
|
||||
* a masked field untouched keeps the stored value. Credentials are saved to
|
||||
* the macOS keychain (encrypted at rest) with a plaintext config.json
|
||||
* fallback when the keychain is unavailable. */
|
||||
function PodcastIndexCredentialsDialog() {
|
||||
const feedStore = useFeedStore();
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const toast = useToast();
|
||||
const source = feedStore.sources().find((s) => s.id === "podcastindex");
|
||||
const [key, setKey] = createSignal("");
|
||||
const [secret, setSecret] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
// Yield navigation keybinds to the Shell router while an input is focused.
|
||||
const keyRef = useInputFocusNav();
|
||||
const secretRef = useInputFocusNav();
|
||||
let keyEl: Renderable | null | undefined;
|
||||
let secretEl: Renderable | null | undefined;
|
||||
|
||||
onMount(() => {
|
||||
// Prefill stored credentials (masked) when re-enabling after a
|
||||
// disable — toggling off never clears them. Masked either way, so a
|
||||
// plaintext-stored key never appears in full in the UI.
|
||||
if (source) {
|
||||
resolveSourceCredentials(source)
|
||||
.then((stored) => {
|
||||
if (stored?.apiKey) setKey(maskCredential(stored.apiKey));
|
||||
if (stored?.apiSecret) setSecret(maskCredential(stored.apiSecret));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setTimeout(() => keyEl?.focus(), 1);
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (saving()) return;
|
||||
const stored = source
|
||||
? await resolveSourceCredentials(source).catch(() => null)
|
||||
: null;
|
||||
const keyValue = key().trim();
|
||||
const secretValue = secret().trim();
|
||||
// A field still showing its masked prefill means "keep what's stored".
|
||||
const apiKey =
|
||||
stored?.apiKey && keyValue === maskCredential(stored.apiKey)
|
||||
? stored.apiKey
|
||||
: keyValue;
|
||||
const apiSecret =
|
||||
stored?.apiSecret && secretValue === maskCredential(stored.apiSecret)
|
||||
? stored.apiSecret
|
||||
: secretValue;
|
||||
if (!apiKey || !apiSecret) {
|
||||
setError(
|
||||
"Both API key and secret are required (free at podcastindex.org)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const ok = await savePodcastIndexCredentials(apiKey, apiSecret).catch(
|
||||
() => false,
|
||||
);
|
||||
setSaving(false);
|
||||
if (!ok) {
|
||||
// Keychain unavailable (non-macOS, locked, sandboxed): plaintext
|
||||
// fallback on the source so the fallback search still works.
|
||||
feedStore.updateSource("podcastindex", {
|
||||
hasCredentials: true,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey,
|
||||
apiSecret,
|
||||
enabled: true,
|
||||
});
|
||||
toast.show({
|
||||
title: "Credentials stored in config.json",
|
||||
message: "macOS keychain unavailable — API keys saved unencrypted.",
|
||||
variant: "warning",
|
||||
});
|
||||
dialog.pop();
|
||||
return;
|
||||
}
|
||||
feedStore.updateSource("podcastindex", {
|
||||
hasCredentials: true,
|
||||
credentialStorage: "keychain",
|
||||
enabled: true,
|
||||
});
|
||||
dialog.pop();
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
border
|
||||
title="Podcast Index API Keys"
|
||||
padding={1}
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
Free key + secret from https://podcastindex.org/. Used as a
|
||||
fallback when other sources return fewer than 3 results.
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>API Key:</text>
|
||||
<input
|
||||
ref={(el: Renderable | null | undefined) => {
|
||||
keyRef(el);
|
||||
keyEl = el;
|
||||
}}
|
||||
value={key()}
|
||||
onInput={setKey}
|
||||
onSubmit={() => secretEl?.focus()}
|
||||
placeholder="e.g. UXKCGDSYGUUEVQJSYDZH"
|
||||
width={30}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>API Secret:</text>
|
||||
<input
|
||||
ref={(el: Renderable | null | undefined) => {
|
||||
secretRef(el);
|
||||
secretEl = el;
|
||||
}}
|
||||
value={secret()}
|
||||
onInput={setSecret}
|
||||
onSubmit={() => save()}
|
||||
placeholder="e.g. yzJe2eE7XV-3eY576dyRZ6wXyAbndh6LUrCZ8KN|"
|
||||
width={40}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
|
||||
<Show when={saving()}>
|
||||
<text fg={theme.textMuted}>Storing credentials...</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>
|
||||
[Enter] save · [Esc] cancel — keys stay stored when disabled.
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
18
src/pages/Settings/SyncError.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/pages/Settings/SyncPanel.tsx
Normal 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`,
|
||||
},
|
||||
];
|
||||
}
|
||||
28
src/pages/Settings/SyncProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/pages/Settings/SyncStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||