Compare commits
33 Commits
1618588a30
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
98
.github/workflows/release.yml
vendored
Normal file
98
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Builds standalone PodTui binaries for each supported OS/arch and attaches
|
||||||
|
# them to a GitHub Release. One runner per platform because Bun cannot
|
||||||
|
# cross-compile — each runner runs `make dist`, which emits a
|
||||||
|
# podtui-<platform>-<arch>.tar.gz (binary + native libs side by side).
|
||||||
|
#
|
||||||
|
# Trigger: push a tag like v0.1.0. Bump VERSION in src/index.tsx in the same
|
||||||
|
# commit as the tag so the released binary reports the tagged version.
|
||||||
|
|
||||||
|
on: # intentional: YAML `on` key
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build (${{ matrix.os }} / ${{ matrix.arch }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: x64
|
||||||
|
plat: linux
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
plat: linux
|
||||||
|
- os: macos-15-intel
|
||||||
|
arch: x64
|
||||||
|
plat: darwin
|
||||||
|
- os: macos-14
|
||||||
|
arch: arm64
|
||||||
|
plat: darwin
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Install fftw (cavacore build dependency)
|
||||||
|
run: |
|
||||||
|
if uname -s | grep -qi darwin; then
|
||||||
|
brew install fftw
|
||||||
|
else
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libfftw3-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build native cavacore library
|
||||||
|
run: scripts/build-cavacore.sh
|
||||||
|
|
||||||
|
- name: Build standalone binary + tarball
|
||||||
|
run: make dist
|
||||||
|
|
||||||
|
- name: Smoke-test binary boot
|
||||||
|
env:
|
||||||
|
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||||
|
run: |
|
||||||
|
# The embedded runtime reads the launching process's CWD bunfig.toml.
|
||||||
|
# This repo's bunfig lists a preload the standalone can't resolve
|
||||||
|
# ("preload not found"), so kicking the binary from the workspace root
|
||||||
|
# would falsely fail every build. cd into a clean dir first.
|
||||||
|
SMOKE_DIR=$(mktemp -d)
|
||||||
|
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
||||||
|
cd "$SMOKE_DIR"
|
||||||
|
./podtui-*/podtui --version
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
||||||
|
path: dist/podtui-*.tar.gz
|
||||||
|
|
||||||
|
upload:
|
||||||
|
name: Attach to GitHub Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all binaries
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
files: artifacts/**/*.tar.gz
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -27,10 +27,10 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.eslintcache
|
.eslintcache
|
||||||
.cache
|
.cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.lockb
|
*.lock
|
||||||
|
|
||||||
# IntelliJ based IDEs
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.harness/
|
||||||
|
.ralpi
|
||||||
|
|||||||
193
CONTRIBUTING.md
Normal file
193
CONTRIBUTING.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# 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 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. **Never add a top-level `preload` to `bunfig.toml`.**
|
||||||
|
A compiled PodTui binary's embedded runtime reads the *launching process's*
|
||||||
|
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
|
||||||
|
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
|
||||||
|
`preload not found`. This is why `bunfig.toml` has **no** top-level
|
||||||
|
`preload`; dev-mode preloading happens via explicit `--preload` flags in
|
||||||
|
`package.json`. The `[test]` section *does* keep a preload — that only
|
||||||
|
affects `bun test`.
|
||||||
|
|
||||||
|
2. **Smoke-test the compiled binary from a bunfig-free dir.**
|
||||||
|
Because of (1), `./dist/podtui --version` run from the repo root launched
|
||||||
|
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
|
||||||
|
before booting. Do the same when testing a release build locally.
|
||||||
|
|
||||||
|
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. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0` → `0.2.0`). Commit and push.
|
||||||
|
2. Tag and push:
|
||||||
|
|
||||||
|
```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-podtui` repo has a scheduled workflow (hourly) that
|
||||||
|
polls GitHub releases, and when a new tag appears, rewrites
|
||||||
|
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||||
|
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
||||||
|
test: `brew install mikefreno/podtui/podtui`.
|
||||||
|
|
||||||
|
### Manual fallback
|
||||||
|
|
||||||
|
If you ever need to sync the tap by hand (or before the hourly job runs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <clone of mikefreno/homebrew-podtui>
|
||||||
|
./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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open items / things to sort out
|
||||||
|
|
||||||
|
- **LICENSE**: `README.md` says "TBD — choose and document a license before
|
||||||
|
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
|
||||||
|
README footer.
|
||||||
|
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||||
|
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||||
|
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||||
|
ships from it directly until a full rebuild replaces it. On other hosts the
|
||||||
|
`make native` build is required — see `scripts/build-cavacore.sh`.
|
||||||
66
Makefile
Normal file
66
Makefile
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 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.
|
||||||
|
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
|
||||||
|
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
|
||||||
|
## from a bunfig-free dir (see release.yml).
|
||||||
|
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
|
||||||
216
README.md
216
README.md
@@ -1,15 +1,221 @@
|
|||||||
# solid
|
# PodTui
|
||||||
|
|
||||||
To install dependencies:
|
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
|
||||||
|
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),
|
||||||
|
mirroring yazi's pane model.
|
||||||
|
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
||||||
|
(My Shows, Discover, Feed tabs).
|
||||||
|
- **Search** across your subscribed shows.
|
||||||
|
- **Audio playback** through an external player with full transport control:
|
||||||
|
play/pause, next/previous, seek, speed, and per-episode resume progress.
|
||||||
|
- **Themeable** and **remappable keybindings**.
|
||||||
|
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||||
|
a system audio player.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
|
||||||
|
tmux, GNOME Terminal, etc.).
|
||||||
|
- An **audio player** on `PATH`. PodTui auto-detects in priority order:
|
||||||
|
|
||||||
|
| Player | Platforms | Seek | Speed | Position tracking |
|
||||||
|
|----------|----------------|:----:|:-----:|:------------------|
|
||||||
|
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
|
||||||
|
| `ffplay` | any | ✔ | ✘ | ✘ |
|
||||||
|
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
|
||||||
|
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
|
||||||
|
|
||||||
|
Install `mpv` for the best experience (`brew install mpv`,
|
||||||
|
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
|
||||||
|
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
|
||||||
|
|
||||||
|
## 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/podtui/podtui # requires mpv: brew install mpv
|
||||||
|
```
|
||||||
|
|
||||||
|
> The formula installs the standalone binary plus its two native libraries
|
||||||
|
> side by side (see [Packaging model](#packaging-model)). It does **not**
|
||||||
|
> depend on Bun.
|
||||||
|
|
||||||
|
### 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
|
```bash
|
||||||
|
curl -sSL -o podtui.tar.gz \
|
||||||
|
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
||||||
|
tar -xzf podtui.tar.gz
|
||||||
|
sudo install -m755 podtui /usr/local/bin/podtui
|
||||||
|
```
|
||||||
|
|
||||||
|
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||||
|
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||||
|
> binary alone), or the native FFI libraries won't load.
|
||||||
|
>
|
||||||
|
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
|
||||||
|
> you launch from. If that file has a `preload` entry (as Bun project
|
||||||
|
> directories often do), startup fails with `preload not found`. Launching
|
||||||
|
> from a normal directory (home, `~/bin`, …) works fine.
|
||||||
|
|
||||||
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S podtui
|
||||||
|
```
|
||||||
|
|
||||||
|
or build from the PKGBUILD (`podtui-bin`). The package installs the released
|
||||||
|
binary and its sibling libraries.
|
||||||
|
|
||||||
|
### 4. From source
|
||||||
|
|
||||||
|
Requires [Bun](https://bun.sh) ≥ 1.2.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/mikefreno/podtui.git
|
||||||
|
cd podtui
|
||||||
bun install
|
bun install
|
||||||
|
bun run build:native # build the cavacore FFI lib from C source
|
||||||
|
bun run dev # run with hot reload, or: bun start
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
## Linux distribution notes
|
||||||
|
|
||||||
|
PodTUI deliberately does **not** ship `.deb`, `.rpm`, Flatpak, or Snap
|
||||||
|
packages. For a terminal application that's overwhelmingly installed through
|
||||||
|
repositories or archives, those formats add desktop-sandboxing overhead and a
|
||||||
|
packaging tax with little benefit. Instead:
|
||||||
|
|
||||||
|
- **GitHub Release tarballs** are the universal path — one upload, works on
|
||||||
|
any distro with `curl` + `tar`.
|
||||||
|
- **AUR (`podtui-bin`)** covers Arch. Anyone on Arch/Manjaro gets the same
|
||||||
|
binary through their native package manager.
|
||||||
|
- **Nix / cross-distro** users can build from source (or a Nix flake can be
|
||||||
|
added later).
|
||||||
|
|
||||||
|
This keeps maintenance to a single build per OS/arch and still reaches the
|
||||||
|
vast majority of desktop Linux users through their preferred path.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Launch `podtui` (or `bun src/index.tsx` from the source tree). 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 `~/.config/podtui/keybinds.jsonc`.
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `j` / `k` | Move cursor down / up |
|
||||||
|
| `J` / `K` | Jump 5 lines |
|
||||||
|
| `ctrl-d` / `ctrl-u` | Page down / up |
|
||||||
|
| `gg` / `G` | Go to top / bottom |
|
||||||
|
| `h` / `l` | Swipe to parent pane / preview pane |
|
||||||
|
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
|
||||||
|
| `Space` | Select / toggle selection |
|
||||||
|
| `v` | Visual mode (multi-select) |
|
||||||
|
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
||||||
|
| `[` / `]` | Previous / next tab |
|
||||||
|
| `P` (shift) | Play / pause |
|
||||||
|
| `N` / `B` | Next / previous episode |
|
||||||
|
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||||
|
| `s` | Search (in a list) |
|
||||||
|
| `f` | Filter |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `:` | Command bar |
|
||||||
|
| `~`, `f1` | Help |
|
||||||
|
| `q`, `ctrl-c` | Quit |
|
||||||
|
| `Esc` | Escape / cancel |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration lives under the XDG config directory — `~/.config/podtui` by
|
||||||
|
default (`$XDG_CONFIG_HOME/podtui` if set).
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) |
|
||||||
|
| `sources.json` | Custom feed sources |
|
||||||
|
| `downloads.json` | Downloaded episode metadata |
|
||||||
|
| `keybinds.jsonc` | Keybinding remaps (see above) |
|
||||||
|
| `themes/` | Optional custom theme files |
|
||||||
|
|
||||||
|
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
|
||||||
|
the same OpenTUI environment variables.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun dev
|
bun install # install dependencies
|
||||||
|
bun run dev # run with hot reload
|
||||||
|
bun test # run the test suite
|
||||||
|
bun run build # bundle JS + copy native libs into dist/
|
||||||
|
make native # rebuild cavacore from C source
|
||||||
|
make lint # type-check (tsc)
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.
|
### Releasing
|
||||||
|
|
||||||
|
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs
|
||||||
|
to your GitHub Release automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dist # build the standalone binary + tarball for THIS platform
|
||||||
|
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
|
||||||
|
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
`make dist` emits a config-independent binary: Bun does not bake bunfig
|
||||||
|
settings into `--compile` output, and the solid JSX transform is registered in
|
||||||
|
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
|
||||||
|
it from any normal directory.
|
||||||
|
|
||||||
|
## Packaging model
|
||||||
|
|
||||||
|
A release tarball is three files sitting side by side:
|
||||||
|
|
||||||
|
```
|
||||||
|
podtui # standalone compiled binary (embeds the Bun runtime)
|
||||||
|
libopentui.<dylib|so> # OpenTUI native renderer FFI library
|
||||||
|
libcavacore.<dylib|so> # cavacore spectrum FFI library (built from C)
|
||||||
|
```
|
||||||
|
|
||||||
|
PodTui loads its native libraries relative to the binary, so **keep them in
|
||||||
|
the same directory**. The compiled binary embeds the Bun runtime, so it runs
|
||||||
|
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
|
||||||
|
is no cross-compilation.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
TBD — choose and document a license before first release.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [OpenTUI](https://github.com/opentui/opentui) — the TUI framework driving the interface
|
||||||
|
|||||||
145
build.ts
145
build.ts
@@ -1,6 +1,32 @@
|
|||||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
import solidPlugin from "@opentui/solid/bun-plugin";
|
||||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { join, dirname } from "node:path"
|
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
|
// Build the JavaScript bundle
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
@@ -10,53 +36,94 @@ await Bun.build({
|
|||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: "external",
|
sourcemap: "external",
|
||||||
plugins: [solidPlugin],
|
plugins: [solidPlugin],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Copy the native library to dist for distribution
|
// Copy the opentui native library to dist for distribution.
|
||||||
const platform = process.platform
|
const platformKey = `${platform}-${arch}`;
|
||||||
const arch = process.arch
|
const platformPkg = platformMap[platformKey];
|
||||||
|
|
||||||
// Map platform/arch to OpenTUI package names
|
|
||||||
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 platformKey = `${platform}-${arch}`
|
|
||||||
const platformPkg = platformMap[platformKey]
|
|
||||||
|
|
||||||
if (platformPkg) {
|
if (platformPkg) {
|
||||||
const libName = platform === "win32"
|
const libName = `libopentui.${libExt}`;
|
||||||
? "opentui.dll"
|
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName);
|
||||||
: platform === "darwin"
|
|
||||||
? "libopentui.dylib"
|
|
||||||
: "libopentui.so"
|
|
||||||
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName)
|
|
||||||
|
|
||||||
if (existsSync(srcPath)) {
|
if (existsSync(srcPath)) {
|
||||||
const destPath = join("dist", libName)
|
const destPath = join("dist", libName);
|
||||||
copyFileSync(srcPath, destPath)
|
copyFileSync(srcPath, destPath);
|
||||||
console.log(`Copied native library: ${libName}`)
|
console.log(`Copied native library: ${libName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy cavacore native library to dist
|
// Copy cavacore native library to dist
|
||||||
const cavacoreLib = platform === "darwin"
|
const cavacoreLib = `libcavacore.${libExt}`;
|
||||||
? "libcavacore.dylib"
|
const cavacoreSrc = join("src", "native", cavacoreLib);
|
||||||
: platform === "win32"
|
|
||||||
? "cavacore.dll"
|
|
||||||
: "libcavacore.so"
|
|
||||||
const cavacoreSrc = join("src", "native", cavacoreLib)
|
|
||||||
|
|
||||||
if (existsSync(cavacoreSrc)) {
|
if (existsSync(cavacoreSrc)) {
|
||||||
copyFileSync(cavacoreSrc, join("dist", cavacoreLib))
|
copyFileSync(cavacoreSrc, join("dist", cavacoreLib));
|
||||||
console.log(`Copied cavacore library: ${cavacoreLib}`)
|
console.log(`Copied cavacore library: ${cavacoreLib}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`)
|
console.warn(
|
||||||
|
`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Build complete")
|
// ── 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Compiled standalone binary: ${outfile}`);
|
||||||
|
|
||||||
|
// Ensure both native libs sit beside the binary.
|
||||||
|
const opentuiSrc = join(
|
||||||
|
"node_modules",
|
||||||
|
`@opentui/core-${platformPkg}`,
|
||||||
|
`libopentui.${libExt}`,
|
||||||
|
);
|
||||||
|
if (existsSync(opentuiSrc)) {
|
||||||
|
copyFileSync(opentuiSrc, join("dist", `libopentui.${libExt}`));
|
||||||
|
}
|
||||||
|
if (!existsSync(join("dist", cavacoreLib))) {
|
||||||
|
console.warn(
|
||||||
|
`Warning: ${cavacoreLib} missing beside the binary — run scripts/build-cavacore.sh`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tarball: podtui + the two native libs (drop the JS bundle dir)
|
||||||
|
const tarRoot = join("dist", `podtui-${platform}-${arch}`);
|
||||||
|
rmSync(tarRoot, { recursive: true, force: true });
|
||||||
|
mkdirSync(tarRoot, { recursive: true });
|
||||||
|
copyFileSync(outfile, join(tarRoot, "podtui"));
|
||||||
|
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
|
||||||
|
const s = join("dist", lib);
|
||||||
|
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
|
||||||
|
}
|
||||||
|
const tar = Bun.spawnSync([
|
||||||
|
"tar",
|
||||||
|
"-czf",
|
||||||
|
`${tarRoot}.tar.gz`,
|
||||||
|
"-C",
|
||||||
|
"dist",
|
||||||
|
`podtui-${platform}-${arch}`,
|
||||||
|
]);
|
||||||
|
if (tar.exitCode !== 0) {
|
||||||
|
console.error(tar.stderr.toString());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Tarball: ${tarRoot}.tar.gz`);
|
||||||
|
rmSync(tarRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Build complete");
|
||||||
|
|||||||
2
bunfig.test.toml
Normal file
2
bunfig.test.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./tests/preload/solid-test-plugin.ts"]
|
||||||
10
bunfig.toml
10
bunfig.toml
@@ -1 +1,9 @@
|
|||||||
preload = ["@opentui/solid/preload"]
|
# NO top-level `preload` here — intentional. A compiled PodTUI binary's
|
||||||
|
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a
|
||||||
|
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the
|
||||||
|
# standalone cannot resolve) makes the binary die at startup with
|
||||||
|
# "preload not found". Dev/test still get the solid transform via explicit
|
||||||
|
# `--preload` flags in package.json and the [test] section below.
|
||||||
|
|
||||||
|
[test]
|
||||||
|
preload = "@opentui/solid/preload"
|
||||||
|
|||||||
19
cava/LICENSE-cava.txt
Normal file
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
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
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
|
||||||
405
discover/featured.json
Normal file
405
discover/featured.json
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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-code-switch",
|
||||||
|
"title": "Code Switch",
|
||||||
|
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510312/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["News", "Culture", "Politics"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-rough-translation",
|
||||||
|
"title": "Rough Translation",
|
||||||
|
"description": "How are the things we're talking about covered in the rest of the world? NPR's Rough Translation takes you to far-off places and shows you the unexpected.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510324/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": ["News", "Culture"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-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", "Politics", "News"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "podcast-tui-app",
|
"name": "podcast-tui-app",
|
||||||
|
"version": "0.1.0",
|
||||||
"module": "src/index.tsx",
|
"module": "src/index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -7,13 +8,13 @@
|
|||||||
"podtui": "./dist/index.js"
|
"podtui": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun src/index.tsx",
|
"start": "bun --preload @opentui/solid/preload src/index.tsx",
|
||||||
"dev": "bun --watch src/index.tsx",
|
"dev": "bun --preload @opentui/solid/preload --watch src/index.tsx",
|
||||||
"build:native": "bash scripts/build-cavacore.sh",
|
"build:native": "bash scripts/build-cavacore.sh",
|
||||||
"build": "bun run build.ts",
|
"build": "bun run build.ts",
|
||||||
"dist": "bun dist/index.js",
|
"dist": "bun dist/index.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"lint": "bun run lint.ts"
|
"lint": "bun tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
@@ -19,35 +19,57 @@ mkdir -p "$OUT_DIR"
|
|||||||
OS="$(uname -s)"
|
OS="$(uname -s)"
|
||||||
ARCH="$(uname -m)"
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
# Resolve fftw3 paths
|
# 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
|
if [ "$OS" = "Darwin" ]; then
|
||||||
if [ "$ARCH" = "arm64" ]; then
|
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
|
|
||||||
else
|
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
|
|
||||||
fi
|
|
||||||
LIB_EXT="dylib"
|
LIB_EXT="dylib"
|
||||||
SHARED_FLAG="-dynamiclib"
|
SHARED_FLAG="-dynamiclib"
|
||||||
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
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
|
else
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr}"
|
|
||||||
LIB_EXT="so"
|
LIB_EXT="so"
|
||||||
SHARED_FLAG="-shared"
|
SHARED_FLAG="-shared"
|
||||||
INSTALL_NAME=""
|
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
|
fi
|
||||||
|
|
||||||
FFTW_INCLUDE="$FFTW_PREFIX/include"
|
FFTW_INCLUDE="$FFTW_PREFIX/include"
|
||||||
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
|
if [ ! -d "$FFTW_INCLUDE" ]; then
|
||||||
|
FFTW_INCLUDE="$FFTW_PREFIX/include/$(basename "$(dirname "$FFTW_STATIC")")"
|
||||||
if [ ! -f "$FFTW_STATIC" ]; then
|
|
||||||
echo "Error: libfftw3.a not found at $FFTW_STATIC"
|
|
||||||
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "$SRC" ]; then
|
if [ ! -f "$SRC" ]; then
|
||||||
echo "Error: cavacore.c not found at $SRC"
|
echo "Error: cavacore.c not found at $SRC"
|
||||||
echo "Ensure the cava submodule is initialized: git submodule update --init"
|
echo "The cava source is vendored under cava/ (from github.com/karlstav/cava, MIT)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
240
scripts/release-tag.sh
Executable file
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-podtui 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-podtui self-updates within the hour (Formula"
|
||||||
|
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||||
612
scripts/tui-harness.tsx
Normal file
612
scripts/tui-harness.tsx
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* PodTUI LLM-interactive harness — stateless per-turn snapshot bridge.
|
||||||
|
*
|
||||||
|
* Each invocation:
|
||||||
|
* 1. Points XDG_CONFIG_HOME / XDG_DATA_HOME / PODTUI_AUDIO_BACKEND at a
|
||||||
|
* sandbox dir under .harness so your real ~/.config/podtui is never touched.
|
||||||
|
* 2. Replays the saved action log (.harness/actions.json) from scratch.
|
||||||
|
* 3. Appends + executes the new action passed on the CLI.
|
||||||
|
* 4. Renders, captures structured spans, and prints: plain frame + distinct
|
||||||
|
* style summary (colors/attrs) + selected store state + captured issues
|
||||||
|
* (stderr / uncaught rejections). Full structured spans are dumped to
|
||||||
|
* .harness/last-frame.json every turn.
|
||||||
|
*
|
||||||
|
* Audio is silent (Noop) by default during the snapshot model so replaying the
|
||||||
|
* log each turn doesn't re-trigger real playback. Pass --audio (or set
|
||||||
|
* PODTUI_AUDIO_BACKEND) to flip to a real backend for the new action only.
|
||||||
|
*
|
||||||
|
* Import order mirrors src/index.tsx (lazy) to avoid a NavigationContext cycle.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun scripts/tui-harness.tsx init [--size 100x30] [--seed]
|
||||||
|
* bun scripts/tui-harness.tsx key <key> [mods...] # mods: ctrl shift meta
|
||||||
|
* bun scripts/tui-harness.tsx arrow <up|down|left|right> [mods...]
|
||||||
|
* bun scripts/tui-harness.tsx enter|escape|tab|space|backspace
|
||||||
|
* bun scripts/tui-harness.tsx type "<text>"
|
||||||
|
* bun scripts/tui-harness.tsx wait <ms>
|
||||||
|
* bun scripts/tui-harness.tsx resize <w> <h>
|
||||||
|
* bun scripts/tui-harness.tsx frame # re-render, no new action
|
||||||
|
* bun scripts/tui-harness.tsx state [all|nav|audio|feed|app]
|
||||||
|
* bun scripts/tui-harness.tsx actions # print action log
|
||||||
|
* bun scripts/tui-harness.tsx reset
|
||||||
|
* bun scripts/tui-harness.tsx seed [--from ~/.config/podtui]
|
||||||
|
*
|
||||||
|
* Flags (after the subcommand):
|
||||||
|
* --size WxH terminal size (default 100x30)
|
||||||
|
* --audio enable real audio backend for the new action only
|
||||||
|
* --no-settle skip the extra render-settle loops
|
||||||
|
* --styles print the distinct-styles sample block (off by default)
|
||||||
|
* --verbose restore the original multi-line pretty output
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
cpSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// ── Paths & sandbox ────────────────────────────────────────────────────────
|
||||||
|
const HANDLES_DIR = ".harness";
|
||||||
|
const CONFIG_HOME = join(HANDLES_DIR, "config-home");
|
||||||
|
const DATA_HOME = join(HANDLES_DIR, "data-home");
|
||||||
|
const ACTIONS_FILE = join(HANDLES_DIR, "actions.json");
|
||||||
|
const FRAME_JSON = join(HANDLES_DIR, "last-frame.json");
|
||||||
|
const FRAME_TXT = join(HANDLES_DIR, "last-frame.txt");
|
||||||
|
const STATE_JSON = join(HANDLES_DIR, "state.json");
|
||||||
|
|
||||||
|
// Sandbox must be active BEFORE any app module is imported, so the app's
|
||||||
|
// config-dir / persistence reads resolve into .harness/*.
|
||||||
|
function activateSandbox(): void {
|
||||||
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
||||||
|
mkdirSync(DATA_HOME, { recursive: true });
|
||||||
|
process.env.XDG_CONFIG_HOME = join(process.cwd(), CONFIG_HOME);
|
||||||
|
process.env.XDG_DATA_HOME = join(process.cwd(), DATA_HOME);
|
||||||
|
// Silent audio during replay by default; --audio flips this after import.
|
||||||
|
if (!process.env.PODTUI_AUDIO_BACKEND)
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action log ─────────────────────────────────────────────────────────────
|
||||||
|
type Mod = "ctrl" | "shift" | "meta" | "super" | "hyper";
|
||||||
|
type Action =
|
||||||
|
| { t: "key"; k: string; mods?: Mod[] }
|
||||||
|
| { t: "arrow"; d: "up" | "down" | "left" | "right"; mods?: Mod[] }
|
||||||
|
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
|
||||||
|
| { t: "type"; s: string }
|
||||||
|
| { t: "wait"; ms: number }
|
||||||
|
| { t: "resize"; w: number; h: number };
|
||||||
|
|
||||||
|
function loadActions(): Action[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(ACTIONS_FILE, "utf8") || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveActions(a: Action[]): void {
|
||||||
|
writeFileSync(ACTIONS_FILE, JSON.stringify(a, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue capture ──────────────────────────────────────────────────────────
|
||||||
|
const issues: string[] = [];
|
||||||
|
// Captured by the StateProbe component rendered inside the provider tree —
|
||||||
|
// Solid contexts can only be read from within the tree, not from outside.
|
||||||
|
let navRef: any = null;
|
||||||
|
function captureIssues(): void {
|
||||||
|
const origErr = console.error;
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
issues.push("stderr: " + args.map(String).join(" "));
|
||||||
|
origErr(...(args as any[]));
|
||||||
|
};
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
issues.push("warn: " + args.map(String).join(" "));
|
||||||
|
origWarn(...(args as any[]));
|
||||||
|
};
|
||||||
|
process.on("uncaughtException", (e) =>
|
||||||
|
issues.push("uncaught: " + ((e as Error)?.stack || String(e))),
|
||||||
|
);
|
||||||
|
process.on("unhandledRejection", (e) =>
|
||||||
|
issues.push("unhandledRejection: " + ((e as Error)?.stack || String(e))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Span rendering ─────────────────────────────────────────────────────────
|
||||||
|
type RGBA = { r: number; g: number; b: number; a: number };
|
||||||
|
type Span = {
|
||||||
|
text: string;
|
||||||
|
fg: RGBA | null;
|
||||||
|
bg: RGBA | null;
|
||||||
|
attributes: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATTR_NAMES: Record<string, number> = {
|
||||||
|
BOLD: 1,
|
||||||
|
DIM: 2,
|
||||||
|
ITALIC: 4,
|
||||||
|
UNDERLINE: 8,
|
||||||
|
BLINK: 16,
|
||||||
|
INVERSE: 32,
|
||||||
|
HIDDEN: 64,
|
||||||
|
STRIKETHROUGH: 128,
|
||||||
|
};
|
||||||
|
|
||||||
|
function hex(c: RGBA | null): string | null {
|
||||||
|
if (!c) return null;
|
||||||
|
if (c.a === 0) return null; // transparent → "default"
|
||||||
|
const [r, g, b] = [c.r, c.g, c.b].map((v) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(v))),
|
||||||
|
);
|
||||||
|
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrLabels(attr: number): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [name, bit] of Object.entries(ATTR_NAMES))
|
||||||
|
if (attr & bit) out.push(name.toLowerCase());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Plain frame text comes from captureCharFrame instead of span reconstruction.)
|
||||||
|
|
||||||
|
function distinctStyles(spans: {
|
||||||
|
lines: { spans: Span[] }[];
|
||||||
|
}): { tag: string; sample: string; n: number }[] {
|
||||||
|
const map = new Map<string, { tag: string; sample: string; n: number }>();
|
||||||
|
for (const line of spans.lines) {
|
||||||
|
for (const s of line.spans) {
|
||||||
|
if (!s.text || s.text.trim() === "") continue;
|
||||||
|
const fg = hex(s.fg as any);
|
||||||
|
const bg = hex(s.bg as any);
|
||||||
|
if (!fg && !bg && s.attributes === 0) continue; // default — skip
|
||||||
|
const tags = attrLabels(s.attributes);
|
||||||
|
const tag = `[fg=${fg ?? "·"} bg=${bg ?? "·"}${tags.length ? " " + tags.join("+") : ""}]`;
|
||||||
|
const ex = map.get(tag);
|
||||||
|
const sample = s.text.replace(/\n/g, "\\n").slice(0, 28);
|
||||||
|
if (ex) {
|
||||||
|
ex.n++;
|
||||||
|
if (ex.sample.length < 14 && sample.length > ex.sample.length)
|
||||||
|
ex.sample = sample;
|
||||||
|
} else {
|
||||||
|
map.set(tag, { tag, sample, n: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.n - a.n).slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arg parsing ────────────────────────────────────────────────────────────
|
||||||
|
function parseFlags(rest: string[]): {
|
||||||
|
flags: Record<string, string | boolean>;
|
||||||
|
positional: string[];
|
||||||
|
} {
|
||||||
|
const flags: Record<string, string | boolean> = {};
|
||||||
|
const positional: string[] = [];
|
||||||
|
for (let i = 0; i < rest.length; i++) {
|
||||||
|
const a = rest[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
if (a === "--audio") flags.audio = true;
|
||||||
|
else if (a === "--no-settle") flags["no-settle"] = true;
|
||||||
|
else if (a === "--styles") flags.styles = true;
|
||||||
|
else if (a === "--verbose") flags.verbose = true;
|
||||||
|
else if (a === "--size") {
|
||||||
|
flags.size = rest[++i];
|
||||||
|
const m = /(\d+)x(\d+)/.exec(String(flags.size));
|
||||||
|
if (m) {
|
||||||
|
flags.w = m[1];
|
||||||
|
flags.h = m[2];
|
||||||
|
}
|
||||||
|
} else if (a === "--from") {
|
||||||
|
flags.from = rest[++i];
|
||||||
|
} else {
|
||||||
|
flags[a.slice(2)] = rest[++i] ?? true;
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAction(cmd: string, positional: string[]): Action | null {
|
||||||
|
const mods = parseMods(positional);
|
||||||
|
const first = positional[0];
|
||||||
|
switch (cmd) {
|
||||||
|
case "key":
|
||||||
|
if (!first) throw new Error("key requires a <key> argument");
|
||||||
|
return { t: "key", k: first, mods: mods.length ? mods : undefined };
|
||||||
|
case "arrow":
|
||||||
|
if (!first || !["up", "down", "left", "right"].includes(first))
|
||||||
|
throw new Error("arrow requires up|down|left|right");
|
||||||
|
return {
|
||||||
|
t: "arrow",
|
||||||
|
d: first as any,
|
||||||
|
mods: mods.length ? mods : undefined,
|
||||||
|
};
|
||||||
|
case "enter":
|
||||||
|
case "escape":
|
||||||
|
case "tab":
|
||||||
|
case "space":
|
||||||
|
case "backspace":
|
||||||
|
return { t: cmd, mods: mods.length ? mods : undefined };
|
||||||
|
case "type":
|
||||||
|
if (first === undefined) throw new Error("type requires <text>");
|
||||||
|
// Re-join the rest in case text had spaces; positional[0] already is first token,
|
||||||
|
// caller should quote. We join all positional as the text.
|
||||||
|
return { t: "type", s: positional.join(" ") };
|
||||||
|
case "wait":
|
||||||
|
if (!first) throw new Error("wait requires <ms>");
|
||||||
|
return { t: "wait", ms: parseInt(first, 10) || 0 };
|
||||||
|
case "resize":
|
||||||
|
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
|
||||||
|
return {
|
||||||
|
t: "resize",
|
||||||
|
w: parseInt(first, 10) || 100,
|
||||||
|
h: parseInt(positional[1], 10) || 30,
|
||||||
|
};
|
||||||
|
case "frame":
|
||||||
|
case "state":
|
||||||
|
case "reset":
|
||||||
|
case "actions":
|
||||||
|
case "init":
|
||||||
|
case "seed":
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown command: ${cmd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execute one action against a mounted setup ──────────────────────────────
|
||||||
|
function fmtMods(mods?: Mod[]): Record<string, boolean> | undefined {
|
||||||
|
if (!mods || !mods.length) return undefined;
|
||||||
|
const o: Record<string, boolean> = {};
|
||||||
|
for (const m of mods) o[m] = true;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execAction(setup: any, a: Action): Promise<void> {
|
||||||
|
const mi = setup.mockInput;
|
||||||
|
switch (a.t) {
|
||||||
|
case "key":
|
||||||
|
mi.pressKey(a.k, fmtMods(a.mods));
|
||||||
|
break;
|
||||||
|
case "arrow":
|
||||||
|
mi.pressArrow(a.d, fmtMods(a.mods));
|
||||||
|
break;
|
||||||
|
case "enter":
|
||||||
|
mi.pressEnter(fmtMods(a.mods));
|
||||||
|
break;
|
||||||
|
case "escape":
|
||||||
|
mi.pressEscape(fmtMods(a.mods));
|
||||||
|
break;
|
||||||
|
case "tab":
|
||||||
|
mi.pressTab(fmtMods(a.mods));
|
||||||
|
break;
|
||||||
|
case "space":
|
||||||
|
mi.pressKey("space");
|
||||||
|
break;
|
||||||
|
case "backspace":
|
||||||
|
mi.pressBackspace(fmtMods(a.mods));
|
||||||
|
break;
|
||||||
|
case "type":
|
||||||
|
await mi.typeText(a.s, 0);
|
||||||
|
break;
|
||||||
|
case "wait":
|
||||||
|
await new Promise((r) => setTimeout(r, a.ms));
|
||||||
|
break;
|
||||||
|
case "resize":
|
||||||
|
setup.resize(a.w, a.h);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await setup.renderOnce();
|
||||||
|
// tiny settle for reactive updates
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (cmd === "reset") {
|
||||||
|
saveActions([]);
|
||||||
|
console.log("✔ actions log cleared.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === "actions") {
|
||||||
|
const a = loadActions();
|
||||||
|
console.log(`Action log (${a.length}):`);
|
||||||
|
console.log(JSON.stringify(a, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
lines: { spans: Span[] }[];
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
cursor: [number, number];
|
||||||
|
};
|
||||||
|
const plainFrame = setup.captureCharFrame();
|
||||||
|
|
||||||
|
// Dump structured spans + plain frame.
|
||||||
|
try {
|
||||||
|
writeFileSync(FRAME_JSON, JSON.stringify(spans));
|
||||||
|
writeFileSync(FRAME_TXT, plainFrame);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Store state snapshot.
|
||||||
|
const state: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const nav = navRef;
|
||||||
|
if (nav) {
|
||||||
|
state.nav = {
|
||||||
|
tab: nav.activeTab?.(),
|
||||||
|
pane: nav.activePane?.(),
|
||||||
|
mode: nav.mode?.(),
|
||||||
|
input: nav.inputFocused?.(),
|
||||||
|
sel: nav.selectedIds?.()?.length ?? 0,
|
||||||
|
ready: nav.ready,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.nav = "NAV_REF not captured (probe did not run)";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
state.nav = "ERR: " + String(e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (audioControls) {
|
||||||
|
state.audio = {
|
||||||
|
backend: audioControls.backendName ? audioControls.backendName() : null,
|
||||||
|
playing: audioControls.isPlaying ? audioControls.isPlaying() : null,
|
||||||
|
pos: audioControls.position ? audioControls.position() : null,
|
||||||
|
dur: audioControls.duration ? audioControls.duration() : null,
|
||||||
|
vol: audioControls.volume ? audioControls.volume() : null,
|
||||||
|
err: audioControls.error ? audioControls.error() : null,
|
||||||
|
ep: audioControls.currentEpisode
|
||||||
|
? audioControls.currentEpisode()?.title
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
state.audio = "ERR: " + String(e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { useFeedStore } = await import("../src/stores/feed");
|
||||||
|
const fs_ = useFeedStore();
|
||||||
|
const feeds = fs_.feeds ? fs_.feeds() : [];
|
||||||
|
state.feed = {
|
||||||
|
count: feeds?.length ?? 0,
|
||||||
|
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
|
||||||
|
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
|
||||||
|
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
state.feed = "ERR: " + String(e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
writeFileSync(STATE_JSON, JSON.stringify(state));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// ── Output ──────────────────────────────────────────────────────────────
|
||||||
|
// Compact by default: trimmed frame, one-line state per section, no styles
|
||||||
|
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
||||||
|
const verbose = !!flags.verbose;
|
||||||
|
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
|
||||||
|
|
||||||
|
// A line is "visually empty" if it's either fully blank OR contains only
|
||||||
|
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
||||||
|
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
||||||
|
// 24-row pane costs 1 line, not 18.
|
||||||
|
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
||||||
|
const isVisuallyEmpty = (l: string): boolean =>
|
||||||
|
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
||||||
|
const frameTrimmed = (() => {
|
||||||
|
const lines = plainFrame
|
||||||
|
.replace(/\n+$/, "")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.replace(/\s+$/, ""));
|
||||||
|
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
||||||
|
lines.pop();
|
||||||
|
const out: string[] = [];
|
||||||
|
let blank = 0;
|
||||||
|
const flushBlanks = () => {
|
||||||
|
if (blank >= 3) out.push(` …${blank} empty`);
|
||||||
|
else for (let i = 0; i < blank; i++) out.push("");
|
||||||
|
blank = 0;
|
||||||
|
};
|
||||||
|
for (const l of lines) {
|
||||||
|
if (isVisuallyEmpty(l)) {
|
||||||
|
blank++;
|
||||||
|
} else {
|
||||||
|
flushBlanks();
|
||||||
|
out.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBlanks();
|
||||||
|
return out.join("\n");
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`,
|
||||||
|
);
|
||||||
|
console.log(frameTrimmed);
|
||||||
|
|
||||||
|
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
||||||
|
if (scope === "all" && (flags.styles || verbose)) {
|
||||||
|
const styles = distinctStyles(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(state.nav));
|
||||||
|
if (want("audio")) console.log("audio " + compact(state.audio));
|
||||||
|
if (want("feed")) console.log("feed " + compact(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 (cmd === "init" || verbose) {
|
||||||
|
console.log(
|
||||||
|
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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().catch((err) => {
|
||||||
|
console.error("HARNESS FAILED:", err?.stack || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
145
src/App.tsx
145
src/App.tsx
@@ -1,131 +1,60 @@
|
|||||||
import { createMemo, ErrorBoundary, Accessor } from "solid-js";
|
import { ErrorBoundary } from "solid-js";
|
||||||
import { useKeyboard, useSelectionHandler } from "@opentui/solid";
|
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
||||||
import { TabNavigation } from "./components/TabNavigation";
|
|
||||||
import { CodeValidation } from "@/components/CodeValidation";
|
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
||||||
import { FeedVisibility } from "@/types/feed";
|
|
||||||
import { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { useRenderer } from "@opentui/solid";
|
|
||||||
import type { AuthScreen } from "@/types/auth";
|
|
||||||
import type { Episode } from "@/types/episode";
|
|
||||||
import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation";
|
|
||||||
import { useTheme, ThemeProvider } from "./context/ThemeContext";
|
import { useTheme, ThemeProvider } from "./context/ThemeContext";
|
||||||
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
|
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
|
||||||
import { NavigationProvider, useNavigation } from "./context/NavigationContext";
|
import {
|
||||||
import { useAudioNavStore, AudioSource } from "./stores/audio-nav";
|
NavigationProvider,
|
||||||
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
} from "./context/NavigationContext";
|
||||||
|
import { TABS } from "./utils/navigation";
|
||||||
|
import { Shell } from "./components/Shell";
|
||||||
|
|
||||||
const DEBUG = import.meta.env.DEBUG;
|
const DEBUG = import.meta.env.DEBUG;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const feedStore = useFeedStore();
|
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
const themeContext = useTheme();
|
const themeContext = useTheme();
|
||||||
const theme = themeContext.theme;
|
const theme = themeContext.theme;
|
||||||
|
|
||||||
// Create a reactive expression for background color
|
|
||||||
const backgroundColor = () => {
|
|
||||||
return themeContext.selected === "system"
|
|
||||||
? "transparent"
|
|
||||||
: themeContext.theme.surface;
|
|
||||||
};
|
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
const audioNav = useAudioNavStore();
|
|
||||||
|
|
||||||
|
// Multimedia keys (physical play/seek keys) still feed the audio backend
|
||||||
|
// regardless of the on-screen yazi keybinds.
|
||||||
useMultimediaKeys({
|
useMultimediaKeys({
|
||||||
playerFocused: () =>
|
playerFocused: () =>
|
||||||
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0,
|
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
||||||
|
? true
|
||||||
|
: false,
|
||||||
inputFocused: () => nav.inputFocused(),
|
inputFocused: () => nav.inputFocused(),
|
||||||
hasEpisode: () => !!audio.currentEpisode(),
|
hasEpisode: () => !!audio.currentEpisode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlayEpisode = (episode: Episode) => {
|
// Mouse text-selection → clipboard (unchanged from the old shell).
|
||||||
audio.play(episode);
|
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
|
||||||
nav.setActiveDepth(1);
|
|
||||||
audioNav.setSource(AudioSource.FEED);
|
|
||||||
};
|
|
||||||
|
|
||||||
useSelectionHandler((selection: any) => {
|
useSelectionHandler((selection: any) => {
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
const text = selection.getSelectedText?.();
|
const text = selection.getSelectedText?.();
|
||||||
if (!text || text.trim().length === 0) return;
|
if (!text || text.trim().length === 0) return;
|
||||||
|
|
||||||
Clipboard.copy(text)
|
Clipboard.copy(text)
|
||||||
.then(() => {
|
.then(() =>
|
||||||
toast.show({ message: "Copied to Clipboard!", variant: "info" });
|
toast.show({ message: "Copied to Clipboard!", variant: "info" }),
|
||||||
})
|
)
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
.finally(() => {
|
.finally(() => renderer.clearSelection());
|
||||||
renderer.clearSelection();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useKeyboard(
|
const backgroundColor = () =>
|
||||||
(keyEvent) => {
|
themeContext.selected === "system"
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
? "transparent"
|
||||||
const isUp = keybind.match("up", keyEvent);
|
: themeContext.theme.surface;
|
||||||
const isDown = keybind.match("down", keyEvent);
|
|
||||||
const isLeft = keybind.match("left", keyEvent);
|
|
||||||
const isRight = keybind.match("right", keyEvent);
|
|
||||||
const isDive = keybind.match("dive", keyEvent);
|
|
||||||
const isOut = keybind.match("out", keyEvent);
|
|
||||||
const isToggle = keybind.match("audio-toggle", keyEvent);
|
|
||||||
const isNext = keybind.match("audio-next", keyEvent);
|
|
||||||
const isPrev = keybind.match("audio-prev", keyEvent);
|
|
||||||
const isSeekForward = keybind.match("audio-seek-forward", keyEvent);
|
|
||||||
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent);
|
|
||||||
const isQuit = keybind.match("quit", keyEvent);
|
|
||||||
const isInverting = keybind.isInverting(keyEvent);
|
|
||||||
|
|
||||||
// only handling top navigation here, cycle through tabs, just to high priority(player) all else to be handled in each tab
|
|
||||||
if (nav.activeDepth() == 0) {
|
|
||||||
if (
|
|
||||||
(isCycle && !isInverting) ||
|
|
||||||
(isDown && !isInverting) ||
|
|
||||||
(isUp && isInverting)
|
|
||||||
) {
|
|
||||||
nav.nextTab();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(isCycle && isInverting) ||
|
|
||||||
(isDown && isInverting) ||
|
|
||||||
(isUp && !isInverting)
|
|
||||||
) {
|
|
||||||
nav.prevTab();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(isDive && !isInverting) ||
|
|
||||||
(isOut && isInverting) ||
|
|
||||||
(isRight && !isInverting) ||
|
|
||||||
(isLeft && isInverting)
|
|
||||||
) {
|
|
||||||
nav.setActiveDepth(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nav.activeDepth() == 1) {
|
|
||||||
if (
|
|
||||||
(isDive && isInverting) ||
|
|
||||||
(isOut && !isInverting) ||
|
|
||||||
(isRight && isInverting) ||
|
|
||||||
(isLeft && !isInverting)
|
|
||||||
) {
|
|
||||||
nav.setActiveDepth(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -134,7 +63,7 @@ export function App() {
|
|||||||
<text fg={theme.error}>
|
<text fg={theme.error}>
|
||||||
Error: {err?.message ?? String(err)}
|
Error: {err?.message ?? String(err)}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
Press a number key (1-6) to switch tabs.
|
Press 1-6 to switch tabs, or : to open the command bar.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -143,13 +72,8 @@ export function App() {
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={
|
backgroundColor={backgroundColor()}
|
||||||
themeContext.selected === "system"
|
|
||||||
? "transparent"
|
|
||||||
: themeContext.theme.surface
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LoadingIndicator />
|
|
||||||
{DEBUG && (
|
{DEBUG && (
|
||||||
<box flexDirection="row" width="100%" height={1}>
|
<box flexDirection="row" width="100%" height={1}>
|
||||||
<text fg={theme.primary}>█</text>
|
<text fg={theme.primary}>█</text>
|
||||||
@@ -162,26 +86,9 @@ export function App() {
|
|||||||
<text fg={theme.text}>█</text>
|
<text fg={theme.text}>█</text>
|
||||||
<text fg={theme.textMuted}>█</text>
|
<text fg={theme.textMuted}>█</text>
|
||||||
<text fg={theme.surface}>█</text>
|
<text fg={theme.surface}>█</text>
|
||||||
<text fg={theme.background}>█</text>
|
|
||||||
<text fg={theme.border}>█</text>
|
|
||||||
<text fg={theme.borderActive}>█</text>
|
|
||||||
<text fg={theme.diffAdded}>█</text>
|
|
||||||
<text fg={theme.diffRemoved}>█</text>
|
|
||||||
<text fg={theme.diffContext}>█</text>
|
|
||||||
<text fg={theme.markdownText}>█</text>
|
|
||||||
<text fg={theme.markdownHeading}>█</text>
|
|
||||||
<text fg={theme.markdownLink}>█</text>
|
|
||||||
<text fg={theme.markdownCode}>█</text>
|
|
||||||
<text fg={theme.syntaxKeyword}>█</text>
|
|
||||||
<text fg={theme.syntaxString}>█</text>
|
|
||||||
<text fg={theme.syntaxNumber}>█</text>
|
|
||||||
<text fg={theme.syntaxFunction}>█</text>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
<box flexDirection="row" width="100%" height="100%">
|
<Shell />
|
||||||
<TabNavigation />
|
|
||||||
{LayerGraph[nav.activeTab()]()}
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { TabId } from "./Tab"
|
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
|
||||||
|
|
||||||
type NavigationProps = {
|
|
||||||
activeTab: TabId
|
|
||||||
onTabSelect: (tab: TabId) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
475
src/components/Shell.tsx
Normal file
475
src/components/Shell.tsx
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* Shell — yazi-style application chrome.
|
||||||
|
*
|
||||||
|
* Renders the active page (which owns its own three-column parent | current |
|
||||||
|
* preview panes) full-width, with a bottom status/command bar that also
|
||||||
|
* carries the tab strip. A single `useKeyboard` router translates keystrokes
|
||||||
|
* (via the sequence-aware keybind matcher) into actions: the unified router
|
||||||
|
* in `@/utils/dispatch` handles tabs (digits `1`-`6`, `[`/`]`), h/l depth
|
||||||
|
* drill/pop + fixed-pane swipe, modes, audio, quit, help, and command; the
|
||||||
|
* pane/list ones are dispatched to the active page over the `nav.action`
|
||||||
|
* event bus. There is no sidebar pane.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, Show, For } from "solid-js";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useToast } from "@/ui/toast";
|
||||||
|
import { emit } from "@/utils/event-bus";
|
||||||
|
import { LayerGraph } from "@/utils/layer-graph";
|
||||||
|
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||||
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
|
|
||||||
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
|
[TABS.FEED]: "Feed",
|
||||||
|
[TABS.MYSHOWS]: "My Shows",
|
||||||
|
[TABS.DISCOVER]: "Discover",
|
||||||
|
[TABS.SEARCH]: "Search",
|
||||||
|
[TABS.PLAYER]: "Player",
|
||||||
|
[TABS.SETTINGS]: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Shell() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const t = theme.theme;
|
||||||
|
const nav = useNavigation();
|
||||||
|
const k = useKeybinds();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
const toast = useToast();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
|
const [showHelp, setShowHelp] = createSignal(false);
|
||||||
|
|
||||||
|
/** 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 ────────────────────────────────────────────────────
|
||||||
|
function runCommand(raw: string) {
|
||||||
|
const cmd = raw.trim();
|
||||||
|
if (!cmd) return;
|
||||||
|
const name = cmd.split(/\s+/)[0].toLowerCase();
|
||||||
|
const arg = cmd.slice(name.length).trim();
|
||||||
|
switch (name) {
|
||||||
|
case "q":
|
||||||
|
case "quit":
|
||||||
|
case "exit":
|
||||||
|
return process.exit(0);
|
||||||
|
case "refresh":
|
||||||
|
case "r":
|
||||||
|
emit("nav.action", {
|
||||||
|
action: "refresh",
|
||||||
|
tab: nav.activeTab(),
|
||||||
|
pane: nav.activePane(),
|
||||||
|
mode: nav.mode(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "play":
|
||||||
|
case "pause":
|
||||||
|
case "p":
|
||||||
|
audio.togglePlayback().catch(() => {});
|
||||||
|
break;
|
||||||
|
case "next":
|
||||||
|
case "n":
|
||||||
|
advanceEpisode(1);
|
||||||
|
break;
|
||||||
|
case "prev":
|
||||||
|
advanceEpisode(-1);
|
||||||
|
break;
|
||||||
|
case "seek": {
|
||||||
|
const n = Number(arg) || 0;
|
||||||
|
audio.seek(n).catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "feed":
|
||||||
|
case "f":
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
break;
|
||||||
|
case "shows":
|
||||||
|
case "myshows":
|
||||||
|
nav.setActiveTab(TABS.MYSHOWS);
|
||||||
|
break;
|
||||||
|
case "discover":
|
||||||
|
case "d":
|
||||||
|
nav.setActiveTab(TABS.DISCOVER);
|
||||||
|
break;
|
||||||
|
case "search":
|
||||||
|
nav.setActiveTab(TABS.SEARCH);
|
||||||
|
break;
|
||||||
|
case "player":
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
break;
|
||||||
|
case "settings":
|
||||||
|
case "set":
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
break;
|
||||||
|
case "help":
|
||||||
|
case "h":
|
||||||
|
setShowHelp((v) => !v);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nav.setCommandError(`unknown command: ${name}`);
|
||||||
|
// re-enter command mode so the user sees the error + can correct
|
||||||
|
nav.enterCommand();
|
||||||
|
nav.setCommandBuffer(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command-mode key handling ───────────────────────────────────────────────
|
||||||
|
function handleCommandKey(evt: any) {
|
||||||
|
if (k.match("escape", evt) || evt.name === "ctrl-c") {
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.exitCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.name === "return" || evt.name === "enter") {
|
||||||
|
evt.preventDefault();
|
||||||
|
const cmd = nav.commandBuffer();
|
||||||
|
runCommand(cmd);
|
||||||
|
nav.exitCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.name === "backspace") {
|
||||||
|
evt.preventDefault();
|
||||||
|
const buf = nav.commandBuffer();
|
||||||
|
if (buf.length === 0) {
|
||||||
|
nav.exitCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav.backspaceCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// printable char
|
||||||
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.appendCommand(evt.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unified router (normal + visual) ───────────────────────────────────────
|
||||||
|
const { dispatch } = createDispatcher({
|
||||||
|
nav,
|
||||||
|
audio: {
|
||||||
|
togglePlayback: audio.togglePlayback,
|
||||||
|
seekRelative: audio.seekRelative,
|
||||||
|
},
|
||||||
|
k,
|
||||||
|
setShowHelp,
|
||||||
|
advanceEpisode,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyboard(
|
||||||
|
(evt: any) => {
|
||||||
|
// Input fields (search boxes, dialogs) own their keys — except Escape,
|
||||||
|
// which defocuses the input so j/k/h navigation resumes (search: h back
|
||||||
|
// to the tab root, j/k to move the recent-searches list).
|
||||||
|
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
|
||||||
|
if (evt.name === "escape") {
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.setInputFocused(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav.mode() === NavMode.COMMAND) {
|
||||||
|
handleCommandKey(evt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = k.tryMatch(evt);
|
||||||
|
if (action) dispatch(action, evt);
|
||||||
|
},
|
||||||
|
{ release: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Status bar fragments ──────────────────────────────────────────────────
|
||||||
|
const nowPlaying = () => {
|
||||||
|
const ep = audio.currentEpisode();
|
||||||
|
if (!ep) return null;
|
||||||
|
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
|
||||||
|
return `♪ ${title}`;
|
||||||
|
};
|
||||||
|
const modeLabel = () =>
|
||||||
|
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
||||||
|
const pendingLabel = () =>
|
||||||
|
k
|
||||||
|
.pending()
|
||||||
|
.map((s) => s.key)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
backgroundColor={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 */}
|
||||||
|
<YaziPaneRow
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
parentLabel="Up"
|
||||||
|
currentLabel="Tabs"
|
||||||
|
previewLabel=""
|
||||||
|
focused
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
height={1}
|
||||||
|
width="100%"
|
||||||
|
backgroundColor={t.backgroundPanel ?? t.background}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={nav.mode() === NavMode.COMMAND}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<text fg={t.accent} paddingLeft={1}>
|
||||||
|
{modeLabel()}
|
||||||
|
</text>
|
||||||
|
<text fg={t.textMuted} paddingLeft={1}>
|
||||||
|
{nav.atRootTab()
|
||||||
|
? "Tabs · root"
|
||||||
|
: `${TAB_LABEL[nav.activeTab()]} · ${
|
||||||
|
nav.isDepthTab()
|
||||||
|
? `depth ${nav.currentDepth()}`
|
||||||
|
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
||||||
|
}`}
|
||||||
|
</text>
|
||||||
|
<Show when={nav.selectedIds().length > 0}>
|
||||||
|
<text fg={t.warning} paddingLeft={1}>
|
||||||
|
● {nav.selectedIds().length}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={nowPlaying()}>
|
||||||
|
<text fg={t.primary} paddingLeft={1}>
|
||||||
|
{nowPlaying()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<box flexGrow={1} />
|
||||||
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
|
{pendingLabel()}
|
||||||
|
</text>
|
||||||
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
|
~
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={t.accent} paddingLeft={1}>
|
||||||
|
:
|
||||||
|
</text>
|
||||||
|
<text fg={t.text}>{nav.commandBuffer()}</text>
|
||||||
|
<text fg={t.textMuted}>▏</text>
|
||||||
|
<Show when={nav.commandError()}>
|
||||||
|
<text fg={t.error} paddingLeft={1}>
|
||||||
|
{nav.commandError()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
|
||||||
|
<Show when={showHelp()}>
|
||||||
|
<HelpOverlay
|
||||||
|
onClose={() => setShowHelp(false)}
|
||||||
|
sections={helpSections(k)}
|
||||||
|
theme={t as any}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function helpSections(k: ReturnType<typeof useKeybinds>) {
|
||||||
|
const p = (a: KeybindActionName) => k.print(a);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
group: "Move",
|
||||||
|
items: [
|
||||||
|
["j/k", "move"],
|
||||||
|
["J/K", "5 lines"],
|
||||||
|
["ctrl-d/u", "half page"],
|
||||||
|
["gg/G", "top/bottom"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Panes",
|
||||||
|
items: [
|
||||||
|
["j/k", "switch tab (tab panel)"],
|
||||||
|
["l/enter", "enter tab content"],
|
||||||
|
["h", "back to tab panel"],
|
||||||
|
["1-6 / [ ]", "switch tabs"],
|
||||||
|
[":", "command"],
|
||||||
|
["~", "help"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Select",
|
||||||
|
items: [
|
||||||
|
["space", "toggle"],
|
||||||
|
["v", "visual"],
|
||||||
|
["ctrl-a", "all"],
|
||||||
|
["esc", "clear"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Audio",
|
||||||
|
items: [
|
||||||
|
[p("audio-toggle"), "play/pause"],
|
||||||
|
[p("audio-next"), "next"],
|
||||||
|
[p("audio-seek-forward"), "fwd 10s"],
|
||||||
|
[p("audio-seek-backward"), "back 10s"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
items: [
|
||||||
|
["enter", "open"],
|
||||||
|
["r", "refresh"],
|
||||||
|
["s", "search"],
|
||||||
|
["f", "filter"],
|
||||||
|
[",", "sort"],
|
||||||
|
[".", "hidden"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpOverlay(props: {
|
||||||
|
onClose: () => void;
|
||||||
|
sections: { group: string; items: string[][] }[];
|
||||||
|
theme: any;
|
||||||
|
}) {
|
||||||
|
useKeyboard((evt: any) => {
|
||||||
|
if (k_match_escape(evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const th = props.theme;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
top={2}
|
||||||
|
left={0}
|
||||||
|
width="100%"
|
||||||
|
alignItems="center"
|
||||||
|
backgroundColor="rgba(0,0,0,160)"
|
||||||
|
onMouseUp={() => props.onClose()}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
border
|
||||||
|
borderColor={th.border}
|
||||||
|
backgroundColor={th.backgroundPanel ?? th.background}
|
||||||
|
padding={1}
|
||||||
|
width={60}
|
||||||
|
onMouseUp={(e: any) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<text fg={th.accent}>
|
||||||
|
Yazi-style keybinds — press ~ or Esc to close
|
||||||
|
</text>
|
||||||
|
<For each={props.sections}>
|
||||||
|
{(sec) => (
|
||||||
|
<box flexDirection="column" marginTop={1}>
|
||||||
|
<text fg={th.textSecondary}>{sec.group}</text>
|
||||||
|
<For each={sec.items}>
|
||||||
|
{(it) => (
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={th.accent}>{String(it[0]).padEnd(14, " ")}</text>
|
||||||
|
<text fg={th.textPrimary ?? th.text}>{it[1]}</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<box marginTop={1}>
|
||||||
|
<text fg={th.textMuted}>
|
||||||
|
Edit ~/.config/podtui/keybinds.jsonc to remap.
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function k_match_escape(evt: any): boolean {
|
||||||
|
return (
|
||||||
|
evt.name === "escape" || evt.name === "~" || (evt.ctrl && evt.name === "[")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
|
||||||
|
* search) into the player tab. */
|
||||||
|
export function playEpisodeAndSwitch(
|
||||||
|
nav: ReturnType<typeof useNavigation>,
|
||||||
|
audio: ReturnType<typeof useAudio>,
|
||||||
|
episode: import("@/types/episode").Episode,
|
||||||
|
) {
|
||||||
|
audio.play(episode);
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
|
||||||
|
useAudioNavStore().setSource(AudioSource.FEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export Episode type for callers building pane trees.
|
||||||
|
export type { Episode } from "@/types/episode";
|
||||||
@@ -1,27 +1,26 @@
|
|||||||
|
import { For } from "solid-js";
|
||||||
import { shortcuts } from "@/config/shortcuts";
|
import { shortcuts } from "@/config/shortcuts";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
|
/** Yazi-style keybind reference. The Shell has its own overlay; this component
|
||||||
|
* is kept for embedding inside Settings or other surfaces. */
|
||||||
export function ShortcutHelp() {
|
export function ShortcutHelp() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box border title="Shortcuts" style={{ padding: 1 }}>
|
<box
|
||||||
|
border
|
||||||
|
title="Shortcuts"
|
||||||
|
style={{ flexDirection: "column", padding: 1 }}
|
||||||
|
>
|
||||||
<box style={{ flexDirection: "column" }}>
|
<box style={{ flexDirection: "column" }}>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<For each={shortcuts}>
|
||||||
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text>
|
{(s) => (
|
||||||
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text>
|
<box style={{ flexDirection: "row" }} gap={2}>
|
||||||
</box>
|
<text fg={theme.accent}>{s.keys}</text>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<text fg={theme.text}>{s.action}</text>
|
||||||
<text fg={theme.text}>{shortcuts[1]?.keys ?? ""} </text>
|
|
||||||
<text fg={theme.text}>{shortcuts[1]?.action ?? ""}</text>
|
|
||||||
</box>
|
|
||||||
<box style={{ flexDirection: "row" }}>
|
|
||||||
<text fg={theme.text}>{shortcuts[2]?.keys ?? ""} </text>
|
|
||||||
<text fg={theme.text}>{shortcuts[2]?.action ?? ""}</text>
|
|
||||||
</box>
|
|
||||||
<box style={{ flexDirection: "row" }}>
|
|
||||||
<text fg={theme.text}>{shortcuts[3]?.keys ?? ""} </text>
|
|
||||||
<text fg={theme.text}>{shortcuts[3]?.action ?? ""}</text>
|
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
89
src/components/TabPanel.tsx
Normal file
89
src/components/TabPanel.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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 { TABS } from "@/utils/navigation";
|
||||||
|
|
||||||
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
|
[TABS.FEED]: "Feed",
|
||||||
|
[TABS.MYSHOWS]: "My Shows",
|
||||||
|
[TABS.DISCOVER]: "Discover",
|
||||||
|
[TABS.SEARCH]: "Search",
|
||||||
|
[TABS.PLAYER]: "Player",
|
||||||
|
[TABS.SETTINGS]: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||||
|
const TAB_ORDER = Object.values(TABS).filter(
|
||||||
|
(v): v is TABS => typeof v === "number",
|
||||||
|
) as TABS[];
|
||||||
|
|
||||||
|
export function TabListPane(props: { muted?: boolean }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
const cursor = () => nav.tabCursor();
|
||||||
|
const activeTab = () => nav.activeTab();
|
||||||
|
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||||
|
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||||
|
const active = () => !props.muted;
|
||||||
|
|
||||||
|
// Same focus-bg / focus-fg contract every other pane uses.
|
||||||
|
const focusBg = (t: TABS) =>
|
||||||
|
t === cursor() && active()
|
||||||
|
? theme.primary
|
||||||
|
: t === cursor()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const focusFg = (t: TABS) =>
|
||||||
|
t === cursor() && active() ? theme.surface : theme.text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<For each={TAB_ORDER}>
|
||||||
|
{(tab) => {
|
||||||
|
const isCursor = () => cursor() === tab;
|
||||||
|
const isActive = () => activeTab() === tab;
|
||||||
|
// The active tab is only accented in the Up/parent position — when this
|
||||||
|
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||||
|
const labelFg = () =>
|
||||||
|
isCursor()
|
||||||
|
? focusFg(tab)
|
||||||
|
: isActive() && !active()
|
||||||
|
? theme.accent
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
height={1}
|
||||||
|
flexDirection="row"
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(tab)}
|
||||||
|
>
|
||||||
|
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||||
|
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||||
|
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
||||||
|
<text fg={labelFg()} paddingLeft={1}>
|
||||||
|
{TAB_LABEL[tab]}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/components/YaziPaneRow.tsx
Normal file
210
src/components/YaziPaneRow.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||||
|
*
|
||||||
|
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
|
||||||
|
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
||||||
|
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
||||||
|
* so the ratio is exact regardless of content width — a column's content can
|
||||||
|
* never stretch its slot.
|
||||||
|
*
|
||||||
|
* Column semantics (per the yazi depth model):
|
||||||
|
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||||
|
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
||||||
|
* current — the current-depth list. The only focusable content column; it
|
||||||
|
* carries the active-border focus ring when `focused` is truthy.
|
||||||
|
* preview — detail of the hovered item in `current`; always muted border.
|
||||||
|
*
|
||||||
|
* The primitive is purely structural: callers pass their own JSX per column
|
||||||
|
* (static elements or accessors) plus header labels. Theme colors are resolved
|
||||||
|
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
||||||
|
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <YaziPaneRow
|
||||||
|
* parent={parentList}
|
||||||
|
* current={currentList}
|
||||||
|
* preview={detail}
|
||||||
|
* parentLabel="Up"
|
||||||
|
* currentLabel="List · 42"
|
||||||
|
* previewLabel="Detail"
|
||||||
|
* focused={isActive}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createMemo, Show } from "solid-js";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||||
|
type PaneLabel = string | (() => string);
|
||||||
|
|
||||||
|
export type YaziPaneRowProps = {
|
||||||
|
/** Parent column content (previous-depth list, or null for a muted
|
||||||
|
* placeholder — the 1/7 slot is always preserved). */
|
||||||
|
parent?: PaneContent;
|
||||||
|
/** Current column content (the focused list). */
|
||||||
|
current?: PaneContent;
|
||||||
|
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||||
|
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||||
|
preview?: PaneContent;
|
||||||
|
parentLabel?: PaneLabel;
|
||||||
|
currentLabel?: PaneLabel;
|
||||||
|
previewLabel?: PaneLabel;
|
||||||
|
/** Whether the current column carries the active-border focus ring. Defaults to
|
||||||
|
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
||||||
|
* preview columns always render muted borders. */
|
||||||
|
focused?: boolean | (() => boolean);
|
||||||
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
|
panes?: 2 | 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function resolveLabel(v: PaneLabel | undefined): string {
|
||||||
|
if (v == null) return "";
|
||||||
|
return typeof v === "function" ? v() : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
||||||
|
* We deliberately do NOT use Solid's `children()` helper here: that helper
|
||||||
|
* flattens accessor children into a stable resolved-nodes array and is the
|
||||||
|
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
|
||||||
|
* switching between a depth-1 list fragment and a depth-2 editor — both
|
||||||
|
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
|
||||||
|
* root swap, freezing the previous subtree in place. Instead we hand the
|
||||||
|
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
|
||||||
|
* which Solid compiles into a tracked `insert` effect that disposes the old
|
||||||
|
* subtree and mounts the new whenever the accessor returns a different
|
||||||
|
* element identity. */
|
||||||
|
function normalizeContent(
|
||||||
|
v: PaneContent | undefined,
|
||||||
|
): () => JSX.Element | undefined {
|
||||||
|
if (v == null) return () => undefined;
|
||||||
|
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Placeholder(props: { color: () => RGBA }) {
|
||||||
|
return (
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={props.color()}>—</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||||
|
function YaziPane(props: {
|
||||||
|
grow: number;
|
||||||
|
label: () => string;
|
||||||
|
content: () => JSX.Element | undefined;
|
||||||
|
borderColor: () => RGBA;
|
||||||
|
scrollFocused: () => boolean;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||||
|
|
||||||
|
// Memoize accessor results so the prop expressions below stay reactive
|
||||||
|
// when the underlying signals (e.g. `focused`) change.
|
||||||
|
const borderColor = createMemo(() => props.borderColor());
|
||||||
|
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={props.grow}
|
||||||
|
flexBasis={0}
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
{/* ── slim header label row ─────────────────────────────────────────── */}
|
||||||
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
|
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||||
|
</box>
|
||||||
|
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
||||||
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
focused={scrollFocused()}
|
||||||
|
border
|
||||||
|
borderColor={borderColor()}
|
||||||
|
backgroundColor={theme.background}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* Render the content accessor directly via a reactive expression.
|
||||||
|
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
|
||||||
|
* effect that re-runs whenever the accessor's tracked signals
|
||||||
|
* change (e.g. `depth()` swapping the root from a list fragment to
|
||||||
|
* an editor). Solid disposes the previously-rendered subtree and
|
||||||
|
* mounts the new element identity. `null`/`undefined` falls back
|
||||||
|
* to the muted placeholder so the parent pane keeps its 1/7 slot
|
||||||
|
* visibly blank at depth 0. This is the correct tool for root
|
||||||
|
* swapping — unlike Solid's `children()` / `<Show>`-children,
|
||||||
|
* which only react to truthiness flips, not truthy<@->truthy root
|
||||||
|
* identity changes.
|
||||||
|
*/}
|
||||||
|
{props.content() ?? <Placeholder color={muted} />}
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||||
|
export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
/** true → the current column gets the active-border focus ring. */
|
||||||
|
const focused = createMemo(() => {
|
||||||
|
const f = props.focused;
|
||||||
|
return typeof f === "function" ? f() : (f ?? true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize static JSX and accessor children into reactive accessors
|
||||||
|
// (see normalizeContent for why we avoid Solid's `children()` helper).
|
||||||
|
const parentContent = normalizeContent(props.parent);
|
||||||
|
const currentContent = normalizeContent(props.current);
|
||||||
|
const previewContent = normalizeContent(props.preview);
|
||||||
|
|
||||||
|
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
|
||||||
|
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||||
|
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
||||||
|
|
||||||
|
// 2-pane mode (parent|current) grows the current column to fill the
|
||||||
|
// preview slot. Defaults to 3 (parent|current|preview).
|
||||||
|
const panes = createMemo(() => props.panes ?? 3);
|
||||||
|
const currentGrow = createMemo(() =>
|
||||||
|
panes() === 2
|
||||||
|
? PANE_RATIO.current + PANE_RATIO.preview
|
||||||
|
: PANE_RATIO.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
|
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||||
|
<YaziPane
|
||||||
|
grow={PANE_RATIO.parent}
|
||||||
|
label={parentLabel}
|
||||||
|
content={parentContent}
|
||||||
|
borderColor={() => theme.border}
|
||||||
|
scrollFocused={() => false}
|
||||||
|
/>
|
||||||
|
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||||
|
<YaziPane
|
||||||
|
grow={currentGrow()}
|
||||||
|
label={currentLabel}
|
||||||
|
content={currentContent}
|
||||||
|
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
||||||
|
scrollFocused={() => focused()}
|
||||||
|
/>
|
||||||
|
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||||
|
<Show when={panes() === 3}>
|
||||||
|
<YaziPane
|
||||||
|
grow={PANE_RATIO.preview}
|
||||||
|
label={previewLabel}
|
||||||
|
content={previewContent}
|
||||||
|
borderColor={() => theme.border}
|
||||||
|
scrollFocused={() => false}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"up": ["up", "k"],
|
|
||||||
"down": ["down", "j"],
|
|
||||||
"left": ["left", "h"],
|
|
||||||
"right": ["right", "l"],
|
|
||||||
"cycle": ["tab"], // this will cycle no matter the depth/orientation
|
|
||||||
"dive": ["return"],
|
|
||||||
"out": ["esc"],
|
|
||||||
"inverseModifier": ["shift"],
|
|
||||||
"leader": ":", // will not trigger while focused on input
|
|
||||||
"quit": ["<leader>q"],
|
|
||||||
"refresh": ["<leader>r"],
|
|
||||||
"audio-toggle": ["<leader>p"],
|
|
||||||
"audio-pause": [],
|
|
||||||
"audio-play": [],
|
|
||||||
"audio-next": ["<leader>n"],
|
|
||||||
"audio-prev": ["<leader>l"],
|
|
||||||
"audio-seek-forward": ["<leader>sf"],
|
|
||||||
"audio-seek-backward": ["<leader>sb"],
|
|
||||||
}
|
|
||||||
73
src/config/keybinds.jsonc
Normal file
73
src/config/keybinds.jsonc
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
// ── 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,
|
||||||
|
// : command bar, q 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 bar & help & quit ────────────────────────────────────────────
|
||||||
|
"command": [":"],
|
||||||
|
"quit": ["q", "ctrl-c"],
|
||||||
|
"help": ["~", "f1"],
|
||||||
|
|
||||||
|
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
||||||
|
"search": ["s"],
|
||||||
|
"filter": ["f"],
|
||||||
|
"sort": [","],
|
||||||
|
"toggle-hidden": ["."],
|
||||||
|
"refresh": ["r"],
|
||||||
|
|
||||||
|
// ── 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+,)
|
||||||
|
}
|
||||||
@@ -1,6 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
|
||||||
|
* Shown in help overlays; the canonical source remains keybinds.jsonc.
|
||||||
|
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
|
||||||
|
*/
|
||||||
export const shortcuts = [
|
export const shortcuts = [
|
||||||
{ keys: "Ctrl+Q", action: "Quit" },
|
{ keys: "j / k", action: "Move down / up (within pane)" },
|
||||||
{ keys: "Ctrl+S", action: "Save" },
|
{ keys: "h / l", action: "Swipe to prev / next pane" },
|
||||||
{ keys: "Left/Right", action: "Switch tabs" },
|
{ keys: "J / K", action: "Jump 5 lines down / up" },
|
||||||
{ keys: "Esc", action: "Close modal" },
|
{ keys: "ctrl-d / u", action: "Half page down / up" },
|
||||||
] as const
|
{ keys: "g g / G", action: "Go to top / bottom of list" },
|
||||||
|
{ keys: "1-6", action: "Go to tab 1-6" },
|
||||||
|
{ keys: "[ / ]", action: "Previous / next tab" },
|
||||||
|
{ keys: "Enter", action: "Open / activate focused item" },
|
||||||
|
{ keys: "Space", action: "Toggle selection on item" },
|
||||||
|
{ keys: "v", action: "Enter visual (range) select mode" },
|
||||||
|
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
|
||||||
|
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
||||||
|
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
||||||
|
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
||||||
|
{ keys: ", / .", action: "Sort / toggle hidden" },
|
||||||
|
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
||||||
|
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
||||||
|
{ keys: "~ / F1", action: "Help" },
|
||||||
|
{ keys: "q", action: "Quit" },
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -7,115 +7,347 @@ import {
|
|||||||
} from "../utils/keybinds-persistence";
|
} from "../utils/keybinds-persistence";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
|
|
||||||
export type KeybindsResolved = {
|
// ── Keybind model ───────────────────────────────────────────────────────────
|
||||||
up: string[];
|
// Yazi-style: every binding is one or more "strokes". A stroke is a single
|
||||||
down: string[];
|
// key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
|
||||||
left: string[];
|
// sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
|
||||||
right: string[];
|
// buffers keystrokes, prefers the longest matching sequence, and exposes the
|
||||||
cycle: string[]; // this will cycle no matter the depth/orientation
|
// pending buffer reactively so the status bar can show it (very yazi).
|
||||||
dive: string[];
|
|
||||||
out: string[];
|
|
||||||
inverseModifier: string;
|
|
||||||
leader: string; // will not trigger while focused on input
|
|
||||||
quit: string[];
|
|
||||||
select: string[]; // for selecting/activating items
|
|
||||||
"audio-toggle": string[];
|
|
||||||
"audio-pause": string[];
|
|
||||||
"audio-play": string[];
|
|
||||||
"audio-next": string[];
|
|
||||||
"audio-prev": string[];
|
|
||||||
"audio-seek-forward": string[];
|
|
||||||
"audio-seek-backward": string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum KeybindAction {
|
/** A single key press. `key` is the lowercase logical key name
|
||||||
UP,
|
* ("j", "return", "space", "up", "f1", ...). */
|
||||||
DOWN,
|
export interface Stroke {
|
||||||
LEFT,
|
key: string;
|
||||||
RIGHT,
|
ctrl?: boolean;
|
||||||
CYCLE,
|
shift?: boolean;
|
||||||
DIVE,
|
meta?: boolean;
|
||||||
OUT,
|
}
|
||||||
QUIT,
|
|
||||||
SELECT,
|
/** Raw config spec for one action: a list of alternative sequences. Each
|
||||||
AUDIO_TOGGLE,
|
* alternative is itself a list of stroke-notation strings. So
|
||||||
AUDIO_PAUSE,
|
* "j" -> [[ {key:"j"} ]]
|
||||||
AUDIO_PLAY,
|
* ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
|
||||||
AUDIO_NEXT,
|
* [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
|
||||||
AUDIO_PREV,
|
export type KeybindSpec = string | (string | string[])[];
|
||||||
AUDIO_SEEK_F,
|
|
||||||
AUDIO_SEEK_B,
|
/** Canonical action names. Must match keys in keybinds.jsonc. */
|
||||||
|
export type KeybindActionName =
|
||||||
|
| "move-down"
|
||||||
|
| "move-up"
|
||||||
|
| "page-down"
|
||||||
|
| "page-up"
|
||||||
|
| "full-down"
|
||||||
|
| "full-up"
|
||||||
|
| "jump-down"
|
||||||
|
| "jump-up"
|
||||||
|
| "goto-top"
|
||||||
|
| "goto-bottom"
|
||||||
|
| "swipe-prev"
|
||||||
|
| "swipe-next"
|
||||||
|
| "open"
|
||||||
|
| "open-interactive"
|
||||||
|
| "toggle-select"
|
||||||
|
| "visual-mode"
|
||||||
|
| "toggle-all"
|
||||||
|
| "invert-all"
|
||||||
|
| "escape"
|
||||||
|
| "tab-prev"
|
||||||
|
| "tab-next"
|
||||||
|
| "tab-goto-1"
|
||||||
|
| "tab-goto-2"
|
||||||
|
| "tab-goto-3"
|
||||||
|
| "tab-goto-4"
|
||||||
|
| "tab-goto-5"
|
||||||
|
| "tab-goto-6"
|
||||||
|
| "command"
|
||||||
|
| "quit"
|
||||||
|
| "help"
|
||||||
|
| "search"
|
||||||
|
| "filter"
|
||||||
|
| "sort"
|
||||||
|
| "toggle-hidden"
|
||||||
|
| "refresh"
|
||||||
|
| "audio-toggle"
|
||||||
|
| "audio-next"
|
||||||
|
| "audio-prev"
|
||||||
|
| "audio-seek-forward"
|
||||||
|
| "audio-seek-backward"
|
||||||
|
// legacy compat (kept so older callers don't crash)
|
||||||
|
| "select"
|
||||||
|
| "leader"
|
||||||
|
| "inverseModifier"
|
||||||
|
| "cycle"
|
||||||
|
| "dive"
|
||||||
|
| "out"
|
||||||
|
| "up"
|
||||||
|
| "down"
|
||||||
|
| "left"
|
||||||
|
| "right"
|
||||||
|
| "audio-pause"
|
||||||
|
| "audio-play";
|
||||||
|
|
||||||
|
/** 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). */
|
||||||
|
export 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. */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sequenceLabel(seq: Stroke[]): string {
|
||||||
|
return seq.map(strokeLabel).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useKeybinds, provider: KeybindProvider } =
|
export const { use: useKeybinds, provider: KeybindProvider } =
|
||||||
createSimpleContext({
|
createSimpleContext({
|
||||||
name: "Keybinds",
|
name: "Keybinds",
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore<KeybindsResolved>({});
|
||||||
up: [],
|
// Resolved sequences per action, recomputed when store changes.
|
||||||
down: [],
|
const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
|
||||||
left: [],
|
{},
|
||||||
right: [],
|
);
|
||||||
cycle: [],
|
|
||||||
dive: [],
|
|
||||||
out: [],
|
|
||||||
inverseModifier: "",
|
|
||||||
leader: "",
|
|
||||||
quit: [],
|
|
||||||
select: [],
|
|
||||||
refresh: [],
|
|
||||||
"audio-toggle": [],
|
|
||||||
"audio-pause": [],
|
|
||||||
"audio-play": [],
|
|
||||||
"audio-next": [],
|
|
||||||
"audio-prev": [],
|
|
||||||
"audio-seek-forward": [],
|
|
||||||
"audio-seek-backward": [],
|
|
||||||
} as KeybindsResolved);
|
|
||||||
const [ready, setReady] = createSignal(false);
|
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() {
|
async function load() {
|
||||||
await copyKeybindsIfNeeded();
|
await copyKeybindsIfNeeded();
|
||||||
const keybinds = await loadKeybindsFromFile();
|
const keybinds = await loadKeybindsFromFile();
|
||||||
setStore(keybinds);
|
setStore(keybinds);
|
||||||
|
recompute();
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saveKeybindsToFile(store);
|
saveKeybindsToFile(store as KeybindsResolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function print(input: keyof KeybindsResolved): string {
|
function print(input: KeybindActionName): string {
|
||||||
const keys = store[input] || [];
|
const alts = resolved()[input] ?? [];
|
||||||
return Array.isArray(keys) ? keys.join(", ") : keys;
|
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(
|
function match(
|
||||||
keybind: keyof KeybindsResolved,
|
name: KeybindActionName,
|
||||||
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
||||||
): boolean {
|
): boolean {
|
||||||
const keys = store[keybind];
|
const alts = resolved()[name] ?? [];
|
||||||
if (!keys) return false;
|
const s = strokeFromEvent(evt);
|
||||||
|
// skip in command/input mode unless explicitly handled by caller
|
||||||
for (const key of keys) {
|
for (const seq of alts) {
|
||||||
if (evt.name === key) return true;
|
if (seq.length === 1 && strokeEq(seq[0], s)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInverting(evt: {
|
/** 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;
|
name: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
shift?: boolean;
|
shift?: boolean;
|
||||||
}) {
|
}): KeybindActionName | null {
|
||||||
if (store.inverseModifier === "ctrl" && evt.ctrl) return true;
|
const stroke = strokeFromEvent(evt);
|
||||||
if (store.inverseModifier === "meta" && evt.meta) return true;
|
const candidate = [...pending(), stroke];
|
||||||
if (store.inverseModifier === "shift" && evt.shift) return true;
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
|
||||||
|
// so it always reports false. Migrated callers should use tryMatch().
|
||||||
|
function isInverting(_evt: {
|
||||||
|
name: string;
|
||||||
|
ctrl?: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
}): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load().catch(() => {});
|
load().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -127,10 +359,17 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
get keybinds() {
|
get keybinds() {
|
||||||
return store;
|
return store;
|
||||||
},
|
},
|
||||||
save,
|
get resolved() {
|
||||||
print,
|
return resolved();
|
||||||
|
},
|
||||||
|
pending,
|
||||||
match,
|
match,
|
||||||
|
tryMatch,
|
||||||
isInverting,
|
isInverting,
|
||||||
|
print,
|
||||||
|
save,
|
||||||
|
load,
|
||||||
|
clearPending,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +1,22 @@
|
|||||||
import { createEffect, createSignal, on } from "solid-js";
|
/**
|
||||||
|
* 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 { createSimpleContext } from "./helper";
|
||||||
import { TABS, TabsCount } from "@/utils/navigation";
|
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 } =
|
export const { use: useNavigation, provider: NavigationProvider } =
|
||||||
createSimpleContext({
|
createSimpleContext({
|
||||||
name: "Navigation",
|
name: "Navigation",
|
||||||
init: () => {
|
init: () => createNavigation(),
|
||||||
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
|
||||||
const [activeDepth, setActiveDepth] = createSignal(0);
|
|
||||||
const [inputFocused, setInputFocused] = createSignal(false);
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => activeTab,
|
|
||||||
() => setActiveDepth(0),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
//conveniences
|
|
||||||
const nextTab = () => {
|
|
||||||
if (activeTab() >= TabsCount) {
|
|
||||||
setActiveTab(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveTab(activeTab() + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevTab = () => {
|
|
||||||
if (activeTab() <= 1) {
|
|
||||||
setActiveTab(TabsCount);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveTab(activeTab() - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeTab,
|
|
||||||
activeDepth,
|
|
||||||
inputFocused,
|
|
||||||
setActiveTab,
|
|
||||||
setActiveDepth,
|
|
||||||
setInputFocused,
|
|
||||||
nextTab,
|
|
||||||
prevTab,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
513
src/context/navigation-store.ts
Normal file
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
|
||||||
|
*
|
||||||
|
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
|
||||||
|
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||||
|
* nav model — which column is focused and where its list cursor lives. The
|
||||||
|
* parent/preview columns are always derived, never focused.
|
||||||
|
*
|
||||||
|
* The tab list is the app's ROOT and participates in the same pane flow as
|
||||||
|
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:
|
||||||
|
*
|
||||||
|
* • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
|
||||||
|
* • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
|
||||||
|
* that tab's content becomes CURRENT and its hovered item PREVIEW
|
||||||
|
* (`enterTabContent`).
|
||||||
|
* • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
|
||||||
|
* own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
|
||||||
|
* OUT of the 3-pane view.
|
||||||
|
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
|
||||||
|
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
|
||||||
|
* stays (out of the panes — no-op).
|
||||||
|
*
|
||||||
|
* Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
|
||||||
|
* ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
|
||||||
|
* the parent/preview are derived. Search drills query→results; Player is a
|
||||||
|
* single now-playing pane under the tab list (2-pane, no preview). Every
|
||||||
|
* tab returns to the root via `h` at depth 0 (`backToTabRoot`).
|
||||||
|
*
|
||||||
|
* Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
|
||||||
|
* `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
|
||||||
|
*/
|
||||||
|
import { createSignal, batch } from "solid-js";
|
||||||
|
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||||
|
|
||||||
|
export enum NavMode {
|
||||||
|
NORMAL = "NORMAL",
|
||||||
|
VISUAL = "VISUAL",
|
||||||
|
COMMAND = "COMMAND",
|
||||||
|
INPUT = "INPUT",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current content pane of the active tab, i.e. the focusable column
|
||||||
|
* (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
|
||||||
|
* pane 0. A tab switch made while focused on content resets `activePane` to
|
||||||
|
* this pane (unless already on the tab list). */
|
||||||
|
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||||
|
|
||||||
|
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
||||||
|
* left of the active tab's content (1..n). It is the app's outermost pane:
|
||||||
|
* starting focus lives here, tab switches made from it keep focus on it, and
|
||||||
|
* swiping left past the first content pane returns to it. Swiping left again
|
||||||
|
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
||||||
|
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
||||||
|
|
||||||
|
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
||||||
|
|
||||||
|
// ── Depth stack ──────────────────────────────────────────────────────────────
|
||||||
|
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
|
||||||
|
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
|
||||||
|
* focused row index within that list. `ctx` optionally carries an id or
|
||||||
|
* payload the page needs to derive the list (e.g. a feed id). */
|
||||||
|
export type DepthFrame = {
|
||||||
|
kind: string;
|
||||||
|
ctx?: string;
|
||||||
|
focus: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Selection store ───────────────────────────────────────────────────────────
|
||||||
|
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
|
||||||
|
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
|
||||||
|
// selection anchored at the focused index.
|
||||||
|
|
||||||
|
type SelectionMap = Record<string, Set<string>>;
|
||||||
|
|
||||||
|
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a fresh, self-contained navigation state graph.
|
||||||
|
*
|
||||||
|
* Exported (not just inlined into the Solid provider) so unit tests can build
|
||||||
|
* a nav graph inside a `createRoot` without rendering any provider tree.
|
||||||
|
*/
|
||||||
|
export function createNavigation() {
|
||||||
|
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
||||||
|
// The root tab panel's cursor — which tab j/k is currently hovering. It is
|
||||||
|
// independent of `activeTab` until l/Enter activates it (activateTabCursor)
|
||||||
|
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
||||||
|
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
||||||
|
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
||||||
|
// App focus starts on the tab list (the app root). `activePane` is always
|
||||||
|
// DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
|
||||||
|
// the `atRootTab` flag describe where focus sits (the tab is the CURRENT
|
||||||
|
// pane when at the root, and slides into the UP/parent pane once content is
|
||||||
|
// opened).
|
||||||
|
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
||||||
|
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
||||||
|
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
||||||
|
const [atRootTabSignal, setAtTabRoot] = createSignal(true);
|
||||||
|
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
|
||||||
|
const [count, setCount] = createSignal<number | null>(null);
|
||||||
|
const [inputFocused, setInputFocused] = createSignal(false);
|
||||||
|
|
||||||
|
// per-tab depth stack. Depth-tabs get a root frame on first visit.
|
||||||
|
const [stacks, setStacks] = createSignal<Partial<Record<TABS, DepthFrame[]>>>(
|
||||||
|
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// per-pane focused index map (unused by depth-tabs, which read/write the
|
||||||
|
// top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
|
||||||
|
// pages). Keyed by `${tab}:${pane}`.
|
||||||
|
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [selections, setSelections] = createSignal<SelectionMap>({});
|
||||||
|
const [visualAnchor, setVisualAnchor] = createSignal<{
|
||||||
|
paneKey: string;
|
||||||
|
index: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [commandBuffer, setCommandBuffer] = createSignal("");
|
||||||
|
const [commandError, setCommandError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
/** Depth stack for a tab (always non-empty — every tab is a depth-tab). */
|
||||||
|
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
|
||||||
|
|
||||||
|
const ensureStack = (tab: TABS) => {
|
||||||
|
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
|
||||||
|
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Apply all tab-switch side effects synchronously. Done here in the
|
||||||
|
* imperative `switchTab` path rather than a reactive `createEffect`
|
||||||
|
* because this module is exercised by unit tests without the OpenTUI
|
||||||
|
* JSX runtime, and in that environment Solid's `createEffect` is a
|
||||||
|
* no-op (server build). Routing every tab change through this helper
|
||||||
|
* keeps the behavior identical under both runtimes.
|
||||||
|
*
|
||||||
|
* - a depth-tab switch from the root keeps the root (the tab list stays
|
||||||
|
* CURRENT); switches made from inside content drop into the new tab's
|
||||||
|
* current/center pane.
|
||||||
|
* - clear mode/command/visual/count state */
|
||||||
|
const applyTabSwitch = (tab: TABS) => {
|
||||||
|
ensureStack(tab);
|
||||||
|
batch(() => {
|
||||||
|
if (atRootTabSignal()) {
|
||||||
|
// a depth-tab switch from the root keeps the root (focus stays on
|
||||||
|
// the tab list); only entering content (enterTabContent) leaves it.
|
||||||
|
} else {
|
||||||
|
setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
}
|
||||||
|
setMode(NavMode.NORMAL);
|
||||||
|
setCount(null);
|
||||||
|
setCommandBuffer("");
|
||||||
|
setCommandError(null);
|
||||||
|
setVisualAnchor(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── depth stack accessors ──────────────────────────────────────────────
|
||||||
|
// Plain functions (not createMemo) so they recompute on every read.
|
||||||
|
// On the client build these are read inside reactive JSX contexts so
|
||||||
|
// their underlying signal reads are still tracked; on the server build
|
||||||
|
// (used by unit tests) createMemo is a no-op that freezes at creation,
|
||||||
|
// so a plain function is the only option that stays correct in tests.
|
||||||
|
const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
|
||||||
|
const currentDepth = (): number => Math.max(0, depthStack().length - 1);
|
||||||
|
const topFrame = (): DepthFrame | undefined =>
|
||||||
|
depthStack()[depthStack().length - 1];
|
||||||
|
const isDepthTab = () => DEPTH_TABS.has(activeTab());
|
||||||
|
|
||||||
|
/** Focus within a given depth's frame (default = current/top). */
|
||||||
|
const depthFocus = (d: number = currentDepth()) =>
|
||||||
|
depthStack()[d]?.focus ?? 0;
|
||||||
|
|
||||||
|
const setDepthFocus = (i: number, d: number = currentDepth()) =>
|
||||||
|
setStacks((s) => {
|
||||||
|
const st = s[activeTab()];
|
||||||
|
if (!st || d < 0 || d >= st.length) return s;
|
||||||
|
const next = st.slice();
|
||||||
|
next[d] = { ...next[d], focus: i };
|
||||||
|
return { ...s, [activeTab()]: next };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Push a child frame (drill in). */
|
||||||
|
const pushDepth = (frame: DepthFrame) =>
|
||||||
|
setStacks((s) => {
|
||||||
|
const st = s[activeTab()] ?? [];
|
||||||
|
return { ...s, [activeTab()]: [...st, frame] };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Pop the top frame (go back up a depth). No-op at root. Returns
|
||||||
|
* true if a frame was popped. */
|
||||||
|
const popDepth = (): boolean => {
|
||||||
|
let popped = false;
|
||||||
|
setStacks((s) => {
|
||||||
|
const st = s[activeTab()] ?? [];
|
||||||
|
if (st.length <= 1) return s;
|
||||||
|
popped = true;
|
||||||
|
return { ...s, [activeTab()]: st.slice(0, -1) };
|
||||||
|
});
|
||||||
|
return popped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── tab switching ──────────────────────────────────────────────────────
|
||||||
|
/** Internal: set activeTab + run all side effects (root-frame seed,
|
||||||
|
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
|
||||||
|
* tab change — programmatic or key-driven — goes through one path. */
|
||||||
|
const switchTab = (tab: TABS) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
// a direct tab switch re-syncs the root panel's cursor so the panel
|
||||||
|
// reflects what is actually active.
|
||||||
|
setTabCursor(tab);
|
||||||
|
applyTabSwitch(tab);
|
||||||
|
};
|
||||||
|
const gotoTab = (tab: TABS) => {
|
||||||
|
if (tab < 1 || tab > TabsCount) return;
|
||||||
|
switchTab(tab);
|
||||||
|
};
|
||||||
|
const nextTab = () => {
|
||||||
|
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
|
||||||
|
switchTab(t);
|
||||||
|
};
|
||||||
|
const prevTab = () => {
|
||||||
|
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
|
||||||
|
switchTab(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── pane focus ──────────────────────────────────────────────────────────
|
||||||
|
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||||
|
|
||||||
|
// (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
|
||||||
|
|
||||||
|
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
||||||
|
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
||||||
|
* with nothing above it. Applies to every tab: a depth-tab switch from
|
||||||
|
* the root keeps it; entering content (`enterTabContent`) clears it; `h`
|
||||||
|
* at content depth 0 regains it via `backToTabRoot`. */
|
||||||
|
const atRootTab = (): boolean => atRootTabSignal();
|
||||||
|
|
||||||
|
/** Open the active tab's content: the tab slides from CURRENT into the
|
||||||
|
* UP/parent pane and focus lands on the content's current pane. */
|
||||||
|
const enterTabContent = () => {
|
||||||
|
setAtTabRoot(false);
|
||||||
|
setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping
|
||||||
|
* out of content at depth 0. */
|
||||||
|
const backToTabRoot = () => {
|
||||||
|
setAtTabRoot(true);
|
||||||
|
setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The tab the root's cursor is hovering (independent of activeTab). */
|
||||||
|
const tabCursor = (): TABS => tabCursorSignal();
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
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,
|
||||||
|
setActiveDepth: setPane, // legacy alias
|
||||||
|
activeDepth: activePane, // legacy alias
|
||||||
|
setInputFocused,
|
||||||
|
nextPane: () => {}, // legacy noop; swipe() replaces this
|
||||||
|
prevPane: () => {},
|
||||||
|
setMode,
|
||||||
|
enterCommand,
|
||||||
|
enterInput,
|
||||||
|
exitCommand,
|
||||||
|
toNormal,
|
||||||
|
// command buffer
|
||||||
|
setCommandBuffer,
|
||||||
|
appendCommand,
|
||||||
|
backspaceCommand,
|
||||||
|
submitCommand,
|
||||||
|
setCommandError,
|
||||||
|
// count
|
||||||
|
pushCountDigit,
|
||||||
|
consumeCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavigationState = ReturnType<typeof createNavigation>;
|
||||||
@@ -12,315 +12,352 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js"
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
type AudioBackend,
|
type AudioBackend,
|
||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
type DetectedPlayer,
|
||||||
} from "../utils/audio-player"
|
} from "../utils/audio-player";
|
||||||
import { emit, on } from "../utils/event-bus"
|
import { emit, on } from "../utils/event-bus";
|
||||||
import { useAppStore } from "../stores/app"
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress"
|
import { useProgressStore } from "../stores/progress";
|
||||||
import { useMediaRegistry } from "../utils/media-registry"
|
import { useMediaRegistry } from "../utils/media-registry";
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed"
|
import type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
import { useFeedStore } from "../stores/feed"
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
// Signals (reactive getters)
|
// Signals (reactive getters)
|
||||||
isPlaying: () => boolean
|
isPlaying: () => boolean;
|
||||||
position: () => number
|
position: () => number;
|
||||||
duration: () => number
|
duration: () => number;
|
||||||
volume: () => number
|
volume: () => number;
|
||||||
speed: () => number
|
speed: () => number;
|
||||||
backendName: () => BackendName
|
backendName: () => BackendName;
|
||||||
error: () => string | null
|
error: () => string | null;
|
||||||
currentEpisode: () => Episode | null
|
currentEpisode: () => Episode | null;
|
||||||
availablePlayers: () => DetectedPlayer[]
|
availablePlayers: () => DetectedPlayer[];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (episode: Episode) => Promise<void>
|
play: (episode: Episode) => Promise<void>;
|
||||||
pause: () => Promise<void>
|
pause: () => Promise<void>;
|
||||||
resume: () => Promise<void>
|
resume: () => Promise<void>;
|
||||||
togglePlayback: () => Promise<void>
|
togglePlayback: () => Promise<void>;
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>;
|
||||||
seek: (seconds: number) => Promise<void>
|
seek: (seconds: number) => Promise<void>;
|
||||||
seekRelative: (delta: number) => Promise<void>
|
seekRelative: (delta: number) => Promise<void>;
|
||||||
setVolume: (volume: number) => Promise<void>
|
setVolume: (volume: number) => Promise<void>;
|
||||||
setSpeed: (speed: number) => Promise<void>
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
switchBackend: (name: BackendName) => Promise<void>
|
switchBackend: (name: BackendName) => Promise<void>;
|
||||||
prev: () => Promise<void>
|
prev: () => Promise<void>;
|
||||||
next: () => Promise<void>
|
next: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton state — shared across all components that call useAudio()
|
// Singleton state — shared across all components that call useAudio()
|
||||||
let backend: AudioBackend | null = null
|
let backend: AudioBackend | null = null;
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let refCount = 0
|
let refCount = 0;
|
||||||
let pollCount = 0 // Counts poll ticks for throttling progress saves
|
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false)
|
const [isPlaying, setIsPlaying] = createSignal(false);
|
||||||
const [position, setPosition] = createSignal(0)
|
const [position, setPosition] = createSignal(0);
|
||||||
const [duration, setDuration] = createSignal(0)
|
const [duration, setDuration] = createSignal(0);
|
||||||
const [volume, setVolume] = createSignal(0.7)
|
const [volume, setVolume] = createSignal(0.7);
|
||||||
const [speed, setSpeed] = createSignal(1)
|
const [speed, setSpeed] = createSignal(1);
|
||||||
const [backendName, setBackendName] = createSignal<BackendName>("none")
|
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
|
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
||||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
|
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
function ensureBackend(): AudioBackend {
|
function ensureBackend(): AudioBackend {
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
const detected = detectPlayers()
|
const detected = detectPlayers();
|
||||||
setAvailablePlayers(detected)
|
setAvailablePlayers(detected);
|
||||||
backend = createAudioBackend()
|
backend = createAudioBackend();
|
||||||
setBackendName(backend.name)
|
setBackendName(backend.name);
|
||||||
|
registerExitTeardown();
|
||||||
|
}
|
||||||
|
return backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||||
|
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||||
|
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
||||||
|
// player (mpv). Without this hook those child processes
|
||||||
|
// survive the host and keep playing audio after the TUI has quit. The
|
||||||
|
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
||||||
|
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
||||||
|
// `exit` listeners.
|
||||||
|
let exitTeardownRegistered = false;
|
||||||
|
function registerExitTeardown(): void {
|
||||||
|
if (exitTeardownRegistered) return;
|
||||||
|
exitTeardownRegistered = true;
|
||||||
|
const teardown = (): void => {
|
||||||
|
stopPolling();
|
||||||
|
try {
|
||||||
|
backend?.dispose();
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
useMediaRegistry().clearNowPlaying();
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.on("exit", teardown);
|
||||||
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||||
|
process.on(sig, () => {
|
||||||
|
teardown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return backend
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling(): void {
|
function startPolling(): void {
|
||||||
stopPolling()
|
stopPolling();
|
||||||
pollCount = 0
|
pollCount = 0;
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
if (!backend || !isPlaying()) return
|
if (!backend || !isPlaying()) return;
|
||||||
try {
|
try {
|
||||||
const pos = await backend.getPosition()
|
const pos = await backend.getPosition();
|
||||||
const dur = await backend.getDuration()
|
const dur = await backend.getDuration();
|
||||||
setPosition(pos)
|
setPosition(pos);
|
||||||
if (dur > 0) setDuration(dur)
|
if (dur > 0) setDuration(dur);
|
||||||
|
|
||||||
// Save progress every ~5 seconds (10 ticks * 500ms)
|
// Save progress every ~5 seconds (10 ticks * 500ms)
|
||||||
pollCount++
|
pollCount++;
|
||||||
if (pollCount % 10 === 0) {
|
if (pollCount % 10 === 0) {
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
// Update platform media position
|
// Update platform media position
|
||||||
const media = useMediaRegistry()
|
const media = useMediaRegistry();
|
||||||
media.setPosition(pos)
|
media.setPosition(pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if backend stopped playing (track ended)
|
// Check if backend stopped playing (track ended)
|
||||||
if (!backend.isPlaying() && isPlaying()) {
|
if (!backend.isPlaying() && isPlaying()) {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false);
|
||||||
stopPolling()
|
stopPolling();
|
||||||
// Save final position on track end
|
// Save final position on track end
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Backend may have been disposed
|
// Backend may have been disposed
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(): void {
|
function stopPolling(): void {
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
clearInterval(pollTimer)
|
clearInterval(pollTimer);
|
||||||
pollTimer = null
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function play(episode: Episode): Promise<void> {
|
async function play(episode: Episode): Promise<void> {
|
||||||
const b = ensureBackend()
|
const b = ensureBackend();
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
if (!episode.audioUrl) {
|
if (!episode.audioUrl) {
|
||||||
setError("No audio URL for this episode")
|
setError("No audio URL for this episode");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore();
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore();
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
const vol = volume()
|
const vol = volume();
|
||||||
const spd = storeSpeed || speed()
|
const spd = storeSpeed || speed();
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id)
|
const savedProgress = progressStore.get(episode.id);
|
||||||
let startPos = 0
|
let startPos = 0;
|
||||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||||
startPos = savedProgress.position
|
startPos = savedProgress.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
await b.play(episode.audioUrl, {
|
await b.play(episode.audioUrl, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
})
|
});
|
||||||
|
|
||||||
setCurrentEpisode(episode)
|
setCurrentEpisode(episode);
|
||||||
setIsPlaying(true)
|
setIsPlaying(true);
|
||||||
setPosition(startPos)
|
setPosition(startPos);
|
||||||
setSpeed(spd)
|
setSpeed(spd);
|
||||||
if (episode.duration) setDuration(episode.duration)
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
|
||||||
// Register with platform media controls
|
// Register with platform media controls
|
||||||
const media = useMediaRegistry()
|
const media = useMediaRegistry();
|
||||||
media.setNowPlaying({
|
media.setNowPlaying({
|
||||||
title: episode.title,
|
title: episode.title,
|
||||||
artist: episode.podcastId,
|
artist: episode.podcastId,
|
||||||
duration: episode.duration,
|
duration: episode.duration,
|
||||||
})
|
});
|
||||||
media.setPlaybackState(true)
|
media.setPlaybackState(true);
|
||||||
if (startPos > 0) media.setPosition(startPos)
|
if (startPos > 0) media.setPosition(startPos);
|
||||||
|
|
||||||
startPolling()
|
startPolling();
|
||||||
emit("player.play", { episodeId: episode.id })
|
emit("player.play", { episodeId: episode.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Playback failed")
|
setError(err instanceof Error ? err.message : "Playback failed");
|
||||||
setIsPlaying(false)
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pause(): Promise<void> {
|
async function pause(): Promise<void> {
|
||||||
if (!backend) return
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
await backend.pause()
|
await backend.pause();
|
||||||
setIsPlaying(false)
|
setIsPlaying(false);
|
||||||
stopPolling()
|
stopPolling();
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
// Save progress on pause
|
// Save progress on pause
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, position(), duration(), speed())
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
emit("player.pause", { episodeId: ep.id })
|
emit("player.pause", { episodeId: ep.id });
|
||||||
|
|
||||||
// Update platform media controls
|
// Update platform media controls
|
||||||
const media = useMediaRegistry()
|
const media = useMediaRegistry();
|
||||||
media.setPlaybackState(false)
|
media.setPlaybackState(false);
|
||||||
media.setPosition(position())
|
media.setPosition(position());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Pause failed")
|
setError(err instanceof Error ? err.message : "Pause failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resume(): Promise<void> {
|
async function resume(): Promise<void> {
|
||||||
if (!backend) return
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
await backend.resume()
|
await backend.resume();
|
||||||
setIsPlaying(true)
|
setIsPlaying(true);
|
||||||
startPolling()
|
startPolling();
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
emit("player.play", { episodeId: ep.id })
|
emit("player.play", { episodeId: ep.id });
|
||||||
const media = useMediaRegistry()
|
const media = useMediaRegistry();
|
||||||
media.setPlaybackState(true)
|
media.setPlaybackState(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Resume failed")
|
setError(err instanceof Error ? err.message : "Resume failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlayback(): Promise<void> {
|
async function togglePlayback(): Promise<void> {
|
||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await pause()
|
await pause();
|
||||||
} else if (currentEpisode()) {
|
} else if (currentEpisode()) {
|
||||||
await resume()
|
await resume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop(): Promise<void> {
|
async function stop(): Promise<void> {
|
||||||
if (!backend) return
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
// Save progress before stopping
|
// Save progress before stopping
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, position(), duration(), speed())
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
}
|
}
|
||||||
await backend.stop()
|
await backend.stop();
|
||||||
setIsPlaying(false)
|
setIsPlaying(false);
|
||||||
setPosition(0)
|
setPosition(0);
|
||||||
setCurrentEpisode(null)
|
setCurrentEpisode(null);
|
||||||
stopPolling()
|
stopPolling();
|
||||||
emit("player.stop", {})
|
emit("player.stop", {});
|
||||||
|
|
||||||
// Clear platform media controls
|
// Clear platform media controls
|
||||||
const media = useMediaRegistry()
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying()
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Stop failed")
|
setError(err instanceof Error ? err.message : "Stop failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seek(seconds: number): Promise<void> {
|
async function seek(seconds: number): Promise<void> {
|
||||||
if (!backend) return
|
if (!backend) return;
|
||||||
const clamped = Math.max(0, Math.min(seconds, duration()))
|
const clamped = Math.max(0, Math.min(seconds, duration()));
|
||||||
try {
|
try {
|
||||||
await backend.seek(clamped)
|
await backend.seek(clamped);
|
||||||
setPosition(clamped)
|
setPosition(clamped);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Seek failed")
|
setError(err instanceof Error ? err.message : "Seek failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seekRelative(delta: number): Promise<void> {
|
async function seekRelative(delta: number): Promise<void> {
|
||||||
await seek(position() + delta)
|
await seek(position() + delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSetVolume(vol: number): Promise<void> {
|
async function doSetVolume(vol: number): Promise<void> {
|
||||||
const clamped = Math.max(0, Math.min(1, vol))
|
const clamped = Math.max(0, Math.min(1, vol));
|
||||||
if (backend) {
|
if (backend) {
|
||||||
try {
|
try {
|
||||||
await backend.setVolume(clamped)
|
await backend.setVolume(clamped);
|
||||||
} catch {
|
} catch {
|
||||||
// Some backends can't change volume at runtime
|
// Some backends can't change volume at runtime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVolume(clamped)
|
setVolume(clamped);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSetSpeed(spd: number): Promise<void> {
|
async function doSetSpeed(spd: number): Promise<void> {
|
||||||
const clamped = Math.max(0.25, Math.min(3, spd))
|
const clamped = Math.max(0.25, Math.min(3, spd));
|
||||||
if (backend) {
|
if (backend) {
|
||||||
try {
|
try {
|
||||||
await backend.setSpeed(clamped)
|
await backend.setSpeed(clamped);
|
||||||
} catch {
|
} catch {
|
||||||
// Some backends can't change speed at runtime
|
// Some backends can't change speed at runtime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSpeed(clamped)
|
setSpeed(clamped);
|
||||||
|
|
||||||
// Sync back to app store
|
// Sync back to app store
|
||||||
try {
|
try {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore();
|
||||||
appStore.updateSettings({ playbackSpeed: clamped })
|
appStore.updateSettings({ playbackSpeed: clamped });
|
||||||
} catch {
|
} catch {
|
||||||
// Store may not be available
|
// Store may not be available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchBackend(name: BackendName): Promise<void> {
|
async function switchBackend(name: BackendName): Promise<void> {
|
||||||
const wasPlaying = isPlaying()
|
const wasPlaying = isPlaying();
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode();
|
||||||
const pos = position()
|
const pos = position();
|
||||||
const vol = volume()
|
const vol = volume();
|
||||||
const spd = speed()
|
const spd = speed();
|
||||||
|
|
||||||
// Stop current backend
|
// Stop current backend
|
||||||
if (backend) {
|
if (backend) {
|
||||||
stopPolling()
|
stopPolling();
|
||||||
backend.dispose()
|
backend.dispose();
|
||||||
backend = null
|
backend = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new backend
|
// Create new backend
|
||||||
backend = createAudioBackend(name)
|
backend = createAudioBackend(name);
|
||||||
setBackendName(backend.name)
|
setBackendName(backend.name);
|
||||||
setAvailablePlayers(detectPlayers())
|
setAvailablePlayers(detectPlayers());
|
||||||
|
|
||||||
// Resume playback if we were playing
|
// Resume playback if we were playing
|
||||||
if (wasPlaying && ep && ep.audioUrl) {
|
if (wasPlaying && ep && ep.audioUrl) {
|
||||||
@@ -329,12 +366,12 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
})
|
});
|
||||||
setIsPlaying(true)
|
setIsPlaying(true);
|
||||||
startPolling()
|
startPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Backend switch failed")
|
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||||
setIsPlaying(false)
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,64 +384,64 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export function useAudio(): AudioControls {
|
export function useAudio(): AudioControls {
|
||||||
// Initialize backend on first use
|
// Initialize backend on first use
|
||||||
ensureBackend()
|
ensureBackend();
|
||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed from app store
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
try {
|
try {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore();
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
if (storeSpeed && storeSpeed !== speed()) {
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
setSpeed(storeSpeed)
|
setSpeed(storeSpeed);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Store may not be available yet
|
// Store may not be available yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refCount++
|
refCount++;
|
||||||
|
|
||||||
// Listen for event bus commands (e.g. from other components)
|
// Listen for event bus commands (e.g. from other components)
|
||||||
const unsubPlay = on("player.play", async (data) => {
|
const unsubPlay = on("player.play", async (data) => {
|
||||||
// External play requests — currently just tracks episodeId.
|
// External play requests — currently just tracks episodeId.
|
||||||
// Episode lookup would require feed store integration.
|
// Episode lookup would require feed store integration.
|
||||||
})
|
});
|
||||||
|
|
||||||
const unsubStop = on("player.stop", async () => {
|
const unsubStop = on("player.stop", async () => {
|
||||||
if (backend && isPlaying()) {
|
if (backend && isPlaying()) {
|
||||||
await backend.stop()
|
await backend.stop();
|
||||||
setIsPlaying(false)
|
setIsPlaying(false);
|
||||||
setPosition(0)
|
setPosition(0);
|
||||||
setCurrentEpisode(null)
|
setCurrentEpisode(null);
|
||||||
stopPolling()
|
stopPolling();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||||
const unsubMediaToggle = on("media.toggle", async () => {
|
const unsubMediaToggle = on("media.toggle", async () => {
|
||||||
await togglePlayback()
|
await togglePlayback();
|
||||||
})
|
});
|
||||||
|
|
||||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
|
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
|
||||||
})
|
});
|
||||||
|
|
||||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
|
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
||||||
})
|
});
|
||||||
|
|
||||||
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||||
await seekRelative(10)
|
await seekRelative(10);
|
||||||
})
|
});
|
||||||
|
|
||||||
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||||
await seekRelative(-10)
|
await seekRelative(-10);
|
||||||
})
|
});
|
||||||
|
|
||||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
|
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||||
await doSetSpeed(next)
|
await doSetSpeed(next);
|
||||||
})
|
});
|
||||||
|
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
@@ -430,10 +467,12 @@ export function useAudio(): AudioControls {
|
|||||||
const podcastId = audioNav.getPodcastId();
|
const podcastId = audioNav.getPodcastId();
|
||||||
if (!podcastId) return;
|
if (!podcastId) return;
|
||||||
|
|
||||||
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
const feed = feedStore
|
||||||
|
.getFilteredFeeds()
|
||||||
|
.find((f) => f.podcast.id === podcastId);
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
|
|
||||||
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIndex = audioNav.getCurrentIndex();
|
const currentIndex = audioNav.getCurrentIndex();
|
||||||
@@ -460,10 +499,12 @@ export function useAudio(): AudioControls {
|
|||||||
const podcastId = audioNav.getPodcastId();
|
const podcastId = audioNav.getPodcastId();
|
||||||
if (!podcastId) return;
|
if (!podcastId) return;
|
||||||
|
|
||||||
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
const feed = feedStore
|
||||||
|
.getFilteredFeeds()
|
||||||
|
.find((f) => f.podcast.id === podcastId);
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
|
|
||||||
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIndex = audioNav.getCurrentIndex();
|
const currentIndex = audioNav.getCurrentIndex();
|
||||||
@@ -477,29 +518,29 @@ export function useAudio(): AudioControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCount--
|
refCount--;
|
||||||
unsubPlay()
|
unsubPlay();
|
||||||
unsubStop()
|
unsubStop();
|
||||||
unsubMediaToggle()
|
unsubMediaToggle();
|
||||||
unsubMediaVolUp()
|
unsubMediaVolUp();
|
||||||
unsubMediaVolDown()
|
unsubMediaVolDown();
|
||||||
unsubMediaSeekFwd()
|
unsubMediaSeekFwd();
|
||||||
unsubMediaSeekBack()
|
unsubMediaSeekBack();
|
||||||
unsubMediaSpeed()
|
unsubMediaSpeed();
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
stopPolling()
|
stopPolling();
|
||||||
if (backend) {
|
if (backend) {
|
||||||
backend.dispose()
|
backend.dispose();
|
||||||
backend = null
|
backend = null;
|
||||||
}
|
}
|
||||||
// Clear media registry on full teardown
|
// Clear media registry on full teardown
|
||||||
const media = useMediaRegistry()
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying()
|
media.clearNowPlaying();
|
||||||
|
|
||||||
refCount = 0
|
refCount = 0;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -524,5 +565,5 @@ export function useAudio(): AudioControls {
|
|||||||
switchBackend,
|
switchBackend,
|
||||||
prev,
|
prev,
|
||||||
next,
|
next,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* regardless of which component is focused. Uses the event bus to
|
* regardless of which component is focused. Uses the event bus to
|
||||||
* decouple key detection from audio control logic.
|
* decouple key detection from audio control logic.
|
||||||
*
|
*
|
||||||
* Keys are only handled when an episode is loaded (or for play/pause,
|
* Volume and speed are app-level settings — adjustable with or without
|
||||||
* always). This prevents accidental volume/seek changes when there's
|
* an episode loaded (they apply to the next playback and persist). Seek
|
||||||
* nothing playing.
|
* is playback-dependent, so it still requires a loaded episode.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid";
|
||||||
import { emit } from "../utils/event-bus"
|
import { emit } from "../utils/event-bus";
|
||||||
|
|
||||||
export type MediaKeyAction =
|
export type MediaKeyAction =
|
||||||
| "media.toggle"
|
| "media.toggle"
|
||||||
@@ -19,7 +19,7 @@ export type MediaKeyAction =
|
|||||||
| "media.volumeDown"
|
| "media.volumeDown"
|
||||||
| "media.seekForward"
|
| "media.seekForward"
|
||||||
| "media.seekBackward"
|
| "media.seekBackward"
|
||||||
| "media.speedCycle"
|
| "media.speedCycle";
|
||||||
|
|
||||||
/** Key-to-action mappings for multimedia controls */
|
/** Key-to-action mappings for multimedia controls */
|
||||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||||
@@ -33,15 +33,15 @@ const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
|||||||
// bus approach — the audio hook only processes event-bus events, and
|
// bus approach — the audio hook only processes event-bus events, and
|
||||||
// Player.tsx calls audio methods directly. We therefore guard with
|
// Player.tsx calls audio methods directly. We therefore guard with
|
||||||
// a "playerFocused" flag passed via options.
|
// a "playerFocused" flag passed via options.
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface MultimediaKeysOptions {
|
export interface MultimediaKeysOptions {
|
||||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||||
playerFocused?: () => boolean
|
playerFocused?: () => boolean;
|
||||||
/** When true, skip handling (text input has focus) */
|
/** When true, skip handling (text input has focus) */
|
||||||
inputFocused?: () => boolean
|
inputFocused?: () => boolean;
|
||||||
/** Whether an episode is currently loaded */
|
/** Whether an episode is currently loaded */
|
||||||
hasEpisode?: () => boolean
|
hasEpisode?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,48 +51,45 @@ export interface MultimediaKeysOptions {
|
|||||||
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||||
useKeyboard((key) => {
|
useKeyboard((key) => {
|
||||||
// Don't intercept when a text input owns the keyboard
|
// Don't intercept when a text input owns the keyboard
|
||||||
if (options.inputFocused?.()) return
|
if (options.inputFocused?.()) return;
|
||||||
|
|
||||||
// Don't intercept when Player component handles its own keys
|
// Don't intercept when Player component handles its own keys
|
||||||
if (options.playerFocused?.()) return
|
if (options.playerFocused?.()) return;
|
||||||
|
|
||||||
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||||
if (key.ctrl || key.meta) return
|
if (key.ctrl || key.meta) return;
|
||||||
|
|
||||||
switch (key.name) {
|
switch (key.name) {
|
||||||
case "space":
|
case "space":
|
||||||
// Toggle play/pause — always valid (may start a loaded episode)
|
// Toggle play/pause — always valid (may start a loaded episode)
|
||||||
emit("media.toggle", {})
|
emit("media.toggle", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "up":
|
case "up":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.volumeUp", {});
|
||||||
emit("media.volumeUp", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
case "down":
|
case "down":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.volumeDown", {});
|
||||||
emit("media.volumeDown", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
case "left":
|
case "left":
|
||||||
if (!options.hasEpisode?.()) return
|
if (!options.hasEpisode?.()) return;
|
||||||
emit("media.seekBackward", {})
|
emit("media.seekBackward", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "right":
|
case "right":
|
||||||
if (!options.hasEpisode?.()) return
|
if (!options.hasEpisode?.()) return;
|
||||||
emit("media.seekForward", {})
|
emit("media.seekForward", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "s":
|
case "s":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.speedCycle", {});
|
||||||
emit("media.speedCycle", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Not a media key — do nothing
|
// Not a media key — do nothing
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
215
src/index.tsx
215
src/index.tsx
@@ -1,17 +1,198 @@
|
|||||||
// Hack: Force TERM to tmux-256color when running in tmux to enable
|
const VERSION = "0.2.0";
|
||||||
// correct palette detection in @opentui/core
|
|
||||||
//if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
|
|
||||||
//process.env.TERM = "tmux-256color"
|
|
||||||
//}
|
|
||||||
|
|
||||||
import { render, useRenderer } from "@opentui/solid";
|
interface CliArgs {
|
||||||
import { App } from "./App";
|
version: boolean;
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
query: string | null;
|
||||||
import { ToastProvider, Toast } from "./ui/toast";
|
play: string | null;
|
||||||
import { KeybindProvider } from "./context/KeybindContext";
|
}
|
||||||
import { NavigationProvider } from "./context/NavigationContext";
|
|
||||||
import { DialogProvider } from "./ui/dialog";
|
function parseArgs(): CliArgs {
|
||||||
import { CommandProvider } from "./ui/command";
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||||
|
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => {
|
||||||
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
|
if (cliArgs.query !== null) {
|
||||||
|
const query = cliArgs.query;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliArgs.play !== null) {
|
||||||
|
const playArg = cliArgs.play;
|
||||||
|
const normalizedArg = playArg.toLowerCase();
|
||||||
|
|
||||||
|
let feedResult: typeof feeds[0] | null = null;
|
||||||
|
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
||||||
|
|
||||||
|
if (normalizedArg === "latest") {
|
||||||
|
let latestFeed: typeof feeds[0] | null = null;
|
||||||
|
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
||||||
|
let latestDate = 0;
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
const ep = feed.episodes[0];
|
||||||
|
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
||||||
|
if (epDate > latestDate) {
|
||||||
|
latestDate = epDate;
|
||||||
|
latestFeed = feed;
|
||||||
|
latestEpisode = ep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedResult = latestFeed;
|
||||||
|
episodeResult = latestEpisode;
|
||||||
|
} else {
|
||||||
|
const parts = normalizedArg.split("/");
|
||||||
|
const showQuery = parts[0];
|
||||||
|
const episodeQuery = parts[1];
|
||||||
|
|
||||||
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(showQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingFeeds.length === 0) {
|
||||||
|
console.log(`No show found matching: ${showQuery}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = matchingFeeds[0];
|
||||||
|
|
||||||
|
if (!episodeQuery) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
feedResult = feed;
|
||||||
|
episodeResult = feed.episodes[0];
|
||||||
|
} else {
|
||||||
|
console.log(`No episodes available for: ${feed.podcast.title}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else if (episodeQuery === "latest") {
|
||||||
|
feedResult = feed;
|
||||||
|
episodeResult = feed.episodes[0];
|
||||||
|
} else {
|
||||||
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
|
ep.title.toLowerCase().includes(episodeQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingEpisode) {
|
||||||
|
feedResult = feed;
|
||||||
|
episodeResult = matchingEpisode;
|
||||||
|
} else {
|
||||||
|
console.log(`Episode not found: ${episodeQuery}`);
|
||||||
|
console.log(`Available episodes for ${feed.podcast.title}:`);
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ep.title}`);
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedResult || !episodeResult) {
|
||||||
|
console.log("Could not find episode to play");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nPlaying: ${episodeResult.title}`);
|
||||||
|
console.log(`Show: ${feedResult.podcast.title}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAudioBackend } = await import("./utils/audio-player");
|
||||||
|
const backend = createAudioBackend();
|
||||||
|
if (episodeResult.audioUrl) {
|
||||||
|
await backend.play(episodeResult.audioUrl);
|
||||||
|
console.log("Playback started (use the UI to control)");
|
||||||
|
} else {
|
||||||
|
console.log("No audio URL available for this episode");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Playback error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
||||||
|
const { App } = await import("./App");
|
||||||
|
const { ThemeProvider } = await import("./context/ThemeContext");
|
||||||
|
const toast = await import("./ui/toast");
|
||||||
|
const { KeybindProvider } = await import("./context/KeybindContext");
|
||||||
|
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||||
|
const { DialogProvider } = await import("./ui/dialog");
|
||||||
|
const { CommandProvider } = await import("./ui/command");
|
||||||
|
|
||||||
function RendererSetup(props: { children: unknown }) {
|
function RendererSetup(props: { children: unknown }) {
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
@@ -22,21 +203,23 @@ function RendererSetup(props: { children: unknown }) {
|
|||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<RendererSetup>
|
<RendererSetup>
|
||||||
<ToastProvider>
|
<toast.ToastProvider>
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
<KeybindProvider>
|
<KeybindProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<App />
|
<App />
|
||||||
<Toast />
|
<toast.Toast />
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</KeybindProvider>
|
</KeybindProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ToastProvider>
|
</toast.ToastProvider>
|
||||||
</RendererSetup>
|
</RendererSetup>
|
||||||
),
|
),
|
||||||
{ useThread: false },
|
{ useThread: false },
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,179 +1,362 @@
|
|||||||
/**
|
/**
|
||||||
* DiscoverPage component - Main discover/browse interface for PodTUI
|
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||||
|
*
|
||||||
|
* depth 0 (current) — category list. Parent pane shows the muted
|
||||||
|
* placeholder (1/7 slot kept).
|
||||||
|
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||||
|
* pane = the categories list.
|
||||||
|
* preview — detail of the hovered item (category summary, or
|
||||||
|
* podcast detail + subscribe action).
|
||||||
|
*
|
||||||
|
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
|
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||||||
|
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||||
|
* column. Moving through categories at depth 0 updates the store's selected
|
||||||
|
* category so the preview follows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show, onMount } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { PodcastCard } from "./PodcastCard";
|
import {
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
useNavigation,
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
NavMode,
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
DEPTH_CENTER_PANE,
|
||||||
|
type PaneId,
|
||||||
|
type DepthFrame,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
enum DiscoverPagePaneType {
|
export const DiscoverPaneCount = 1;
|
||||||
CATEGORIES = 1,
|
|
||||||
SHOWS = 2,
|
|
||||||
}
|
|
||||||
export const DiscoverPaneCount = 2;
|
|
||||||
|
|
||||||
export function DiscoverPage() {
|
function DiscoverPage() {
|
||||||
const discoverStore = useDiscoverStore();
|
const discoverStore = useDiscoverStore();
|
||||||
const [showIndex, setShowIndex] = createSignal(0);
|
const { theme } = useTheme();
|
||||||
const [categoryIndex, setCategoryIndex] = createSignal(0);
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
|
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()]);
|
||||||
|
|
||||||
|
const curLen = () =>
|
||||||
|
depth() === 0 ? categories().length : podcasts().length;
|
||||||
|
|
||||||
|
const ensureFocus = () => {
|
||||||
|
if (categories().length > 0 && focus(0) >= categories().length)
|
||||||
|
nav.setDepthFocus(categories().length - 1, 0);
|
||||||
|
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||||
|
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||||
|
};
|
||||||
|
onMount(ensureFocus);
|
||||||
|
|
||||||
|
// Auto-fetch the featured-shows manifest on first mount (network failure is
|
||||||
|
// non-fatal — the list stays empty until the user hits refresh).
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
useKeyboard(
|
discoverStore.refresh().catch(() => {});
|
||||||
(keyEvent: any) => {
|
|
||||||
const isDown = keybind.match("down", keyEvent);
|
|
||||||
const isUp = keybind.match("up", keyEvent);
|
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
|
||||||
|
|
||||||
if (isSelect) {
|
|
||||||
const filteredPodcasts = discoverStore.filteredPodcasts();
|
|
||||||
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
|
|
||||||
setShowIndex(showIndex() + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredPodcasts = discoverStore.filteredPodcasts();
|
|
||||||
if (filteredPodcasts.length === 0) return;
|
|
||||||
|
|
||||||
if (isDown) {
|
|
||||||
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
|
|
||||||
} else if (isUp) {
|
|
||||||
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
|
|
||||||
} else if (isCycle) {
|
|
||||||
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: false },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCategorySelect = (categoryId: string) => {
|
onMount(() => {
|
||||||
discoverStore.setSelectedCategory(categoryId);
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
if (index >= 0) setCategoryIndex(index);
|
return podcasts()[i]?.id;
|
||||||
setShowIndex(0);
|
});
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleShowSelect = (index: number) => {
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
setShowIndex(index);
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubscribe = (podcast: { id: string }) => {
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
discoverStore.toggleSubscription(podcast.id);
|
function open() {
|
||||||
};
|
if (depth() === 0) {
|
||||||
|
const c = focusedCategory();
|
||||||
|
if (!c) return;
|
||||||
|
discoverStore.setSelectedCategory(c.id);
|
||||||
|
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 1) {
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod) discoverStore.toggleSubscription(pod.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { theme } = useTheme();
|
// ── nav.action handler ────────────────────────────────────────────────────
|
||||||
return (
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
|
"move-down": () => step(1),
|
||||||
|
"move-up": () => step(-1),
|
||||||
|
"jump-down": () => step(5),
|
||||||
|
"jump-up": () => step(-5),
|
||||||
|
"page-down": () => step(10),
|
||||||
|
"page-up": () => step(-10),
|
||||||
|
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||||
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
|
open: () => open(),
|
||||||
|
"toggle-select": () => {
|
||||||
|
if (depth() >= 1) {
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod) nav.toggleSelected(pod.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refresh: () => {
|
||||||
|
discoverStore.refresh().catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function step(delta: number) {
|
||||||
|
nav.move(delta, curLen());
|
||||||
|
// keep the store's selected category synced with the focused row at depth 0
|
||||||
|
if (depth() === 0) {
|
||||||
|
const c = focusedCategory();
|
||||||
|
if (c) discoverStore.setSelectedCategory(c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
|
ensureFocus();
|
||||||
|
PAGE_ACTIONS[data.action]?.();
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
on("nav.action", onAction);
|
||||||
|
onCleanup(() => off("nav.action", onAction));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||||
|
i === lf && active ? theme.surface : theme.text;
|
||||||
|
|
||||||
|
const currentLabel = () =>
|
||||||
|
depth() === 0
|
||||||
|
? "Categories"
|
||||||
|
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||||
|
|
||||||
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||||
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
|
// mounts/unmounts cleanly on depth change.
|
||||||
|
const parentContent = () => (
|
||||||
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
|
<For each={categories()}>
|
||||||
|
{(cat, index) => (
|
||||||
<box
|
<box
|
||||||
border
|
flexDirection="row"
|
||||||
padding={1}
|
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
|
|
||||||
? theme.border
|
|
||||||
: theme.accent
|
|
||||||
}
|
|
||||||
flexDirection="column"
|
|
||||||
gap={1}
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||||
>
|
>
|
||||||
<text
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
fg={
|
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||||
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
|
|
||||||
? theme.accent
|
|
||||||
: theme.text
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Categories:
|
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="column" gap={1}>
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
<For each={discoverStore.categories}>
|
{cat.name}
|
||||||
{(category) => {
|
</text>
|
||||||
const isSelected = () =>
|
</box>
|
||||||
discoverStore.selectedCategory() === category.id;
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── current pane ───────────────────────────────────────────────────────────
|
||||||
|
const currentContent = () => (
|
||||||
|
<>
|
||||||
|
{/* depth 0: categories */}
|
||||||
|
<Show when={depth() === 0}>
|
||||||
|
<For each={categories()}>
|
||||||
|
{(cat, index) => {
|
||||||
|
const lf = () => focusedCatIdx();
|
||||||
|
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||||
return (
|
return (
|
||||||
<SelectableBox
|
<box
|
||||||
selected={isSelected}
|
flexDirection="row"
|
||||||
onMouseDown={() => handleCategorySelect(category.id)}
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
discoverStore.setSelectedCategory(cat.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectableText selected={isSelected} primary>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{category.icon} {category.name}
|
{index() === lf() ? "❯" : " "}
|
||||||
</SelectableText>
|
</text>
|
||||||
</SelectableBox>
|
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||||
|
<Show when={selected()}>
|
||||||
|
<text fg={index() === lf() ? theme.surface : theme.accent}>
|
||||||
|
*
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
</Show>
|
||||||
|
{/* depth ≥1: results */}
|
||||||
|
<Show when={depth() >= 1}>
|
||||||
|
<Show
|
||||||
|
when={podcasts().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
}
|
||||||
|
>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(podcast, index) => {
|
||||||
|
const lf = () => focusedPodIdx();
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
flexGrow={1}
|
gap={0}
|
||||||
border
|
paddingLeft={1}
|
||||||
borderColor={
|
paddingRight={1}
|
||||||
nav.activeDepth() == DiscoverPagePaneType.SHOWS
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
? theme.accent
|
onMouseDown={() => {
|
||||||
: theme.border
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
}
|
nav.setDepthFocus(index(), 1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box padding={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<SelectableText
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
selected={() => false}
|
{index() === lf() ? "❯" : " "}
|
||||||
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
|
|
||||||
>
|
|
||||||
Trending in{" "}
|
|
||||||
{DISCOVER_CATEGORIES.find(
|
|
||||||
(c) => c.id === discoverStore.selectedCategory(),
|
|
||||||
)?.name ?? "All"}
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="column" height="100%">
|
|
||||||
<Show
|
|
||||||
fallback={
|
|
||||||
<box padding={2}>
|
|
||||||
{discoverStore.filteredPodcasts().length !== 0 ? (
|
|
||||||
<text fg={theme.warning}>Loading trending shows...</text>
|
|
||||||
) : (
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
No podcasts found in this category.
|
|
||||||
</text>
|
</text>
|
||||||
)}
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
</box>
|
{podcast.title}
|
||||||
}
|
</text>
|
||||||
when={
|
<Show when={podcast.isSubscribed}>
|
||||||
!discoverStore.isLoading() &&
|
<text
|
||||||
discoverStore.filteredPodcasts().length === 0
|
fg={index() === lf() ? theme.surface : theme.success}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<scrollbox
|
[+]
|
||||||
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
|
</text>
|
||||||
>
|
|
||||||
<box flexDirection="column">
|
|
||||||
<For each={discoverStore.filteredPodcasts()}>
|
|
||||||
{(podcast, index) => (
|
|
||||||
<PodcastCard
|
|
||||||
podcast={podcast}
|
|
||||||
selected={
|
|
||||||
index() === showIndex() &&
|
|
||||||
nav.activeDepth() == DiscoverPagePaneType.SHOWS
|
|
||||||
}
|
|
||||||
onSelect={() => handleShowSelect(index())}
|
|
||||||
onSubscribe={() => handleSubscribe(podcast)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
<Show when={podcast.author}>
|
||||||
|
<text
|
||||||
|
fg={index() === lf() ? theme.surface : muted()}
|
||||||
|
paddingLeft={2}
|
||||||
|
>
|
||||||
|
by {podcast.author}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
|
const previewContent = () =>
|
||||||
|
depth() === 0 ? (
|
||||||
|
// depth 0 preview: hovered category
|
||||||
|
<Show
|
||||||
|
when={focusedCategory()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No category focused</text>
|
||||||
|
</box>
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
{(cat) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{cat().name}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{(cat() as any).description ??
|
||||||
|
`Browse top podcasts in ${cat().name}.`}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter/l: open · h: back</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
) : (
|
||||||
|
// depth ≥1 preview: hovered podcast + subscribe
|
||||||
|
<Show
|
||||||
|
when={focusedPodcast()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No podcast focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(pod) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{pod().title}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={pod().author}>
|
||||||
|
<text fg={muted()}>by {pod().author}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={pod().isSubscribed}>
|
||||||
|
<text fg={theme.success}>✓ Subscribed</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={!pod().isSubscribed}>
|
||||||
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{pod().description?.slice(0, 400) ?? "No description available."}
|
||||||
|
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<Show when={(pod().categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={(pod().categories ?? []).slice(0, 4)}>
|
||||||
|
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={pod().feedUrl}>
|
||||||
|
<text fg={muted()}>Feed: {pod().feedUrl}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
preview={previewContent}
|
||||||
|
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
||||||
|
currentLabel={currentLabel}
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={isActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DiscoverPage };
|
||||||
|
|||||||
@@ -1,189 +1,305 @@
|
|||||||
/**
|
/**
|
||||||
* FeedPage - Shows latest episodes across all subscribed shows
|
* FeedPage — flat chronological list of episodes across all subscribed feeds.
|
||||||
* Reverse chronological order, grouped by date
|
*
|
||||||
|
* 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 `<YaziPaneRow>` (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 { createSignal, For, Show, onMount } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import {
|
||||||
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
type PaneId,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
|
||||||
|
|
||||||
enum FeedPaneType {
|
|
||||||
FEED = 1,
|
|
||||||
}
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
const ITEMS_PER_BATCH = 50;
|
type EpItem = { episode: Episode; feed: Feed };
|
||||||
|
|
||||||
export function FeedPage() {
|
function FeedPage() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const nav = useNavigation();
|
const downloadStore = useDownloadStore();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
|
const muted = () => theme.muted || theme.text;
|
||||||
string | undefined
|
const nav = useNavigation();
|
||||||
>();
|
|
||||||
const allEpisodes = () => feedStore.getAllEpisodesChronological();
|
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||||
const keybind = useKeybinds();
|
const episodes = createMemo<EpItem[]>(
|
||||||
const [focusedIndex, setFocusedIndex] = createSignal(0);
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
|
);
|
||||||
|
const focus = () => nav.depthFocus(0);
|
||||||
|
const focusedEpIdx = () =>
|
||||||
|
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
|
||||||
|
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||||
|
const curLen = () => episodes().length;
|
||||||
|
|
||||||
|
const ensureFocus = () => {
|
||||||
|
if (episodes().length > 0 && focus() >= episodes().length)
|
||||||
|
nav.setDepthFocus(episodes().length - 1, 0);
|
||||||
|
};
|
||||||
|
onMount(ensureFocus);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
useKeyboard(
|
nav.registerResolver(
|
||||||
(keyEvent: any) => {
|
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||||
const isDown = keybind.match("down", keyEvent);
|
(i) => episodes()[i]?.episode.id,
|
||||||
const isUp = keybind.match("up", keyEvent);
|
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
|
||||||
|
|
||||||
if (isSelect) {
|
|
||||||
const episodes = allEpisodes();
|
|
||||||
if (episodes.length > 0 && episodes[focusedIndex()]) {
|
|
||||||
setSelectedEpisodeID(episodes[focusedIndex()].episode.id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const episodes = allEpisodes();
|
|
||||||
if (episodes.length === 0) return;
|
|
||||||
|
|
||||||
if (isDown) {
|
|
||||||
setFocusedIndex((i) => (i + 1) % episodes.length);
|
|
||||||
} else if (isUp) {
|
|
||||||
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
|
|
||||||
} else if (isCycle) {
|
|
||||||
setFocusedIndex((i) => (i + 1) % episodes.length);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: false },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
return format(date, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
};
|
const formatDuration = (s: number) => {
|
||||||
|
const mins = Math.floor(s / 60);
|
||||||
const groupEpisodesByDate = () => {
|
|
||||||
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
|
|
||||||
|
|
||||||
for (const item of allEpisodes()) {
|
|
||||||
const dateKey = formatDate(new Date(item.episode.pubDate));
|
|
||||||
if (!groups[dateKey]) {
|
|
||||||
groups[dateKey] = [];
|
|
||||||
}
|
|
||||||
groups[dateKey].push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
|
|
||||||
// Convert date strings back to Date objects for proper chronological sorting
|
|
||||||
const dateA = new Date(a);
|
|
||||||
const dateB = new Date(b);
|
|
||||||
// Sort in descending order (newest first)
|
|
||||||
return dateB.getTime() - dateA.getTime();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
const hrs = Math.floor(mins / 60);
|
||||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||||
return `${mins}m`;
|
};
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const playEpisode = (item: EpItem | undefined) => {
|
||||||
|
if (!item) return;
|
||||||
|
audio.play(item.episode).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.FEED);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ── open ───────────────────────────────────────────────────────────────────
|
||||||
<box
|
function open() {
|
||||||
border
|
playEpisode(focusedItem());
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
|
|
||||||
}
|
}
|
||||||
backgroundColor={theme.background}
|
|
||||||
flexDirection="column"
|
// ── nav.action handler ────────────────────────────────────────────────────
|
||||||
height="100%"
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
width="100%"
|
"move-down": () => step(1),
|
||||||
>
|
"move-up": () => step(-1),
|
||||||
|
"jump-down": () => step(5),
|
||||||
|
"jump-up": () => step(-5),
|
||||||
|
"page-down": () => step(10),
|
||||||
|
"page-up": () => step(-10),
|
||||||
|
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||||
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
|
open: () => open(),
|
||||||
|
"toggle-select": () => {
|
||||||
|
const item = focusedItem();
|
||||||
|
if (item) nav.toggleSelected(item.episode.id);
|
||||||
|
},
|
||||||
|
refresh: () => {
|
||||||
|
feedStore.refreshAllFeeds().catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function step(delta: number) {
|
||||||
|
nav.move(delta, curLen());
|
||||||
|
}
|
||||||
|
const onAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
|
ensureFocus();
|
||||||
|
PAGE_ACTIONS[data.action]?.();
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
on("nav.action", onAction);
|
||||||
|
onCleanup(() => off("nav.action", onAction));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
// Row highlight within the list. `active=true` only for the current pane.
|
||||||
|
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||||
|
i === listFocus && active
|
||||||
|
? theme.primary
|
||||||
|
: i === listFocus
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||||
|
i === listFocus && active ? theme.surface : theme.text;
|
||||||
|
|
||||||
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
|
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
|
||||||
|
const parentContent = () => <TabListPane muted />;
|
||||||
|
|
||||||
|
// ── current pane: the flat episodes list (the only focusable column) ──────
|
||||||
|
const currentContent = () => (
|
||||||
<Show
|
<Show
|
||||||
when={allEpisodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={2}>
|
<box padding={1}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
|
||||||
No episodes yet. Subscribe to shows from Discover or Search.
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<scrollbox
|
<For each={episodes()}>
|
||||||
height="100%"
|
{(item, index) => {
|
||||||
focused={nav.activeDepth() == FeedPaneType.FEED}
|
const fi = () => focusedEpIdx();
|
||||||
>
|
|
||||||
<For each={groupEpisodesByDate()}>
|
|
||||||
{([date, items]) => (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<SelectableText selected={() => false} primary>
|
|
||||||
{date}
|
|
||||||
</SelectableText>
|
|
||||||
<For each={items}>
|
|
||||||
{(item) => {
|
|
||||||
const isSelected = () => {
|
|
||||||
if (
|
|
||||||
nav.activeTab() == TABS.FEED &&
|
|
||||||
nav.activeDepth() == FeedPaneType.FEED &&
|
|
||||||
selectedEpisodeID() &&
|
|
||||||
selectedEpisodeID() === item.episode.id
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
const isFocused = () => {
|
|
||||||
const episodes = allEpisodes();
|
|
||||||
const currentIndex = episodes.findIndex(
|
|
||||||
(e: any) => e.episode.id === item.episode.id,
|
|
||||||
);
|
|
||||||
return currentIndex === focusedIndex();
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<SelectableBox
|
<box
|
||||||
selected={isSelected}
|
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
paddingTop={0}
|
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||||
paddingBottom={0}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
setSelectedEpisodeID(item.episode.id);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
const episodes = allEpisodes();
|
nav.setDepthFocus(index(), 0);
|
||||||
setFocusedIndex(
|
|
||||||
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectableText selected={isSelected} primary>
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
|
{index() === fi() ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
|
{item.episode.episodeNumber
|
||||||
|
? `#${item.episode.episodeNumber} `
|
||||||
|
: ""}
|
||||||
{item.episode.title}
|
{item.episode.title}
|
||||||
</SelectableText>
|
</text>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
</box>
|
||||||
<SelectableText selected={isSelected} primary>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
{item.feed.podcast.title}
|
<text fg={index() === fi() ? theme.surface : theme.info}>
|
||||||
</SelectableText>
|
{formatDate(item.episode.pubDate)}
|
||||||
<SelectableText selected={isSelected} tertiary>
|
</text>
|
||||||
{formatDuration(item.episode.duration)}
|
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||||
</SelectableText>
|
{formatDuration(item.episode.duration)}
|
||||||
|
</text>
|
||||||
|
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||||
|
{item.feed.customName || item.feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={nav.isSelected(item.episode.id)}>
|
||||||
|
<text fg={theme.warning}>●</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={downloadLabel(item.episode.id)}>
|
||||||
|
<text fg={downloadColor(item.episode.id)}>
|
||||||
|
{downloadLabel(item.episode.id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</SelectableBox>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
</Show>
|
||||||
</scrollbox>
|
);
|
||||||
|
|
||||||
|
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
||||||
|
const previewContent = () => (
|
||||||
|
<Show
|
||||||
|
when={focusedItem()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{item().episode.episodeNumber
|
||||||
|
? `#${item().episode.episodeNumber} `
|
||||||
|
: ""}
|
||||||
|
{item().episode.title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||||
|
<Show when={downloadLabel(item().episode.id)}>
|
||||||
|
<text fg={downloadColor(item().episode.id)}>
|
||||||
|
{downloadLabel(item().episode.id)}
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{item().feed.customName || item().feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={item().feed.podcast.author}>
|
||||||
|
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{item().episode.description?.slice(0, 400) ??
|
||||||
|
"No description available."}
|
||||||
|
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · space: select · h back</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
preview={previewContent}
|
||||||
|
parentLabel="Up"
|
||||||
|
currentLabel={currentLabel}
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={isActive}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { FeedPage };
|
||||||
|
|||||||
@@ -1,124 +1,105 @@
|
|||||||
/**
|
/**
|
||||||
* MyShowsPage - Two-panel file-explorer style view
|
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||||
* Left panel: list of subscribed shows
|
*
|
||||||
* Right panel: episodes for the selected show
|
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
||||||
|
* placeholder (1/7 slot kept).
|
||||||
|
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||||
|
* preview — detail of the hovered item in the current column.
|
||||||
|
*
|
||||||
|
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
|
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||||
|
* 0). j/k move only within the current column.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import {
|
||||||
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
type PaneId,
|
||||||
|
type DepthFrame,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
enum MyShowsPaneType {
|
export const MyShowsPaneCount = 1;
|
||||||
SHOWS = 1,
|
|
||||||
EPISODES = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MyShowsPaneCount = 2;
|
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
const audio = useAudio();
|
||||||
const [showIndex, setShowIndex] = createSignal(0);
|
|
||||||
const [episodeIndex, setEpisodeIndex] = createSignal(0);
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mutedColor = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
onMount(() => {
|
const stack = nav.depthStack;
|
||||||
useKeyboard(
|
const depth = nav.currentDepth;
|
||||||
(keyEvent: any) => {
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
const isDown = keybind.match("down", keyEvent);
|
|
||||||
const isUp = keybind.match("up", keyEvent);
|
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
|
||||||
|
|
||||||
const shows = feedStore.getFilteredFeeds();
|
|
||||||
const episodesList = episodes();
|
|
||||||
const selected = selectedShow();
|
|
||||||
|
|
||||||
if (isSelect) {
|
|
||||||
if (shows.length > 0 && showIndex() < shows.length) {
|
|
||||||
setShowIndex(showIndex() + 1);
|
|
||||||
}
|
|
||||||
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
|
|
||||||
setEpisodeIndex(episodeIndex() + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shows.length > 0) {
|
|
||||||
if (isDown) {
|
|
||||||
setShowIndex((i) => (i + 1) % shows.length);
|
|
||||||
} else if (isUp) {
|
|
||||||
setShowIndex((i) => (i - 1 + shows.length) % shows.length);
|
|
||||||
} else if (isCycle) {
|
|
||||||
setShowIndex((i) => (i + 1) % shows.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (episodesList.length > 0) {
|
|
||||||
if (isDown) {
|
|
||||||
setEpisodeIndex((i) => (i + 1) % episodesList.length);
|
|
||||||
} else if (isUp) {
|
|
||||||
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
|
|
||||||
} else if (isCycle) {
|
|
||||||
setEpisodeIndex((i) => (i + 1) % episodesList.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: false },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Threshold: load more when within this many items of the end */
|
|
||||||
const LOAD_MORE_THRESHOLD = 5;
|
|
||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
const selectedShow = createMemo(() => {
|
const focusedShowIdx = () =>
|
||||||
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation
|
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||||
});
|
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
||||||
|
|
||||||
const episodes = createMemo(() => {
|
// depth-1 frame ctx = the drilled feed id
|
||||||
const show = selectedShow();
|
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 [];
|
if (!show) return [];
|
||||||
return [...show.episodes].sort(
|
return [...show.episodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const focusedEpIdx = () =>
|
||||||
|
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||||
|
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
||||||
return format(date, "MMM d, yyyy");
|
|
||||||
|
const ensureFocus = () => {
|
||||||
|
if (shows().length > 0 && focus(0) >= shows().length)
|
||||||
|
nav.setDepthFocus(shows().length - 1, 0);
|
||||||
|
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||||
|
nav.setDepthFocus(episodes().length - 1, 1);
|
||||||
};
|
};
|
||||||
|
onMount(ensureFocus);
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
onMount(() => {
|
||||||
const mins = Math.floor(seconds / 60);
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
|
if (depth() === 0) return shows()[i]?.id;
|
||||||
|
return episodes()[i]?.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
const formatDuration = (s: number) => {
|
||||||
|
const mins = Math.floor(s / 60);
|
||||||
const hrs = Math.floor(mins / 60);
|
const hrs = Math.floor(mins / 60);
|
||||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||||
return `${mins}m`;
|
|
||||||
};
|
};
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
/** Get download status label for an episode */
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
const downloadLabel = (episodeId: string): string => {
|
|
||||||
const status = downloadStore.getDownloadStatus(episodeId);
|
|
||||||
switch (status) {
|
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
return "[Q]";
|
return "[Q]";
|
||||||
case DownloadStatus.DOWNLOADING: {
|
case DownloadStatus.DOWNLOADING:
|
||||||
const pct = downloadStore.getDownloadProgress(episodeId);
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
return `[${pct}%]`;
|
|
||||||
}
|
|
||||||
case DownloadStatus.COMPLETED:
|
case DownloadStatus.COMPLETED:
|
||||||
return "[DL]";
|
return "[DL]";
|
||||||
case DownloadStatus.FAILED:
|
case DownloadStatus.FAILED:
|
||||||
@@ -127,204 +108,314 @@ export function MyShowsPage() {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
const handleRefresh = async () => {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
const show = selectedShow();
|
|
||||||
if (!show) return;
|
|
||||||
setIsRefreshing(true);
|
|
||||||
await feedStore.refreshFeed(show.id);
|
|
||||||
setIsRefreshing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnsubscribe = () => {
|
|
||||||
const show = selectedShow();
|
|
||||||
if (!show) return;
|
|
||||||
feedStore.removeFeed(show.id);
|
|
||||||
setShowIndex((i) => Math.max(0, i - 1));
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Get download status color */
|
|
||||||
const downloadColor = (episodeId: string): string => {
|
|
||||||
const status = downloadStore.getDownloadStatus(episodeId);
|
|
||||||
switch (status) {
|
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
return theme.warning.toString();
|
return theme.warning;
|
||||||
case DownloadStatus.DOWNLOADING:
|
case DownloadStatus.DOWNLOADING:
|
||||||
return theme.primary.toString();
|
return theme.primary;
|
||||||
case DownloadStatus.COMPLETED:
|
case DownloadStatus.COMPLETED:
|
||||||
return theme.success.toString();
|
return theme.success;
|
||||||
case DownloadStatus.FAILED:
|
case DownloadStatus.FAILED:
|
||||||
return theme.error.toString();
|
return theme.error;
|
||||||
default:
|
default:
|
||||||
return mutedColor().toString();
|
return muted();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const playEpisode = (ep: Episode) => {
|
||||||
|
audio.play(ep).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
<box flexDirection="row" flexGrow={1} width="100%">
|
function open() {
|
||||||
<box flexDirection="column" height="100%">
|
if (depth() === 0) {
|
||||||
<Show when={isRefreshing()}>
|
const show = selectedShow();
|
||||||
<text fg={theme.warning}>Refreshing...</text>
|
if (!show) return;
|
||||||
</Show>
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||||
<Show
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
when={shows().length > 0}
|
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||||
fallback={
|
return;
|
||||||
<box padding={1}>
|
|
||||||
<text fg={theme.muted}>
|
|
||||||
No shows yet. Subscribe from Discover or Search.
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
}
|
||||||
>
|
if (depth() >= 1) {
|
||||||
<scrollbox
|
const ep = focusedEpisode();
|
||||||
border
|
if (ep) playEpisode(ep);
|
||||||
height="100%"
|
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() == MyShowsPaneType.SHOWS
|
|
||||||
? theme.accent
|
|
||||||
: theme.border
|
|
||||||
}
|
}
|
||||||
focused={nav.activeDepth() == MyShowsPaneType.SHOWS}
|
}
|
||||||
>
|
|
||||||
|
// ── nav.action ──────────────────────────────────────────────────────────────
|
||||||
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
|
"move-down": () => step(1),
|
||||||
|
"move-up": () => step(-1),
|
||||||
|
"jump-down": () => step(5),
|
||||||
|
"jump-up": () => step(-5),
|
||||||
|
"page-down": () => step(10),
|
||||||
|
"page-up": () => step(-10),
|
||||||
|
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||||
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
|
open: () => open(),
|
||||||
|
"toggle-select": () => {
|
||||||
|
if (depth() >= 1) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refresh: () => {
|
||||||
|
const show = selectedShow();
|
||||||
|
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function step(delta: number) {
|
||||||
|
nav.move(delta, curLen());
|
||||||
|
}
|
||||||
|
const onAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
|
ensureFocus();
|
||||||
|
PAGE_ACTIONS[data.action]?.();
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
on("nav.action", onAction);
|
||||||
|
onCleanup(() => off("nav.action", onAction));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||||
|
i === lf && active ? theme.surface : theme.text;
|
||||||
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
|
const currentLabel = () =>
|
||||||
|
depth() === 0
|
||||||
|
? `Shows (${shows().length})`
|
||||||
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
|
// ── 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()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => (
|
{(feed, index) => {
|
||||||
|
const lf = () => nav.depthFocus(0);
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
index() === showIndex() ? theme.primary : undefined
|
>
|
||||||
}
|
<text fg={focusFg(index(), lf(), false)}>
|
||||||
onMouseDown={() => {
|
{index() === lf() ? "❯" : " "}
|
||||||
setShowIndex(index());
|
</text>
|
||||||
setEpisodeIndex(0);
|
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
||||||
audioNav.setSource(
|
<text fg={muted()}>({feed.episodes.length})</text>
|
||||||
AudioSource.MY_SHOWS,
|
</box>
|
||||||
selectedShow()?.podcast.id,
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
</For>
|
||||||
<text
|
</Show>
|
||||||
fg={index() === showIndex() ? theme.surface : theme.text}
|
);
|
||||||
>
|
|
||||||
{index() === showIndex() ? ">" : " "}
|
// ── current pane: the current-depth list ───────────────────────────────────
|
||||||
|
const currentContent = () => (
|
||||||
|
<>
|
||||||
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||||
|
<Show when={depth() === 0}>
|
||||||
|
<Show
|
||||||
|
when={shows().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>
|
||||||
|
No shows. Subscribe from Discover/Search.
|
||||||
</text>
|
</text>
|
||||||
<text
|
</box>
|
||||||
fg={index() === showIndex() ? theme.surface : theme.text}
|
}
|
||||||
>
|
>
|
||||||
{feed.customName || feed.podcast.title}
|
<For each={shows()}>
|
||||||
|
{(feed, index) => {
|
||||||
|
const lf = () => focusedShowIdx();
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
|
{index() === lf() ? "❯" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={index() === showIndex() ? undefined : theme.text}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
|
{showTitle(feed)}
|
||||||
|
</text>
|
||||||
|
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||||
({feed.episodes.length})
|
({feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</Show>
|
||||||
<box flexDirection="column" height="100%">
|
{/* depth ≥1: episodes */}
|
||||||
<Show
|
<Show when={depth() >= 1}>
|
||||||
when={selectedShow()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={theme.muted}>Select a show</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text>
|
<text fg={muted()}>No episodes. :refresh</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<scrollbox
|
|
||||||
border
|
|
||||||
height="100%"
|
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() == MyShowsPaneType.EPISODES
|
|
||||||
? theme.accent
|
|
||||||
: theme.border
|
|
||||||
}
|
|
||||||
focused={nav.activeDepth() == MyShowsPaneType.EPISODES}
|
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(episode, index) => (
|
{(ep, index) => {
|
||||||
|
const lf = () => focusedEpIdx();
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
index() === episodeIndex() ? theme.primary : undefined
|
onMouseDown={() => {
|
||||||
}
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
onMouseDown={() => setEpisodeIndex(index())}
|
nav.setDepthFocus(index(), 1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
fg={
|
{index() === lf() ? "❯" : " "}
|
||||||
index() === episodeIndex()
|
|
||||||
? theme.surface
|
|
||||||
: theme.text
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{index() === episodeIndex() ? ">" : " "}
|
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
fg={
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||||
index() === episodeIndex()
|
{ep.title}
|
||||||
? theme.surface
|
|
||||||
: theme.text
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{episode.episodeNumber
|
|
||||||
? `#${episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{episode.title}
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text
|
<text fg={index() === lf() ? theme.surface : theme.info}>
|
||||||
fg={index() === episodeIndex() ? undefined : theme.info}
|
{formatDate(ep.pubDate)}
|
||||||
>
|
|
||||||
{formatDate(episode.pubDate)}
|
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.muted}>
|
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||||
{formatDuration(episode.duration)}
|
{formatDuration(ep.duration)}
|
||||||
</text>
|
</text>
|
||||||
<Show when={downloadLabel(episode.id)}>
|
<Show when={nav.isSelected(ep.id)}>
|
||||||
<text fg={downloadColor(episode.id)}>
|
<text fg={theme.warning}>●</text>
|
||||||
{downloadLabel(episode.id)}
|
</Show>
|
||||||
|
<Show when={downloadLabel(ep.id)}>
|
||||||
|
<text fg={downloadColor(ep.id)}>
|
||||||
|
{downloadLabel(ep.id)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={feedStore.isLoadingMore()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
|
const previewContent = () =>
|
||||||
|
depth() === 0 ? (
|
||||||
|
// depth 0 preview: hovered show
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={selectedShow()}
|
||||||
!feedStore.isLoadingMore() &&
|
fallback={
|
||||||
selectedShow() &&
|
<box padding={1}>
|
||||||
feedStore.hasMoreEpisodes(selectedShow()!.id)
|
<text fg={muted()}>No show focused</text>
|
||||||
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
{(show) => (
|
||||||
<text fg={theme.muted}>Scroll down for more episodes</text>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{showTitle(show())}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={show().podcast.author}>
|
||||||
|
<text fg={muted()}>by {show().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{show().episodes.length} episodes
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter/l: open · h: back</text>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
) : (
|
||||||
</Show>
|
// depth ≥1 preview: hovered episode
|
||||||
|
<Show
|
||||||
|
when={focusedEpisode()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
||||||
|
{ep().title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
||||||
|
<Show when={downloadLabel(ep().id)}>
|
||||||
|
<text fg={downloadColor(ep().id)}>
|
||||||
|
{downloadLabel(ep().id)}
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={selectedShow()?.podcast.author}>
|
||||||
|
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{ep().description?.slice(0, 400) ?? "No description available."}
|
||||||
|
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · space: select · h: back</text>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
preview={previewContent}
|
||||||
|
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
|
||||||
|
currentLabel={currentLabel}
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={isActive}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,68 @@
|
|||||||
import type { BackendName } from "../utils/audio-player"
|
import type { BackendName } from "@/utils/audio-player";
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type PlaybackControlsProps = {
|
type PlaybackControlsProps = {
|
||||||
isPlaying: boolean
|
isPlaying: boolean;
|
||||||
volume: number
|
volume: number;
|
||||||
speed: number
|
speed: number;
|
||||||
backendName?: BackendName
|
backendName?: BackendName;
|
||||||
hasAudioUrl?: boolean
|
hasAudioUrl?: boolean;
|
||||||
onToggle: () => void
|
onToggle: () => void;
|
||||||
onPrev: () => void
|
onPrev: () => void;
|
||||||
onNext: () => void
|
onNext: () => void;
|
||||||
onVolumeChange: (value: number) => void
|
onVolumeChange: (value: number) => void;
|
||||||
onSpeedChange: (value: number) => void
|
onSpeedChange: (value: number) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||||
mpv: "mpv",
|
mpv: "mpv",
|
||||||
ffplay: "ffplay",
|
|
||||||
afplay: "afplay",
|
|
||||||
system: "system",
|
|
||||||
none: "none",
|
none: "none",
|
||||||
}
|
};
|
||||||
|
|
||||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}>
|
<box
|
||||||
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}>
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
alignItems="center"
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onPrev}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<text fg={theme.primary}>[Prev]</text>
|
<text fg={theme.primary}>[Prev]</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onToggle}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onNext}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<text fg={theme.primary}>[Next]</text>
|
<text fg={theme.primary}>[Next]</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Vol</text>
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
|
<text fg={theme.textMuted}>↑↓</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
<text fg={theme.textMuted}>s</text>
|
||||||
</box>
|
</box>
|
||||||
{props.backendName && props.backendName !== "none" && (
|
{props.backendName && props.backendName !== "none" && (
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
@@ -60,5 +81,5 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</box>
|
</box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 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 (YaziPaneRow `panes={2}`). Audio transport (play/pause,
|
||||||
|
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
||||||
|
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
||||||
|
* tab root.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
import { useKeybinds } from "@/context/KeybindContext";
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { onMount } from "solid-js";
|
|
||||||
|
|
||||||
enum PlayerPaneType {
|
|
||||||
PLAYER = 1,
|
|
||||||
}
|
|
||||||
export const PlayerPaneCount = 1;
|
export const PlayerPaneCount = 1;
|
||||||
|
|
||||||
export function PlayerPage() {
|
export function PlayerPage() {
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
|
||||||
const keybind = useKeybinds();
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
useKeyboard(
|
|
||||||
(keyEvent: any) => {
|
|
||||||
if (keybind.match("audio-toggle", keyEvent)) {
|
|
||||||
audio.togglePlayback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keybind.match("audio-seek-forward", keyEvent)) {
|
|
||||||
audio.seek(audio.currentEpisode()?.duration ?? 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keybind.match("audio-seek-backward", keyEvent)) {
|
|
||||||
audio.seek(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: false },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const progressPercent = () => {
|
const progressPercent = () => {
|
||||||
const d = audio.duration();
|
const d = audio.duration();
|
||||||
@@ -54,37 +42,49 @@ export function PlayerPage() {
|
|||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ── parent pane: the tab list (muted) ──────────────────────────────────────
|
||||||
<box flexDirection="column" gap={1} width="100%">
|
const parentContent = () => <TabListPane muted />;
|
||||||
|
|
||||||
|
// ── current pane: now playing ───────────────────────────────────────────────
|
||||||
|
const currentContent = () => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<strong>Now Playing</strong>
|
<strong>Now Playing</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.muted}>
|
<text fg={muted()}>
|
||||||
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||||
{progressPercent()}%)
|
{progressPercent()}%)
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{audio.error() && <text fg={theme.error}>{audio.error()}</text>}
|
<Show when={audio.error()}>
|
||||||
|
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<box
|
<Show
|
||||||
border
|
when={audio.currentEpisode()}
|
||||||
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border}
|
fallback={
|
||||||
padding={1}
|
<box padding={1}>
|
||||||
flexDirection="column"
|
<text fg={muted()}>No episode loaded.</text>
|
||||||
gap={1}
|
</box>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<strong>{audio.currentEpisode()?.title}</strong>
|
<strong>{ep().title}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
|
|
||||||
|
|
||||||
<RealtimeWaveform
|
<RealtimeWaveform
|
||||||
visualizerConfig={(() => {
|
visualizerConfig={(() => {
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
|
// bars is width-derived in RealtimeWaveform; pass only the
|
||||||
|
// audio-processing params here.
|
||||||
return {
|
return {
|
||||||
bars: viz.bars,
|
|
||||||
noiseReduction: viz.noiseReduction,
|
noiseReduction: viz.noiseReduction,
|
||||||
lowCutOff: viz.lowCutOff,
|
lowCutOff: viz.lowCutOff,
|
||||||
highCutOff: viz.highCutOff,
|
highCutOff: viz.highCutOff,
|
||||||
@@ -92,6 +92,8 @@ export function PlayerPage() {
|
|||||||
})()}
|
})()}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
isPlaying={audio.isPlaying()}
|
isPlaying={audio.isPlaying()}
|
||||||
@@ -101,10 +103,26 @@ export function PlayerPage() {
|
|||||||
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
||||||
onToggle={audio.togglePlayback}
|
onToggle={audio.togglePlayback}
|
||||||
onPrev={() => audio.seek(0)}
|
onPrev={() => audio.seek(0)}
|
||||||
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
|
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
|
||||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>
|
||||||
|
{"P play/pause N next B prev ◀▶ seek h back"}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
parentLabel="Up"
|
||||||
|
currentLabel="Player"
|
||||||
|
panes={2}
|
||||||
|
focused={isActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
import {
|
import {
|
||||||
loadCavaCore,
|
loadCavaCore,
|
||||||
type CavaCore,
|
type CavaCore,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,14 +53,28 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
// Track whether cavacore is available
|
|
||||||
const [available, setAvailable] = createSignal(false);
|
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
let cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
let reader: AudioStreamReader | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let sampleBuffer: Float64Array | null = null;
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
|
||||||
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
|
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
|
||||||
|
// chrome (scrollbox border + box padding + waveform border + padding).
|
||||||
|
// Falls back to 64 before the renderer reports a real size.
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
const numBars = () => {
|
||||||
|
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
|
||||||
|
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
|
||||||
|
const width = dimensions().width;
|
||||||
|
if (!width) return 64;
|
||||||
|
return Math.max(
|
||||||
|
8,
|
||||||
|
Math.min(256, Math.floor((width * current) / total) - 8),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||||
|
|
||||||
const initCava = () => {
|
const initCava = () => {
|
||||||
@@ -66,11 +82,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
cava = loadCavaCore();
|
cava = loadCavaCore();
|
||||||
if (!cava) {
|
if (!cava) {
|
||||||
setAvailable(false);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailable(true);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,9 +95,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
if (!url || !initCava() || !cava) return;
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides
|
// Initialize cavacore with current resolution + any overrides.
|
||||||
|
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||||
|
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: 32,
|
bars: numBars(),
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
...props.visualizerConfig,
|
...props.visualizerConfig,
|
||||||
@@ -136,16 +152,16 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const output = cava.execute(input);
|
const output = cava.execute(input);
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
// Copy bar values to a new array for the signal
|
||||||
setBarData(Array.from(output));
|
setBarData(Array.from(output as Float64Array));
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
[
|
[
|
||||||
audio.isPlaying,
|
audio.isPlaying,
|
||||||
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
|
() => audio.currentEpisode()?.audioUrl ?? "",
|
||||||
audio.speed,
|
audio.speed,
|
||||||
() => 32,
|
numBars,
|
||||||
],
|
],
|
||||||
([playing, url, speed]) => {
|
([playing, url, speed]) => {
|
||||||
if (playing && url) {
|
if (playing && url) {
|
||||||
@@ -204,11 +220,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
const numBars = 32;
|
const count = numBars();
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
// If no data yet, show empty placeholder
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(numBars);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="row" gap={0}>
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
<text fg="#3b4252">{placeholder}</text>
|
||||||
@@ -216,7 +232,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const played = Math.floor(numBars * playedRatio());
|
const played = Math.floor(count * playedRatio());
|
||||||
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
||||||
const futureColor = "#3b4252";
|
const futureColor = "#3b4252";
|
||||||
|
|
||||||
@@ -239,8 +255,8 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (event: { x: number }) => {
|
const handleClick = (event: { x: number }) => {
|
||||||
const numBars = 32;
|
const count = numBars();
|
||||||
const ratio = event.x / numBars;
|
const ratio = event.x / count;
|
||||||
const next = Math.max(
|
const next = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
||||||
@@ -249,7 +265,12 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
|
<box
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
padding={1}
|
||||||
|
onMouseDown={handleClick}
|
||||||
|
>
|
||||||
{renderLine()}
|
{renderLine()}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,159 +1,297 @@
|
|||||||
/**
|
/**
|
||||||
* SearchPage component - Main search interface for PodTUI
|
* SearchPage — yazi depth-stack view of podcast search.
|
||||||
|
*
|
||||||
|
* depth 0 (current) — query input row + recent-searches list (navigable
|
||||||
|
* with j/k when the input is defocused). Parent pane
|
||||||
|
* shows the tab list (muted); preview shows a hint.
|
||||||
|
* depth 1 (current) — search results list. Parent pane shows the submitted
|
||||||
|
* query (muted, read-only); preview shows the detail of
|
||||||
|
* the focused result.
|
||||||
|
*
|
||||||
|
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||||||
|
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||||
|
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||||
|
* input (or on a focused recent at depth 0) submits the query and pushes to
|
||||||
|
* depth 1 (results). `h` pops: results→query, query→tab root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, Show, onMount } from "solid-js";
|
import {
|
||||||
import { useKeyboard } from "@opentui/solid";
|
createSignal,
|
||||||
|
createMemo,
|
||||||
|
createEffect,
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
onMount,
|
||||||
|
onCleanup,
|
||||||
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
import { SearchResults } from "./SearchResults";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { SearchHistory } from "./SearchHistory";
|
import { format } from "date-fns";
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { MyShowsPage } from "../MyShows/MyShowsPage";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import {
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
type PaneId,
|
||||||
|
type DepthFrame,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { SearchResult } from "@/types/source";
|
||||||
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
enum SearchPaneType {
|
export const SearchPaneCount = 1;
|
||||||
INPUT = 1,
|
|
||||||
RESULTS = 2,
|
|
||||||
HISTORY = 3,
|
|
||||||
}
|
|
||||||
export const SearchPaneCount = 3;
|
|
||||||
|
|
||||||
export function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const [resultIndex, setResultIndex] = createSignal(0);
|
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(0);
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
onMount(() => {
|
const stack = nav.depthStack;
|
||||||
useKeyboard(
|
const depth = nav.currentDepth;
|
||||||
(keyEvent: any) => {
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
const isDown = keybind.match("down", keyEvent);
|
|
||||||
const isUp = keybind.match("up", keyEvent);
|
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
|
||||||
|
|
||||||
if (isSelect) {
|
// depth 1's ctx carries the submitted query string.
|
||||||
const results = searchStore.results();
|
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||||
if (results.length > 0 && resultIndex() < results.length) {
|
|
||||||
setResultIndex(resultIndex() + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = searchStore.results();
|
// ── input focusing ────────────────────────────────────────────────────────
|
||||||
if (results.length === 0) return;
|
// `inputFocused` is true while the query input is being typed in. The Shell
|
||||||
|
// router yields keys to the <input> while this is true; Escape (in Shell)
|
||||||
if (isDown) {
|
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||||||
setResultIndex((i) => (i + 1) % results.length);
|
//
|
||||||
} else if (isUp) {
|
// Typing is the default only on the query depth (0); the results depth
|
||||||
setResultIndex((i) => (i - 1 + results.length) % results.length);
|
// (1) is always list-navigation. Drive `inputFocused` straight off
|
||||||
} else if (isCycle) {
|
// `depth()` rather than seeding it `true` on mount and patching on change:
|
||||||
setResultIndex((i) => (i + 1) % results.length);
|
// the depth stack persists across tab switches, so re-mounting this page
|
||||||
}
|
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
|
||||||
},
|
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
|
||||||
{ release: false },
|
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
||||||
);
|
// scroll responds.
|
||||||
|
//
|
||||||
|
// The effect only re-runs on a depth transition, so Escape (defocus) and
|
||||||
|
// `s` (refocus) at the same depth are not clobbered.
|
||||||
|
onMount(() => nav.setInputFocused(depth() === 0));
|
||||||
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
|
createEffect(() => {
|
||||||
|
nav.setInputFocused(depth() === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = async () => {
|
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||||
const query = inputValue().trim();
|
const results = () => searchStore.results();
|
||||||
if (query) {
|
const focusedResultIdx = () =>
|
||||||
await searchStore.search(query);
|
results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
|
||||||
if (searchStore.results().length > 0) {
|
const focusedResult = createMemo(() => {
|
||||||
//setFocusArea("results"); //TODO: move level
|
const list = results();
|
||||||
setResultIndex(0);
|
if (list.length === 0) return undefined;
|
||||||
}
|
return list[focusedResultIdx()];
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// ── recents (depth 0) ────────────────────────────────────────────────────
|
||||||
|
const recents = () => searchStore.history();
|
||||||
|
const curLen = () => (depth() === 0 ? recents().length : results().length);
|
||||||
|
|
||||||
|
const ensureFocus = () => {
|
||||||
|
if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
|
||||||
|
nav.setDepthFocus(results().length - 1, 1);
|
||||||
|
};
|
||||||
|
onMount(ensureFocus);
|
||||||
|
|
||||||
|
// Register a visual-mode resolver for the results list (depth 1).
|
||||||
|
onMount(() => {
|
||||||
|
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
||||||
|
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
const runSearch = (query: string) => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
searchStore.search(q).catch(() => {});
|
||||||
|
nav.pushDepth({
|
||||||
|
kind: "search:results",
|
||||||
|
ctx: q,
|
||||||
|
focus: 0,
|
||||||
|
} as DepthFrame);
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHistorySelect = async (query: string) => {
|
const handleSubmit = () => runSearch(inputValue());
|
||||||
|
|
||||||
|
const selectRecent = (query: string) => {
|
||||||
setInputValue(query);
|
setInputValue(query);
|
||||||
await searchStore.search(query);
|
runSearch(query);
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
//setFocusArea("results"); //TODO: move level
|
|
||||||
setResultIndex(0);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResultSelect = (result: SearchResult) => {
|
const handleSubscribe = (result: SearchResult) => {
|
||||||
//props.onSubscribe?.(result);
|
// Actually add the feed to the feed store, then mark the result subscribed
|
||||||
|
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
||||||
searchStore.markSubscribed(result.podcast.id);
|
searchStore.markSubscribed(result.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
<box flexDirection="column" height="100%" gap={1} width="100%">
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
{/* Search Header */}
|
"move-down": () => step(1),
|
||||||
<box flexDirection="column" gap={1}>
|
"move-up": () => step(-1),
|
||||||
<text fg={theme.text}>
|
"jump-down": () => step(5),
|
||||||
<strong>Search Podcasts</strong>
|
"jump-up": () => step(-5),
|
||||||
</text>
|
"page-down": () => step(10),
|
||||||
|
"page-up": () => step(-10),
|
||||||
|
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||||
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
|
open: () => open(),
|
||||||
|
"toggle-select": () => {
|
||||||
|
if (depth() === 1) {
|
||||||
|
const r = focusedResult();
|
||||||
|
if (r) nav.toggleSelected(r.podcast.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
search: () => {
|
||||||
|
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||||
|
if (depth() === 0) nav.setInputFocused(true);
|
||||||
|
},
|
||||||
|
refresh: () => {
|
||||||
|
const q = submittedQuery() || inputValue().trim();
|
||||||
|
if (q) searchStore.search(q).catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
{/* Search Input */}
|
function step(delta: number) {
|
||||||
|
nav.move(delta, curLen());
|
||||||
|
}
|
||||||
|
function open() {
|
||||||
|
if (depth() === 0) {
|
||||||
|
// Enter/l on a focused recent search → submit it and drill to results.
|
||||||
|
const list = recents();
|
||||||
|
const idx = Math.min(focus(0), list.length - 1);
|
||||||
|
const q = list[idx];
|
||||||
|
if (q) selectRecent(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() === 1) {
|
||||||
|
const r = focusedResult();
|
||||||
|
if (r) handleSubscribe(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
|
ensureFocus();
|
||||||
|
PAGE_ACTIONS[data.action]?.();
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
on("nav.action", onAction);
|
||||||
|
onCleanup(() => off("nav.action", onAction));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
const inputActive = () => nav.inputFocused() && depth() === 0;
|
||||||
|
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||||
|
i === listFocus && active
|
||||||
|
? theme.primary
|
||||||
|
: i === listFocus
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||||
|
i === listFocus && active ? theme.surface : theme.text;
|
||||||
|
|
||||||
|
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||||||
|
const parentContent = () => (
|
||||||
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textSecondary}>Query</text>
|
||||||
|
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>h: back to query</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── current pane ────────────────────────────────────────────────────────────
|
||||||
|
const currentContent = () => (
|
||||||
|
<>
|
||||||
|
<Show when={depth() === 0}>
|
||||||
|
{/* query input row + recent searches */}
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
<text fg="gray">Search:</text>
|
<text fg={muted()}>Query:</text>
|
||||||
<input
|
<input
|
||||||
value={inputValue()}
|
value={inputValue()}
|
||||||
onInput={(value) => {
|
onInput={setInputValue}
|
||||||
setInputValue(value);
|
onSubmit={() => handleSubmit()}
|
||||||
}}
|
placeholder="Enter podcast name..."
|
||||||
placeholder="Enter podcast name, topic, or author..."
|
focused={inputActive()}
|
||||||
focused={nav.activeDepth() === SearchPaneType.INPUT}
|
width={28}
|
||||||
width={50}
|
|
||||||
/>
|
/>
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={handleSearch}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[Enter] Search</text>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<Show when={searchStore.isSearching()}>
|
<Show when={searchStore.isSearching()}>
|
||||||
<text fg={theme.warning}>Searching...</text>
|
<text fg={theme.warning}>Searching...</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={searchStore.error()}>
|
<Show when={searchStore.error()}>
|
||||||
<text fg={theme.error}>{searchStore.error()}</text>
|
<text fg={theme.error}>{searchStore.error()}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>Recent</text>
|
||||||
{/* Main Content - Results or History */}
|
<Show
|
||||||
<box flexDirection="row" height="100%" gap={2}>
|
when={recents().length > 0}
|
||||||
{/* Results Panel */}
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
{inputActive()
|
||||||
|
? "Enter to search"
|
||||||
|
: "s to type · Enter to search"}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={recents()}>
|
||||||
|
{(query, index) => {
|
||||||
|
const lf = () => focus(0);
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="row"
|
||||||
flexGrow={1}
|
gap={1}
|
||||||
border
|
paddingLeft={1}
|
||||||
borderColor={
|
paddingRight={1}
|
||||||
nav.activeDepth() === SearchPaneType.RESULTS
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
? theme.accent
|
onMouseDown={() => {
|
||||||
: theme.border
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
}
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box padding={1}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
<text
|
{index() === lf() ? "❯" : " "}
|
||||||
fg={
|
</text>
|
||||||
nav.activeDepth() === SearchPaneType.RESULTS
|
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
||||||
? theme.primary
|
</box>
|
||||||
: theme.muted
|
);
|
||||||
}
|
}}
|
||||||
>
|
</For>
|
||||||
Results ({searchStore.results().length})
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>
|
||||||
|
{inputActive()
|
||||||
|
? "Enter to search · Esc to defocus"
|
||||||
|
: "j/k recents · s to type · h back"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={depth() >= 1}>
|
||||||
|
{/* results list */}
|
||||||
<Show
|
<Show
|
||||||
when={searchStore.results().length > 0}
|
when={results().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={2}>
|
<box padding={1}>
|
||||||
<text fg={theme.muted}>
|
<text fg={muted()}>
|
||||||
{searchStore.query()
|
{searchStore.query()
|
||||||
? "No results found"
|
? "No results found"
|
||||||
: "Enter a search term to find podcasts"}
|
: "Enter a search term to find podcasts"}
|
||||||
@@ -161,44 +299,136 @@ export function SearchPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SearchResults
|
<For each={results()}>
|
||||||
results={searchStore.results()}
|
{(result, index) => {
|
||||||
selectedIndex={resultIndex()}
|
const fi = () => focusedResultIdx();
|
||||||
focused={nav.activeDepth() === SearchPaneType.RESULTS}
|
return (
|
||||||
onSelect={handleResultSelect}
|
<box
|
||||||
onChange={setResultIndex}
|
flexDirection="column"
|
||||||
isSearching={searchStore.isSearching()}
|
gap={0}
|
||||||
error={searchStore.error()}
|
paddingLeft={1}
|
||||||
/>
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
|
{index() === fi() ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
|
{result.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={result.podcast.isSubscribed}>
|
||||||
|
<text
|
||||||
|
fg={index() === fi() ? theme.surface : theme.success}
|
||||||
|
>
|
||||||
|
[+]
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={result.podcast.author}>
|
||||||
{/* History Sidebar */}
|
|
||||||
<box width={30} border borderColor={theme.border}>
|
|
||||||
<box padding={1} flexDirection="column">
|
|
||||||
<box paddingBottom={1}>
|
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
nav.activeDepth() === SearchPaneType.HISTORY
|
paddingLeft={2}
|
||||||
? theme.primary
|
|
||||||
: theme.muted
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
History
|
by {result.podcast.author}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</Show>
|
||||||
<SearchHistory
|
|
||||||
history={searchStore.history()}
|
|
||||||
selectedIndex={historyIndex()}
|
|
||||||
focused={nav.activeDepth() === SearchPaneType.HISTORY}
|
|
||||||
onSelect={handleHistorySelect}
|
|
||||||
onRemove={searchStore.removeFromHistory}
|
|
||||||
onClear={searchStore.clearHistory}
|
|
||||||
onChange={setHistoryIndex}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── preview pane ────────────────────────────────────────────────────────────
|
||||||
|
const previewContent = () =>
|
||||||
|
depth() === 0 ? (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>Search</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>Type a query, press Enter to search.</text>
|
||||||
|
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||||
|
<For each={recents().slice(0, 6)}>
|
||||||
|
{(q) => <text fg={muted()}>‣ {q}</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<Show
|
||||||
|
when={focusedResult()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No result focused</text>
|
||||||
|
</box>
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
{(result) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>{result().podcast.title}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={result().podcast.author}>
|
||||||
|
<text fg={muted()}>by {result().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={result().podcast.description}>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{result().podcast.description!.slice(0, 400) ??
|
||||||
|
"No description available."}
|
||||||
|
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||||||
|
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||||
|
</text>
|
||||||
|
<Show when={result().sourceName}>
|
||||||
|
<text fg={muted()}>Source: {result().sourceName}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<Show when={!result().podcast.isSubscribed}>
|
||||||
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={result().podcast.isSubscribed}>
|
||||||
|
<text fg={theme.success}>Already subscribed</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLabel = () =>
|
||||||
|
depth() === 0
|
||||||
|
? `Search · ${recents().length} recent`
|
||||||
|
: `Results · ${results().length}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
preview={previewContent}
|
||||||
|
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
||||||
|
currentLabel={currentLabel}
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={isActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SearchPage };
|
||||||
|
|||||||
@@ -14,13 +14,8 @@ const typeLabel = (sourceType?: SourceType) => {
|
|||||||
return "Source";
|
return "Source";
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
// No module-level typeColor here — it needs the theme from the component.
|
||||||
if (sourceType === SourceType.API) return theme.primary;
|
// The correct definition lives inside SourceBadge below.
|
||||||
if (sourceType === SourceType.RSS) return theme.success;
|
|
||||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
|
||||||
return theme.textMuted;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SourceBadge(props: SourceBadgeProps) {
|
export function SourceBadge(props: SourceBadgeProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const label = () => props.sourceName || props.sourceId;
|
const label = () => props.sourceName || props.sourceId;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createSignal } from "solid-js";
|
/**
|
||||||
import { useKeyboard } from "@opentui/solid";
|
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
||||||
import { useAppStore } from "@/stores/app";
|
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
* driven by the Shell router via nav.action.
|
||||||
import type { ThemeName } from "@/types/settings";
|
*/
|
||||||
|
|
||||||
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import type { ThemeName } from "@/types/settings";
|
||||||
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||||
{ value: "system", label: "System" },
|
{ value: "system", label: "System" },
|
||||||
@@ -15,145 +17,78 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
|||||||
{ value: "custom", label: "Custom" },
|
{ value: "custom", label: "Custom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PreferencesPanel() {
|
export function usePreferencesItems(): SettingItem[] {
|
||||||
const appStore = useAppStore();
|
const app = useAppStore();
|
||||||
const { theme } = useTheme();
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("theme");
|
|
||||||
|
|
||||||
const settings = () => appStore.state().settings;
|
const settings = () => app.state().settings;
|
||||||
const preferences = () => appStore.state().preferences;
|
const prefs = () => app.state().preferences;
|
||||||
|
|
||||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
return [
|
||||||
if (key.name === "tab") {
|
{
|
||||||
const fields: FocusField[] = [
|
id: "theme",
|
||||||
"theme",
|
label: "Theme",
|
||||||
"font",
|
kind: "select",
|
||||||
"speed",
|
display: () =>
|
||||||
"explicit",
|
THEME_LABELS.find((t) => t.value === settings().theme)?.label ??
|
||||||
"auto",
|
settings().theme,
|
||||||
];
|
help: () =>
|
||||||
const idx = fields.indexOf(focusField());
|
`Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`,
|
||||||
const next = key.shift
|
cycle: (dir) => {
|
||||||
? (idx - 1 + fields.length) % fields.length
|
|
||||||
: (idx + 1) % fields.length;
|
|
||||||
setFocusField(fields[next]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "left" || key.name === "h") {
|
|
||||||
stepValue(-1);
|
|
||||||
}
|
|
||||||
if (key.name === "right" || key.name === "l") {
|
|
||||||
stepValue(1);
|
|
||||||
}
|
|
||||||
if (key.name === "space" || key.name === "return") {
|
|
||||||
toggleValue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepValue = (delta: number) => {
|
|
||||||
const field = focusField();
|
|
||||||
if (field === "theme") {
|
|
||||||
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
|
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
|
||||||
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length;
|
const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length;
|
||||||
appStore.setTheme(THEME_LABELS[next].value);
|
app.setTheme(THEME_LABELS[next].value);
|
||||||
return;
|
},
|
||||||
}
|
},
|
||||||
if (field === "font") {
|
{
|
||||||
const next = Math.min(20, Math.max(10, settings().fontSize + delta));
|
id: "fontSize",
|
||||||
appStore.updateSettings({ fontSize: next });
|
label: "Font Size",
|
||||||
return;
|
kind: "number",
|
||||||
}
|
display: () => `${settings().fontSize}px`,
|
||||||
if (field === "speed") {
|
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(
|
const next = Math.min(
|
||||||
2,
|
2,
|
||||||
Math.max(0.5, settings().playbackSpeed + delta * 0.1),
|
Math.max(0.5, settings().playbackSpeed + dir * 0.1),
|
||||||
);
|
|
||||||
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleValue = () => {
|
|
||||||
const field = focusField();
|
|
||||||
if (field === "explicit") {
|
|
||||||
appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
|
|
||||||
}
|
|
||||||
if (field === "auto") {
|
|
||||||
appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboard(handleKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Preferences</text>
|
|
||||||
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
|
|
||||||
Theme:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
|
|
||||||
Font Size:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>{settings().fontSize}px</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
|
|
||||||
Playback:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text
|
|
||||||
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
|
|
||||||
>
|
|
||||||
Show Explicit:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text
|
|
||||||
fg={preferences().showExplicit ? theme.success : theme.textMuted}
|
|
||||||
>
|
|
||||||
{preferences().showExplicit ? "On" : "Off"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Space]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
|
|
||||||
Auto Download:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text
|
|
||||||
fg={preferences().autoDownload ? theme.success : theme.textMuted}
|
|
||||||
>
|
|
||||||
{preferences().autoDownload ? "On" : "Off"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Space]</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
|
||||||
</box>
|
|
||||||
);
|
);
|
||||||
|
app.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "showExplicit",
|
||||||
|
label: "Show Explicit",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (prefs().showExplicit ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Whether to list explicit episodes.\nType: toggle\nDefault: true\nCurrent: ${prefs().showExplicit}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updatePreferences({
|
||||||
|
showExplicit: !prefs().showExplicit,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autoDownload",
|
||||||
|
label: "Auto Download",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (prefs().autoDownload ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updatePreferences({
|
||||||
|
autoDownload: !prefs().autoDownload,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +1,532 @@
|
|||||||
import { createSignal, For, onMount } from "solid-js";
|
/**
|
||||||
import { useKeyboard } from "@opentui/solid";
|
* SettingsPage — yazi depth-stack settings.
|
||||||
import { SourceManager } from "./SourceManager";
|
*
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
* depth 0 — sections list (Sync / Sources / Preferences / Visualizer / ...)
|
||||||
import { PreferencesPanel } from "./PreferencesPanel";
|
* depth 1 — the focused section's items as a navigable list
|
||||||
import { SyncPanel } from "./SyncPanel";
|
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
||||||
import { VisualizerSettings } from "./VisualizerSettings";
|
*
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
* Renders entirely through `<YaziPaneRow>` (parent | current | preview):
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
||||||
|
* blank placeholder at depth 0 (1/7 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.
|
||||||
|
*/
|
||||||
|
|
||||||
enum SettingsPaneType {
|
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
|
||||||
SYNC = 1,
|
import { rgbToHex, type RGBA } from "@opentui/core";
|
||||||
SOURCES = 2,
|
import { useTheme, type ThemeResolved } from "@/context/ThemeContext";
|
||||||
PREFERENCES = 3,
|
import {
|
||||||
VISUALIZER = 4,
|
useNavigation,
|
||||||
ACCOUNT = 5,
|
NavMode,
|
||||||
}
|
DEPTH_CENTER_PANE,
|
||||||
export const SettingsPaneCount = 5;
|
type PaneId,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||||
|
import { usePreferencesItems } from "./PreferencesPanel";
|
||||||
|
import { useVisualizerItems } from "./VisualizerSettings";
|
||||||
|
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||||
|
import { useSourceItems } from "./SourceManager";
|
||||||
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
|
export const SettingsPaneCount = 1;
|
||||||
{ id: SettingsPaneType.SYNC, label: "Sync" },
|
|
||||||
{ id: SettingsPaneType.SOURCES, label: "Sources" },
|
const SECTIONS: SettingsSectionDef[] = [
|
||||||
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
|
{
|
||||||
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
|
id: 0,
|
||||||
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
|
label: "Sync",
|
||||||
|
description: "Import/export subscriptions and sync status.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
label: "Sources",
|
||||||
|
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
label: "Preferences",
|
||||||
|
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
label: "Visualizer",
|
||||||
|
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
label: "Account",
|
||||||
|
description: "Account login & OAuth (not yet implemented).",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Resolve the items for a section id at render time. Section 4 (Account) has
|
||||||
|
* no items yet. */
|
||||||
|
function sectionItems(sectionId: number): SettingItem[] {
|
||||||
|
switch (sectionId) {
|
||||||
|
case 0:
|
||||||
|
return useSyncItems();
|
||||||
|
case 1:
|
||||||
|
return useSourceItems();
|
||||||
|
case 2:
|
||||||
|
return usePreferencesItems();
|
||||||
|
case 3:
|
||||||
|
return useVisualizerItems();
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
// Helper function to check if a depth is active
|
const stack = nav.depthStack;
|
||||||
const isActive = (depth: SettingsPaneType): boolean => {
|
const depth = nav.currentDepth;
|
||||||
return nav.activeDepth() === depth;
|
|
||||||
|
// ── 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get the current depth as a number
|
// ── drill / open dispatch ───────────────────────────────────────────────
|
||||||
const currentDepth = () => nav.activeDepth() as number;
|
function open() {
|
||||||
|
const d = depth();
|
||||||
onMount(() => {
|
if (d === 0) {
|
||||||
useKeyboard(
|
// drill into the focused section's items
|
||||||
(keyEvent: any) => {
|
const id = focusedSection().id;
|
||||||
const isDown = keybind.match("down", keyEvent);
|
nav.pushDepth({
|
||||||
const isUp = keybind.match("up", keyEvent);
|
kind: `settings:${id}`,
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
ctx: String(id),
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
focus: 0,
|
||||||
|
});
|
||||||
if (isSelect) {
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (d === 1) {
|
||||||
const nextDepth = isDown
|
const it = focusedItem();
|
||||||
? (nav.activeDepth() % SettingsPaneCount) + 1
|
if (!it) return;
|
||||||
: (nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1;
|
switch (it.kind) {
|
||||||
|
case "toggle":
|
||||||
if (isCycle) {
|
it.toggle?.();
|
||||||
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
|
return;
|
||||||
} else {
|
case "action":
|
||||||
nav.setActiveDepth(nextDepth);
|
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;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{ release: false },
|
if (d === 2) {
|
||||||
);
|
// in an editor: Enter adjusts/cycles a number/select forward, toggles
|
||||||
|
const it = editorItem();
|
||||||
|
if (!it) return;
|
||||||
|
if (it.kind === "number" || it.kind === "select") it.cycle?.(1);
|
||||||
|
else if (it.kind === "toggle") it.toggle?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── movement (j/k etc.) routed by the Shell over nav.action ───────────────
|
||||||
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
|
"move-down": () => step(1),
|
||||||
|
"move-up": () => step(-1),
|
||||||
|
"jump-down": () => step(5),
|
||||||
|
"jump-up": () => step(-5),
|
||||||
|
"page-down": () => step(10),
|
||||||
|
"page-up": () => step(-10),
|
||||||
|
"goto-top": () => nav.gotoIndex(0, len()),
|
||||||
|
"goto-bottom": () => nav.gotoIndex(len() - 1, len()),
|
||||||
|
open: () => open(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function len(): number {
|
||||||
|
const d = depth();
|
||||||
|
if (d === 0) return SECTIONS.length;
|
||||||
|
if (d === 1) return items().length;
|
||||||
|
return 0; // depth 2 editor: no list length; j/k cycles instead
|
||||||
|
}
|
||||||
|
function step(delta: number) {
|
||||||
|
const d = depth();
|
||||||
|
if (d === 2) {
|
||||||
|
// editor: j/k nudges the value
|
||||||
|
const it = editorItem();
|
||||||
|
if (it?.kind === "number" || it?.kind === "select")
|
||||||
|
it.cycle?.(delta as -1 | 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav.move(delta, len());
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
// ignore actions meant for non-center panes
|
||||||
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
|
const handler = PAGE_ACTIONS[data.action];
|
||||||
|
if (handler) handler();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
on("nav.action", onAction);
|
||||||
|
// keep a resolver so visual-mode range selection grows by section/item id
|
||||||
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
|
const d = depth();
|
||||||
|
if (d === 0) return SECTIONS[i]?.id.toString();
|
||||||
|
if (d === 1) return items()[i]?.id;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
onCleanup(() => off("nav.action", onAction));
|
||||||
|
|
||||||
|
// when leaving a sync editor (h to pop), close any open dialog overlay
|
||||||
|
onCleanup(() => closeSyncEditor());
|
||||||
|
|
||||||
|
// ── render helpers ───────────────────────────────────────────────────────
|
||||||
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
|
// Whether the currently-focused settings row is the Theme select — the
|
||||||
|
// only item whose Detail pane carries a color breakdown below the help text.
|
||||||
|
const isThemeItem = () => {
|
||||||
|
const d = depth();
|
||||||
|
if (d === 1) return focusedItem()?.id === "theme";
|
||||||
|
if (d === 2) return editorItem()?.id === "theme";
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// preview text for the right column
|
||||||
|
const previewText = createMemo<string>(() => {
|
||||||
|
const d = depth();
|
||||||
|
if (d === 0) {
|
||||||
|
return `${focusedSection().label}\n\n${focusedSection().description}\n\nDrill in (Enter/l) to open this section's settings.`;
|
||||||
|
}
|
||||||
|
if (d === 1) {
|
||||||
|
const it = focusedItem();
|
||||||
|
return it?.help() ?? "No item.";
|
||||||
|
}
|
||||||
|
// editor: same help, plus note
|
||||||
|
const it = editorItem();
|
||||||
|
return it
|
||||||
|
? `${it.help()}\n\n— Editor —\nj/k adjust · h back`
|
||||||
|
: "No editor.";
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
// ── column label ───────────────────────────────────────────────────────────
|
||||||
<box flexDirection="column" gap={1} height="100%" width="100%">
|
const currentLabel = () => {
|
||||||
<box flexDirection="row" gap={1}>
|
const d = depth();
|
||||||
|
if (d === 0) return "Settings";
|
||||||
|
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
||||||
|
return editorItem()?.label ?? "Editor";
|
||||||
|
};
|
||||||
|
const parentLabel = () => {
|
||||||
|
const d = depth();
|
||||||
|
if (d === 1) return "Sections";
|
||||||
|
if (d === 2) return sectionForDepth1()?.label ?? "";
|
||||||
|
return "Up";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
||||||
|
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
||||||
|
// 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}>
|
<For each={SECTIONS}>
|
||||||
{(section, index) => (
|
{(section, index) => (
|
||||||
<box
|
<Row
|
||||||
border
|
label={section.label}
|
||||||
borderColor={theme.border}
|
focused={index() === focusedSectionIdx()}
|
||||||
padding={0}
|
active={false}
|
||||||
backgroundColor={
|
/>
|
||||||
currentDepth() === section.id ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
onMouseDown={() => nav.setActiveDepth(section.id)}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
currentDepth() === section.id ? theme.text : theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
[{index() + 1}] {section.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</box>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<box
|
// ── current pane: current-depth list (or editor at depth 2) ───────────────
|
||||||
border
|
const currentContent = () => (
|
||||||
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border}
|
<>
|
||||||
flexGrow={1}
|
<Show when={depth() === 0}>
|
||||||
padding={1}
|
<For each={SECTIONS}>
|
||||||
flexDirection="column"
|
{(section, index) => (
|
||||||
gap={1}
|
<Row
|
||||||
|
label={section.label}
|
||||||
|
focused={index() === focusedSectionIdx()}
|
||||||
|
active={isActive()}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
<Show when={depth() === 1}>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={items()}>
|
||||||
|
{(it, index) => (
|
||||||
|
<Row
|
||||||
|
label={`${it.label}`}
|
||||||
|
value={it.display()}
|
||||||
|
focused={index() === focusedItemIdx()}
|
||||||
|
active={isActive()}
|
||||||
|
hint={hintFor(it)}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={items().length === 0}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={depth() === 2}>
|
||||||
|
{/* depth 2: editor */}
|
||||||
|
<Show
|
||||||
|
when={editorItem()?.renderEditor}
|
||||||
|
fallback={<GenericEditor item={editorItem()!} />}
|
||||||
>
|
>
|
||||||
{isActive(SettingsPaneType.SYNC) && <SyncPanel />}
|
{editorItem()!.renderEditor!()}
|
||||||
{isActive(SettingsPaneType.SOURCES) && (
|
</Show>
|
||||||
<SourceManager focused />
|
</Show>
|
||||||
)}
|
</>
|
||||||
{isActive(SettingsPaneType.PREFERENCES) && (
|
);
|
||||||
<PreferencesPanel />
|
|
||||||
)}
|
// ── preview pane ──────────────────────────────────────────────────────────
|
||||||
{isActive(SettingsPaneType.VISUALIZER) && (
|
const previewContent = () => (
|
||||||
<VisualizerSettings />
|
<box padding={1} flexDirection="column">
|
||||||
)}
|
{/* Keep everything on a stable root so Solid re-resolves the swap
|
||||||
{isActive(SettingsPaneType.ACCOUNT) && (
|
between plain help text and the theme breakdown on focus move. */}
|
||||||
<box flexDirection="column" gap={1}>
|
<Show when={isThemeItem()} fallback={<MultiLine text={previewText()} />}>
|
||||||
<text fg={theme.textMuted}>Account</text>
|
<MultiLine text={previewText()} />
|
||||||
</box>
|
<ThemeBreakdown />
|
||||||
)}
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
preview={previewContent}
|
||||||
|
parentLabel={parentLabel}
|
||||||
|
currentLabel={currentLabel}
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={isActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-kind hint glyph shown at the right of an item row. */
|
||||||
|
function hintFor(it: SettingItem): string {
|
||||||
|
switch (it.kind) {
|
||||||
|
case "toggle":
|
||||||
|
return "⏻";
|
||||||
|
case "number":
|
||||||
|
case "select":
|
||||||
|
return "±";
|
||||||
|
case "action":
|
||||||
|
return "↵";
|
||||||
|
case "editor":
|
||||||
|
return "→";
|
||||||
|
case "info":
|
||||||
|
return "·";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row(props: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
focused: boolean;
|
||||||
|
active: boolean;
|
||||||
|
hint?: string;
|
||||||
|
onMouseDown?: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const bg = () =>
|
||||||
|
props.focused && props.active
|
||||||
|
? theme.primary
|
||||||
|
: props.focused
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () => (props.focused && props.active ? theme.surface : theme.text);
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>{props.focused ? "❯" : " "}</text>
|
||||||
|
<text fg={fg()}>{props.label}</text>
|
||||||
|
<Show when={props.value}>
|
||||||
|
<box flexGrow={1} />
|
||||||
|
<text fg={props.focused ? fg() : theme.textMuted}>{props.value}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.hint}>
|
||||||
|
<text fg={theme.textMuted}>{props.hint}</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,317 +1,141 @@
|
|||||||
/**
|
/**
|
||||||
* Source management component for PodTUI
|
* SourceManager — exposes podcast sources as SettingItems for the depth-stack.
|
||||||
* Add, remove, and configure podcast sources
|
*
|
||||||
|
* • "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 } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { SourceType } from "@/types/source";
|
import { SourceType } from "@/types/source";
|
||||||
import type { PodcastSource } from "@/types/source";
|
import type { PodcastSource } from "@/types/source";
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
export function useSourceItems(): SettingItem[] {
|
||||||
focused?: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
|
|
||||||
|
|
||||||
export function SourceManager(props: SourceManagerProps) {
|
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const { theme } = useTheme();
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
||||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("list");
|
|
||||||
const [newSourceUrl, setNewSourceUrl] = createSignal("");
|
|
||||||
const [newSourceName, setNewSourceName] = createSignal("");
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const sources = () => feedStore.sources();
|
const typeBadge = (s: PodcastSource) =>
|
||||||
|
s.type === SourceType.API
|
||||||
|
? "[API]"
|
||||||
|
: s.type === SourceType.RSS
|
||||||
|
? "[RSS]"
|
||||||
|
: "[?]";
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
const items: SettingItem[] = [
|
||||||
if (key.name === "escape") {
|
{
|
||||||
if (focusArea() !== "list") {
|
id: "add",
|
||||||
setFocusArea("list");
|
label: "Add Source",
|
||||||
setError(null);
|
kind: "editor",
|
||||||
} else if (props.onClose) {
|
display: () => "+",
|
||||||
props.onClose();
|
help: () =>
|
||||||
}
|
`Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`,
|
||||||
return;
|
renderEditor: () => <AddSourceForm />,
|
||||||
}
|
},
|
||||||
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const areas: FocusArea[] = [
|
|
||||||
"list",
|
|
||||||
"country",
|
|
||||||
"language",
|
|
||||||
"explicit",
|
|
||||||
"add",
|
|
||||||
"url",
|
|
||||||
];
|
];
|
||||||
const idx = areas.indexOf(focusArea());
|
|
||||||
const nextIdx = key.shift
|
|
||||||
? (idx - 1 + areas.length) % areas.length
|
|
||||||
: (idx + 1) % areas.length;
|
|
||||||
setFocusArea(areas[nextIdx]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusArea() === "list") {
|
for (const s of feedStore.sources()) {
|
||||||
if (key.name === "up" || key.name === "k") {
|
items.push({
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
id: `src:${s.id}`,
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
label: s.name,
|
||||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
|
kind: "toggle",
|
||||||
} else if (
|
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
||||||
key.name === "return" ||
|
help: () =>
|
||||||
key.name === "space"
|
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||||
) {
|
toggle: () => feedStore.toggleSource(s.id),
|
||||||
const source = sources()[selectedIndex()];
|
|
||||||
if (source) {
|
|
||||||
feedStore.toggleSource(source.id);
|
|
||||||
}
|
|
||||||
} else if (key.name === "d" || key.name === "delete") {
|
|
||||||
const source = sources()[selectedIndex()];
|
|
||||||
if (source) {
|
|
||||||
const removed = feedStore.removeSource(source.id);
|
|
||||||
if (!removed) {
|
|
||||||
setError("Cannot remove default sources");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (key.name === "a") {
|
|
||||||
setFocusArea("add");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusArea() === "country") {
|
|
||||||
if (
|
|
||||||
key.name === "enter" ||
|
|
||||||
key.name === "return" ||
|
|
||||||
key.name === "space"
|
|
||||||
) {
|
|
||||||
const source = sources()[selectedIndex()];
|
|
||||||
if (source && source.type === SourceType.API) {
|
|
||||||
const next = source.country === "US" ? "GB" : "US";
|
|
||||||
feedStore.updateSource(source.id, { country: next });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusArea() === "explicit") {
|
|
||||||
if (
|
|
||||||
key.name === "return" ||
|
|
||||||
key.name === "space"
|
|
||||||
) {
|
|
||||||
const source = sources()[selectedIndex()];
|
|
||||||
if (source && source.type === SourceType.API) {
|
|
||||||
feedStore.updateSource(source.id, {
|
|
||||||
allowExplicit: !source.allowExplicit,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusArea() === "language") {
|
function AddSourceForm() {
|
||||||
if (
|
const feedStore = useFeedStore();
|
||||||
key.name === "return" ||
|
const { theme } = useTheme();
|
||||||
key.name === "space"
|
const [name, setName] = createSignal("");
|
||||||
) {
|
const [url, setUrl] = createSignal("");
|
||||||
const source = sources()[selectedIndex()];
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
if (source && source.type === SourceType.API) {
|
|
||||||
const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
|
|
||||||
feedStore.updateSource(source.id, { language: next });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSource = () => {
|
const submit = () => {
|
||||||
const url = newSourceUrl().trim();
|
const u = url().trim();
|
||||||
const name = newSourceName().trim() || `Custom Source`;
|
if (!u) {
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
setError("URL is required");
|
setError("URL is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(u);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid URL format");
|
setError("Invalid URL format");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
feedStore.addSource({
|
feedStore.addSource({
|
||||||
name,
|
name: name().trim() || "Custom Source",
|
||||||
type: "rss" as SourceType,
|
type: SourceType.RSS,
|
||||||
baseUrl: url,
|
baseUrl: u,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
description: `Custom RSS feed: ${url}`,
|
description: `Custom RSS feed: ${u}`,
|
||||||
});
|
});
|
||||||
|
setName("");
|
||||||
setNewSourceUrl("");
|
setUrl("");
|
||||||
setNewSourceName("");
|
|
||||||
setFocusArea("list");
|
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSourceIcon = (source: PodcastSource) => {
|
|
||||||
if (source.type === SourceType.API) return "[API]";
|
|
||||||
if (source.type === SourceType.RSS) return "[RSS]";
|
|
||||||
return "[?]";
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedSource = () => sources()[selectedIndex()];
|
|
||||||
const isApiSource = () => selectedSource()?.type === SourceType.API;
|
|
||||||
const sourceCountry = () => selectedSource()?.country || "US";
|
|
||||||
const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
|
|
||||||
const sourceLanguage = () => selectedSource()?.language || "en_us";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}>
|
<box flexDirection="column" padding={1} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<strong>Podcast Sources</strong>
|
<strong>Add Source</strong>
|
||||||
</text>
|
</text>
|
||||||
<box border borderColor={theme.border} padding={0} onMouseDown={props.onClose}>
|
|
||||||
<text fg={theme.primary}>[Esc] Close</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
|
||||||
|
|
||||||
{/* Source list */}
|
|
||||||
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
|
||||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
|
|
||||||
Sources:
|
|
||||||
</text>
|
|
||||||
<scrollbox height={6}>
|
|
||||||
<For each={sources()}>
|
|
||||||
{(source, index) => (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
padding={0}
|
|
||||||
onMouseDown={() => {
|
|
||||||
setSelectedIndex(index());
|
|
||||||
setFocusArea("list");
|
|
||||||
feedStore.toggleSource(source.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{focusArea() === "list" && index() === selectedIndex()
|
|
||||||
? ">"
|
|
||||||
: " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{source.name}
|
|
||||||
</SelectableText>
|
|
||||||
</SelectableBox>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Space/Enter to toggle, d to delete, a to add
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* API settings */}
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<SelectableText selected={() => false} primary={isApiSource()}>
|
|
||||||
{isApiSource()
|
|
||||||
? "API Settings"
|
|
||||||
: "API Settings (select an API source)"}
|
|
||||||
</SelectableText>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={
|
|
||||||
focusArea() === "country" ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectableText selected={() => false} primary={focusArea() === "country"}>
|
|
||||||
Country: {sourceCountry()}
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={
|
|
||||||
focusArea() === "language" ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectableText selected={() => false} primary={focusArea() === "language"}>
|
|
||||||
Language:{" "}
|
|
||||||
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={
|
|
||||||
focusArea() === "explicit" ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectableText selected={() => false} primary={focusArea() === "explicit"}>
|
|
||||||
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
<SelectableText selected={() => false} tertiary>
|
|
||||||
Enter/Space to toggle focused setting
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Add new source form */}
|
|
||||||
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
|
||||||
<SelectableText selected={() => false} primary={focusArea() === "add" || focusArea() === "url"}>
|
|
||||||
Add New Source:
|
|
||||||
</SelectableText>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<SelectableText selected={() => false} tertiary>Name:</SelectableText>
|
<text fg={theme.textMuted}>Name:</text>
|
||||||
<input
|
<input
|
||||||
value={newSourceName()}
|
value={name()}
|
||||||
onInput={setNewSourceName}
|
onInput={setName}
|
||||||
placeholder="My Custom Feed"
|
placeholder="My Custom Feed"
|
||||||
focused={props.focused && focusArea() === "add"}
|
|
||||||
width={25}
|
width={25}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<SelectableText selected={() => false} tertiary>URL:</SelectableText>
|
<text fg={theme.textMuted}>URL:</text>
|
||||||
<input
|
<input
|
||||||
value={newSourceUrl()}
|
value={url()}
|
||||||
onInput={(v) => {
|
onInput={(v) => {
|
||||||
setNewSourceUrl(v);
|
setUrl(v);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com/feed.rss"
|
placeholder="https://example.com/feed.rss"
|
||||||
focused={props.focused && focusArea() === "url"}
|
|
||||||
width={35}
|
width={35}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
<box
|
||||||
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}>
|
border
|
||||||
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText>
|
borderColor={theme.border}
|
||||||
|
padding={0}
|
||||||
|
width={15}
|
||||||
|
onMouseDown={submit}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary}>[+] Add</text>
|
||||||
</box>
|
</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>
|
</box>
|
||||||
|
</Show>
|
||||||
{/* Error message */}
|
|
||||||
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
|
|
||||||
|
|
||||||
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,57 @@
|
|||||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
/**
|
||||||
let current = value
|
* SyncPanel — exposes Import / Export / status as SettingItems. The Import and
|
||||||
return [() => current, (next) => {
|
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
||||||
current = next
|
*/
|
||||||
}]
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { ImportDialog } from "./ImportDialog";
|
||||||
|
import { ExportDialog } from "./ExportDialog";
|
||||||
|
import { SyncStatus } from "./SyncStatus";
|
||||||
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
|
// Module-level state so the action items can open their dialogs as depth-2
|
||||||
|
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
|
||||||
|
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
export { syncEditor };
|
||||||
|
export function closeSyncEditor() {
|
||||||
|
setSyncEditor(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { ImportDialog } from "./ImportDialog"
|
export function useSyncItems(): SettingItem[] {
|
||||||
import { ExportDialog } from "./ExportDialog"
|
return [
|
||||||
import { SyncStatus } from "./SyncStatus"
|
{
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
id: "import",
|
||||||
|
label: "Import",
|
||||||
export function SyncPanel() {
|
kind: "editor",
|
||||||
const { theme } = useTheme();
|
display: () => "→",
|
||||||
const mode = createSignal<"import" | "export" | null>(null)
|
help: () =>
|
||||||
|
`Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`,
|
||||||
return (
|
renderEditor: () => <ImportDialog />,
|
||||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
},
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
{
|
||||||
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}>
|
id: "export",
|
||||||
<text fg={theme.text}>Import</text>
|
label: "Export",
|
||||||
</box>
|
kind: "editor",
|
||||||
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}>
|
display: () => "→",
|
||||||
<text fg={theme.text}>Export</text>
|
help: () =>
|
||||||
</box>
|
`Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`,
|
||||||
</box>
|
renderEditor: () => <ExportDialog />,
|
||||||
<SyncStatus />
|
},
|
||||||
{mode[0]() === "import" ? <ImportDialog /> : null}
|
{
|
||||||
{mode[0]() === "export" ? <ExportDialog /> : null}
|
id: "status",
|
||||||
</box>
|
label: "Status",
|
||||||
)
|
kind: "info",
|
||||||
|
display: () => "Idle",
|
||||||
|
help: () =>
|
||||||
|
`Last sync status. (Sync is run from the import/export dialogs.)\nType: info`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the live sync status block (used by the Settings page header for the
|
||||||
|
* Sync section, when relevant). */
|
||||||
|
export function SyncStatusBlock() {
|
||||||
|
return <SyncStatus />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +1,81 @@
|
|||||||
/**
|
/**
|
||||||
* VisualizerSettings — settings panel for the real-time audio visualizer.
|
* VisualizerSettings — exposes bars/sensitivity/noise/lowCut/highCut as
|
||||||
*
|
* SettingItems for the yazi depth-stack. No own useKeyboard.
|
||||||
* Allows adjusting bar count, noise reduction, sensitivity, and
|
|
||||||
* frequency cutoffs. All changes persist via the app store.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
|
export function useVisualizerItems(): SettingItem[] {
|
||||||
|
const app = useAppStore();
|
||||||
|
const viz = () => app.state().settings.visualizer;
|
||||||
|
|
||||||
const FIELDS: FocusField[] = [
|
return [
|
||||||
"bars",
|
{
|
||||||
"sensitivity",
|
id: "bars",
|
||||||
"noise",
|
label: "Bars",
|
||||||
"lowCut",
|
kind: "number",
|
||||||
"highCut",
|
display: () => String(viz().bars),
|
||||||
];
|
help: () =>
|
||||||
|
`Number of visualizer bars.\nType: number (8–128, step 8)\nDefault: 64\nCurrent: ${viz().bars}\nj/k to −/+8.`,
|
||||||
export function VisualizerSettings() {
|
cycle: (dir) =>
|
||||||
const appStore = useAppStore();
|
app.updateVisualizer({
|
||||||
const { theme } = useTheme();
|
bars: Math.min(128, Math.max(8, viz().bars + dir * 8)),
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("bars");
|
}),
|
||||||
|
},
|
||||||
const viz = () => appStore.state().settings.visualizer;
|
{
|
||||||
|
id: "sensitivity",
|
||||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
label: "Auto Sensitivity",
|
||||||
if (key.name === "tab") {
|
kind: "toggle",
|
||||||
const idx = FIELDS.indexOf(focusField());
|
display: () => (viz().sensitivity === 1 ? "On" : "Off"),
|
||||||
const next = key.shift
|
help: () =>
|
||||||
? (idx - 1 + FIELDS.length) % FIELDS.length
|
`Automatic gain sensitivity.\nType: toggle\nDefault: on\nCurrent: ${viz().sensitivity === 1 ? "on" : "off"}\nSpace/Enter to toggle.`,
|
||||||
: (idx + 1) % FIELDS.length;
|
toggle: () =>
|
||||||
setFocusField(FIELDS[next]);
|
app.updateVisualizer({
|
||||||
return;
|
sensitivity: viz().sensitivity === 1 ? 0 : 1,
|
||||||
}
|
}),
|
||||||
|
},
|
||||||
if (key.name === "left" || key.name === "h") {
|
{
|
||||||
stepValue(-1);
|
id: "noiseReduction",
|
||||||
}
|
label: "Noise Reduction",
|
||||||
if (key.name === "right" || key.name === "l") {
|
kind: "number",
|
||||||
stepValue(1);
|
display: () => viz().noiseReduction.toFixed(2),
|
||||||
}
|
help: () =>
|
||||||
};
|
`FFT noise reduction factor.\nType: number (0.00–1.00, step 0.05)\nDefault: 0.20\nCurrent: ${viz().noiseReduction.toFixed(2)}\nj/k to −/+0.05.`,
|
||||||
|
cycle: (dir) =>
|
||||||
const stepValue = (delta: number) => {
|
app.updateVisualizer({
|
||||||
const field = focusField();
|
noiseReduction: Math.min(
|
||||||
const v = viz();
|
|
||||||
|
|
||||||
switch (field) {
|
|
||||||
case "bars": {
|
|
||||||
// Step by 8: 8, 16, 24, 32, ..., 128
|
|
||||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8));
|
|
||||||
appStore.updateVisualizer({ bars: next });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "sensitivity": {
|
|
||||||
// Toggle: 0 (manual) or 1 (auto)
|
|
||||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "noise": {
|
|
||||||
// Step by 0.05: 0.0 – 1.0
|
|
||||||
const next = Math.min(
|
|
||||||
1,
|
1,
|
||||||
Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))),
|
Math.max(0, Number((viz().noiseReduction + dir * 0.05).toFixed(2))),
|
||||||
);
|
),
|
||||||
appStore.updateVisualizer({ noiseReduction: next });
|
}),
|
||||||
break;
|
},
|
||||||
}
|
{
|
||||||
case "lowCut": {
|
id: "lowCutOff",
|
||||||
// Step by 10: 20 – 500 Hz
|
label: "Low Cutoff",
|
||||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10));
|
kind: "number",
|
||||||
appStore.updateVisualizer({ lowCutOff: next });
|
display: () => `${viz().lowCutOff} Hz`,
|
||||||
break;
|
help: () =>
|
||||||
}
|
`Lower frequency cutoff.\nType: number (20–500 Hz, step 10)\nDefault: 20\nCurrent: ${viz().lowCutOff}\nj/k to −/+10.`,
|
||||||
case "highCut": {
|
cycle: (dir) =>
|
||||||
// Step by 500: 1000 – 20000 Hz
|
app.updateVisualizer({
|
||||||
const next = Math.min(
|
lowCutOff: Math.min(500, Math.max(20, viz().lowCutOff + dir * 10)),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "highCutOff",
|
||||||
|
label: "High Cutoff",
|
||||||
|
kind: "number",
|
||||||
|
display: () => `${viz().highCutOff} Hz`,
|
||||||
|
help: () =>
|
||||||
|
`Upper frequency cutoff.\nType: number (1000–20000 Hz, step 500)\nDefault: 20000\nCurrent: ${viz().highCutOff}\nj/k to −/+500.`,
|
||||||
|
cycle: (dir) =>
|
||||||
|
app.updateVisualizer({
|
||||||
|
highCutOff: Math.min(
|
||||||
20000,
|
20000,
|
||||||
Math.max(1000, v.highCutOff + delta * 500),
|
Math.max(1000, viz().highCutOff + dir * 500),
|
||||||
);
|
),
|
||||||
appStore.updateVisualizer({ highCutOff: next });
|
}),
|
||||||
break;
|
},
|
||||||
}
|
];
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboard(handleKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Visualizer</text>
|
|
||||||
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
|
|
||||||
Bars:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>{viz().bars}</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
focusField() === "sensitivity" ? theme.primary : theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Auto Sensitivity:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text
|
|
||||||
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
|
|
||||||
>
|
|
||||||
{viz().sensitivity === 1 ? "On" : "Off"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
|
|
||||||
Noise Reduction:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text
|
|
||||||
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
|
|
||||||
>
|
|
||||||
Low Cutoff:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text
|
|
||||||
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
|
|
||||||
>
|
|
||||||
High Cutoff:
|
|
||||||
</text>
|
|
||||||
<box border borderColor={theme.border} padding={0}>
|
|
||||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/pages/Settings/types.ts
Normal file
45
src/pages/Settings/types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Settings item model — each settings section exposes a list of items that the
|
||||||
|
* SettingsPage renders through the yazi depth-stack (sections → items → editor).
|
||||||
|
*
|
||||||
|
* All movement flows through the Shell's nav.action router (j/k move, Enter/l
|
||||||
|
* drill, h back), so panels no longer register their own useKeyboard — that was
|
||||||
|
* the root cause of the "right pane ignores keys / double-handled input" bugs.
|
||||||
|
*/
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
|
||||||
|
export type SettingItemKind =
|
||||||
|
| "toggle"
|
||||||
|
| "number"
|
||||||
|
| "select"
|
||||||
|
| "action"
|
||||||
|
| "editor"
|
||||||
|
| "info";
|
||||||
|
|
||||||
|
export interface SettingItem {
|
||||||
|
/** Stable id within its section. */
|
||||||
|
id: string;
|
||||||
|
/** One-line label shown in the items list. */
|
||||||
|
label: string;
|
||||||
|
/** Category — decides how the item is interacted with. */
|
||||||
|
kind: SettingItemKind;
|
||||||
|
/** Current value as a short string (shown to the right of the label). */
|
||||||
|
display: () => string;
|
||||||
|
/** Help text for the preview pane: description, type, default, current. */
|
||||||
|
help: () => string;
|
||||||
|
/** For number/select: nudge the value by -1 or +1 (j/k at depth 2). */
|
||||||
|
cycle?: (dir: -1 | 1) => void;
|
||||||
|
/** For toggle: flip the value (Space/Enter at depth 1). */
|
||||||
|
toggle?: () => void;
|
||||||
|
/** For action: run immediately (Enter at depth 1). */
|
||||||
|
run?: () => void;
|
||||||
|
/** For editor: a bespoke depth-2 editor component. */
|
||||||
|
renderEditor?: () => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSectionDef {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
items?: () => SettingItem[];
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
bars: 32,
|
bars: 64,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Discover store for PodTUI
|
* Discover store for PodTUI
|
||||||
* Manages trending/popular podcasts and category filtering
|
* Manages trending/popular podcasts and category filtering.
|
||||||
|
*
|
||||||
|
* The featured-shows list is fetched at runtime from a JSON file hosted in the
|
||||||
|
* GitHub repo (discover/featured.json on the `master` branch), so the list
|
||||||
|
* can be updated without shipping a new release. The feed URL, de-duped set,
|
||||||
|
* and version field act as the cache key — a fresh fetch only happens when the
|
||||||
|
* version bumps or the cache window (24h) expires.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import type { Podcast } from "../types/podcast"
|
import type { Podcast } from "../types/podcast";
|
||||||
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
export interface DiscoverCategory {
|
export interface DiscoverCategory {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||||
@@ -24,168 +31,166 @@ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
|||||||
{ id: "sports", name: "Sports", icon: "#" },
|
{ id: "sports", name: "Sports", icon: "#" },
|
||||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||||
{ id: "arts", name: "Arts", icon: "@" },
|
{ id: "arts", name: "Arts", icon: "@" },
|
||||||
]
|
];
|
||||||
|
|
||||||
/** Mock trending podcasts */
|
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
||||||
const TRENDING_PODCASTS: Podcast[] = [
|
// The raw GitHub URL serving discover/featured.json from the master branch.
|
||||||
{
|
// Update this file in the repo (no release needed) to refresh the list.
|
||||||
id: "trend-1",
|
const FEATURED_JSON_URL =
|
||||||
title: "AI Today",
|
"https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
|
||||||
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
|
||||||
feedUrl: "https://example.com/aitoday.rss",
|
/** Cache window for the remote featured list (24 hours) */
|
||||||
author: "Tech Futures",
|
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
categories: ["Technology", "Science"],
|
|
||||||
|
/** Shape of a single entry in the remote JSON */
|
||||||
|
interface FeaturedEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
feedUrl: string;
|
||||||
|
author?: string;
|
||||||
|
categories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of the remote JSON manifest */
|
||||||
|
interface FeaturedManifest {
|
||||||
|
version: number;
|
||||||
|
podcasts: FeaturedEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
|
||||||
|
function entryToPodcast(entry: FeaturedEntry): Podcast {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
description: entry.description,
|
||||||
|
feedUrl: entry.feedUrl,
|
||||||
|
author: entry.author,
|
||||||
|
categories: entry.categories ?? [],
|
||||||
coverUrl: undefined,
|
coverUrl: undefined,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
isSubscribed: false,
|
isSubscribed: false,
|
||||||
},
|
};
|
||||||
{
|
}
|
||||||
id: "trend-2",
|
|
||||||
title: "The History Hour",
|
/** Reconcile isSubscribed state across the discover list against the feed store */
|
||||||
description: "Fascinating stories from history that shaped our world today.",
|
function syncSubscriptionState(
|
||||||
feedUrl: "https://example.com/historyhour.rss",
|
podcasts: Podcast[],
|
||||||
author: "History Channel",
|
subscribedUrls: Set<string>,
|
||||||
categories: ["Education", "History"],
|
subscribedIds: Set<string>,
|
||||||
lastUpdated: new Date(),
|
): Podcast[] {
|
||||||
isSubscribed: false,
|
return podcasts.map((p) => ({
|
||||||
},
|
...p,
|
||||||
{
|
isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
|
||||||
id: "trend-3",
|
}));
|
||||||
title: "Comedy Gold",
|
}
|
||||||
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
|
||||||
feedUrl: "https://example.com/comedygold.rss",
|
|
||||||
author: "Laugh Factory",
|
|
||||||
categories: ["Comedy", "Entertainment"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-4",
|
|
||||||
title: "Market Watch",
|
|
||||||
description: "Daily financial news, stock analysis, and investing tips.",
|
|
||||||
feedUrl: "https://example.com/marketwatch.rss",
|
|
||||||
author: "Finance Daily",
|
|
||||||
categories: ["Business", "News"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-5",
|
|
||||||
title: "Science Weekly",
|
|
||||||
description: "Breaking science news and in-depth analysis of the latest research.",
|
|
||||||
feedUrl: "https://example.com/scienceweekly.rss",
|
|
||||||
author: "Science Network",
|
|
||||||
categories: ["Science", "Education"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-6",
|
|
||||||
title: "True Crime Files",
|
|
||||||
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
|
||||||
feedUrl: "https://example.com/truecrime.rss",
|
|
||||||
author: "Crime Network",
|
|
||||||
categories: ["True Crime", "Documentary"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-7",
|
|
||||||
title: "Wellness Journey",
|
|
||||||
description: "Tips for mental and physical health, meditation, and mindful living.",
|
|
||||||
feedUrl: "https://example.com/wellness.rss",
|
|
||||||
author: "Health Media",
|
|
||||||
categories: ["Health", "Self-Help"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-8",
|
|
||||||
title: "Sports Talk Live",
|
|
||||||
description: "Live commentary, analysis, and interviews from the world of sports.",
|
|
||||||
feedUrl: "https://example.com/sportstalk.rss",
|
|
||||||
author: "Sports Network",
|
|
||||||
categories: ["Sports", "News"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-9",
|
|
||||||
title: "Creative Minds",
|
|
||||||
description: "Interviews with artists, designers, and creative professionals.",
|
|
||||||
feedUrl: "https://example.com/creativeminds.rss",
|
|
||||||
author: "Arts Weekly",
|
|
||||||
categories: ["Arts", "Culture"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-10",
|
|
||||||
title: "Dev Talk",
|
|
||||||
description: "Software development, programming tutorials, and tech career advice.",
|
|
||||||
feedUrl: "https://example.com/devtalk.rss",
|
|
||||||
author: "Code Academy",
|
|
||||||
categories: ["Technology", "Education"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Create discover store */
|
/** Create discover store */
|
||||||
export function createDiscoverStore() {
|
export function createDiscoverStore() {
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||||
|
|
||||||
|
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
/** Reconcile local isSubscribed flags with the feed store */
|
||||||
|
const syncSubscriptions = () => {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feeds = feedStore.feeds();
|
||||||
|
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
|
||||||
|
const ids = new Set(feeds.map((f) => f.podcast.id));
|
||||||
|
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetch the featured-shows manifest from GitHub if stale */
|
||||||
|
const refresh = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Skip if cache is still fresh
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(FEATURED_JSON_URL, {
|
||||||
|
headers: { "User-Agent": "PodTUI/1.0" },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const manifest = (await resp.json()) as FeaturedManifest;
|
||||||
|
if (!manifest?.podcasts?.length) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the podcast list from the manifest entries
|
||||||
|
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||||
|
cachedAt = now;
|
||||||
|
setPodcasts(fetched);
|
||||||
|
|
||||||
|
// Reflect current feed-store subscriptions
|
||||||
|
syncSubscriptions();
|
||||||
|
} catch {
|
||||||
|
// Network failure — keep whatever we have (stale or empty)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
/** Get filtered podcasts by category */
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
const category = selectedCategory()
|
const category = selectedCategory();
|
||||||
if (category === "all") {
|
if (category === "all") {
|
||||||
return podcasts()
|
return podcasts();
|
||||||
}
|
}
|
||||||
|
|
||||||
return podcasts().filter((p) => {
|
return podcasts().filter((p) => {
|
||||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
|
||||||
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
return cats.some((c) =>
|
||||||
})
|
c.includes(category.toLowerCase().replace("-", " ")),
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Subscribe to a podcast */
|
/** Subscribe to a podcast */
|
||||||
const subscribe = (podcastId: string) => {
|
const subscribe = (podcastId: string) => {
|
||||||
setPodcasts((prev) =>
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
prev.map((p) =>
|
if (podcast) {
|
||||||
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
// Actually add the feed to the feed store
|
||||||
)
|
const feedStore = useFeedStore();
|
||||||
)
|
feedStore.addFeed(podcast, "discover").catch(() => {});
|
||||||
}
|
}
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Unsubscribe from a podcast */
|
/** Unsubscribe from a podcast */
|
||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
setPodcasts((prev) =>
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
prev.map((p) =>
|
if (podcast) {
|
||||||
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
// Remove the feed from the feed store
|
||||||
)
|
const feedStore = useFeedStore();
|
||||||
)
|
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||||
}
|
}
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Toggle subscription */
|
/** Toggle subscription */
|
||||||
const toggleSubscription = (podcastId: string) => {
|
const toggleSubscription = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId)
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast?.isSubscribed) {
|
if (podcast?.isSubscribed) {
|
||||||
unsubscribe(podcastId)
|
unsubscribe(podcastId);
|
||||||
} else {
|
} else {
|
||||||
subscribe(podcastId)
|
subscribe(podcastId);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh trending podcasts (mock) */
|
|
||||||
const refresh = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
|
||||||
// In real app, would fetch from API
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
@@ -201,15 +206,15 @@ export function createDiscoverStore() {
|
|||||||
unsubscribe,
|
unsubscribe,
|
||||||
toggleSubscription,
|
toggleSubscription,
|
||||||
refresh,
|
refresh,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton discover store */
|
/** Singleton discover store */
|
||||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||||
|
|
||||||
export function useDiscoverStore() {
|
export function useDiscoverStore() {
|
||||||
if (!discoverStoreInstance) {
|
if (!discoverStoreInstance) {
|
||||||
discoverStoreInstance = createDiscoverStore()
|
discoverStoreInstance = createDiscoverStore();
|
||||||
}
|
}
|
||||||
return discoverStoreInstance
|
return discoverStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { createSignal } from "solid-js";
|
|||||||
import { FeedVisibility } from "../types/feed";
|
import { FeedVisibility } from "../types/feed";
|
||||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
import type { Episode, EpisodeStatus } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource, SourceType } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source";
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { parseRSSFeed } from "../api/rss-parser";
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +69,11 @@ export function createFeedStore() {
|
|||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
} else if (f.visibility === "all") {
|
} else if (f.visibility === "all") {
|
||||||
// Only show private feeds if authenticated
|
// Only show private feeds if authenticated
|
||||||
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated);
|
result = result.filter(
|
||||||
|
(feed) =>
|
||||||
|
feed.visibility === FeedVisibility.PUBLIC ||
|
||||||
|
authStore.isAuthenticated,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
// Filter by source
|
||||||
@@ -184,12 +188,22 @@ export function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Check if a feed with this URL already exists */
|
||||||
|
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||||
|
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||||
|
};
|
||||||
|
|
||||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||||
const addFeed = async (
|
const addFeed = async (
|
||||||
podcast: Podcast,
|
podcast: Podcast,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||||
) => {
|
): Promise<Feed | null> => {
|
||||||
|
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||||
|
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||||
|
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const feedId = crypto.randomUUID();
|
const feedId = crypto.randomUUID();
|
||||||
const episodes = await fetchEpisodes(
|
const episodes = await fetchEpisodes(
|
||||||
podcast.feedUrl,
|
podcast.feedUrl,
|
||||||
@@ -300,6 +314,20 @@ export function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
|
||||||
|
const removeFeedByUrl = (feedUrl: string) => {
|
||||||
|
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
|
||||||
|
if (feed) {
|
||||||
|
fullEpisodeCache.delete(feed.id);
|
||||||
|
episodeLoadCount.delete(feed.id);
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||||
|
saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Update a feed */
|
/** Update a feed */
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
@@ -470,7 +498,9 @@ export function createFeedStore() {
|
|||||||
setFilter,
|
setFilter,
|
||||||
setSelectedFeedId,
|
setSelectedFeedId,
|
||||||
addFeed,
|
addFeed,
|
||||||
|
hasFeedByUrl,
|
||||||
removeFeed,
|
removeFeed,
|
||||||
|
removeFeedByUrl,
|
||||||
updateFeed,
|
updateFeed,
|
||||||
togglePinned,
|
togglePinned,
|
||||||
refreshFeed,
|
refreshFeed,
|
||||||
|
|||||||
@@ -3,39 +3,39 @@
|
|||||||
* Manages search state, history, and results
|
* Manages search state, history, and results
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts } from "../utils/search"
|
import { searchPodcasts } from "../utils/search";
|
||||||
import { useFeedStore } from "./feed"
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult } from "../types/source"
|
import type { SearchResult } from "../types/source";
|
||||||
|
|
||||||
const STORAGE_KEY = "podtui_search_history"
|
const STORAGE_KEY = "podtui_search_history";
|
||||||
const MAX_HISTORY = 20
|
const MAX_HISTORY = 20;
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
query: string
|
query: string;
|
||||||
isSearching: boolean
|
isSearching: boolean;
|
||||||
results: SearchResult[]
|
results: SearchResult[];
|
||||||
error: string | null
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL = 1000 * 60 * 5
|
const CACHE_TTL = 1000 * 60 * 5;
|
||||||
|
|
||||||
/** Load search history from localStorage */
|
/** Load search history from localStorage */
|
||||||
function loadHistory(): string[] {
|
function loadHistory(): string[] {
|
||||||
if (typeof localStorage === "undefined") return []
|
if (typeof localStorage === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
return stored ? JSON.parse(stored) : []
|
return stored ? JSON.parse(stored) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save search history to localStorage */
|
/** Save search history to localStorage */
|
||||||
function saveHistory(history: string[]): void {
|
function saveHistory(history: string[]): void {
|
||||||
if (typeof localStorage === "undefined") return
|
if (typeof localStorage === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
@@ -43,18 +43,18 @@ function saveHistory(history: string[]): void {
|
|||||||
|
|
||||||
/** Create search store */
|
/** Create search store */
|
||||||
export function createSearchStore() {
|
export function createSearchStore() {
|
||||||
const feedStore = useFeedStore()
|
const feedStore = useFeedStore();
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("");
|
||||||
const [isSearching, setIsSearching] = createSignal(false)
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
const [results, setResults] = createSignal<SearchResult[]>([])
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [history, setHistory] = createSignal<string[]>(loadHistory())
|
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
||||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||||
|
|
||||||
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||||
const feeds = feedStore.feeds()
|
const feeds = feedStore.feeds();
|
||||||
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
|
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
|
||||||
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
|
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
|
||||||
|
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -65,83 +65,99 @@ export function createSearchStore() {
|
|||||||
subscribedUrls.has(item.podcast.feedUrl) ||
|
subscribedUrls.has(item.podcast.feedUrl) ||
|
||||||
subscribedIds.has(item.podcast.id),
|
subscribedIds.has(item.podcast.id),
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Perform search (multi-source implementation) */
|
/** Perform search (multi-source implementation) */
|
||||||
const search = async (searchQuery: string): Promise<void> => {
|
const search = async (searchQuery: string): Promise<void> => {
|
||||||
const q = searchQuery.trim()
|
const q = searchQuery.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setResults([])
|
setResults([]);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuery(q)
|
setQuery(q);
|
||||||
setIsSearching(true)
|
setIsSearching(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
// Add to history
|
// Add to history
|
||||||
addToHistory(q)
|
addToHistory(q);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sources = feedStore.sources()
|
const sources = feedStore.sources();
|
||||||
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
|
const enabledSourceIds = sources
|
||||||
const sourceIds = selectedSources().length > 0
|
.filter((s) => s.enabled)
|
||||||
? selectedSources()
|
.map((s) => s.id);
|
||||||
: enabledSourceIds
|
const sourceIds =
|
||||||
|
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
|
||||||
|
|
||||||
|
// Empty query guard already returned above; if there are no enabled
|
||||||
|
// sources, tell the user instead of returning an empty list that looks
|
||||||
|
// like a network outage.
|
||||||
|
if (enabledSourceIds.length === 0) {
|
||||||
|
setError(
|
||||||
|
"No search sources are enabled. Enable one in Settings → Sources.",
|
||||||
|
);
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
||||||
cacheTtl: CACHE_TTL,
|
cacheTtl: CACHE_TTL,
|
||||||
})
|
});
|
||||||
|
|
||||||
setResults(applySubscribedStatus(searchResults))
|
setResults(applySubscribedStatus(searchResults));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError("Search failed. Please try again.")
|
setError(
|
||||||
setResults([])
|
e instanceof Error && e.message
|
||||||
|
? e.message
|
||||||
|
: "Search failed. Please try again.",
|
||||||
|
);
|
||||||
|
setResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false)
|
setIsSearching(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Add query to history */
|
/** Add query to history */
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
// Remove duplicates and add to front
|
// Remove duplicates and add to front
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase())
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY)
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||||
saveHistory(updated)
|
saveHistory(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Clear search history */
|
/** Clear search history */
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([])
|
setHistory([]);
|
||||||
saveHistory([])
|
saveHistory([]);
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Remove single history item */
|
/** Remove single history item */
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((h) => h !== q)
|
const updated = prev.filter((h) => h !== q);
|
||||||
saveHistory(updated)
|
saveHistory(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Clear results */
|
/** Clear results */
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
setResults([])
|
setResults([]);
|
||||||
setQuery("")
|
setQuery("");
|
||||||
setError(null)
|
setError(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Mark a podcast as subscribed in results */
|
/** Mark a podcast as subscribed in results */
|
||||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
setResults((prev) =>
|
setResults((prev) =>
|
||||||
prev.map((result) => {
|
prev.map((result) => {
|
||||||
const matchesId = result.podcast.id === podcastId
|
const matchesId = result.podcast.id === podcastId;
|
||||||
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false
|
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
|
||||||
if (matchesId || matchesUrl) {
|
if (matchesId || matchesUrl) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -149,12 +165,12 @@ export function createSearchStore() {
|
|||||||
...result.podcast,
|
...result.podcast,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
return result;
|
||||||
return result
|
}),
|
||||||
})
|
);
|
||||||
)
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
@@ -173,15 +189,15 @@ export function createSearchStore() {
|
|||||||
removeFromHistory,
|
removeFromHistory,
|
||||||
setSelectedSources,
|
setSelectedSources,
|
||||||
markSubscribed,
|
markSubscribed,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton search store */
|
/** Singleton search store */
|
||||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
||||||
|
|
||||||
export function useSearchStore() {
|
export function useSearchStore() {
|
||||||
if (!searchStoreInstance) {
|
if (!searchStoreInstance) {
|
||||||
searchStoreInstance = createSearchStore()
|
searchStoreInstance = createSearchStore();
|
||||||
}
|
}
|
||||||
return searchStoreInstance
|
return searchStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export type DesktopTheme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type VisualizerSettings = {
|
export type VisualizerSettings = {
|
||||||
/** Number of frequency bars (8–128, default: 32) */
|
/** Number of frequency bars (8–128, default: 64) */
|
||||||
bars: number;
|
bars: number;
|
||||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
sensitivity: number;
|
sensitivity: number;
|
||||||
|
|||||||
@@ -180,12 +180,14 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
// Open command palette on ctrl+p or command_list keybind
|
// Open the command palette via the `command` keybind (bound to `:` in
|
||||||
|
// keybinds.jsonc). The old hardcoded "command_list" name was never a
|
||||||
|
// canonical action, so the palette was unreachable dead code.
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (value.suspended()) return;
|
if (value.suspended()) return;
|
||||||
if (dialog.isOpen) return;
|
if (dialog.isOpen) return;
|
||||||
if (evt.defaultPrevented) return;
|
if (evt.defaultPrevented) return;
|
||||||
if (keybind.match("command_list", evt)) {
|
if (keybind.match("command", evt)) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
value.show();
|
value.show();
|
||||||
return;
|
return;
|
||||||
@@ -279,7 +281,11 @@ function CommandDialog(props: {
|
|||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Command list */}
|
{/* Command list */}
|
||||||
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<For each={filteredOptions().slice(0, 10)}>
|
<For each={filteredOptions().slice(0, 10)}>
|
||||||
{(option, index) => (
|
{(option, index) => (
|
||||||
<SelectableBox
|
<SelectableBox
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,18 +11,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** PCM output format constants */
|
/** PCM output format constants */
|
||||||
const SAMPLE_RATE = 44100
|
const SAMPLE_RATE = 44100;
|
||||||
const CHANNELS = 1
|
const CHANNELS = 1;
|
||||||
const BYTES_PER_SAMPLE = 2 // s16le
|
const BYTES_PER_SAMPLE = 2; // s16le
|
||||||
|
|
||||||
/** How many samples to buffer (~1 second) */
|
/** How many samples to buffer (~1 second) */
|
||||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE
|
const RING_BUFFER_SAMPLES = SAMPLE_RATE;
|
||||||
|
|
||||||
export interface AudioStreamReaderOptions {
|
export interface AudioStreamReaderOptions {
|
||||||
/** Audio URL or file path to decode */
|
/** Audio URL or file path to decode */
|
||||||
url: string
|
url: string;
|
||||||
/** Sample rate (default: 44100) */
|
/** Sample rate (default: 44100) */
|
||||||
sampleRate?: number
|
sampleRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,32 +30,32 @@ export interface AudioStreamReaderOptions {
|
|||||||
* Each start() increments this; the read loop checks it to know
|
* Each start() increments this; the read loop checks it to know
|
||||||
* if it's been superseded and should bail out.
|
* if it's been superseded and should bail out.
|
||||||
*/
|
*/
|
||||||
let globalGeneration = 0
|
let globalGeneration = 0;
|
||||||
|
|
||||||
export class AudioStreamReader {
|
export class AudioStreamReader {
|
||||||
private proc: ReturnType<typeof Bun.spawn> | null = null
|
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||||
private ringBuffer: Float64Array
|
private ringBuffer: Float64Array;
|
||||||
private writePos = 0
|
private writePos = 0;
|
||||||
private totalSamplesWritten = 0
|
private totalSamplesWritten = 0;
|
||||||
private _running = false
|
private _running = false;
|
||||||
private generation = 0
|
private generation = 0;
|
||||||
readonly url: string
|
readonly url: string;
|
||||||
private sampleRate: number
|
private sampleRate: number;
|
||||||
|
|
||||||
constructor(options: AudioStreamReaderOptions) {
|
constructor(options: AudioStreamReaderOptions) {
|
||||||
this.url = options.url
|
this.url = options.url;
|
||||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE
|
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
||||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES)
|
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the reader is actively reading samples. */
|
/** Whether the reader is actively reading samples. */
|
||||||
get running(): boolean {
|
get running(): boolean {
|
||||||
return this._running
|
return this._running;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total number of samples written since start(). */
|
/** Total number of samples written since start(). */
|
||||||
get samplesWritten(): number {
|
get samplesWritten(): number {
|
||||||
return this.totalSamplesWritten
|
return this.totalSamplesWritten;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,72 +72,88 @@ export class AudioStreamReader {
|
|||||||
*/
|
*/
|
||||||
start(startPosition = 0, speed = 1): void {
|
start(startPosition = 0, speed = 1): void {
|
||||||
// Always kill the previous process first — no early return on _running
|
// Always kill the previous process first — no early return on _running
|
||||||
this.killProcess()
|
this.killProcess();
|
||||||
|
|
||||||
if (!Bun.which("ffmpeg")) {
|
if (!Bun.which("ffmpeg")) {
|
||||||
throw new Error("ffmpeg not found — required for audio visualization")
|
throw new Error("ffmpeg not found — required for audio visualization");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment generation so any lingering read loop from a previous
|
// Increment generation so any lingering read loop from a previous
|
||||||
// start() will see a mismatch and exit.
|
// start() will see a mismatch and exit.
|
||||||
this.generation = ++globalGeneration
|
this.generation = ++globalGeneration;
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-loglevel", "quiet",
|
"-loglevel",
|
||||||
"-reconnect", "1",
|
"quiet",
|
||||||
"-reconnect_streamed", "1",
|
// Read input at native frame rate so decoded PCM stays in sync with
|
||||||
"-reconnect_delay_max", "5",
|
// real-time playback. Without -re, ffmpeg greedily decodes the whole
|
||||||
]
|
// file as fast as possible: the ring buffer fills with audio seconds
|
||||||
|
// ahead of the player (laggy bars), then the process exits when it
|
||||||
|
// hits EOF (bars freeze ~10s in).
|
||||||
|
"-re",
|
||||||
|
"-reconnect",
|
||||||
|
"1",
|
||||||
|
"-reconnect_streamed",
|
||||||
|
"1",
|
||||||
|
"-reconnect_delay_max",
|
||||||
|
"5",
|
||||||
|
];
|
||||||
|
|
||||||
// Seek before input for network efficiency
|
// Seek before input for network efficiency
|
||||||
if (startPosition > 0) {
|
if (startPosition > 0) {
|
||||||
args.push("-ss", String(startPosition))
|
args.push("-ss", String(startPosition));
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push("-i", this.url)
|
args.push("-i", this.url);
|
||||||
|
|
||||||
// Apply speed via atempo filter if not 1x.
|
// Apply speed via atempo filter if not 1x.
|
||||||
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
||||||
if (speed !== 1 && speed > 0) {
|
if (speed !== 1 && speed > 0) {
|
||||||
args.push("-af", buildAtempoChain(speed))
|
args.push("-af", buildAtempoChain(speed));
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(
|
args.push(
|
||||||
"-ac", String(CHANNELS),
|
"-ac",
|
||||||
"-ar", String(this.sampleRate),
|
String(CHANNELS),
|
||||||
"-f", "s16le",
|
"-ar",
|
||||||
"-acodec", "pcm_s16le",
|
String(this.sampleRate),
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
"-",
|
"-",
|
||||||
)
|
);
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
this.proc = Bun.spawn(args, {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
})
|
});
|
||||||
|
|
||||||
this._running = true
|
this._running = true;
|
||||||
this.writePos = 0
|
this.writePos = 0;
|
||||||
this.totalSamplesWritten = 0
|
this.totalSamplesWritten = 0;
|
||||||
|
|
||||||
// Capture generation for this run
|
// Capture generation for this run
|
||||||
const myGeneration = this.generation
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
// Start async reading loop
|
// Start async reading loop
|
||||||
this.readLoop(myGeneration)
|
this.readLoop(myGeneration);
|
||||||
|
|
||||||
// Detect process exit
|
// Detect process exit
|
||||||
this.proc.exited.then(() => {
|
this.proc.exited
|
||||||
|
.then(() => {
|
||||||
// Only clear _running if this is still the current generation
|
// Only clear _running if this is still the current generation
|
||||||
if (this.generation === myGeneration) {
|
if (this.generation === myGeneration) {
|
||||||
this._running = false
|
this._running = false;
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._running = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,21 +164,27 @@ export class AudioStreamReader {
|
|||||||
* @returns Number of samples written to `out`.
|
* @returns Number of samples written to `out`.
|
||||||
*/
|
*/
|
||||||
read(out: Float64Array): number {
|
read(out: Float64Array): number {
|
||||||
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length)
|
const available = Math.min(
|
||||||
if (available <= 0) return 0
|
out.length,
|
||||||
|
this.totalSamplesWritten,
|
||||||
|
this.ringBuffer.length,
|
||||||
|
);
|
||||||
|
if (available <= 0) return 0;
|
||||||
|
|
||||||
// Read the most recent `available` samples from the ring buffer
|
// Read the most recent `available` samples from the ring buffer
|
||||||
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length
|
const readStart =
|
||||||
|
(this.writePos - available + this.ringBuffer.length) %
|
||||||
|
this.ringBuffer.length;
|
||||||
|
|
||||||
if (readStart + available <= this.ringBuffer.length) {
|
if (readStart + available <= this.ringBuffer.length) {
|
||||||
out.set(this.ringBuffer.subarray(readStart, readStart + available))
|
out.set(this.ringBuffer.subarray(readStart, readStart + available));
|
||||||
} else {
|
} else {
|
||||||
const firstChunk = this.ringBuffer.length - readStart
|
const firstChunk = this.ringBuffer.length - readStart;
|
||||||
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length))
|
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
|
||||||
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk)
|
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
return available
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,59 +193,67 @@ export class AudioStreamReader {
|
|||||||
*/
|
*/
|
||||||
stop(): void {
|
stop(): void {
|
||||||
// Bump generation to invalidate any running read loop
|
// Bump generation to invalidate any running read loop
|
||||||
this.generation = ++globalGeneration
|
this.generation = ++globalGeneration;
|
||||||
this._running = false
|
this._running = false;
|
||||||
this.killProcess()
|
this.killProcess();
|
||||||
this.writePos = 0
|
this.writePos = 0;
|
||||||
this.totalSamplesWritten = 0
|
this.totalSamplesWritten = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restart the reader at a new position and/or speed.
|
* Restart the reader at a new position and/or speed.
|
||||||
*/
|
*/
|
||||||
restart(startPosition = 0, speed = 1): void {
|
restart(startPosition = 0, speed = 1): void {
|
||||||
this.start(startPosition, speed)
|
this.start(startPosition, speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kill the ffmpeg process without touching generation/state. */
|
/** Kill the ffmpeg process without touching generation/state. */
|
||||||
private killProcess(): void {
|
private killProcess(): void {
|
||||||
if (this.proc) {
|
if (this.proc) {
|
||||||
try { this.proc.kill() } catch { /* ignore */ }
|
try {
|
||||||
this.proc = null
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||||
private async readLoop(myGeneration: number): Promise<void> {
|
private async readLoop(myGeneration: number): Promise<void> {
|
||||||
const stdout = this.proc?.stdout
|
const stdout = this.proc?.stdout;
|
||||||
if (!stdout || typeof stdout === "number") return
|
if (!stdout || typeof stdout === "number") return;
|
||||||
|
|
||||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||||
try {
|
try {
|
||||||
while (this.generation === myGeneration) {
|
while (this.generation === myGeneration) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read();
|
||||||
if (done || this.generation !== myGeneration) break
|
if (done || this.generation !== myGeneration) break;
|
||||||
if (!value || value.byteLength === 0) continue
|
if (!value || value.byteLength === 0) continue;
|
||||||
|
|
||||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
|
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
||||||
if (sampleCount === 0) continue
|
if (sampleCount === 0) continue;
|
||||||
|
|
||||||
const int16View = new Int16Array(
|
const int16View = new Int16Array(
|
||||||
value.buffer,
|
value.buffer,
|
||||||
value.byteOffset,
|
value.byteOffset,
|
||||||
sampleCount,
|
sampleCount,
|
||||||
)
|
);
|
||||||
|
|
||||||
for (let i = 0; i < sampleCount; i++) {
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
this.ringBuffer[this.writePos] = int16View[i]
|
this.ringBuffer[this.writePos] = int16View[i];
|
||||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length
|
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
||||||
this.totalSamplesWritten++
|
this.totalSamplesWritten++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Stream ended or process killed — expected during stop()
|
// Stream ended or process killed — expected during stop()
|
||||||
} finally {
|
} finally {
|
||||||
try { reader.releaseLock() } catch { /* ignore */ }
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,18 +264,18 @@ export class AudioStreamReader {
|
|||||||
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
|
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
|
||||||
*/
|
*/
|
||||||
function buildAtempoChain(speed: number): string {
|
function buildAtempoChain(speed: number): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = [];
|
||||||
let remaining = Math.max(0.25, Math.min(4, speed))
|
let remaining = Math.max(0.25, Math.min(4, speed));
|
||||||
|
|
||||||
while (remaining > 100) {
|
while (remaining > 100) {
|
||||||
parts.push("atempo=100.0")
|
parts.push("atempo=100.0");
|
||||||
remaining /= 100
|
remaining /= 100;
|
||||||
}
|
}
|
||||||
while (remaining < 0.5) {
|
while (remaining < 0.5) {
|
||||||
parts.push("atempo=0.5")
|
parts.push("atempo=0.5");
|
||||||
remaining /= 0.5
|
remaining /= 0.5;
|
||||||
}
|
}
|
||||||
parts.push(`atempo=${remaining}`)
|
parts.push(`atempo=${remaining}`);
|
||||||
|
|
||||||
return parts.join(",")
|
return parts.join(",");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,27 +16,29 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs";
|
||||||
import { join, dirname } from "path"
|
import { join, dirname } from "path";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CavaCoreConfig {
|
export interface CavaCoreConfig {
|
||||||
/** Number of frequency bars (default: 32) */
|
/** Number of frequency bars (default: 32) */
|
||||||
bars?: number
|
bars?: number;
|
||||||
/** Audio sample rate in Hz (default: 44100) */
|
/** Audio sample rate in Hz (default: 44100) */
|
||||||
sampleRate?: number
|
sampleRate?: number;
|
||||||
/** Number of audio channels (default: 1 = mono) */
|
/** Number of audio channels (default: 1 = mono) */
|
||||||
channels?: number
|
channels?: number;
|
||||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
autosens?: number
|
autosens?: number;
|
||||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||||
noiseReduction?: number
|
noiseReduction?: number;
|
||||||
/** Low frequency cutoff in Hz (default: 50) */
|
/** Low frequency cutoff in Hz (default: 50) */
|
||||||
lowCutOff?: number
|
lowCutOff?: number;
|
||||||
/** High frequency cutoff in Hz (default: 10000) */
|
/** High frequency cutoff in Hz (default: 10000) */
|
||||||
highCutOff?: number
|
highCutOff?: number;
|
||||||
|
/** Output scaling mode: 0 = linear (default), 1 = decibel */
|
||||||
|
scalingMode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: Required<CavaCoreConfig> = {
|
const DEFAULTS: Required<CavaCoreConfig> = {
|
||||||
@@ -47,20 +49,25 @@ const DEFAULTS: Required<CavaCoreConfig> = {
|
|||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
highCutOff: 10000,
|
highCutOff: 10000,
|
||||||
}
|
scalingMode: 0,
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
|
type CavaLib = {
|
||||||
|
symbols: Record<string, (...args: any[]) => any>;
|
||||||
|
close(): void;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Library resolution ───────────────────────────────────────────────
|
// ── Library resolution ───────────────────────────────────────────────
|
||||||
|
|
||||||
function findLibrary(): string | null {
|
function findLibrary(): string | null {
|
||||||
const platform = process.platform
|
const platform = process.platform;
|
||||||
const libName = platform === "darwin"
|
const libName =
|
||||||
|
platform === "darwin"
|
||||||
? "libcavacore.dylib"
|
? "libcavacore.dylib"
|
||||||
: platform === "win32"
|
: platform === "win32"
|
||||||
? "cavacore.dll"
|
? "cavacore.dll"
|
||||||
: "libcavacore.so"
|
: "libcavacore.so";
|
||||||
|
|
||||||
// Candidate paths, in priority order:
|
// Candidate paths, in priority order:
|
||||||
// 1. src/native/ (development)
|
// 1. src/native/ (development)
|
||||||
@@ -70,39 +77,39 @@ function findLibrary(): string | null {
|
|||||||
join(import.meta.dir, "..", "native", libName),
|
join(import.meta.dir, "..", "native", libName),
|
||||||
join(dirname(process.execPath), libName),
|
join(dirname(process.execPath), libName),
|
||||||
join(process.cwd(), "dist", libName),
|
join(process.cwd(), "dist", libName),
|
||||||
]
|
];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (existsSync(candidate)) return candidate
|
if (existsSync(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CavaCore class ───────────────────────────────────────────────────
|
// ── CavaCore class ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export class CavaCore {
|
export class CavaCore {
|
||||||
private lib: CavaLib
|
private lib: CavaLib;
|
||||||
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null
|
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null;
|
||||||
private inputBuffer: Float64Array | null = null
|
private inputBuffer: Float64Array | null = null;
|
||||||
private outputBuffer: Float64Array | null = null
|
private outputBuffer: Float64Array | null = null;
|
||||||
private _bars = 0
|
private _bars = 0;
|
||||||
private _channels = 1
|
private _channels = 1;
|
||||||
private _destroyed = false
|
private _destroyed = false;
|
||||||
|
|
||||||
/** Use loadCavaCore() instead of constructing directly. */
|
/** Use loadCavaCore() instead of constructing directly. */
|
||||||
constructor(lib: CavaLib) {
|
constructor(lib: CavaLib) {
|
||||||
this.lib = lib
|
this.lib = lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of frequency bars configured. */
|
/** Number of frequency bars configured. */
|
||||||
get bars(): number {
|
get bars(): number {
|
||||||
return this._bars
|
return this._bars;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this instance has been initialized (and not yet destroyed). */
|
/** Whether this instance has been initialized (and not yet destroyed). */
|
||||||
get isReady(): boolean {
|
get isReady(): boolean {
|
||||||
return this.plan !== null && !this._destroyed
|
return this.plan !== null && !this._destroyed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,12 +119,12 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
init(config: CavaCoreConfig = {}): void {
|
init(config: CavaCoreConfig = {}): void {
|
||||||
if (this.plan) {
|
if (this.plan) {
|
||||||
this.destroy()
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = { ...DEFAULTS, ...config }
|
const cfg = { ...DEFAULTS, ...config };
|
||||||
this._bars = cfg.bars
|
this._bars = cfg.bars;
|
||||||
this._channels = cfg.channels
|
this._channels = cfg.channels;
|
||||||
|
|
||||||
this.plan = this.lib.symbols.cava_init(
|
this.plan = this.lib.symbols.cava_init(
|
||||||
cfg.bars,
|
cfg.bars,
|
||||||
@@ -127,15 +134,16 @@ export class CavaCore {
|
|||||||
cfg.noiseReduction,
|
cfg.noiseReduction,
|
||||||
cfg.lowCutOff,
|
cfg.lowCutOff,
|
||||||
cfg.highCutOff,
|
cfg.highCutOff,
|
||||||
)
|
cfg.scalingMode,
|
||||||
|
);
|
||||||
|
|
||||||
if (!this.plan) {
|
if (!this.plan) {
|
||||||
throw new Error("cava_init returned null — initialization failed")
|
throw new Error("cava_init returned null — initialization failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-allocate output buffer (bars * channels)
|
// Pre-allocate output buffer (bars * channels)
|
||||||
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels)
|
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels);
|
||||||
this._destroyed = false
|
this._destroyed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,23 +156,23 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
execute(samples: Float64Array): Float64Array {
|
execute(samples: Float64Array): Float64Array {
|
||||||
if (!this.plan || !this.outputBuffer) {
|
if (!this.plan || !this.outputBuffer) {
|
||||||
throw new Error("CavaCore not initialized — call init() first")
|
throw new Error("CavaCore not initialized — call init() first");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse input buffer if same size, otherwise allocate new
|
// Reuse input buffer if same size, otherwise allocate new
|
||||||
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
||||||
this.inputBuffer = new Float64Array(samples.length)
|
this.inputBuffer = new Float64Array(samples.length);
|
||||||
}
|
}
|
||||||
this.inputBuffer.set(samples)
|
this.inputBuffer.set(samples);
|
||||||
|
|
||||||
this.lib.symbols.cava_execute(
|
this.lib.symbols.cava_execute(
|
||||||
ptr(this.inputBuffer),
|
ptr(this.inputBuffer),
|
||||||
samples.length,
|
samples.length,
|
||||||
ptr(this.outputBuffer),
|
ptr(this.outputBuffer),
|
||||||
this.plan,
|
this.plan,
|
||||||
)
|
);
|
||||||
|
|
||||||
return this.outputBuffer
|
return this.outputBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,12 +181,12 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.plan && !this._destroyed) {
|
if (this.plan && !this._destroyed) {
|
||||||
this.lib.symbols.cava_destroy(this.plan)
|
this.lib.symbols.cava_destroy(this.plan);
|
||||||
this.plan = null
|
this.plan = null;
|
||||||
this._destroyed = true
|
this._destroyed = true;
|
||||||
}
|
}
|
||||||
this.inputBuffer = null
|
this.inputBuffer = null;
|
||||||
this.outputBuffer = null
|
this.outputBuffer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +199,8 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
export function loadCavaCore(): CavaCore | null {
|
export function loadCavaCore(): CavaCore | null {
|
||||||
try {
|
try {
|
||||||
const libPath = findLibrary()
|
const libPath = findLibrary();
|
||||||
if (!libPath) return null
|
if (!libPath) return null;
|
||||||
|
|
||||||
const lib = dlopen(libPath, {
|
const lib = dlopen(libPath, {
|
||||||
cava_init: {
|
cava_init: {
|
||||||
@@ -204,6 +212,7 @@ export function loadCavaCore(): CavaCore | null {
|
|||||||
FFIType.double, // noise_reduction
|
FFIType.double, // noise_reduction
|
||||||
FFIType.i32, // low_cut_off
|
FFIType.i32, // low_cut_off
|
||||||
FFIType.i32, // high_cut_off
|
FFIType.i32, // high_cut_off
|
||||||
|
FFIType.i32, // scaling_mode
|
||||||
],
|
],
|
||||||
returns: FFIType.ptr,
|
returns: FFIType.ptr,
|
||||||
},
|
},
|
||||||
@@ -220,11 +229,11 @@ export function loadCavaCore(): CavaCore | null {
|
|||||||
args: [FFIType.ptr], // plan
|
args: [FFIType.ptr], // plan
|
||||||
returns: FFIType.void,
|
returns: FFIType.void,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return new CavaCore(lib as CavaLib)
|
return new CavaCore(lib as CavaLib);
|
||||||
} catch {
|
} catch {
|
||||||
// Library load failed — missing dylib, wrong arch, etc.
|
// Library load failed — missing dylib, wrong arch, etc.
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
251
src/utils/dispatch.ts
Normal file
251
src/utils/dispatch.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* dispatch — the yazi-style unified keybind router.
|
||||||
|
*
|
||||||
|
* Extracted from `src/components/Shell.tsx` into this pure (no-JSX, no
|
||||||
|
* @opentui/solid) module so `dispatch()` is unit-testable with `bun test`
|
||||||
|
* directly — mirroring how task 01 split `navigation-store` out of the
|
||||||
|
* NavigationContext so the nav model could be exercised without the OpenTUI
|
||||||
|
* JSX runtime (supplied only by the build-time bun-plugin).
|
||||||
|
*
|
||||||
|
* The Shell builds a `DispatcherDeps` from its live hooks + the audio-side
|
||||||
|
* `advanceEpisode` helper, then forwards every matched keystroke action to
|
||||||
|
* `dispatch`. Behavioural rules (tab root + task 06):
|
||||||
|
*
|
||||||
|
* • The tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is
|
||||||
|
* the CURRENT pane with nothing above it:
|
||||||
|
* `k`/`j` (`move-down`/`move-up`) move a cursor through the tabs
|
||||||
|
* (highlight follows `tabCursor`; the active tab is untouched),
|
||||||
|
* `l`/Enter (`swipe-next`/`open`) open the hovered tab (`activateTabCursor`)
|
||||||
|
* — the tab slides into UP and its content becomes CURRENT; `h`
|
||||||
|
* (`swipe-prev`) at the root stays (out of the panes); `1-6` / `[`/`]`
|
||||||
|
* switch tabs directly (re-syncing the cursor).
|
||||||
|
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
|
||||||
|
* switch tabs; focus keeps its context (root iff already at the root,
|
||||||
|
* otherwise the content `DEPTH_CENTER_PANE`).
|
||||||
|
* • `h`/`l` are `swipe-prev`/`swipe-next` in content (every tab is a
|
||||||
|
* depth-tab): `l` at the current pane drills in (emits `open`); `h` pops
|
||||||
|
* a depth when depth > 0; at depth 0 `h` returns to the tab root
|
||||||
|
* (`backToTabRoot`), where the tab becomes CURRENT again.
|
||||||
|
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
|
||||||
|
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
|
||||||
|
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
|
||||||
|
* are unchanged from the pre-rewrite Shell. */
|
||||||
|
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
|
||||||
|
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
|
||||||
|
import { TABS, TabsCount } from "@/utils/navigation";
|
||||||
|
import { emit } from "@/utils/event-bus";
|
||||||
|
|
||||||
|
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
|
||||||
|
export { DEPTH_CENTER_PANE, NavMode };
|
||||||
|
|
||||||
|
/** The payload carried on the `nav.action` event bus. Mirrors the typed event
|
||||||
|
* in utils/event-bus.ts but duplicated here so this module stays dep-light. */
|
||||||
|
export type NavActionEvent = {
|
||||||
|
action: KeybindActionName;
|
||||||
|
tab: TABS;
|
||||||
|
pane: number;
|
||||||
|
mode: NavMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Actions the active page is responsible for (pane/list-local). These flow
|
||||||
|
* to the current pane only via `emit("nav.action", …)`. There is no
|
||||||
|
* SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */
|
||||||
|
export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||||
|
new Set<KeybindActionName>([
|
||||||
|
"move-down",
|
||||||
|
"move-up",
|
||||||
|
"page-down",
|
||||||
|
"page-up",
|
||||||
|
"full-down",
|
||||||
|
"full-up",
|
||||||
|
"jump-down",
|
||||||
|
"jump-up",
|
||||||
|
"goto-top",
|
||||||
|
"goto-bottom",
|
||||||
|
"toggle-select",
|
||||||
|
"visual-mode",
|
||||||
|
"toggle-all",
|
||||||
|
"invert-all",
|
||||||
|
"open",
|
||||||
|
"open-interactive",
|
||||||
|
"search",
|
||||||
|
"filter",
|
||||||
|
"sort",
|
||||||
|
"toggle-hidden",
|
||||||
|
"refresh",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||||
|
export function tabByDigit(action: KeybindActionName): TABS | null {
|
||||||
|
if (action.startsWith("tab-goto-")) {
|
||||||
|
const n = Number(action.slice("tab-goto-".length));
|
||||||
|
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies the unified keybind dispatcher closes over. `advanceEpisode`
|
||||||
|
* is passed in (it lives over the full audio/feed/toast surface in Shell) so
|
||||||
|
* the dispatcher only needs the subset it touches directly. */
|
||||||
|
export type DispatcherDeps = {
|
||||||
|
nav: NavigationState;
|
||||||
|
audio: {
|
||||||
|
togglePlayback: () => Promise<void>;
|
||||||
|
seekRelative: (n: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
k: { clearPending: () => void };
|
||||||
|
setShowHelp: (fn: (v: boolean) => boolean) => void;
|
||||||
|
advanceEpisode: (offset: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build the unified router (normal + visual modes). Returns `dispatch` —
|
||||||
|
* the closure Shell's `useKeyboard` calls with each matched action. */
|
||||||
|
export function createDispatcher(deps: DispatcherDeps) {
|
||||||
|
const { nav, audio, k, setShowHelp, advanceEpisode } = deps;
|
||||||
|
|
||||||
|
function dispatch(
|
||||||
|
action: KeybindActionName,
|
||||||
|
evt: { preventDefault: () => void },
|
||||||
|
) {
|
||||||
|
const tab = nav.activeTab();
|
||||||
|
const pane = nav.activePane();
|
||||||
|
switch (action) {
|
||||||
|
// ── modes ──
|
||||||
|
case "escape":
|
||||||
|
evt.preventDefault();
|
||||||
|
if (nav.mode() === NavMode.VISUAL) {
|
||||||
|
nav.toNormal();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
k.clearPending();
|
||||||
|
break;
|
||||||
|
case "command":
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.enterCommand();
|
||||||
|
break;
|
||||||
|
case "visual-mode":
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.enterVisual();
|
||||||
|
break;
|
||||||
|
case "toggle-select":
|
||||||
|
evt.preventDefault();
|
||||||
|
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||||
|
break;
|
||||||
|
case "toggle-all":
|
||||||
|
case "invert-all":
|
||||||
|
evt.preventDefault();
|
||||||
|
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── tabs (the only tab switchers) ──
|
||||||
|
case "tab-next":
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.nextTab();
|
||||||
|
break;
|
||||||
|
case "tab-prev":
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.prevTab();
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const dt = tabByDigit(action);
|
||||||
|
if (dt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.setActiveTab(dt);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// ── tab root focus ──
|
||||||
|
// At the app root the tab list is the CURRENT pane: j/k move the tab
|
||||||
|
// cursor (active tab untouched), l/Enter open the hovered tab (switch
|
||||||
|
// to it + enter its content), h stays inert (out of the panes).
|
||||||
|
if (nav.atRootTab()) {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (action === "move-down") {
|
||||||
|
nav.moveTabCursor(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "move-up") {
|
||||||
|
nav.moveTabCursor(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "open") {
|
||||||
|
nav.activateTabCursor();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "swipe-next") {
|
||||||
|
nav.activateTabCursor();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "swipe-prev") break;
|
||||||
|
}
|
||||||
|
// ── pane swipe / depth nav ──
|
||||||
|
// Every tab is a depth-tab: `l` at the center drills in (emits `open`);
|
||||||
|
// `h` at the center pops a depth when depth > 0, and at depth 0 returns
|
||||||
|
// to the tab root (the tab becomes CURRENT again).
|
||||||
|
if (action === "swipe-prev") {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (nav.currentDepth() > 0) nav.popDepth();
|
||||||
|
else nav.backToTabRoot(); // depth 0 → tab root
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "swipe-next") {
|
||||||
|
evt.preventDefault();
|
||||||
|
emit("nav.action", {
|
||||||
|
action: "open",
|
||||||
|
tab,
|
||||||
|
pane: DEPTH_CENTER_PANE,
|
||||||
|
mode: nav.mode(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// ── audio transport (global) ──
|
||||||
|
if (action === "audio-toggle") {
|
||||||
|
evt.preventDefault();
|
||||||
|
audio.togglePlayback().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "audio-seek-forward") {
|
||||||
|
evt.preventDefault();
|
||||||
|
audio.seekRelative(10).catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "audio-seek-backward") {
|
||||||
|
evt.preventDefault();
|
||||||
|
audio.seekRelative(-10).catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "audio-next") {
|
||||||
|
evt.preventDefault();
|
||||||
|
advanceEpisode(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (action === "audio-prev") {
|
||||||
|
evt.preventDefault();
|
||||||
|
advanceEpisode(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// ── global app ──
|
||||||
|
if (action === "quit") {
|
||||||
|
evt.preventDefault();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (action === "help") {
|
||||||
|
evt.preventDefault();
|
||||||
|
setShowHelp((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── page-local list/pane actions ──
|
||||||
|
if (PAGE_ACTIONS.has(action)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dispatch };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the depth-frame type for convenience.
|
||||||
|
export type { DepthFrame };
|
||||||
@@ -20,128 +20,148 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type EventHandler<T = unknown> = (data: T) => void
|
type EventHandler<T = unknown> = (data: T) => void;
|
||||||
|
|
||||||
// Export EventHandler type for external use
|
// Export EventHandler type for external use
|
||||||
export type { EventHandler }
|
export type { EventHandler };
|
||||||
|
|
||||||
interface EventBusInstance {
|
interface EventBusInstance {
|
||||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
||||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
||||||
off<T = unknown>(event: string, handler: EventHandler<T>): void
|
off<T = unknown>(event: string, handler: EventHandler<T>): void;
|
||||||
emit<T = unknown>(event: string, data: T): void
|
emit<T = unknown>(event: string, data: T): void;
|
||||||
clear(): void
|
clear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEventBus(): EventBusInstance {
|
function createEventBus(): EventBusInstance {
|
||||||
const handlers = new Map<string, Set<EventHandler>>()
|
const handlers = new Map<string, Set<EventHandler>>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||||
if (!handlers.has(event)) {
|
if (!handlers.has(event)) {
|
||||||
handlers.set(event, new Set())
|
handlers.set(event, new Set());
|
||||||
}
|
}
|
||||||
handlers.get(event)!.add(handler as EventHandler)
|
handlers.get(event)!.add(handler as EventHandler);
|
||||||
|
|
||||||
// Return unsubscribe function
|
// Return unsubscribe function
|
||||||
return () => {
|
return () => {
|
||||||
this.off(event, handler)
|
this.off(event, handler);
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||||
const wrappedHandler: EventHandler<T> = (data) => {
|
const wrappedHandler: EventHandler<T> = (data) => {
|
||||||
this.off(event, wrappedHandler)
|
this.off(event, wrappedHandler);
|
||||||
handler(data)
|
handler(data);
|
||||||
}
|
};
|
||||||
return this.on(event, wrappedHandler)
|
return this.on(event, wrappedHandler);
|
||||||
},
|
},
|
||||||
|
|
||||||
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
||||||
const eventHandlers = handlers.get(event)
|
const eventHandlers = handlers.get(event);
|
||||||
if (eventHandlers) {
|
if (eventHandlers) {
|
||||||
eventHandlers.delete(handler as EventHandler)
|
eventHandlers.delete(handler as EventHandler);
|
||||||
if (eventHandlers.size === 0) {
|
if (eventHandlers.size === 0) {
|
||||||
handlers.delete(event)
|
handlers.delete(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
emit<T = unknown>(event: string, data: T): void {
|
emit<T = unknown>(event: string, data: T): void {
|
||||||
const eventHandlers = handlers.get(event)
|
const eventHandlers = handlers.get(event);
|
||||||
if (eventHandlers) {
|
if (eventHandlers) {
|
||||||
for (const handler of eventHandlers) {
|
for (const handler of eventHandlers) {
|
||||||
try {
|
try {
|
||||||
handler(data)
|
handler(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in event handler for "${event}":`, error)
|
console.error(`Error in event handler for "${event}":`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
handlers.clear()
|
handlers.clear();
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton event bus instance
|
// Singleton event bus instance
|
||||||
export const EventBus = createEventBus()
|
export const EventBus = createEventBus();
|
||||||
|
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { TABS } from "@/utils/navigation";
|
||||||
|
import type { PaneId, NavMode } from "@/context/NavigationContext";
|
||||||
|
|
||||||
// Common event types for the application
|
// Common event types for the application
|
||||||
export type AppEvents = {
|
export type AppEvents = {
|
||||||
"theme.changed": { theme: string; mode: "dark" | "light" }
|
"theme.changed": { theme: string; mode: "dark" | "light" };
|
||||||
"theme.mode.changed": { mode: "dark" | "light" }
|
"theme.mode.changed": { mode: "dark" | "light" };
|
||||||
"theme.reload": {}
|
"theme.reload": {};
|
||||||
"navigation.tab.changed": { tab: string; previousTab?: string }
|
"navigation.tab.changed": { tab: string; previousTab?: string };
|
||||||
"navigation.layer.changed": { depth: number; previousDepth: number }
|
"navigation.layer.changed": { depth: number; previousDepth: number };
|
||||||
"feed.subscribed": { feedId: string; feedUrl: string }
|
"feed.subscribed": { feedId: string; feedUrl: string };
|
||||||
"feed.unsubscribed": { feedId: string }
|
"feed.unsubscribed": { feedId: string };
|
||||||
"player.play": { episodeId: string }
|
"player.play": { episodeId: string };
|
||||||
"player.pause": { episodeId: string }
|
"player.pause": { episodeId: string };
|
||||||
"player.stop": {}
|
"player.stop": {};
|
||||||
"auth.login": { userId: string }
|
"auth.login": { userId: string };
|
||||||
"auth.logout": {}
|
"auth.logout": {};
|
||||||
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
|
"toast.show": {
|
||||||
"dialog.open": { dialogId: string }
|
message: string;
|
||||||
"dialog.close": { dialogId?: string }
|
variant: "info" | "success" | "warning" | "error";
|
||||||
"command.execute": { command: string; args?: unknown }
|
title?: string;
|
||||||
"clipboard.copied": { text: string }
|
duration?: number;
|
||||||
"selection.start": { x: number; y: number }
|
};
|
||||||
"selection.end": { text: string }
|
"dialog.open": { dialogId: string };
|
||||||
|
"dialog.close": { dialogId?: string };
|
||||||
|
"command.execute": { command: string; args?: unknown };
|
||||||
|
// Yazi-style unified router → active page dispatch. The Shell router
|
||||||
|
// emits these; each page subscribes to the subset it implements.
|
||||||
|
"nav.action": {
|
||||||
|
action: KeybindActionName;
|
||||||
|
tab: TABS;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
};
|
||||||
|
"clipboard.copied": { text: string };
|
||||||
|
"selection.start": { x: number; y: number };
|
||||||
|
"selection.end": { text: string };
|
||||||
|
|
||||||
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
||||||
"media.toggle": {}
|
"media.toggle": {};
|
||||||
"media.volumeUp": {}
|
"media.volumeUp": {};
|
||||||
"media.volumeDown": {}
|
"media.volumeDown": {};
|
||||||
"media.seekForward": {}
|
"media.seekForward": {};
|
||||||
"media.seekBackward": {}
|
"media.seekBackward": {};
|
||||||
"media.speedCycle": {}
|
"media.speedCycle": {};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Type-safe emit and on functions
|
// Type-safe emit and on functions
|
||||||
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
|
export function emit<K extends keyof AppEvents>(
|
||||||
EventBus.emit(event, data)
|
event: K,
|
||||||
|
data: AppEvents[K],
|
||||||
|
): void {
|
||||||
|
EventBus.emit(event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function on<K extends keyof AppEvents>(
|
export function on<K extends keyof AppEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
handler: EventHandler<AppEvents[K]>
|
handler: EventHandler<AppEvents[K]>,
|
||||||
): () => void {
|
): () => void {
|
||||||
return EventBus.on(event, handler)
|
return EventBus.on(event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function once<K extends keyof AppEvents>(
|
export function once<K extends keyof AppEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
handler: EventHandler<AppEvents[K]>
|
handler: EventHandler<AppEvents[K]>,
|
||||||
): () => void {
|
): () => void {
|
||||||
return EventBus.once(event, handler)
|
return EventBus.once(event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function off<K extends keyof AppEvents>(
|
export function off<K extends keyof AppEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
handler: EventHandler<AppEvents[K]>
|
handler: EventHandler<AppEvents[K]>,
|
||||||
): void {
|
): void {
|
||||||
EventBus.off(event, handler)
|
EventBus.off(event, handler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
|
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
|
||||||
*
|
*
|
||||||
* Handles copying keybind.jsonc from package to user config directory
|
* Handles copying keybinds.jsonc from package to user config directory
|
||||||
* and loading/saving keybind configurations.
|
* and loading/saving keybind configurations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { copyFile, mkdir } from "fs/promises";
|
import { copyFile } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { parseJSONC } from "./jsonc";
|
import { parseJSONC } from "./jsonc";
|
||||||
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
|
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
|
||||||
@@ -15,33 +15,63 @@ const KEYBINDS_SOURCE = path.join(
|
|||||||
process.cwd(),
|
process.cwd(),
|
||||||
"src",
|
"src",
|
||||||
"config",
|
"config",
|
||||||
"keybind.jsonc",
|
"keybinds.jsonc",
|
||||||
);
|
);
|
||||||
const KEYBINDS_FILE = "keybinds.jsonc";
|
const KEYBINDS_FILE = "keybinds.jsonc";
|
||||||
|
|
||||||
/** Default keybinds from package */
|
/** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the
|
||||||
|
* app works before a user keybinds file is copied into place. */
|
||||||
const DEFAULT_KEYBINDS: KeybindsResolved = {
|
const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||||
up: ["up", "k"],
|
// movement
|
||||||
down: ["down", "j"],
|
"move-down": ["j", "down"],
|
||||||
left: ["left", "h"],
|
"move-up": ["k", "up"],
|
||||||
right: ["right", "l"],
|
"page-down": ["ctrl-d"],
|
||||||
cycle: ["tab"],
|
"page-up": ["ctrl-u"],
|
||||||
dive: ["return"],
|
"full-down": ["ctrl-f"],
|
||||||
select: ["return"],
|
"full-up": ["ctrl-b"],
|
||||||
out: ["esc"],
|
"jump-down": ["J"],
|
||||||
inverseModifier: "shift",
|
"jump-up": ["K"],
|
||||||
leader: ":",
|
"goto-top": [["g", "g"]],
|
||||||
quit: ["<leader>q"],
|
"goto-bottom": ["G"],
|
||||||
"audio-toggle": ["<leader>p"],
|
// pane swipe
|
||||||
"audio-pause": [],
|
"swipe-prev": ["h", "left"],
|
||||||
"audio-play": [],
|
"swipe-next": ["l", "right"],
|
||||||
"audio-next": ["<leader>n"],
|
// open / select
|
||||||
"audio-prev": ["<leader>l"],
|
open: ["return", "enter"],
|
||||||
"audio-seek-forward": ["<leader>sf"],
|
"open-interactive": ["shift-return"],
|
||||||
"audio-seek-backward": ["<leader>sb"],
|
"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 / help / quit
|
||||||
|
command: [":"],
|
||||||
|
quit: ["q", "ctrl-c"],
|
||||||
|
help: ["~", "f1"],
|
||||||
|
// list ops
|
||||||
|
search: ["s"],
|
||||||
|
filter: ["f"],
|
||||||
|
sort: [","],
|
||||||
|
"toggle-hidden": ["."],
|
||||||
|
refresh: ["r"],
|
||||||
|
// audio transport (preserved; shifted single keys, no collisions)
|
||||||
|
"audio-toggle": ["P"],
|
||||||
|
"audio-next": ["N"],
|
||||||
|
"audio-prev": ["B"],
|
||||||
|
"audio-seek-forward": ["shift-."],
|
||||||
|
"audio-seek-backward": ["shift-,"],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Copy keybind.jsonc to user config directory on first run */
|
/** Copy keybinds.jsonc to user config directory on first run */
|
||||||
export async function copyKeybindsIfNeeded(): Promise<void> {
|
export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
@@ -70,6 +100,7 @@ export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
|||||||
|
|
||||||
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
||||||
|
|
||||||
|
// Merge so partial user configs inherit defaults for missing keys.
|
||||||
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_KEYBINDS;
|
return DEFAULT_KEYBINDS;
|
||||||
|
|||||||
48
src/utils/layer-graph.ts
Normal file
48
src/utils/layer-graph.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* layer-graph — maps each TAB id to its page component + pane count.
|
||||||
|
*
|
||||||
|
* Split out of `navigation.ts` so that the nav-model primitives (TABS,
|
||||||
|
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in
|
||||||
|
* `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests
|
||||||
|
* import the pure navigation store without pulling the OpenTUI JSX runtime
|
||||||
|
* (which is only provided by the build-time @opentui/solid bun-plugin).
|
||||||
|
*
|
||||||
|
* The page modules live alongside their pages and export `<count>PaneCount`
|
||||||
|
* constants describing how many focusable panes each fixed page owns.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DiscoverPage,
|
||||||
|
DiscoverPaneCount,
|
||||||
|
} from "@/pages/Discover/DiscoverPage";
|
||||||
|
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
|
||||||
|
import {
|
||||||
|
MyShowsPage,
|
||||||
|
MyShowsPaneCount,
|
||||||
|
} from "@/pages/MyShows/MyShowsPage";
|
||||||
|
import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
|
||||||
|
import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
|
||||||
|
import {
|
||||||
|
SettingsPage,
|
||||||
|
SettingsPaneCount,
|
||||||
|
} from "@/pages/Settings/SettingsPage";
|
||||||
|
import { TABS } from "@/utils/navigation";
|
||||||
|
|
||||||
|
/** Maps a TAB id to the page component that renders it. */
|
||||||
|
export const LayerGraph = {
|
||||||
|
[TABS.FEED]: FeedPage,
|
||||||
|
[TABS.MYSHOWS]: MyShowsPage,
|
||||||
|
[TABS.DISCOVER]: DiscoverPage,
|
||||||
|
[TABS.SEARCH]: SearchPage,
|
||||||
|
[TABS.PLAYER]: PlayerPage,
|
||||||
|
[TABS.SETTINGS]: SettingsPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-tab focusable-pane counts (forwarded from each page's `*PaneCount`). */
|
||||||
|
export const LayerDepths = {
|
||||||
|
[TABS.FEED]: FeedPaneCount,
|
||||||
|
[TABS.MYSHOWS]: MyShowsPaneCount,
|
||||||
|
[TABS.DISCOVER]: DiscoverPaneCount,
|
||||||
|
[TABS.SEARCH]: SearchPaneCount,
|
||||||
|
[TABS.PLAYER]: PlayerPaneCount,
|
||||||
|
[TABS.SETTINGS]: SettingsPaneCount,
|
||||||
|
};
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
import { DiscoverPage, DiscoverPaneCount } from "@/pages/Discover/DiscoverPage";
|
|
||||||
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
|
|
||||||
import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage";
|
|
||||||
import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
|
|
||||||
import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
|
|
||||||
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
|
|
||||||
|
|
||||||
export enum DIRECTION {
|
export enum DIRECTION {
|
||||||
Increment,
|
Increment,
|
||||||
Decrement,
|
Decrement,
|
||||||
@@ -20,19 +13,71 @@ export enum TABS {
|
|||||||
}
|
}
|
||||||
export const TabsCount = 6;
|
export const TabsCount = 6;
|
||||||
|
|
||||||
export const LayerGraph = {
|
/** Tabs that use the yazi depth-stack model (prev | current | preview
|
||||||
[TABS.FEED]: FeedPage,
|
* columns, infinite drill via push/pop). Search drills query→results, and
|
||||||
[TABS.MYSHOWS]: MyShowsPage,
|
* Player drills into its single now-playing pane under the tab list (the
|
||||||
[TABS.DISCOVER]: DiscoverPage,
|
* parent=/tabs, current=player, preview hidden). */
|
||||||
[TABS.SEARCH]: SearchPage,
|
export const DEPTH_TABS: ReadonlySet<TABS> = new Set([
|
||||||
[TABS.PLAYER]: PlayerPage,
|
TABS.FEED,
|
||||||
[TABS.SETTINGS]: SettingsPage,
|
TABS.MYSHOWS,
|
||||||
};
|
TABS.DISCOVER,
|
||||||
export const LayerDepths = {
|
TABS.SEARCH,
|
||||||
[TABS.FEED]: FeedPaneCount,
|
TABS.PLAYER,
|
||||||
[TABS.MYSHOWS]: MyShowsPaneCount,
|
TABS.SETTINGS,
|
||||||
[TABS.DISCOVER]: DiscoverPaneCount,
|
]);
|
||||||
[TABS.SEARCH]: SearchPaneCount,
|
|
||||||
[TABS.PLAYER]: PlayerPaneCount,
|
/** Root (depth-0) frame for a depth-tab — identifies the top-level list each
|
||||||
[TABS.SETTINGS]: SettingsPaneCount,
|
* page renders at root. Pages interpret the `kind` to derive their list. */
|
||||||
|
export function rootFrameFor(
|
||||||
|
tab: TABS,
|
||||||
|
): import("@/context/NavigationContext").DepthFrame {
|
||||||
|
switch (tab) {
|
||||||
|
case TABS.FEED:
|
||||||
|
return { kind: "feeds", focus: 0 };
|
||||||
|
case TABS.MYSHOWS:
|
||||||
|
return { kind: "shows", focus: 0 };
|
||||||
|
case TABS.DISCOVER:
|
||||||
|
return { kind: "discover:categories", focus: 0 };
|
||||||
|
case TABS.SEARCH:
|
||||||
|
return { kind: "search:query", focus: 0 };
|
||||||
|
case TABS.PLAYER:
|
||||||
|
return { kind: "player:nowplaying", focus: 0 };
|
||||||
|
case TABS.SETTINGS:
|
||||||
|
return { kind: "settings:sections", focus: 0 };
|
||||||
|
default:
|
||||||
|
return { kind: "root", focus: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`,
|
||||||
|
// split out so this module stays free of `.tsx`/JSX imports (unit-testable).
|
||||||
|
|
||||||
|
// Yazi-style pane grow ratios (parent : current : preview). Panes use
|
||||||
|
// flexGrow (Yoga) so columns always sum to the row width regardless of
|
||||||
|
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||||
|
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||||
|
//
|
||||||
|
// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT
|
||||||
|
// touch these values. Task 02 re-tunes them to the remake target ratios
|
||||||
|
// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there.
|
||||||
|
export const PANE_RATIO = {
|
||||||
|
parent: 1,
|
||||||
|
current: 3,
|
||||||
|
preview: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Number of *focusable* content panes per tab. The three visible columns
|
||||||
|
// (parent | current | preview) are a *render* concern, NOT three panes — for
|
||||||
|
// depth-tabs only the current column (index 0) is focusable, so this is 1.
|
||||||
|
// Every tab is now a depth-tab: each drills with `l` (push) and pops with `h`
|
||||||
|
// (returns to the tab root at depth 0) via the Shell dispatch. Defined here
|
||||||
|
// (after TABS) to avoid re-introducing the old NavigationContext top-level-
|
||||||
|
// init circular deadlock.
|
||||||
|
export const TabPaneCount: Record<TABS, number> = {
|
||||||
|
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||||
|
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
||||||
|
[TABS.DISCOVER]: 1, // depth: categories → results → preview
|
||||||
|
[TABS.SEARCH]: 1, // depth: query → results, preview=detail
|
||||||
|
[TABS.PLAYER]: 1, // depth: now-playing (2-pane, no preview)
|
||||||
|
[TABS.SETTINGS]: 1, // depth: sections → items → editor
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,146 +1,162 @@
|
|||||||
import { searchSourceByType } from "./source-searcher"
|
import { searchSourceByType } from "./source-searcher";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source"
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode";
|
||||||
|
|
||||||
type SearchCacheEntry = {
|
type SearchCacheEntry = {
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
results: SearchResult[]
|
results: SearchResult[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type SearchOptions = {
|
type SearchOptions = {
|
||||||
cacheTtl?: number
|
cacheTtl?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const searchCache = new Map<string, SearchCacheEntry>()
|
const searchCache = new Map<string, SearchCacheEntry>();
|
||||||
const rateLimitState = new Map<string, number[]>()
|
const rateLimitState = new Map<string, number[]>();
|
||||||
const RATE_LIMIT_WINDOW_MS = 60000
|
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||||
const RATE_LIMIT_MAX_CALLS = 20
|
const RATE_LIMIT_MAX_CALLS = 20;
|
||||||
|
|
||||||
const throttleSource = async (sourceId: string) => {
|
const throttleSource = async (sourceId: string) => {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
const windowStart = now - RATE_LIMIT_WINDOW_MS
|
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||||
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
const timestamps =
|
||||||
|
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
||||||
|
|
||||||
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
||||||
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now
|
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
|
||||||
if (waitMs > 0) {
|
if (waitMs > 0) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
const updated =
|
||||||
updated.push(Date.now())
|
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
||||||
rateLimitState.set(sourceId, updated)
|
updated.push(Date.now());
|
||||||
}
|
rateLimitState.set(sourceId, updated);
|
||||||
|
};
|
||||||
|
|
||||||
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||||
const keySources = [...sourceIds].sort().join(",")
|
const keySources = [...sourceIds].sort().join(",");
|
||||||
return `${query.toLowerCase()}::${keySources}`
|
return `${query.toLowerCase()}::${keySources}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||||
Date.now() - entry.timestamp < ttl
|
Date.now() - entry.timestamp < ttl;
|
||||||
|
|
||||||
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||||
const map = new Map<string, SearchResult>()
|
const map = new Map<string, SearchResult>();
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
|
const key =
|
||||||
const existing = map.get(key)
|
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||||
|
const existing = map.get(key);
|
||||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||||
map.set(key, result)
|
map.set(key, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(map.values())
|
return Array.from(map.values());
|
||||||
}
|
};
|
||||||
|
|
||||||
export const searchPodcasts = async (
|
export const searchPodcasts = async (
|
||||||
query: string,
|
query: string,
|
||||||
sourceIds: string[],
|
sourceIds: string[],
|
||||||
sources: PodcastSource[],
|
sources: PodcastSource[],
|
||||||
options: SearchOptions = {}
|
options: SearchOptions = {},
|
||||||
): Promise<SearchResult[]> => {
|
): Promise<SearchResult[]> => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return []
|
if (!trimmed) return [];
|
||||||
|
|
||||||
const activeSources = sources.filter(
|
const activeSources = sources.filter(
|
||||||
(source) => sourceIds.includes(source.id) && source.enabled
|
(source) => sourceIds.includes(source.id) && source.enabled,
|
||||||
)
|
);
|
||||||
|
|
||||||
if (activeSources.length === 0) return []
|
if (activeSources.length === 0) {
|
||||||
|
// No enabled sources — surface a clear cause instead of returning empty,
|
||||||
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
|
// which otherwise looks indistinguishable from a network failure.
|
||||||
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
|
if (sourceIds.length === 0) {
|
||||||
const cached = searchCache.get(cacheKey)
|
throw new Error("No search sources are enabled");
|
||||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
}
|
||||||
return cached.results
|
throw new Error("No enabled sources match the selected search sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: SearchResult[] = []
|
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
|
||||||
const errors: Error[] = []
|
const cacheKey = buildCacheKey(
|
||||||
|
trimmed,
|
||||||
|
activeSources.map((s) => s.id),
|
||||||
|
);
|
||||||
|
const cached = searchCache.get(cacheKey);
|
||||||
|
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||||
|
return cached.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const errors: Error[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
activeSources.map(async (source) => {
|
activeSources.map(async (source) => {
|
||||||
try {
|
try {
|
||||||
await throttleSource(source.id)
|
await throttleSource(source.id);
|
||||||
const sourceResults = await searchSourceByType(trimmed, source)
|
const sourceResults = await searchSourceByType(trimmed, source);
|
||||||
results.push(...sourceResults)
|
results.push(...sourceResults);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(error as Error)
|
errors.push(error as Error);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
|
|
||||||
const deduped = dedupeResults(results)
|
const deduped = dedupeResults(results);
|
||||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||||
|
|
||||||
if (sorted.length === 0 && errors.length > 0) {
|
if (sorted.length === 0 && errors.length > 0) {
|
||||||
throw new Error("Search failed for all sources")
|
throw new Error("Search failed for all sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
|
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
|
||||||
return sorted
|
return sorted;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ItunesEpisodeResult = {
|
type ItunesEpisodeResult = {
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
trackName?: string
|
trackName?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
shortDescription?: string
|
shortDescription?: string;
|
||||||
releaseDate?: string
|
releaseDate?: string;
|
||||||
trackTimeMillis?: number
|
trackTimeMillis?: number;
|
||||||
episodeUrl?: string
|
episodeUrl?: string;
|
||||||
previewUrl?: string
|
previewUrl?: string;
|
||||||
trackViewUrl?: string
|
trackViewUrl?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ItunesEpisodeResponse = {
|
type ItunesEpisodeResponse = {
|
||||||
resultCount: number
|
resultCount: number;
|
||||||
results: ItunesEpisodeResult[]
|
results: ItunesEpisodeResult[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export const searchEpisodes = async (
|
export const searchEpisodes = async (
|
||||||
query: string,
|
query: string,
|
||||||
feedId: string
|
feedId: string,
|
||||||
): Promise<Episode[]> => {
|
): Promise<Episode[]> => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return []
|
if (!trimmed) return [];
|
||||||
|
|
||||||
const url = new URL("https://itunes.apple.com/search")
|
const url = new URL("https://itunes.apple.com/search");
|
||||||
url.searchParams.set("term", trimmed)
|
url.searchParams.set("term", trimmed);
|
||||||
url.searchParams.set("media", "podcast")
|
url.searchParams.set("media", "podcast");
|
||||||
url.searchParams.set("entity", "podcastEpisode")
|
url.searchParams.set("entity", "podcastEpisode");
|
||||||
url.searchParams.set("country", "US")
|
url.searchParams.set("country", "US");
|
||||||
url.searchParams.set("lang", "en_us")
|
url.searchParams.set("lang", "en_us");
|
||||||
|
|
||||||
const response = await fetch(url.toString())
|
const response = await fetch(url.toString());
|
||||||
if (!response.ok) return []
|
if (!response.ok) return [];
|
||||||
|
|
||||||
const data = (await response.json()) as ItunesEpisodeResponse
|
const data = (await response.json()) as ItunesEpisodeResponse;
|
||||||
return data.results
|
return data.results
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (!item.trackName) return null
|
if (!item.trackName) return null;
|
||||||
const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}`
|
const id = item.trackId
|
||||||
const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || ""
|
? `episode-${item.trackId}`
|
||||||
|
: `episode-${item.trackName}`;
|
||||||
|
const audioUrl =
|
||||||
|
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -148,9 +164,11 @@ export const searchEpisodes = async (
|
|||||||
title: item.trackName,
|
title: item.trackName,
|
||||||
description: item.description || item.shortDescription || "",
|
description: item.description || item.shortDescription || "",
|
||||||
audioUrl,
|
audioUrl,
|
||||||
duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0,
|
duration: item.trackTimeMillis
|
||||||
|
? Math.round(item.trackTimeMillis / 1000)
|
||||||
|
: 0,
|
||||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
.filter((item): item is Episode => Boolean(item))
|
.filter((item): item is Episode => Boolean(item));
|
||||||
}
|
};
|
||||||
|
|||||||
54
tasks/yazi-remake/01-rearchitect-nav-model.md
Normal file
54
tasks/yazi-remake/01-rearchitect-nav-model.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 01. Rearchitect nav model — remove the sidebar pane
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-01
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, nav-model, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Remove the always-on `SIDEBAR_PANE` concept from the navigation context so `activeTab` is plain tab state (not a pane), establishing clean parent|current|preview semantics for the yazi remake.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/context/NavigationContext.tsx` — delete `SIDEBAR_PANE` constant and all references; `activeTab` is no longer a pane
|
||||||
|
- `src/utils/navigation.ts` — update `TabPaneCount` semantics; depth-tabs = 1 focusable pane (current), the 3 visible columns are a render concern not 3 panes
|
||||||
|
- Updated header/comment block describing the parent|current|preview model
|
||||||
|
- `swipe()` / `popDepth()` reworked: depth-tabs `l`=drill (`open`), `h`=pop (noop at depth 0); fixed-pane tabs `h/l` move between parent/current/preview
|
||||||
|
- Tab-enter resets focus to `DEPTH_CENTER_PANE` (current pane), not a sidebar
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- Audit every reference to `SIDEBAR_PANE` across the codebase (grep)
|
||||||
|
- In `NavigationContext.tsx`: delete the `SIDEBAR_PANE = -1` export and the `focusedIndex`/`setFocusedIndex` SIDEBAR_PANE branch added previously
|
||||||
|
- Set the initial `activePane` signal and the tab-switch createEffect to reset to `DEPTH_CENTER_PANE` (the current pane), not `SIDEBAR_PANE`
|
||||||
|
- Rework `swipe()` to clamp to `[0, paneCount-1]` for fixed-pane tabs (the sidebar is no longer in the chain); depth-tabs don't use `swipe` for drill/pop (that lives in Shell dispatch)
|
||||||
|
- In `utils/navigation.ts`: confirm `TabPaneCount` reflects focusable content panes only (depth-tabs = 1, Search = 3, Player = 1); update `PANE_RATIO` leave-behind note (ratio change happens in task 02)
|
||||||
|
- Update the file header comment block to describe parent|current|preview
|
||||||
|
- Run `lens_diagnostics` on the two files
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Unit: `focusedIndex(DEPTH_CENTER_PANE)` on a depth-tab returns the top frame's focus; `setFocusedIndex` writes to the top frame (Arrange a tab with a 2-frame stack, Act by calling setFocusedIndex, Assert topFrame.focus updated)
|
||||||
|
- Integration: tab-switch effect sets `activePane` to `DEPTH_CENTER_PANE` (not -1); `swipe(-1, 3)` on a fixed tab clamps to 0 not -1
|
||||||
|
- e2e (harness): app boots with `nav.state.pane === 0` (current), not -1
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- No symbol `SIDEBAR_PANE` exists anywhere in `src/`
|
||||||
|
- Initial `activePane` === `DEPTH_CENTER_PANE` (0)
|
||||||
|
- Tab-enter sets `activePane` to `DEPTH_CENTER_PANE`
|
||||||
|
- `swipe()` lower bound is 0 (no `-1`)
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `grep -rn "SIDEBAR_PANE" src/` returns nothing
|
||||||
|
- `bun run build` passes
|
||||||
|
- `lens_diagnostics` paths=[`src/context/NavigationContext.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- This task unblocks 03/04/05/06. It must not delete `DEPTH_CENTER_PANE` — that constant is generalised to "the current pane" and retained
|
||||||
|
- `SIDEBAR_ACTIONS` (added in Shell in a prior turn) is removed in task 06 (the keybind rewrite), not here — but Shell will temporarily fail to compile after this task until 05/06 land; that's expected and the build command ignores type errors, so gate success on grep + targeted diagnostics, not the full build
|
||||||
56
tasks/yazi-remake/02-build-three-pane-layout-primitive.md
Normal file
56
tasks/yazi-remake/02-build-three-pane-layout-primitive.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 02. Build the reusable 3-pane layout primitive (1:3:3 ratio, stable parent slot)
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-02
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, layout, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Create one reusable `<YaziPaneRow>` primitive that renders three bordered columns (parent | current | preview) at a 1:3:3 grow ratio with a stable 1/7 parent slot even when blank, so every list tab shares an identical, layout-stable shell.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/components/YaziPaneRow.tsx` — new component: props `parent`, `current`, `preview` (Solid JSX/accessors), `parentLabel`, `currentLabel`, `previewLabel`, `focused` (boolean, defaults to current)
|
||||||
|
- `src/utils/navigation.ts` — `PANE_RATIO` updated to `{ parent: 1, current: 3, preview: 3 }` (was `{ parent: 1, current: 4, preview: 3 }`)
|
||||||
|
- Each pane: bordered `scrollbox` + slim header label row (height=1)
|
||||||
|
- Parent pane keeps its 1/7 `flexGrow` slot even when empty (renders a muted placeholder, never `width:0`)
|
||||||
|
- Focus ring (border color = accent on current; muted `border` on parent & preview)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- Set `PANE_RATIO = { parent: 1, current: 3, preview: 3 }` in `utils/navigation.ts`
|
||||||
|
- Create `YaziPaneRow.tsx` exporting a component that lays out three `<box flexGrow={PANE_RATIO.x}>` columns in a row
|
||||||
|
- Each column: a height-1 header `<box>` with the label text, then a `<scrollbox height="100%" border borderColor=…>` rendering the passed children
|
||||||
|
- Thread a `theme` via `useTheme()` inside the primitive (don't require callers to pass colors)
|
||||||
|
- `focused` prop controls which column gets the accent border — default current; parent & preview always muted
|
||||||
|
- Ensure the parent column renders a muted placeholder box (e.g. a single `<text fg={muted}>—</text>` or empty) when its children are null, but critically keeps `flexGrow={PANE_RATIO.parent}` so width never collapses
|
||||||
|
- Add a JSDoc header describing the yazi 1:3:3 contract
|
||||||
|
- Run diagnostics on the new file
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Unit: the primitive renders three boxes with flexGrow 1/3/3 regardless of null children (Arrange null parent, render, Assert three columns present with correct flexGrow)
|
||||||
|
- Integration: toggling `focused` swaps the accent border onto the requested column
|
||||||
|
- e2e (harness): a page using the primitive shows three equal-ratio columns with the parent column visibly non-zero width even when blank
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- `PANE_RATIO` is `{ parent: 1, current: 3, preview: 3 }`
|
||||||
|
- `YaziPaneRow` accepts parent/current/preview children + labels + focused
|
||||||
|
- Parent column width never collapses to 0 (stable 1/7 slot)
|
||||||
|
- Only the focused column shows the accent border
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `grep -n "PANE_RATIO" src/utils/navigation.ts` shows the new 1:3:3 values
|
||||||
|
- `lens_diagnostics` paths=[`src/components/YaziPaneRow.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
|
||||||
|
- Harness: render a throwaway page using `<YaziPaneRow>`; confirm 3 columns at 1:3:3 via the frame
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Independent of task 01 (no nav-state dependency) — can be built in parallel
|
||||||
|
- Callers (tasks 03/04) pass their own parent/current/preview JSX; the primitive is purely structural
|
||||||
|
- opentui scrollbox: use `focused` only on the current pane so scroll focus follows the cursor
|
||||||
58
tasks/yazi-remake/03-convert-list-tabs-to-primitive.md
Normal file
58
tasks/yazi-remake/03-convert-list-tabs-to-primitive.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 03. Convert Feed/MyShows/Discover/Settings to the shared parent|current|preview primitive
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-03
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P2
|
||||||
|
depends_on: [yazi-remake-01, yazi-remake-02]
|
||||||
|
tags: [implementation, pages, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Rewrite the four depth-stack list tabs to render through `<YaziPaneRow>`, with the previous-depth list now visible in the parent pane (blank at depth 0), the current-depth list in current, and the hovered item in preview — eliminating per-page bespoke 3-column JSX.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/pages/Feed/FeedPage.tsx` — rewritten to use `<YaziPaneRow>`; parent = previous-depth list, current = current-depth list, preview = hovered item detail
|
||||||
|
- `src/pages/MyShows/MyShowsPage.tsx` — same conversion
|
||||||
|
- `src/pages/Discover/DiscoverPage.tsx` — same conversion
|
||||||
|
- `src/pages/Settings/SettingsPage.tsx` — same conversion (sections → items → editor)
|
||||||
|
- Each page's `nav.action` handler retained but only acts on the current pane
|
||||||
|
- All per-page bespoke row/flexbox 3-column JSX removed
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- For each of the four pages, read the current implementation to extract the parent/current/preview content builders
|
||||||
|
- Wrap the page body in `<YaziPaneRow parent={…} current={…} preview={…} focused={isActive} />`
|
||||||
|
- Parent pane: render the previous-depth frame's list (depth-1). At depth 0 the parent receives null/placeholder (the primitive keeps the slot)
|
||||||
|
- Current pane: the current-depth list, focusable, with `onMouseDown` row handlers calling `nav.setActivePane(DEPTH_CENTER_PANE)` + `nav.setDepthFocus(i, depth)`
|
||||||
|
- Preview pane: hovered-item detail derived from `focusedIndex(DEPTH_CENTER_PANE)` (unchanged logic, just relocated into the preview slot)
|
||||||
|
- Keep `pushDepth`/`popDepth` calls in the `open` action (drill) — behaviour unchanged, only layout changes
|
||||||
|
- Remove the old inline `<box flexGrow={PANE_RATIO.parent/current/preview}>` columns in favour of the primitive
|
||||||
|
- Verify each page's `nav.action` handler guards on `data.pane === DEPTH_CENTER_PANE && nav.activePane() === DEPTH_CENTER_PANE`
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Unit: each page's `open` action pushes a frame and the parent pane switches from blank to the previous list (Arrange depth 0, Act open, Assert stack length 2 and parent renders the old list)
|
||||||
|
- Integration: `h` (pop) returns parent to blank at depth 0; `l` (drill) populates parent with the previous list
|
||||||
|
- e2e (harness): Feed depth 0→1→2 shows parent blank → previous feeds list → previous episodes list; Settings sections→items→editor shows the chain in the parent pane
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- All four pages render via `<YaziPaneRow>` (no bespoke 3-column JSX remains)
|
||||||
|
- Parent pane is blank at depth 0, populated at depth ≥ 1
|
||||||
|
- Drilling (l/Enter) populates the parent with the previous-depth list
|
||||||
|
- Popping (h) empties the parent back to blank at depth 0
|
||||||
|
- j/k move focus only within the current pane
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `grep -rn "YaziPaneRow" src/pages/` returns 4 files
|
||||||
|
- `lens_diagnostics` paths over the four page files severity=error → 0 findings
|
||||||
|
- Harness walk: `init` → navigate Feed → `l` (drill) → `l` (drill) → `h` (pop) → `h` (pop); confirm parent slot transitions blank→list→list→blank
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on 01 (pane model) and 02 (the primitive) being merged
|
||||||
|
- The already-working `<Show when={item}>{(item) => (… item() …)}</Show>` accessor pattern for opentui `<Show>` callbacks must be preserved in preview panes
|
||||||
|
- Keep `LoadingIndicator` usages where they exist
|
||||||
52
tasks/yazi-remake/04-fit-search-and-player-panes.md
Normal file
52
tasks/yazi-remake/04-fit-search-and-player-panes.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 04. Fit Search and Player into the 3-pane (1:3:3) model
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-04
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P2
|
||||||
|
depends_on: [yazi-remake-01, yazi-remake-02]
|
||||||
|
tags: [implementation, pages, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Bring the two fixed-layout tabs (Search, Player) into the same 1:3:3 parent|current|preview shell, deciding per-page whether to adopt the depth-stack or stay fixed-3-pane, while applying the new ratios throughout.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`; parent = query input + recent-search history, current = results list, preview = focused-result detail
|
||||||
|
- `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`; current = now-playing transport, preview = episode description/notes, parent = blank placeholder (or compact episode list if available)
|
||||||
|
- Decision recorded in each file's header comment: depth-stack vs fixed-3-pane
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- Read both pages to understand their current pane semantics
|
||||||
|
- Search: map INPUT→parent, RESULTS→current, DETAIL→preview inside `<YaziPaneRow>`. If the 1/7 parent slot is too narrow for the input box, widen parent for Search only by passing an override ratio OR move the query into current and results into parent — pick the option that keeps the input usable and document it
|
||||||
|
- Search: keep the `inputFocused` effect (Shell yields keys to `<input>` when current-pane focus is on the query) — adapt to whichever pane the input lives in
|
||||||
|
- Player: single content pane; parent = blank/placeholder (1/7), current = transport + progress + controls (3/7), preview = episode art/description/notes (3/7). If no preview data, render a muted placeholder but keep the slot
|
||||||
|
- Confirm fixed-pane tab swipe (h/l between parent/current/preview) still routes correctly for Search
|
||||||
|
- Run diagnostics
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Unit: Search's `handleSubmit` swipes to the results pane and sets focus index 0 (Arrange empty results, Act submit, Assert activePane === results pane & focusedIndex 0)
|
||||||
|
- Integration: Player renders with parent blank and the transport in current
|
||||||
|
- e2e (harness): Search shows query | results | detail at 1:3:3; Player shows blank | transport | notes at 1:3:3
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- Both pages render via `<YaziPaneRow>` at 1:3:3
|
||||||
|
- Search input remains typeable (Shell yields keys when the query pane is focused)
|
||||||
|
- Player's transport is in the current pane with focus
|
||||||
|
- No layout collapse: parent & preview keep their slots even if blank
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` returns 2 files
|
||||||
|
- `lens_diagnostics` paths over both files severity=error → 0 findings
|
||||||
|
- Harness: navigate to Search, type a query, press Enter, see results in current + detail in preview; navigate to Player, see transport + notes
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on 01 (pane model — though Search is fixed-pane, the model cleanup affects `swipe` bounds) and 02 (the primitive)
|
||||||
|
- If Search input at 1/7 is genuinely too tight (~14 cols at 100w), prefer moving the query into the current pane for Search only and the results into parent — but confirm width with the harness before committing
|
||||||
|
- Player is single-content; the 1:3:3 with blanks is mostly cosmetic but keeps the layout globally consistent
|
||||||
56
tasks/yazi-remake/05-rebuild-shell-chrome.md
Normal file
56
tasks/yazi-remake/05-rebuild-shell-chrome.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 05. Rebuild Shell chrome — drop sidebar, add yazi bottom status/tab bar
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-05
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P1
|
||||||
|
depends_on: [yazi-remake-01]
|
||||||
|
tags: [implementation, shell-chrome, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Remove the always-on left tab sidebar entirely and replace it with a full-width page area above a slim yazi-style bottom bar that surfaces the active tab, depth/counts, selection, now-playing, and a discoverable tab strip.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/components/Shell.tsx` — sidebar JSX deleted; render `LayerGraph[tab]()` full-width + a rebuilt bottom status/command bar
|
||||||
|
- Bottom bar (normal mode): mode label, `TAB_LABEL[tab] · depth N · i/len` (or `pane i/n` for fixed tabs), selection count `●N`, now-playing `♪ title`, pending-keybind hint, and a compact tab strip `[1]Feed [2]MyShows …` with the active tab marked
|
||||||
|
- Bottom bar (command mode): `:` prompt + buffer + error (unchanged, just relocated if needed)
|
||||||
|
- Help overlay kept; now-playing relocated from the old sidebar footer into the status bar
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- Read `Shell.tsx` and delete the entire left tab sidebar `<box flexDirection="column" width={14}>…` block
|
||||||
|
- Replace the middle row with a single full-width `<box flexGrow={1}>{LayerGraph[nav.activeTab()]()}</box>`
|
||||||
|
- Rebuild the bottom bar: a height-1 `<box flexDirection="row">` with the fragments described above
|
||||||
|
- Tab strip: render `Object.values(TABS)` filtered to numbers; for each tab show `[N] Label` with the active tab inverted/highlighted (accent bg or `≡` marker)
|
||||||
|
- Status fragment: `nav.activePane() === DEPTH_CENTER_PANE ? (isDepthTab ? \`depth ${currentDepth()}\` : \`pane ${activePane()+1}/${count}\`) : 'tabs'` — but since the sidebar is gone, default to the depth/pane string (focus starts on current)
|
||||||
|
- Relocate `nowPlaying()` text from the sidebar footer into the bottom bar
|
||||||
|
- Keep `runCommand`, `handleCommandKey`, the help overlay, and `playEpisodeAndSwitch` untouched
|
||||||
|
- Run diagnostics
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Unit: `nowPlaying()` formats `♪ <truncated title>` (Arrange a current episode, Assert the string)
|
||||||
|
- Integration: switching tabs updates the tab strip's active marker and the status tab label
|
||||||
|
- e2e (harness): `init` shows no left sidebar, a full-width page, and a bottom bar containing the tab strip + `Feed · depth 0`; cycling tabs moves the strip's active marker
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- No `width={14}` sidebar `<box>` remains in `Shell.tsx`
|
||||||
|
- The active page fills the full content width
|
||||||
|
- The bottom bar shows the active tab, depth, counts, selection, now-playing, and the tab strip
|
||||||
|
- The active tab is visually marked in the strip
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `grep -n "width={14}" src/components/Shell.tsx` returns nothing
|
||||||
|
- `grep -n "LayerGraph" src/components/Shell.tsx` shows the full-width render
|
||||||
|
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
|
||||||
|
- Harness: `init` frame has no sidebar column and shows the tab strip in the last row
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on 01 (the pane model: focus starts on current, so the status fragment no longer needs the `SIDEBAR_PANE` branch)
|
||||||
|
- Task 06 rewrites the dispatch keybinds in this same file; do the chrome here and leave the dispatch `SIDEBAR_ACTIONS` branch for 06 to remove (or remove it here if 01 already deleted the constant — coordinate with 01)
|
||||||
|
- `playEpisodeAndSwitch` and the command bar must keep working
|
||||||
56
tasks/yazi-remake/06-rewire-keybinds.md
Normal file
56
tasks/yazi-remake/06-rewire-keybinds.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 06. Rewire keybinds — h/l drill+pop, digits switch tabs, focus starts on current
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-06
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P1
|
||||||
|
depends_on: [yazi-remake-01, yazi-remake-05]
|
||||||
|
tags: [implementation, keybinds, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Rewire the Shell dispatch so the sidebar's special-cased j/k branch is gone, h/l drill/pop on depth-tabs and swipe on fixed tabs, digit keys + `[ ]` are the sole tab switcher, and app focus starts on the current pane.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/components/Shell.tsx` (dispatch) — `SIDEBAR_ACTIONS` set + the `if (nav.activePane() === SIDEBAR_PANE)` branch deleted
|
||||||
|
- `h`/`l` unified: depth-tabs `l`=current-drills (`open` emit), `h`=current-pops (noop at depth 0); fixed-pane tabs `h/l`=`swipe(∓1, count)`
|
||||||
|
- `1`-`6` / `tab-goto-*`, `tab-next`/`tab-prev` (`[`/`]`) — the only tab switchers
|
||||||
|
- Initial focus + tab-enter land on `DEPTH_CENTER_PANE`
|
||||||
|
- `keybinds.jsonc` reviewed (update labels/help only if needed)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- Read the current `dispatch()` (post task 01 it references a deleted `SIDEBAR_PANE` — fix the compile here)
|
||||||
|
- Remove the `SIDEBAR_ACTIONS` constant and its branch
|
||||||
|
- In the `default` case, implement: digit/tab-goto → `setActiveTab`; `swipe-prev` → (depth-tab & current & depth>0) `popDepth` else (depth-tab & current & depth==0) noop else `swipe(-1, count)`; `swipe-next` → (depth-tab & current) emit `open` else `swipe(1, count)`
|
||||||
|
- Move/list actions (`move-down/up`, `jump-*`, `page-*`, `goto-top/bottom`) flow to `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only
|
||||||
|
- Confirm `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches unchanged
|
||||||
|
- Verify the app boot path sets focus to current (task 01 set the signal; confirm dispatch doesn't override)
|
||||||
|
- Run diagnostics + harness key sequence
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Unit: `dispatch("move-down")` on a depth-tab current pane emits `nav.action {action:"move-down"}` (Arrange current pane, Act, Assert emit)
|
||||||
|
- Integration: `dispatch("swipe-next")` on a depth-tab at depth 0 emits `open` (drill); `dispatch("swipe-prev")` at depth 1 pops to depth 0; at depth 0 `swipe-prev` is a noop
|
||||||
|
- e2e (harness): `l` drills (depth 0→1, parent populates), `h` pops (1→0, parent blanks), `1`/`2`/`3` switch tabs, `j`/`k` move the current list cursor without changing depth
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- No `SIDEBAR_PANE` or `SIDEBAR_ACTIONS` references in `Shell.tsx`
|
||||||
|
- `h` at depth 0 is a noop (does not error, does not change pane)
|
||||||
|
- `l` at current on a depth-tab drills (depth+1)
|
||||||
|
- Digit keys switch tabs; focus lands on current pane
|
||||||
|
- `j`/`k` move within current only
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `grep -n "SIDEBAR" src/components/Shell.tsx` returns nothing
|
||||||
|
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
|
||||||
|
- Harness: `init` (focus on current) → `l` (depth 1, parent filled) → `l` (depth 2) → `h` (depth 1) → `h` (depth 0, parent blank) → `3` (Discover tab, focus on current) → `j`/`k` move
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on 01 (pane model: `swipe` bounds, no SIDEBAR) and 05 (dispatch lives in the rebuilt Shell)
|
||||||
|
- If `keybinds.jsonc` has a `tab-next`/`tab-prev` mapping conflict, resolve here
|
||||||
|
- The noop `h` at depth 0 should feel inert (yazi: at root, `h` does nothing)
|
||||||
98
tasks/yazi-remake/07-blocker-task-04-player-search.md
Normal file
98
tasks/yazi-remake/07-blocker-task-04-player-search.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 07-blocker — Task 04 (Search + Player) never converted to YaziPaneRow
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-07-blocker
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P0
|
||||||
|
blocks: [yazi-remake-07]
|
||||||
|
blocked_by: [yazi-remake-04]
|
||||||
|
tags: [blocker, verification, tasks-required]
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Task 04 (`04-fit-search-and-player-panes.md`) is marked complete, but its
|
||||||
|
core deliverable was never implemented:
|
||||||
|
|
||||||
|
> - `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`
|
||||||
|
> - `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`
|
||||||
|
|
||||||
|
`grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` → **0 files**.
|
||||||
|
|
||||||
|
## Evidence (from task 07 harness walk-through, 100×30)
|
||||||
|
|
||||||
|
### Player ❌
|
||||||
|
|
||||||
|
`src/pages/Player/PlayerPage.tsx` renders a single full-width
|
||||||
|
`<scrollbox>` (now-playing transport + controls). No parent pane, no preview
|
||||||
|
pane. Frame `.harness/player-current.txt`:
|
||||||
|
|
||||||
|
```
|
||||||
|
│ Now Playing 0:00 / 0:00 (0%) │
|
||||||
|
...
|
||||||
|
│ │ │[Prev]│ │[Play]│ │[Next]│ Vol 70% Speed 1x ... │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected (task 04 + feature exit criteria): `blank | transport | notes` at
|
||||||
|
1/7 : 3/7 : 3/7.
|
||||||
|
|
||||||
|
### Search ⚠️ ratio wrong
|
||||||
|
|
||||||
|
`src/pages/Search/SearchPage.tsx` renders three custom `<box flexGrow={
|
||||||
|
PANE_RATIO.* }>` columns but **omits `flexBasis={0}`**, so Yoga distributes
|
||||||
|
space by natural content width (the input box is `width={28}`). Measured
|
||||||
|
column widths at 100 cols: **31 / 32 / 31** (equal thirds), NOT the target
|
||||||
|
**~14 / 43 / 43** (1:3:3).
|
||||||
|
|
||||||
|
`<YaziPaneRow>` exists precisely to set `flexBasis={0}` per column and force
|
||||||
|
the exact 1:3:3 ratio regardless of content (see its header comment). Routing
|
||||||
|
Search through it fixes the ratio for free.
|
||||||
|
|
||||||
|
## Why not patched in task 07
|
||||||
|
|
||||||
|
Task 07 is explicitly the verification gate. Its notes:
|
||||||
|
|
||||||
|
> - This is the gate for the whole feature — do not mark done if any criterion
|
||||||
|
> fails; open a blocker task instead
|
||||||
|
> - If the harness reveals a visual regression (e.g. parent collapses, ratios
|
||||||
|
> off), file it against the responsible task (02 or 03) rather than
|
||||||
|
> patching here
|
||||||
|
|
||||||
|
This is a missing implementation in task 04, not a regression in 02/03, so the
|
||||||
|
responsible task is 04. Patches belong there.
|
||||||
|
|
||||||
|
## Failing exit criteria
|
||||||
|
|
||||||
|
- "All tabs render three stable columns at 1/7 : 3/7 : 3/7" — Player fails
|
||||||
|
(not 3 columns); Search fails (wrong ratio).
|
||||||
|
- "`5` → Player: blank|transport|notes at 1:3:3" — fails.
|
||||||
|
- "`4` → Search: query|results|detail at 1:3:3" — 3 columns yes, ratio wrong.
|
||||||
|
|
||||||
|
## Fix plan (task 04 do-over)
|
||||||
|
|
||||||
|
1. `src/pages/Player/PlayerPage.tsx` — wrap the existing transport JSX in a
|
||||||
|
`<YaziPaneRow current={transport} parent={undefined} preview={notes} />`.
|
||||||
|
Parent should fall through to the primitive's muted `—` placeholder (it
|
||||||
|
already keeps its 1/7 slot when blank). Preview = episode description /
|
||||||
|
waveform (currently inline under "Now Playing"). Keep `PlayerPaneCount=1`
|
||||||
|
(the visible columns are a render concern; only current=0 is focusable).
|
||||||
|
2. `src/pages/Search/SearchPage.tsx` — replace the three custom `<box
|
||||||
|
flexGrow={PANE_RATIO.*}>` columns with a single `<YaziPaneRow
|
||||||
|
parent={queryInput+recent} current={resultsList} preview={detail}
|
||||||
|
focused={!inputFocused() ? /* results */ : false} />`. Keep the
|
||||||
|
`inputFocused` effect so the Shell yields keys to the native `<input>`
|
||||||
|
when the query pane is focused — note `inputFocused` is a Search-owned
|
||||||
|
signal; YaziPaneRow's `focused` prop only drives the accent ring + scroll
|
||||||
|
focus, which for Search can stay on the current (results) column.
|
||||||
|
3. Remove the now-dead custom ratio code from both files after the swap.
|
||||||
|
4. Re-run: `grep -rn YaziPaneRow src/pages/Search src/pages/Player` → 2 files;
|
||||||
|
`bun run build`; `bun test`; harness walk-through: Player shows 3 cols,
|
||||||
|
Search cols measure ~14/43/43.
|
||||||
|
|
||||||
|
## Verification gates (re-run task 07 after fix)
|
||||||
|
|
||||||
|
- `bun run build` → "Build complete"
|
||||||
|
- `bun test` → 0 fail
|
||||||
|
- harness:
|
||||||
|
- Player frame has 3 bordered columns (parent `—`, current transport,
|
||||||
|
preview notes/placeholder) at 1:3:3
|
||||||
|
- Search frame columns measure ~14/43/43
|
||||||
61
tasks/yazi-remake/07-verify-remake.md
Normal file
61
tasks/yazi-remake/07-verify-remake.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 07. Verify the remake — build + diagnostics + harness walk-through
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: yazi-remake-07
|
||||||
|
feature: yazi-remake
|
||||||
|
priority: P1
|
||||||
|
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
|
||||||
|
tags: [verification, tests-required]
|
||||||
|
status: BLOCKED # see .harness/verification-07.md + tasks/yazi-remake/07-blocker-task-04-player-search.md
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Confirm the yazi remake meets every exit criterion via a clean build, zero diagnostics, and a full harness walk-through of every tab and depth.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- A passing `bun run build`
|
||||||
|
- `lens_diagnostics mode=all` with zero errors across edited files
|
||||||
|
- Harness frames + state proving the parent|current|preview 1:3:3 layout, drill/pop behaviour, tab switching, and status bar across all six tabs
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- Run `bun run build` — expect "Build complete"
|
||||||
|
- Run `lens_diagnostics mode=all severity=error` — expect 0 findings across all session-edited files
|
||||||
|
- Run the drive harness (`scripts/tui-harness.tsx`) walk-through:
|
||||||
|
- `init` → confirm no sidebar, 3 columns at 1:3:3, focus on current, bottom tab strip visible
|
||||||
|
- Feed: `l` (depth 0→1, parent fills) → `l` (1→2) → `h` (2→1) → `h` (1→0, parent blanks) ; `j`/`k` move current
|
||||||
|
- `2` → MyShows: drill show→episodes, parent reflects
|
||||||
|
- `3` → Discover: category→results, parent shows categories
|
||||||
|
- `6` → Settings: sections→items→editor, parent shows the previous list at each depth
|
||||||
|
- `4` → Search: query|results|detail at 1:3:3; type + Enter works
|
||||||
|
- `5` → Player: blank|transport|notes at 1:3:3
|
||||||
|
- Capture the status bar content (active tab + depth + counts + now-playing + tab strip) from a representative frame
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Build: `bun run build` exits 0 with "Build complete"
|
||||||
|
- Diagnostics: `lens_diagnostics` mode=all → 0 errors
|
||||||
|
- Harness (integration/e2e): the walk-through above produces the expected frames & state (parent blank at depth 0, populates on drill, blanks on pop; digits switch tabs; h noop at depth 0)
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- `bun run build` passes
|
||||||
|
- `lens_diagnostics` mode=all reports zero errors
|
||||||
|
- All six tabs render 3 stable columns at 1:3:3
|
||||||
|
- Parent pane is blank at depth 0; drill fills it with the previous-depth list; pop empties it
|
||||||
|
- `h` is a noop at depth 0; `l` drills; `1-6`/`[`/`]` switch tabs; `j/k` move current only
|
||||||
|
- No sidebar; focus starts on current; bottom bar shows active tab + depth + counts + tab strip
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun run build 2>&1 | tail -3` → "Build complete"
|
||||||
|
- `lens_diagnostics` mode=all severity=error → "No error issues…"
|
||||||
|
- Harness `state nav` after `init` shows `pane === 0` (current), not -1
|
||||||
|
- Harness frames for Feed depth 0/1/2 show the parent slot transition blank→list→list
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- This is the gate for the whole feature — do not mark done if any criterion fails; open a blocker task instead
|
||||||
|
- If the harness reveals a visual regression (e.g. parent collapses, ratios off), file it against the responsible task (02 or 03) rather than patching here
|
||||||
|
- Save a representative `.harness/last-frame.txt` snapshot if a visual reference is useful for future sessions
|
||||||
40
tasks/yazi-remake/README.md
Normal file
40
tasks/yazi-remake/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Yazi UI Remake
|
||||||
|
|
||||||
|
Objective: Remake the PodTUI shell into a yazi-pure parent|current|preview 3-pane layout (1:3:3 ratio) with a bottom tab strip and no always-on sidebar.
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
||||||
|
- [x] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
|
||||||
|
- [x] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
|
||||||
|
- [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
|
||||||
|
- [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
||||||
|
- [x] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
||||||
|
- [x] 07 — verify-remake → `07-verify-remake.md`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- 03 depends on 01
|
||||||
|
- 03 depends on 02
|
||||||
|
- 04 depends on 01
|
||||||
|
- 04 depends on 02
|
||||||
|
- 05 depends on 01
|
||||||
|
- 06 depends on 01
|
||||||
|
- 06 depends on 05
|
||||||
|
- 07 depends on 03
|
||||||
|
- 07 depends on 04
|
||||||
|
- 07 depends on 05
|
||||||
|
- 07 depends on 06
|
||||||
|
|
||||||
|
## Exit criteria
|
||||||
|
|
||||||
|
- The feature is complete when the left tab sidebar is gone; tabs switch only via digit keys `1-6` / `[ ]` and a bottom tab strip
|
||||||
|
- All tabs render three stable columns at 1/7 : 3/7 : 3/7 (parent | current | preview)
|
||||||
|
- The parent pane renders the previous-depth list and is blank (but keeps its 1/7 slot) at depth 0
|
||||||
|
- `h`/`l` drill (push) and pop depths on list tabs; `h` is a noop at depth 0
|
||||||
|
- `j`/`k` move within the current pane only; focus starts on the current pane
|
||||||
|
- Feed depth 0→1→2, MyShows, Discover, Settings (sections→items→editor), Search, and Player all render correctly via the drive harness
|
||||||
|
- `bun run build` passes and `lens_diagnostics` (mode=all) reports zero errors
|
||||||
|
- The bottom status bar shows active tab + depth + counts, selection count, now-playing, and the tab strip
|
||||||
38
tests/audio-dispose.test.ts
Normal file
38
tests/audio-dispose.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Audio backend dispose regression test.
|
||||||
|
*
|
||||||
|
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
||||||
|
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
||||||
|
* keep spawned players (mpv) from surviving the host, useAudio
|
||||||
|
* registers a `process.on("exit")` handler that synchronously disposes the
|
||||||
|
* backend. The exit handler's whole job is "kill the child process", so this
|
||||||
|
* test pins the contract directly: a backend holding a real spawned subprocess
|
||||||
|
* must have killed it once `dispose()` returns.
|
||||||
|
*
|
||||||
|
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
||||||
|
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
||||||
|
* mpv is the only real backend, and it uses the kill-on-dispose
|
||||||
|
* exercising one is enough to guard the family.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { MpvBackend } from "../src/utils/audio-player";
|
||||||
|
|
||||||
|
test("MpvBackend.dispose() kills the spawned child process", async () => {
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
// Inject a real long-lived subprocess as if mpv had been spawned.
|
||||||
|
const child = Bun.spawn(["sleep", "60"], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
});
|
||||||
|
(backend as unknown as { proc: typeof child }).proc = child;
|
||||||
|
|
||||||
|
// Sanity: the child is alive.
|
||||||
|
expect(child.killed).toBe(false);
|
||||||
|
|
||||||
|
backend.dispose();
|
||||||
|
|
||||||
|
// dispose() sent SIGTERM (proc.kill()); wait for the child to exit.
|
||||||
|
await child.exited;
|
||||||
|
expect(child.killed).toBe(true);
|
||||||
|
});
|
||||||
@@ -2,14 +2,29 @@
|
|||||||
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
||||||
* Run: bun tests/cavacore-smoke.ts
|
* Run: bun tests/cavacore-smoke.ts
|
||||||
*/
|
*/
|
||||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||||
import { join } from "path"
|
import { join } from "path";
|
||||||
|
|
||||||
const libPath = join(import.meta.dir, "..", "src", "native", "libcavacore.dylib")
|
const libPath = join(
|
||||||
|
import.meta.dir,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"native",
|
||||||
|
"libcavacore.dylib",
|
||||||
|
);
|
||||||
|
|
||||||
const lib = dlopen(libPath, {
|
const lib = dlopen(libPath, {
|
||||||
cava_init: {
|
cava_init: {
|
||||||
args: [FFIType.i32, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.double, FFIType.i32, FFIType.i32],
|
args: [
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.u32,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.double,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.i32,
|
||||||
|
],
|
||||||
returns: FFIType.ptr,
|
returns: FFIType.ptr,
|
||||||
},
|
},
|
||||||
cava_execute: {
|
cava_execute: {
|
||||||
@@ -20,39 +35,52 @@ const lib = dlopen(libPath, {
|
|||||||
args: [FFIType.ptr],
|
args: [FFIType.ptr],
|
||||||
returns: FFIType.void,
|
returns: FFIType.void,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const bars = 10
|
const bars = 10;
|
||||||
const rate = 44100
|
const rate = 44100;
|
||||||
const channels = 1
|
const channels = 1;
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
const plan = lib.symbols.cava_init(bars, rate, channels, 1, 0.77, 50, 10000)
|
const plan = lib.symbols.cava_init(
|
||||||
|
bars,
|
||||||
|
rate,
|
||||||
|
channels,
|
||||||
|
1,
|
||||||
|
0.77,
|
||||||
|
50,
|
||||||
|
10000,
|
||||||
|
0 /* CAVA_SCALING_LINEAR */,
|
||||||
|
);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
console.error("FAIL: cava_init returned null")
|
console.error("FAIL: cava_init returned null");
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("cava_init OK, plan pointer:", plan)
|
console.log("cava_init OK, plan pointer:", plan);
|
||||||
|
|
||||||
// Generate a 200Hz sine wave test signal
|
// Generate a 200Hz sine wave test signal
|
||||||
const bufferSize = 512
|
const bufferSize = 512;
|
||||||
const cavaIn = new Float64Array(bufferSize)
|
const cavaIn = new Float64Array(bufferSize);
|
||||||
const cavaOut = new Float64Array(bars * channels)
|
const cavaOut = new Float64Array(bars * channels);
|
||||||
|
|
||||||
for (let k = 0; k < 100; k++) {
|
for (let k = 0; k < 100; k++) {
|
||||||
for (let n = 0; n < bufferSize; n++) {
|
for (let n = 0; n < bufferSize; n++) {
|
||||||
cavaIn[n] = Math.sin(2 * Math.PI * 200 / rate * (n + k * bufferSize)) * 20000
|
cavaIn[n] =
|
||||||
|
Math.sin(((2 * Math.PI * 200) / rate) * (n + k * bufferSize)) * 20000;
|
||||||
}
|
}
|
||||||
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan)
|
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("cava_execute OK, output:", Array.from(cavaOut).map(v => v.toFixed(3)))
|
console.log(
|
||||||
|
"cava_execute OK, output:",
|
||||||
|
Array.from(cavaOut).map((v) => v.toFixed(3)),
|
||||||
|
);
|
||||||
|
|
||||||
// Check that bar 2 (200Hz) has the peak
|
// Check that bar 2 (200Hz) has the peak
|
||||||
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut))
|
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut));
|
||||||
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`)
|
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`);
|
||||||
|
|
||||||
// Destroy
|
// Destroy
|
||||||
lib.symbols.cava_destroy(plan)
|
lib.symbols.cava_destroy(plan);
|
||||||
console.log("cava_destroy OK")
|
console.log("cava_destroy OK");
|
||||||
console.log("\nSMOKE TEST PASSED")
|
console.log("\nSMOKE TEST PASSED");
|
||||||
|
|||||||
330
tests/dispatch-keybinds.test.ts
Normal file
330
tests/dispatch-keybinds.test.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* dispatch-keybinds.test.ts — yazi remake task 06/07 unit + integration tests.
|
||||||
|
*
|
||||||
|
* Exercises the rewired unified keybind router (`createDispatcher`) directly,
|
||||||
|
* without the OpenTUI render tree, mirroring how task 01 made the nav store a
|
||||||
|
* plain factory for `bun test`. Covers the task 06 acceptance + integration
|
||||||
|
* cases, plus the tab-root routing:
|
||||||
|
*
|
||||||
|
* • Tab root: j/k (`move-down`/`move-up`) move a tab cursor without touching
|
||||||
|
* the active tab; `open`/`swipe-next` (l/Enter) open the hovered tab and
|
||||||
|
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
|
||||||
|
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
|
||||||
|
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
|
||||||
|
* • Every tab is a depth-tab: `swipe-next` (l) at depth 0 emits `open`
|
||||||
|
* (drill); `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the
|
||||||
|
* tab root. Player has no deeper drill (single now-playing pane).
|
||||||
|
* • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
|
||||||
|
* tabs and preserve focus context (root stays root for depth-tabs, content
|
||||||
|
* stays content).
|
||||||
|
* • `j`/`k` (move-down/up) in content flow to `nav.action` for the current
|
||||||
|
* pane.
|
||||||
|
*
|
||||||
|
* The dispatcher is built with fake audio/k/help deps (the paths under test
|
||||||
|
* never reach the audio or advanceEpisode branches) and a real nav store.
|
||||||
|
*/
|
||||||
|
import { test, expect, mock } from "bun:test";
|
||||||
|
import { createRoot } from "solid-js";
|
||||||
|
import {
|
||||||
|
createNavigation,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
} from "../src/context/navigation-store";
|
||||||
|
import { TABS } from "../src/utils/navigation";
|
||||||
|
import { createDispatcher, type DispatcherDeps } from "../src/utils/dispatch";
|
||||||
|
import { on } from "../src/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "../src/context/KeybindContext";
|
||||||
|
|
||||||
|
/** Build a real nav store + a dispatcher wired to fake audio/k/help deps, all
|
||||||
|
* inside a reactive root (disposed after). Returns both so a test can read
|
||||||
|
* nav state and call dispatch. */
|
||||||
|
function withHarness(
|
||||||
|
fn: (api: {
|
||||||
|
nav: ReturnType<typeof createNavigation>;
|
||||||
|
dispatch: (action: KeybindActionName) => void;
|
||||||
|
toggleHelp: () => boolean;
|
||||||
|
helpOpen: () => boolean;
|
||||||
|
}) => void,
|
||||||
|
) {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const nav = createNavigation();
|
||||||
|
let help = false;
|
||||||
|
const deps: DispatcherDeps = {
|
||||||
|
nav,
|
||||||
|
audio: {
|
||||||
|
togglePlayback: async () => {},
|
||||||
|
seekRelative: async () => {},
|
||||||
|
},
|
||||||
|
k: { clearPending: () => {} },
|
||||||
|
setShowHelp: (fn) => {
|
||||||
|
help = fn(help);
|
||||||
|
},
|
||||||
|
advanceEpisode: () => {},
|
||||||
|
};
|
||||||
|
const { dispatch } = createDispatcher(deps);
|
||||||
|
const evt = () => ({ preventDefault: mock(() => {}) });
|
||||||
|
fn({
|
||||||
|
nav,
|
||||||
|
dispatch: (action) => dispatch(action, evt() as any),
|
||||||
|
toggleHelp: () => (help = !help),
|
||||||
|
helpOpen: () => help,
|
||||||
|
});
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture nav.action emits during `fn`. Returns the captured payloads. */
|
||||||
|
function captureNavActions(fn: () => void) {
|
||||||
|
const captured: { action: KeybindActionName; tab: TABS; pane: number }[] = [];
|
||||||
|
const unsub = on("nav.action", (d) => {
|
||||||
|
captured.push(d as any);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab root: j/k move the cursor; l/Enter open the hovered tab ──────────────
|
||||||
|
test("dispatch('move-down') on the tab root moves the cursor (no emit, active tab untouched)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("move-down"));
|
||||||
|
expect(events).toHaveLength(0); // j on the root moves the cursor only
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED); // active tab untouched until opened
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('move-up') on the tab root moves the cursor up (clamped, no wrap)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||||
|
dispatch("move-up");
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.FEED); // clamped at the top
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('open') on the tab root opens the hovered tab and enters its content", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
dispatch("move-down"); // cursor -> MYSHOWS
|
||||||
|
dispatch("move-down"); // cursor -> DISCOVER
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||||
|
dispatch("open");
|
||||||
|
expect(nav.activeTab()).toBe(TABS.DISCOVER); // the hovered tab is opened
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-next') on the tab root opens the hovered tab and enters its content (no emit)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
dispatch("move-down"); // cursor -> MYSHOWS
|
||||||
|
const events = captureNavActions(() => dispatch("swipe-next"));
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') on the tab root is inert (stays, no emit)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unit: move-down emits nav.action on the current pane only ────────────────
|
||||||
|
test("dispatch('move-down') on a depth-tab current pane emits nav.action {action:'move-down'} on the current pane", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.FEED); // depth-tab → enter content to test the list move
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("move-down"));
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].action).toBe("move-down");
|
||||||
|
expect(events[0].tab).toBe(TABS.FEED);
|
||||||
|
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.MYSHOWS);
|
||||||
|
nav.enterTabContent();
|
||||||
|
const beforeDepth = nav.currentDepth();
|
||||||
|
const events = captureNavActions(() => dispatch("move-up"));
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].action).toBe("move-up");
|
||||||
|
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||||
|
// depth is untouched by j/k (only h/l and the page's open() touch it).
|
||||||
|
expect(nav.currentDepth()).toBe(beforeDepth);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
|
||||||
|
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.DISCOVER);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("swipe-next"));
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].action).toBe("open");
|
||||||
|
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||||
|
// the drill (pushDepth) is the page's job on `open`; dispatch only emits.
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
nav.enterTabContent();
|
||||||
|
// simulate the page's open() having drilled one level.
|
||||||
|
nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 });
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||||
|
expect(events).toHaveLength(0); // a pop emits nothing — it just pops
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
// focus stays in content (depth > 0 pop does not return to the root).
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') on Search at depth 0 returns to the tab root", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.SEARCH); // depth-tab, query root
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
|
||||||
|
dispatch("swipe-prev");
|
||||||
|
// h at depth 0 returns to the tab root — so Search isn't a dead end.
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
// a second h at the root is inert (out of the panes).
|
||||||
|
dispatch("swipe-prev");
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') on the single-pane Player tab returns to the tab root", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.PLAYER); // depth-tab, single now-playing pane
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
|
||||||
|
dispatch("swipe-prev");
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') at depth 1 (results) pops to depth 0 (query)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.SEARCH); // depth-tab: query(0) → results(1)
|
||||||
|
nav.enterTabContent();
|
||||||
|
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
|
||||||
|
dispatch("swipe-prev");
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.atRootTab()).toBe(false); // h at depth>0 stays in content
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
|
||||||
|
test("tab-goto-N from the root keeps depth-tabs at the root", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
// focus starts on the tab root.
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
|
||||||
|
dispatch("tab-goto-3"); // → Discover
|
||||||
|
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.DISCOVER); // cursor re-synced
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
|
||||||
|
dispatch("tab-goto-2"); // → MyShows
|
||||||
|
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
|
||||||
|
// every tab is a depth-tab now: switching to Search from the root
|
||||||
|
// keeps the root too (Enter/l opens content).
|
||||||
|
dispatch("tab-goto-4"); // → Search
|
||||||
|
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.SEARCH);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab-goto-N from content keeps focus in the active tab's content", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
|
||||||
|
dispatch("tab-goto-3"); // → Discover
|
||||||
|
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
|
||||||
|
dispatch("tab-goto-4"); // → Search (depth-tab) lands its current pane
|
||||||
|
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab-next (]) / tab-prev ([) cycle tabs and keep focus context", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
|
||||||
|
dispatch("tab-next");
|
||||||
|
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
|
||||||
|
dispatch("tab-prev");
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("help toggles open on the 'help' action", () => {
|
||||||
|
withHarness(({ dispatch, helpOpen }) => {
|
||||||
|
expect(helpOpen()).toBe(false);
|
||||||
|
dispatch("help");
|
||||||
|
expect(helpOpen()).toBe(true);
|
||||||
|
dispatch("help");
|
||||||
|
expect(helpOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
tests/keybind-matcher.test.ts
Normal file
131
tests/keybind-matcher.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { parseStroke, parseBindingSpec } from "../src/context/KeybindContext";
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
"move-down": parseBindingSpec(["j", "down"]),
|
||||||
|
"move-up": parseBindingSpec(["k", "up"]),
|
||||||
|
"goto-top": parseBindingSpec([["g", "g"]]),
|
||||||
|
"goto-bottom": parseBindingSpec(["G"]),
|
||||||
|
"toggle-select": parseBindingSpec(["space"]),
|
||||||
|
"swipe-prev": parseBindingSpec(["h", "left"]),
|
||||||
|
"audio-toggle": parseBindingSpec(["P"]),
|
||||||
|
"audio-seek-forward": parseBindingSpec(["shift-."]),
|
||||||
|
"audio-seek-backward": parseBindingSpec(["shift-,"]),
|
||||||
|
sort: parseBindingSpec([","]),
|
||||||
|
quit: parseBindingSpec(["q"]),
|
||||||
|
command: parseBindingSpec([":"]),
|
||||||
|
"tab-next": parseBindingSpec(["]"]),
|
||||||
|
} as Record<string, ReturnType<typeof parseBindingSpec>>;
|
||||||
|
|
||||||
|
function eq(a: any, b: any) {
|
||||||
|
return (
|
||||||
|
a.key === b.key &&
|
||||||
|
!!a.ctrl === !!b.ctrl &&
|
||||||
|
!!a.shift === !!b.shift &&
|
||||||
|
!!a.meta === !!b.meta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function classify(candidate: any[]) {
|
||||||
|
const exact: string[] = [];
|
||||||
|
const prefix: string[] = [];
|
||||||
|
for (const name of Object.keys(cfg)) {
|
||||||
|
for (const seq of cfg[name]) {
|
||||||
|
if (seq.length < candidate.length) continue;
|
||||||
|
let p = true;
|
||||||
|
for (let i = 0; i < candidate.length; i++)
|
||||||
|
if (!eq(seq[i], candidate[i])) {
|
||||||
|
p = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!p) continue;
|
||||||
|
if (seq.length === candidate.length) exact.push(name);
|
||||||
|
else prefix.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { exact, prefix };
|
||||||
|
}
|
||||||
|
function longest(names: string[]) {
|
||||||
|
let best = names[0],
|
||||||
|
bl = 0;
|
||||||
|
for (const n of names)
|
||||||
|
for (const s of cfg[n])
|
||||||
|
if (s.length > bl) {
|
||||||
|
bl = s.length;
|
||||||
|
best = n;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function sim(strokes: any[]) {
|
||||||
|
let pending: any[] = [];
|
||||||
|
let fired: string | null = null;
|
||||||
|
for (const st of strokes) {
|
||||||
|
const cand = [...pending, st];
|
||||||
|
const { exact, prefix } = classify(cand);
|
||||||
|
if (prefix.length > 0) {
|
||||||
|
pending = cand;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (exact.length === 0) {
|
||||||
|
const fresh = classify([st]);
|
||||||
|
if (fresh.prefix.length > 0) {
|
||||||
|
pending = [st];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fresh.exact.length > 0) fired = longest(fresh.exact);
|
||||||
|
else fired = null;
|
||||||
|
} else fired = longest(exact);
|
||||||
|
pending = [];
|
||||||
|
}
|
||||||
|
return fired;
|
||||||
|
}
|
||||||
|
const E = (k: string, o: any = {}) => ({ key: k, ...o });
|
||||||
|
|
||||||
|
let pass = 0,
|
||||||
|
fail = 0;
|
||||||
|
function check(label: string, got: string | null, want: string | null) {
|
||||||
|
const ok = got === want;
|
||||||
|
if (ok) pass++;
|
||||||
|
else fail++;
|
||||||
|
console.log(
|
||||||
|
`${ok ? "PASS" : "FAIL"} ${label} => ${got}${ok ? "" : ` (want ${want})`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
check("j -> move-down", sim([E("j")]), "move-down");
|
||||||
|
check("down -> move-down", sim([E("down")]), "move-down");
|
||||||
|
check("gg -> goto-top", sim([E("g"), E("g")]), "goto-top");
|
||||||
|
check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom");
|
||||||
|
check("space -> toggle-select", sim([E("space")]), "toggle-select");
|
||||||
|
check("q -> quit", sim([E("q")]), "quit");
|
||||||
|
check(": -> command", sim([E(":")]), "command");
|
||||||
|
check("] -> tab-next", sim([E("]")]), "tab-next");
|
||||||
|
check(
|
||||||
|
"shift+p -> audio-toggle",
|
||||||
|
sim([E("p", { shift: true })]),
|
||||||
|
"audio-toggle",
|
||||||
|
);
|
||||||
|
check("plain p -> null", sim([E("p")]), null);
|
||||||
|
check(
|
||||||
|
"shift+, -> audio-seek-backward",
|
||||||
|
sim([E(",", { shift: true })]),
|
||||||
|
"audio-seek-backward",
|
||||||
|
);
|
||||||
|
check(", -> sort", sim([E(",")]), "sort");
|
||||||
|
check(
|
||||||
|
"shift+. -> audio-seek-forward",
|
||||||
|
sim([E(".", { shift: true })]),
|
||||||
|
"audio-seek-forward",
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"g then j -> move-down (timeout-ish fallthrough)",
|
||||||
|
sim([E("g"), E("j")]),
|
||||||
|
"move-down",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
parseStroke("ctrl-d"),
|
||||||
|
parseStroke("G"),
|
||||||
|
parseStroke(">"),
|
||||||
|
parseStroke("shift-return"),
|
||||||
|
);
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
if (fail > 0) process.exit(1);
|
||||||
265
tests/nav-model.test.ts
Normal file
265
tests/nav-model.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* nav-model.test.ts — yazi remake task 01/07 unit/integration tests.
|
||||||
|
*
|
||||||
|
* Covers the tab-list-as-root navigation model:
|
||||||
|
* • createNavigation() exposes the nav factory directly (no Solid render
|
||||||
|
* needed), wrapped in a createRoot so effects register/dispose.
|
||||||
|
* • focus starts on the tab list — the app root. `atRootTab()` is true while
|
||||||
|
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
|
||||||
|
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
|
||||||
|
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
|
||||||
|
* for the fixed-pane Search/Player tabs (they clear `atRootTab` on switch
|
||||||
|
* and regain it via `backToTabRoot`, the `h`-back-up path).
|
||||||
|
* • the root tab list is a normal list: `tabCursor` is independent of
|
||||||
|
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
|
||||||
|
* the hovered tab + enters content, and direct tab switches re-sync it.
|
||||||
|
* • depth-tab focusedIndex depth-current read/writes the top frame's focus.
|
||||||
|
* • swipe() is clamped to [1, paneCount] (content panes only; there is no
|
||||||
|
* pane-0 tab slot — the tab root is a flag, not a pane).
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRoot } from "solid-js";
|
||||||
|
import {
|
||||||
|
createNavigation,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
NavMode,
|
||||||
|
} from "../src/context/navigation-store";
|
||||||
|
import { TABS } from "../src/utils/navigation";
|
||||||
|
|
||||||
|
/** Build a fresh nav graph inside a reactive root and run `fn` against it.
|
||||||
|
* Disposes the root afterwards so effects/signals don't leak between tests. */
|
||||||
|
function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const nav = createNavigation();
|
||||||
|
fn(nav);
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("createNavigation starts on the tab root (atRootTab true)", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── depth-tab focus: reads/writes the top frame's focus ───────────────────────
|
||||||
|
test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
// FeeD is a depth-tab; its root frame is a the top frame on the stack.
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0);
|
||||||
|
// setFocusedIndex writes to the *top* frame, not a pane map.
|
||||||
|
nav.setFocusedIndex(DEPTH_CENTER_PANE, 7);
|
||||||
|
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(7);
|
||||||
|
// topFrame focus reflects the write.
|
||||||
|
expect(nav.topFrame()?.focus).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setFocusedIndex on a 2-frame stack writes only the top frame", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
nav.enterTabContent();
|
||||||
|
// root frame focus 3, then push a child frame whose focus is 5.
|
||||||
|
nav.setFocusedIndex(DEPTH_CENTER_PANE, 3);
|
||||||
|
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 5 });
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
// writing the current pane updates only the top (child) frame.
|
||||||
|
nav.setFocusedIndex(DEPTH_CENTER_PANE, 9);
|
||||||
|
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(9);
|
||||||
|
// the previous depth's focus is untouched.
|
||||||
|
expect(nav.depthFocus(0)).toBe(3);
|
||||||
|
// popping restores the parent frame's focus.
|
||||||
|
expect(nav.popDepth()).toBe(true);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── popDepth is a noop at depth 0 ────────────────────────────────────────────
|
||||||
|
test("popDepth at depth 0 is a noop (returns false, no frame lost)", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.MYSHOWS);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.popDepth()).toBe(false);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.depthStack().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── tab switching keeps focus context ────────────────────────────────────────
|
||||||
|
test("tab switch keeps focus context: at the root it stays at the root", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
// focus starts on the tab root.
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
// switching depth-tabs from the root must not drop focus into content.
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
nav.setActiveTab(TABS.MYSHOWS);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab switch keeps focus context: in content it stays in content", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
// switching tabs from content keeps the content context.
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching to a Search/Player tab keeps the root (depth-tab)", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
// at root, opening Search keeps the root: every tab is a depth-tab now,
|
||||||
|
// so Enter/l is required to drop into content. `h`-back-up still works.
|
||||||
|
nav.setActiveTab(TABS.SEARCH);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
nav.backToTabRoot();
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
// Player is also a depth-tab now.
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── tab root <-> content transitions ─────────────────────────────────────────
|
||||||
|
test("enterTabContent/backToTabRoot round-trip between root and content", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
nav.backToTabRoot();
|
||||||
|
expect(nav.atRootTab()).toBe(true);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enterTabContent preserves the active tab's depth", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 0 });
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
// moving between the root and content never touches the depth stack.
|
||||||
|
nav.backToTabRoot();
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
expect(nav.popDepth()).toBe(true);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tabCursor starts on the active tab", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moveTabCursor moves the cursor without changing the active tab, clamped at the ends", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.MYSHOWS); // cursor syncs to the active tab
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||||
|
nav.moveTabCursor(1);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.MYSHOWS); // active tab untouched
|
||||||
|
nav.moveTabCursor(-1);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||||
|
// clamp: from FEED, up stays FEED; from SETTINGS, down stays SETTINGS.
|
||||||
|
nav.moveTabCursor(-1); // MYSHOWS -> FEED
|
||||||
|
nav.moveTabCursor(-1); // FEED -> FEED (clamped)
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
nav.moveTabCursor(1); // SETTINGS -> SETTINGS (clamped)
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.SETTINGS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activateTabCursor switches to the hovered tab and enters its content", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.moveTabCursor(1); // cursor -> MYSHOWS
|
||||||
|
nav.moveTabCursor(1); // cursor -> DISCOVER
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
nav.activateTabCursor();
|
||||||
|
expect(nav.activeTab()).toBe(TABS.DISCOVER); // hovered tab opened
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct tab switches re-sync the tab cursor", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.moveTabCursor(1); // cursor -> MYSHOWS
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab-switch resets mode/visual/command state", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.enterVisual();
|
||||||
|
expect(nav.mode()).toBe(NavMode.VISUAL);
|
||||||
|
nav.setActiveTab(TABS.DISCOVER);
|
||||||
|
expect(nav.mode()).toBe(NavMode.NORMAL);
|
||||||
|
expect(nav.visualAnchor()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
|
||||||
|
test("Search is a depth-tab: query root drills to results and back", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.SEARCH);
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
expect(nav.topFrame()?.kind).toBe("search:query");
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
// Enter on the query submits → push a results frame.
|
||||||
|
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
// h at depth 1 pops back to the query.
|
||||||
|
expect(nav.popDepth()).toBe(true);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Player is a single-depth depth-tab (now-playing only)", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
expect(nav.topFrame()?.kind).toBe("player:nowplaying");
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.currentDepth()).toBe(0); // no deeper drill
|
||||||
|
expect(nav.popDepth()).toBe(false); // noop at depth 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ensureStack seeds a root frame on first visit to a depth-tab ─────────────
|
||||||
|
test("switching to a fresh depth-tab seeds its root frame", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(TABS.DISCOVER);
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
expect(nav.depthStack().length).toBe(1);
|
||||||
|
expect(nav.topFrame()?.kind).toBe("discover:categories");
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
expect(nav.topFrame()?.kind).toBe("settings:sections");
|
||||||
|
});
|
||||||
|
});
|
||||||
127
tests/yazi-pages-depth.test.ts
Normal file
127
tests/yazi-pages-depth.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* yazi-pages-depth.test.ts — task 03 page contract tests.
|
||||||
|
*
|
||||||
|
* Every depth-stack tab (Feed / MyShows / Discover / Search / Player /
|
||||||
|
* Settings) renders through `<YaziPaneRow>` with the parent pane reading the
|
||||||
|
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
|
||||||
|
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
|
||||||
|
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
|
||||||
|
* pages depend on for every depth-tab, asserting the parent-slot data model:
|
||||||
|
*
|
||||||
|
* • depth 0 → stack has exactly the root frame (parent pane is blank)
|
||||||
|
* • drill(l)→ push a child frame; stack length 2, parent = previous list
|
||||||
|
* • drill(l)→ push again; stack length 3 (Settings sections→items→editor)
|
||||||
|
* • pop(h) → stack shrinks; parent returns to the previous list
|
||||||
|
* • pop(h) → back at root; parent is blank again
|
||||||
|
*
|
||||||
|
* The visual "blank → list → list → blank" transition is the union of this
|
||||||
|
* data model (which list each depth renders) with `<YaziPaneRow>`'s null
|
||||||
|
* placeholder (covered by yazi-pane-row.test.tsx). Tested at the store level
|
||||||
|
* because the page `open()` closures are not exported and the nav store is
|
||||||
|
* the shared contract all four pages route through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRoot } from "solid-js";
|
||||||
|
import {
|
||||||
|
createNavigation,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
} from "../src/context/navigation-store";
|
||||||
|
import { TABS, DEPTH_TABS } from "../src/utils/navigation";
|
||||||
|
import type { DepthFrame } from "../src/context/NavigationContext";
|
||||||
|
|
||||||
|
function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
fn(createNavigation());
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The depth-tabs that render via <YaziPaneRow> (task 03 conversion). */
|
||||||
|
const CONVERTED_TABS = [
|
||||||
|
TABS.FEED,
|
||||||
|
TABS.MYSHOWS,
|
||||||
|
TABS.DISCOVER,
|
||||||
|
TABS.SEARCH,
|
||||||
|
TABS.PLAYER,
|
||||||
|
TABS.SETTINGS,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tab of CONVERTED_TABS) {
|
||||||
|
const name = TABS[tab];
|
||||||
|
|
||||||
|
test(`${name}: depth 0 → 1 → 2 push/pop keeps the parent-slot contract`, () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(tab);
|
||||||
|
expect(nav.isDepthTab()).toBe(true);
|
||||||
|
|
||||||
|
// depth 0: exactly the root frame → parent pane renders blank.
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.depthStack()).toHaveLength(1);
|
||||||
|
|
||||||
|
// drill (l): page open() pushes a child frame — parent becomes
|
||||||
|
// the previous-depth list.
|
||||||
|
const child: DepthFrame = {
|
||||||
|
kind: `${name.toLowerCase()}:child`,
|
||||||
|
ctx: "c1",
|
||||||
|
focus: 0,
|
||||||
|
};
|
||||||
|
nav.pushDepth(child);
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
expect(nav.depthStack()).toHaveLength(2);
|
||||||
|
// the parent (depth 0) frame is still the root; the top is the child.
|
||||||
|
expect(nav.depthStack()[0]).toBe(nav.depthStack()[0]);
|
||||||
|
expect(nav.topFrame()).toEqual(child);
|
||||||
|
|
||||||
|
// drill again (l): push a second child — parent shows the first
|
||||||
|
// child's list (the chain Settings exercises: sections→items→editor).
|
||||||
|
const grandchild: DepthFrame = {
|
||||||
|
kind: `${name.toLowerCase()}:grand`,
|
||||||
|
ctx: "g1",
|
||||||
|
focus: 0,
|
||||||
|
};
|
||||||
|
nav.pushDepth(grandchild);
|
||||||
|
expect(nav.currentDepth()).toBe(2);
|
||||||
|
expect(nav.depthStack()).toHaveLength(3);
|
||||||
|
expect(nav.topFrame()).toEqual(grandchild);
|
||||||
|
|
||||||
|
// pop (h): back to depth 1 — parent frame is the root, top is child.
|
||||||
|
expect(nav.popDepth()).toBe(true);
|
||||||
|
expect(nav.currentDepth()).toBe(1);
|
||||||
|
expect(nav.depthStack()).toHaveLength(2);
|
||||||
|
expect(nav.topFrame()).toEqual(child);
|
||||||
|
|
||||||
|
// pop (h): back to depth 0 — parent pane is blank again.
|
||||||
|
expect(nav.popDepth()).toBe(true);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.depthStack()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${name}: pop (h) at depth 0 is a noop (returns false, root kept)`, () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
nav.setActiveTab(tab);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.popDepth()).toBe(false);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
// the root frame is preserved (parent stays blank, not undefined).
|
||||||
|
expect(nav.depthStack()).toHaveLength(1);
|
||||||
|
expect(nav.topFrame()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DEPTH_TABS covers exactly the depth-stack pages ────────────────────────
|
||||||
|
test("DEPTH_TABS is exactly the depth-stack tabs", () => {
|
||||||
|
expect([...DEPTH_TABS].sort()).toEqual(
|
||||||
|
[
|
||||||
|
TABS.FEED,
|
||||||
|
TABS.MYSHOWS,
|
||||||
|
TABS.DISCOVER,
|
||||||
|
TABS.SEARCH,
|
||||||
|
TABS.PLAYER,
|
||||||
|
TABS.SETTINGS,
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
239
tests/yazi-pane-row.test.tsx
Normal file
239
tests/yazi-pane-row.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* YaziPaneRow tests — the 1:3:3 parent|current|preview layout primitive.
|
||||||
|
*
|
||||||
|
* Verified through the opentui test renderer's captured frames (the same
|
||||||
|
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||||
|
* observable as rendered column widths and border colors.
|
||||||
|
*
|
||||||
|
* • Unit: three columns render at 1:3:3 (e.g. 14/43/43 of 100) even when the
|
||||||
|
* parent and preview children are null, and the blank parent keeps its
|
||||||
|
* slot with a muted placeholder.
|
||||||
|
* • Integration: toggling `focused` moves the accent focus ring onto/off the
|
||||||
|
* current column; parent & preview borders stay muted either way.
|
||||||
|
*
|
||||||
|
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
|
||||||
|
* in bunfig.toml registers the solid JSX transform for the test runner, so
|
||||||
|
* JSX in this file compiles exactly like app code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterAll } from "bun:test";
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
|
import { YaziPaneRow } from "../src/components/YaziPaneRow";
|
||||||
|
|
||||||
|
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
||||||
|
type Frame = { lines: { spans: Span[] }[] };
|
||||||
|
|
||||||
|
// ── Frame introspection helpers ─────────────────────────────────────────────
|
||||||
|
function hexOf(fg: Span["fg"]): string | null {
|
||||||
|
if (!fg?.buffer) return null;
|
||||||
|
const b = fg.buffer;
|
||||||
|
if (b[3] === 0) return null;
|
||||||
|
return (
|
||||||
|
"#" +
|
||||||
|
[0, 1, 2]
|
||||||
|
.map((i) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(b[i] * 255)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0"),
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column border colors, scanned from the top border row (`┌───┐…`). */
|
||||||
|
function columnBorders(spans: Frame): string[] {
|
||||||
|
const line = spans.lines[1];
|
||||||
|
if (!line) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const sp of line.spans) {
|
||||||
|
for (const ch of sp.text) {
|
||||||
|
if (ch === "┌") out.push(hexOf(sp.fg) ?? "default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column widths (including borders), from the top border row. */
|
||||||
|
function columnWidths(spans: Frame): number[] {
|
||||||
|
const line = spans.lines[1];
|
||||||
|
if (!line) return [];
|
||||||
|
const widths: number[] = [];
|
||||||
|
for (const sp of line.spans) {
|
||||||
|
for (const ch of sp.text) {
|
||||||
|
if (ch === "┌") widths.push(0);
|
||||||
|
else if (widths.length && ch === "─") widths[widths.length - 1]++;
|
||||||
|
else if (widths.length && ch === "┐") widths[widths.length - 1] += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element children must be accessors (`() => JSX`): JSX elements are only
|
||||||
|
// constructed inside the renderer context (during the test render pass), so
|
||||||
|
// creating them eagerly in the test body would throw "No renderer found".
|
||||||
|
type TestPaneProps = {
|
||||||
|
parent?: unknown;
|
||||||
|
current?: (() => unknown) | unknown;
|
||||||
|
preview?: unknown;
|
||||||
|
focused?: unknown;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function renderPaneRow(props: TestPaneProps): Promise<{
|
||||||
|
spans: Frame;
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<YaziPaneRow
|
||||||
|
parent={props.parent as any}
|
||||||
|
current={props.current as any}
|
||||||
|
preview={props.preview as any}
|
||||||
|
parentLabel="Up"
|
||||||
|
currentLabel="List"
|
||||||
|
previewLabel="Detail"
|
||||||
|
focused={props.focused as any}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ width: props.width ?? 100, height: props.height ?? 8, useThread: false },
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await setup.renderOnce();
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
}
|
||||||
|
const spans = setup.captureSpans() as unknown as Frame;
|
||||||
|
return {
|
||||||
|
spans,
|
||||||
|
destroy: async () => {
|
||||||
|
setup.renderer.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanups: (() => void | Promise<void>)[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const c of cleanups) {
|
||||||
|
try {
|
||||||
|
await c();
|
||||||
|
} catch {
|
||||||
|
// renderer already torn down — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
|
||||||
|
describe("YaziPaneRow layout", () => {
|
||||||
|
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
const widths = columnWidths(spans);
|
||||||
|
expect(widths).toHaveLength(3);
|
||||||
|
const [p, c, v] = widths;
|
||||||
|
// 100-wide row splits as 14 / 43 / 43 (1/7 : 3/7 : 3/7, borders included).
|
||||||
|
expect(p).toBe(14);
|
||||||
|
expect(c).toBe(43);
|
||||||
|
expect(v).toBe(43);
|
||||||
|
// Exact 1:3:3 proportion (within 1 col rounding).
|
||||||
|
expect(c).toBeGreaterThanOrEqual(p * 3 - 1);
|
||||||
|
expect(c).toBeLessThanOrEqual(p * 3 + 1);
|
||||||
|
expect(v).toBeGreaterThanOrEqual(p * 3 - 1);
|
||||||
|
expect(v).toBeLessThanOrEqual(p * 3 + 1);
|
||||||
|
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
|
||||||
|
expect(p).toBeGreaterThan(4);
|
||||||
|
const body = spans.lines
|
||||||
|
.map((l) => l.spans.map((s) => s.text).join(""))
|
||||||
|
.join("\n");
|
||||||
|
expect(body).toContain("—");
|
||||||
|
expect(body).toContain("ITEM");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the 1/7 parent slot across widths (ratio stable)", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>x</text>,
|
||||||
|
preview: null,
|
||||||
|
width: 70,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
const [p, c, v] = columnWidths(spans);
|
||||||
|
expect(p).toBe(10); // 70 → 10 / 30 / 30
|
||||||
|
expect(c).toBe(30);
|
||||||
|
expect(v).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Integration: focused toggles the accent ring on the current column ─────
|
||||||
|
describe("YaziPaneRow focus ring", () => {
|
||||||
|
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
focused: true,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
const [parent, current, preview] = columnBorders(spans);
|
||||||
|
// parent & preview are muted; current is the (different) accent color.
|
||||||
|
expect(parent).toBe(preview);
|
||||||
|
expect(current).not.toBe(parent);
|
||||||
|
expect(current).not.toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("focused=false mutes the current column (no accent ring anywhere)", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
focused: false,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
const [parent, current, preview] = columnBorders(spans);
|
||||||
|
expect(current).toBe(parent);
|
||||||
|
expect(preview).toBe(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts an accessor for focused (reactive boolean)", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
focused: () => true,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
const [parent, current] = columnBorders(spans);
|
||||||
|
expect(current).not.toBe(parent); // accessor resolves true → accent ring
|
||||||
|
|
||||||
|
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
focused: () => false,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy2);
|
||||||
|
const [p2, c2] = columnBorders(spans2);
|
||||||
|
expect(c2).toBe(p2); // accessor resolves false → muted
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to focused (current column carries the accent ring)", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
const [parent, current] = columnBorders(spans);
|
||||||
|
expect(current).not.toBe(parent);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "@opentui/solid",
|
"jsxImportSource": "@opentui/solid",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"types": ["bun-types"],
|
"types": ["bun-types"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "tests/**/*"]
|
"include": ["src/**/*", "tests/**/*", "scripts/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user