Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac1ec1162 | |||
| 4a94ff5910 | |||
| 2e69868ffc | |||
| 491a736c32 | |||
| 12bd6be4bc | |||
| d2f6c5c525 | |||
| f758b53336 | |||
| b0bfa41028 | |||
| 25307f83e9 | |||
| db285530b6 | |||
| e1cdd6b2a5 | |||
| 2abdbaa4e9 | |||
| 1d06156b8b |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -66,12 +66,14 @@ jobs:
|
||||
env:
|
||||
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||
run: |
|
||||
# The embedded runtime reads the launching process's CWD bunfig.toml.
|
||||
# This repo's bunfig lists a preload the standalone can't resolve
|
||||
# ("preload not found"), so kicking the binary from the workspace root
|
||||
# would falsely fail every build. cd into a clean dir first.
|
||||
# The binary is compiled with bunfig autoload disabled
|
||||
# (autoloadBunfig: false in build.ts), so it must boot even from a
|
||||
# directory holding a bunfig.toml with a top-level preload the
|
||||
# standalone can't resolve. Plant one to make this a real regression
|
||||
# test for "preload not found".
|
||||
SMOKE_DIR=$(mktemp -d)
|
||||
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
||||
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
||||
cd "$SMOKE_DIR"
|
||||
./podtui-*/podtui --version
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
|
||||
| `bun run lint` | Type-check |
|
||||
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
||||
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
||||
| `make dist-mac` / `make dist-linux` | Aliases for `dist` on their platform (CI runs these) |
|
||||
| `make clean` | Remove `dist/` |
|
||||
|
||||
## Repository layout
|
||||
@@ -90,19 +91,21 @@ Cavacore smoke test: `bun tests/cavacore-smoke.ts`
|
||||
|
||||
## Gotchas (read before touching anything)
|
||||
|
||||
1. **Never add a top-level `preload` to `bunfig.toml`.**
|
||||
A compiled PodTui binary's embedded runtime reads the *launching process's*
|
||||
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
|
||||
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
|
||||
`preload not found`. This is why `bunfig.toml` has **no** top-level
|
||||
`preload`; dev-mode preloading happens via explicit `--preload` flags in
|
||||
`package.json`. The `[test]` section *does* keep a preload — that only
|
||||
affects `bun test`.
|
||||
1. **The compiled binary must keep bunfig autoload disabled.**
|
||||
`build.ts` compiles the standalone with `autoloadBunfig: false`, so its
|
||||
embedded runtime *never* reads the launching CWD's `bunfig.toml`. Without
|
||||
that flag, a top-level `preload` in the CWD bunfig (common in Bun project
|
||||
dirs) resolves against the CWD rather than the binary and kills startup
|
||||
with `preload not found`. Don't remove the flag. Preloads for dev/test
|
||||
belong in the explicit `--preload` flags in `package.json` and the
|
||||
`[test]` section of `bunfig.toml` — not as a top-level entry.
|
||||
|
||||
2. **Smoke-test the compiled binary from a bunfig-free dir.**
|
||||
Because of (1), `./dist/podtui --version` run from the repo root launched
|
||||
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
|
||||
before booting. Do the same when testing a release build locally.
|
||||
2. **Smoke-test the binary from a dir with a poisoned bunfig.**
|
||||
The CI smoke test unpacks the tarball into a `mktemp` dir, drops a
|
||||
`bunfig.toml` containing an unresolvable top-level `preload` next to it,
|
||||
and boots the binary — proving bunfig autoload stayed disabled. `./dist/
|
||||
podtui --version` must work from any directory, including the repo root;
|
||||
do the same check when testing a release build locally.
|
||||
|
||||
3. **Homebrew's dylib-repair warning is benign.**
|
||||
`brew install` may print “load commands do not fit in the header … needs
|
||||
@@ -159,14 +162,14 @@ Releases are built and published from **tags**
|
||||
|
||||
4. A release is auto-created with all 4 tarballs attached. `brew` never
|
||||
sees the new version: the **tap self-updates**: the
|
||||
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
|
||||
`mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
|
||||
polls GitHub releases, and when a new tag appears, rewrites
|
||||
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
||||
test: `brew install mikefreno/podtui/podtui`.
|
||||
test: `brew install mikefreno/tap/podtui`.
|
||||
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
|
||||
staged, not yet published (AUR account registrations are closed; see the
|
||||
README note in section 3). On each release, keep the AUR sources in sync
|
||||
README's Installation section). On each release, keep the AUR sources in sync
|
||||
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
|
||||
entries, keep the `LICENSE` asset source (the workflow above uploads
|
||||
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
|
||||
@@ -177,7 +180,7 @@ Releases are built and published from **tags**
|
||||
If you ever need to sync the tap by hand (or before the hourly job runs):
|
||||
|
||||
```bash
|
||||
cd <clone of mikefreno/homebrew-podtui>
|
||||
cd <clone of mikefreno/homebrew-tap>
|
||||
./scripts/sync-formula.sh 0.2.0
|
||||
git commit -am 'podtui 0.2.0' && git push
|
||||
```
|
||||
@@ -190,13 +193,36 @@ make dist # builds the binary + tarball for THIS machine only
|
||||
|
||||
Bun cannot cross-compile — the other platforms come from CI.
|
||||
|
||||
## Distribution & packaging
|
||||
|
||||
A release tarball is three files sitting side by side: the `podtui` binary
|
||||
plus its two FFI libraries (`libopentui.<dylib|so>`,
|
||||
`libcavacore.<dylib|so>`). The sibling rule above is why they ship together.
|
||||
|
||||
PodTui deliberately ships **no** `.deb`, `.rpm`, Flatpak, or Snap packages:
|
||||
for a terminal app that's overwhelmingly installed through repositories or
|
||||
archives, those formats add desktop-sandboxing overhead and a packaging tax
|
||||
with little benefit. Instead:
|
||||
|
||||
- **GitHub Release tarballs** are the universal path — one upload per
|
||||
OS/arch, works on any distro with `curl` + `tar`.
|
||||
- **AUR (`podtui-bin`)** covers Arch/Manjaro with the same binary through the
|
||||
native package manager.
|
||||
- **Nix / cross-distro** users build from source (or a Nix flake can be added
|
||||
later).
|
||||
|
||||
This keeps maintenance to a single build per OS/arch while still reaching the
|
||||
vast majority of desktop Linux users. The AUR PKGBUILD lives in
|
||||
`packaging/aur/` and can be built locally to test before publication:
|
||||
|
||||
```bash
|
||||
cd packaging/aur && makepkg -si
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open items / things to sort out
|
||||
|
||||
- **LICENSE**: `README.md` says "TBD — choose and document a license before
|
||||
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
|
||||
README footer.
|
||||
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||
|
||||
9
LICENSE
9
LICENSE
@@ -19,12 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
This project bundles third-party components under their own licenses:
|
||||
|
||||
- **cava** (karlstav/cava, vendored under `cava/`) — MIT,
|
||||
Copyright (c) 2015 Karl Stavestrand. See `cava/LICENSE-cava.txt`.
|
||||
- **Bun runtime** (embedded in the standalone binary) — MIT.
|
||||
- **OpenTUI** (`@opentui/core`) — MIT.
|
||||
5
Makefile
5
Makefile
@@ -47,9 +47,8 @@ native:
|
||||
scripts/build-cavacore.sh
|
||||
|
||||
## Standalone binary + native-libs tarball for the current platform.
|
||||
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
|
||||
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
|
||||
## from a bunfig-free dir (see release.yml).
|
||||
## Built with bunfig autoload disabled (build.ts sets autoloadBunfig: false),
|
||||
## so the embedded runtime ignores any bunfig.toml in the launching directory.
|
||||
dist:
|
||||
bun run build.ts --compile
|
||||
|
||||
|
||||
228
README.md
228
README.md
@@ -1,7 +1,6 @@
|
||||
# PodTui
|
||||
|
||||
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
|
||||
built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
||||
A keyboard-first, terminal podcast client built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
||||
browse episodes in a three-pane file-manager layout, and play audio through an
|
||||
external player with full transport control — all from your terminal.
|
||||
|
||||
@@ -11,8 +10,7 @@ external player with full transport control — all from your terminal.
|
||||
`Enter` to open, `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.
|
||||
- **Three-pane view** — parent / current / preview (Up | Current | Preview).
|
||||
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
||||
(My Shows, Discover, Feed tabs).
|
||||
- **Search** across your subscribed shows.
|
||||
@@ -22,22 +20,28 @@ external player with full transport control — all from your terminal.
|
||||
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||
a system audio player.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install PodTui ([Installation](#installation)) and make sure **mpv** is in
|
||||
your `PATH`.
|
||||
2. Run `podtui` in your terminal.
|
||||
3. Press `3` to open **Discover** (or `4` to open **Search**, then `s`), drill
|
||||
in with `Enter`, and press `Enter` on a show to subscribe.
|
||||
4. Press `1` (**Feed**) or `2` (**My Shows**), open an episode with `Enter`,
|
||||
and use `P` to play/pause, `N`/`B` for next/previous, and `shift-.` /
|
||||
`shift-,` to seek.
|
||||
|
||||
Press `~` any time for in-app help. All keys are remappable — see
|
||||
[Keybindings](#keybindings).
|
||||
|
||||
## Requirements
|
||||
|
||||
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
|
||||
tmux, GNOME Terminal, etc.).
|
||||
- An **audio player** on `PATH`. PodTui auto-detects in priority order:
|
||||
|
||||
| Player | Platforms | Seek | Speed | Position tracking |
|
||||
|----------|----------------|:----:|:-----:|:------------------|
|
||||
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
|
||||
| `ffplay` | any | ✔ | ✘ | ✘ |
|
||||
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
|
||||
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
|
||||
|
||||
Install `mpv` for the best experience (`brew install mpv`,
|
||||
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
|
||||
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
|
||||
Ghostty, tmux etc.).
|
||||
- **mpv** on `PATH` for audio playback. PodTui drives mpv over JSON IPC, so
|
||||
seek, speed, and position tracking all work. Without `mpv` on `PATH`,
|
||||
playback is a silent no-op (the `none` backend) — see
|
||||
[Troubleshooting](#troubleshooting).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -47,13 +51,9 @@ Linux (arm64/x64). Pick whichever fits your platform.
|
||||
### 1. Homebrew (macOS)
|
||||
|
||||
```sh
|
||||
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv
|
||||
brew install mikefreno/tap/podtui
|
||||
```
|
||||
|
||||
> The formula installs the standalone binary plus its two native libraries
|
||||
> side by side (see [Packaging model](#packaging-model)). It does **not**
|
||||
> depend on Bun.
|
||||
|
||||
### 2. Standalone tarball (all platforms)
|
||||
|
||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
||||
@@ -71,69 +71,29 @@ sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
|
||||
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||
> binary alone), or the native FFI libraries won't load.
|
||||
>
|
||||
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
|
||||
> you launch from. If that file has a `preload` entry (as Bun project
|
||||
> directories often do), startup fails with `preload not found`. Launching
|
||||
> from a normal directory (home, `~/bin`, …) works fine.
|
||||
|
||||
### 3. Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Status: PKGBUILD ready, not yet on the AUR (see note below)
|
||||
yay -S podtui-bin # once published
|
||||
```
|
||||
|
||||
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
||||
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and
|
||||
its two FFI sibling libraries into `/usr/lib/podtui/` with a `/usr/bin/podtui`
|
||||
symlink, and pulls in `mpv` (the sole audio backend) as a dependency.
|
||||
Requires an AUR helper ([paru](https://github.com/morgan/paru)); the package
|
||||
pulls in `mpv` as a dependency.
|
||||
|
||||
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready
|
||||
> in `packaging/aur/` and can be built locally today:
|
||||
>
|
||||
> ```bash
|
||||
> cd packaging/aur && makepkg -si
|
||||
> ```
|
||||
>
|
||||
> Publishing is on hold until [AUR account registrations](https://aur.archlinux.org)
|
||||
> reopen (suspended while the AUR team works on suspicious-package
|
||||
> moderation). Once a key can be registered, push `PKGBUILD` + `.SRCINFO`
|
||||
> with `git push ssh://aur@aur.archlinux.org/podtui-bin` and update this note.
|
||||
> **Not yet on the AUR.** The `podtui-bin` package is staged and awaiting
|
||||
> publication (AUR account registrations are currently suspended). Until it
|
||||
> lands, use the standalone tarball above.
|
||||
|
||||
### 4. From source
|
||||
|
||||
Requires [Bun](https://bun.sh) ≥ 1.2.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mikefreno/podtui.git
|
||||
cd podtui
|
||||
bun install
|
||||
bun run build:native # build the cavacore FFI lib from C source
|
||||
bun run dev # run with hot reload, or: bun start
|
||||
```
|
||||
|
||||
## Linux distribution notes
|
||||
|
||||
PodTUI deliberately does **not** ship `.deb`, `.rpm`, Flatpak, or Snap
|
||||
packages. For a terminal application that's overwhelmingly installed through
|
||||
repositories or archives, those formats add desktop-sandboxing overhead and a
|
||||
packaging tax with little benefit. Instead:
|
||||
|
||||
- **GitHub Release tarballs** are the universal path — one upload, works on
|
||||
any distro with `curl` + `tar`.
|
||||
- **AUR (`podtui-bin`)** covers Arch. Anyone on Arch/Manjaro gets the same
|
||||
binary through their native package manager.
|
||||
- **Nix / cross-distro** users can build from source (or a Nix flake can be
|
||||
added later).
|
||||
|
||||
This keeps maintenance to a single build per OS/arch and still reaches the
|
||||
vast majority of desktop Linux users through their preferred path.
|
||||
PodTui is written in TypeScript and runs on [Bun](https://bun.sh). To build
|
||||
from source (development, distro packaging, unreleased versions), see
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Usage
|
||||
|
||||
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~`
|
||||
for the in-app help.
|
||||
Launch `podtui`. Press `~` for the in-app help.
|
||||
|
||||
### Command-line flags
|
||||
|
||||
@@ -145,30 +105,70 @@ for the in-app help.
|
||||
|
||||
### Keybindings
|
||||
|
||||
All keys are remappable — edit `~/.config/podtui/keybinds.jsonc`.
|
||||
All keys are remappable — edit `keybinds.jsonc` in your config directory
|
||||
(see [Configuration](#configuration)).
|
||||
|
||||
**Movement**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `j` / `k` | Move cursor down / up |
|
||||
| `J` / `K` | Jump 5 lines |
|
||||
| `j` / `k` (or `down` / `up`) | Move down / up |
|
||||
| `J` / `K` | Jump 5 lines down / up |
|
||||
| `ctrl-d` / `ctrl-u` | Page down / up |
|
||||
| `ctrl-f` / `ctrl-b` | Full page down / up |
|
||||
| `gg` / `G` | Go to top / bottom |
|
||||
| `h` / `l` | Swipe to parent pane / preview pane |
|
||||
|
||||
**Panes**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `h` / `l` (or `left` / `right`) | Focus parent pane / preview pane |
|
||||
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
|
||||
| `Space` | Select / toggle selection |
|
||||
| `shift-enter` | Open with the interactive variant |
|
||||
|
||||
**Selection**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `Space` | Toggle selection |
|
||||
| `v` | Visual mode (multi-select) |
|
||||
| `ctrl-a` | Select / deselect all |
|
||||
| `ctrl-r` | Invert selection |
|
||||
| `Esc` | Cancel / escape |
|
||||
|
||||
**Tabs**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
||||
| `[` / `]` | Previous / next tab |
|
||||
| `P` (shift) | Play / pause |
|
||||
|
||||
**Commands, help, quit**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `:` or `q` | Open the command palette (type `q` + `Enter` there to quit) |
|
||||
| `Q` or `ctrl-c` | Quit |
|
||||
| `~` or `f1` | In-app help |
|
||||
|
||||
**Lists**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `s` | Search |
|
||||
| `f` | Filter |
|
||||
| `,` | Sort |
|
||||
| `.` | Toggle hidden |
|
||||
| `r` | Refresh |
|
||||
| `x` | Unsubscribe the focused show (My Shows) |
|
||||
|
||||
**Audio**
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `P` | Play / pause |
|
||||
| `N` / `B` | Next / previous episode |
|
||||
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||
| `s` | Search (in a list) |
|
||||
| `f` | Filter |
|
||||
| `r` | Refresh |
|
||||
| `:` | Command bar |
|
||||
| `~`, `f1` | Help |
|
||||
| `q`, `ctrl-c` | Quit |
|
||||
| `Esc` | Escape / cancel |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -177,56 +177,40 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) |
|
||||
| `sources.json` | Custom feed sources |
|
||||
| `config.json` | Unified settings (theme, playback speed, download path), feeds, and custom feed sources |
|
||||
| `downloads.json` | Downloaded episode metadata |
|
||||
| `keybinds.jsonc` | Keybinding remaps (see above) |
|
||||
| `themes/` | Optional custom theme files |
|
||||
|
||||
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
|
||||
the same OpenTUI environment variables.
|
||||
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
|
||||
into `config.json` on first run.
|
||||
|
||||
## Development
|
||||
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`.
|
||||
|
||||
```bash
|
||||
bun install # install dependencies
|
||||
bun run dev # run with hot reload
|
||||
bun test # run the test suite
|
||||
bun run build # bundle JS + copy native libs into dist/
|
||||
make native # rebuild cavacore from C source
|
||||
make lint # type-check (tsc)
|
||||
```
|
||||
## Troubleshooting
|
||||
|
||||
### Releasing
|
||||
**`preload not found` at startup** — this used to happen when the binary was
|
||||
launched from a Bun project directory whose `bunfig.toml` had a `preload`
|
||||
entry. Releases are compiled with bunfig autoload disabled
|
||||
(`autoloadBunfig: false`), so current binaries ignore the CWD's `bunfig.toml`
|
||||
entirely. If you still hit it, you're on an old release — upgrade.
|
||||
|
||||
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs
|
||||
to your GitHub Release automatically:
|
||||
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
|
||||
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
|
||||
|
||||
```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
|
||||
```
|
||||
**Homebrew prints a dylib warning** — “load commands do not fit in the header
|
||||
… needs `-headerpad`” is benign: the app loads its libraries by path, the
|
||||
install completes, and the app boots normally.
|
||||
|
||||
`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.
|
||||
**The app won't start / no spectrum after moving files** — `podtui` loads its
|
||||
two native libraries relative to the binary, so keep `podtui`,
|
||||
`libopentui.*`, and `libcavacore.*` together in the same directory (the
|
||||
tarball unpacks them side by side).
|
||||
|
||||
## Packaging model
|
||||
## Building from source / contributing
|
||||
|
||||
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.
|
||||
Development setup, the test suite, packaging, and the release process are
|
||||
documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
6
build.ts
6
build.ts
@@ -82,6 +82,12 @@ if (COMPILE) {
|
||||
plugins: [solidPlugin],
|
||||
compile: {
|
||||
outfile,
|
||||
// Don't let the embedded runtime autoload the launching CWD's
|
||||
// bunfig.toml. A top-level `preload` there (common in Bun project
|
||||
// dirs) resolves against the CWD, not the binary, so startup dies
|
||||
// with "preload not found". With autoload disabled, the binary is
|
||||
// config-independent and boots from any directory.
|
||||
autoloadBunfig: false,
|
||||
},
|
||||
});
|
||||
console.log(`Compiled standalone binary: ${outfile}`);
|
||||
|
||||
11
bunfig.toml
11
bunfig.toml
@@ -1,9 +1,8 @@
|
||||
# NO top-level `preload` here — intentional. A compiled PodTUI binary's
|
||||
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a
|
||||
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the
|
||||
# standalone cannot resolve) makes the binary die at startup with
|
||||
# "preload not found". Dev/test still get the solid transform via explicit
|
||||
# `--preload` flags in package.json and the [test] section below.
|
||||
# No top-level `preload` here — dev/test get the solid JSX transform via the
|
||||
# explicit `--preload` flags in package.json and the [test] section below.
|
||||
# Releases don't read this file at all: build.ts compiles the standalone with
|
||||
# `autoloadBunfig: false`, so its embedded runtime ignores any bunfig.toml in
|
||||
# the launching directory — no more "preload not found" from CWD bunfigs.
|
||||
|
||||
[test]
|
||||
preload = "@opentui/solid/preload"
|
||||
|
||||
@@ -18,21 +18,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@opentui/core": "^0.1.77",
|
||||
"@opentui/solid": "^0.1.77",
|
||||
"babel-preset-solid": "1.9.9",
|
||||
"date-fns": "^4.1.0",
|
||||
"solid-js": "^1.9.9",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.11"
|
||||
"solid-js": "^1.9.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ for r in $REMOTES; do
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
||||
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}"
|
||||
echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
|
||||
echo ""
|
||||
read -p "Proceed? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
@@ -236,5 +236,5 @@ echo ""
|
||||
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
||||
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
||||
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula"
|
||||
echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
|
||||
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||
|
||||
@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
|
||||
} else if (a === "--from") {
|
||||
flags.from = rest[++i];
|
||||
} else {
|
||||
flags[a.slice(2)] = rest[++i] ?? true;
|
||||
throw new Error(`unknown flag: ${a}`);
|
||||
}
|
||||
} else {
|
||||
positional.push(a);
|
||||
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
|
||||
return mods;
|
||||
}
|
||||
|
||||
function buildAction(cmd: string, positional: string[]): Action | null {
|
||||
// Per-command builders. Leading positional tokens that name a modifier
|
||||
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
|
||||
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
|
||||
const mods = parseMods(positional);
|
||||
const first = positional[0];
|
||||
switch (cmd) {
|
||||
case "key":
|
||||
if (!first) throw new Error("key requires a <key> argument");
|
||||
return { t: "key", k: first, mods: mods.length ? mods : undefined };
|
||||
case "arrow":
|
||||
if (!first || !["up", "down", "left", "right"].includes(first))
|
||||
throw new Error("arrow requires up|down|left|right");
|
||||
return {
|
||||
t: "arrow",
|
||||
d: first as any,
|
||||
mods: mods.length ? mods : undefined,
|
||||
};
|
||||
case "enter":
|
||||
case "escape":
|
||||
case "tab":
|
||||
case "space":
|
||||
case "backspace":
|
||||
return { t: cmd, mods: mods.length ? mods : undefined };
|
||||
case "type":
|
||||
if (first === undefined) throw new Error("type requires <text>");
|
||||
// Re-join the rest in case text had spaces; positional[0] already is first token,
|
||||
// caller should quote. We join all positional as the text.
|
||||
return { t: "type", s: positional.join(" ") };
|
||||
case "wait":
|
||||
if (!first) throw new Error("wait requires <ms>");
|
||||
return { t: "wait", ms: parseInt(first, 10) || 0 };
|
||||
case "resize":
|
||||
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
|
||||
return {
|
||||
t: "resize",
|
||||
w: parseInt(first, 10) || 100,
|
||||
h: parseInt(positional[1], 10) || 30,
|
||||
};
|
||||
case "frame":
|
||||
case "state":
|
||||
case "reset":
|
||||
case "actions":
|
||||
case "init":
|
||||
case "seed":
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`unknown command: ${cmd}`);
|
||||
}
|
||||
return mods.length ? mods : undefined;
|
||||
};
|
||||
|
||||
const BUILDERS: Record<string, (positional: string[]) => Action> = {
|
||||
key: (p) => {
|
||||
if (!p[0]) throw new Error("key requires a <key> argument");
|
||||
return { t: "key", k: p[0], mods: modsOrUndefined(p) };
|
||||
},
|
||||
arrow: (p) => {
|
||||
if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
|
||||
throw new Error("arrow requires up|down|left|right");
|
||||
return {
|
||||
t: "arrow",
|
||||
d: p[0] as "up" | "down" | "left" | "right",
|
||||
mods: modsOrUndefined(p),
|
||||
};
|
||||
},
|
||||
enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
|
||||
escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
|
||||
tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
|
||||
space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
|
||||
backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
|
||||
type: (p) => {
|
||||
if (p[0] === undefined) throw new Error("type requires <text>");
|
||||
// Re-join the rest in case text had spaces; p[0] already is first token,
|
||||
// caller should quote. We join all positional as the text.
|
||||
return { t: "type", s: p.join(" ") };
|
||||
},
|
||||
wait: (p) => {
|
||||
if (!p[0]) throw new Error("wait requires <ms>");
|
||||
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
|
||||
},
|
||||
resize: (p) => {
|
||||
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
|
||||
return {
|
||||
t: "resize",
|
||||
w: parseInt(p[0], 10) || 100,
|
||||
h: parseInt(p[1], 10) || 30,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function buildAction(cmd: string, positional: string[]): Action | null {
|
||||
const builder = BUILDERS[cmd];
|
||||
if (builder) return builder(positional);
|
||||
// Local-only commands return early in main before this is reached; keep
|
||||
// the null contract so the public behavior is unchanged.
|
||||
if (
|
||||
cmd === "frame" ||
|
||||
cmd === "state" ||
|
||||
cmd === "reset" ||
|
||||
cmd === "actions" ||
|
||||
cmd === "init" ||
|
||||
cmd === "seed"
|
||||
)
|
||||
return null;
|
||||
// Single table-miss error for any unknown command.
|
||||
throw new Error(`unknown command: ${cmd}`);
|
||||
}
|
||||
|
||||
// ── Execute one action against a mounted setup ──────────────────────────────
|
||||
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
activateSandbox();
|
||||
captureIssues();
|
||||
// ── Mount, snapshot & output (extracted from main) ─────────────────────────
|
||||
// A line is "visually empty" if it's either fully blank OR contains only
|
||||
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
||||
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
||||
// 24-row pane costs 1 line, not 18.
|
||||
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
||||
const isVisuallyEmpty = (l: string): boolean =>
|
||||
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0] ?? "frame";
|
||||
const { flags, positional } = parseFlags(argv.slice(1));
|
||||
function trimFrame(plainFrame: string): string {
|
||||
const lines = plainFrame
|
||||
.replace(/\n+$/, "")
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/\s+$/, ""));
|
||||
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
||||
lines.pop();
|
||||
const out: string[] = [];
|
||||
let blank = 0;
|
||||
const flushBlanks = () => {
|
||||
if (blank >= 3) out.push(` …${blank} empty`);
|
||||
else for (let i = 0; i < blank; i++) out.push("");
|
||||
blank = 0;
|
||||
};
|
||||
for (const l of lines) {
|
||||
if (isVisuallyEmpty(l)) {
|
||||
blank++;
|
||||
} else {
|
||||
flushBlanks();
|
||||
out.push(l);
|
||||
}
|
||||
}
|
||||
flushBlanks();
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// Local-only commands that don't mount.
|
||||
// Local-only commands that don't mount. Returns true if handled (main returns).
|
||||
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
|
||||
if (cmd === "reset") {
|
||||
saveActions([]);
|
||||
console.log("✔ actions log cleared.");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (cmd === "actions") {
|
||||
const a = loadActions();
|
||||
console.log(`Action log (${a.length}):`);
|
||||
console.log(JSON.stringify(a, null, 2));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (cmd === "seed") {
|
||||
const from = String(
|
||||
@@ -349,9 +392,29 @@ async function main() {
|
||||
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
||||
cpSync(from, dest, { recursive: true });
|
||||
console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type FrameCapture = {
|
||||
lines: { spans: Span[] }[];
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursor: [number, number];
|
||||
};
|
||||
|
||||
async function mountApp(
|
||||
flags: Record<string, string | boolean>,
|
||||
cmd: string,
|
||||
positional: string[],
|
||||
): Promise<{
|
||||
setup: any;
|
||||
spans: FrameCapture;
|
||||
plainFrame: string;
|
||||
audioControls: any;
|
||||
actions: Action[];
|
||||
}> {
|
||||
// Size settings.
|
||||
let width = 100;
|
||||
let height = 30;
|
||||
@@ -448,12 +511,7 @@ async function main() {
|
||||
// Final settle + capture.
|
||||
await setup.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const spans = setup.captureSpans() as {
|
||||
lines: { spans: Span[] }[];
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursor: [number, number];
|
||||
};
|
||||
const spans = setup.captureSpans() as FrameCapture;
|
||||
const plainFrame = setup.captureCharFrame();
|
||||
|
||||
// Dump structured spans + plain frame.
|
||||
@@ -462,6 +520,10 @@ async function main() {
|
||||
writeFileSync(FRAME_TXT, plainFrame);
|
||||
} catch {}
|
||||
|
||||
return { setup, spans, plainFrame, audioControls, actions };
|
||||
}
|
||||
|
||||
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
|
||||
// Store state snapshot.
|
||||
const state: Record<string, unknown> = {};
|
||||
try {
|
||||
@@ -514,54 +576,31 @@ async function main() {
|
||||
try {
|
||||
writeFileSync(STATE_JSON, JSON.stringify(state));
|
||||
} catch {}
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Output ──────────────────────────────────────────────────────────────
|
||||
function emitOutput(p: {
|
||||
spans: FrameCapture;
|
||||
plainFrame: string;
|
||||
state: Record<string, unknown>;
|
||||
actions: Action[];
|
||||
cmd: string;
|
||||
flags: Record<string, string | boolean>;
|
||||
positional: string[];
|
||||
}): void {
|
||||
// Compact by default: trimmed frame, one-line state per section, no styles
|
||||
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
||||
const verbose = !!flags.verbose;
|
||||
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
|
||||
|
||||
// A line is "visually empty" if it's either fully blank OR contains only
|
||||
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
||||
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
||||
// 24-row pane costs 1 line, not 18.
|
||||
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
||||
const isVisuallyEmpty = (l: string): boolean =>
|
||||
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
||||
const frameTrimmed = (() => {
|
||||
const lines = plainFrame
|
||||
.replace(/\n+$/, "")
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/\s+$/, ""));
|
||||
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
||||
lines.pop();
|
||||
const out: string[] = [];
|
||||
let blank = 0;
|
||||
const flushBlanks = () => {
|
||||
if (blank >= 3) out.push(` …${blank} empty`);
|
||||
else for (let i = 0; i < blank; i++) out.push("");
|
||||
blank = 0;
|
||||
};
|
||||
for (const l of lines) {
|
||||
if (isVisuallyEmpty(l)) {
|
||||
blank++;
|
||||
} else {
|
||||
flushBlanks();
|
||||
out.push(l);
|
||||
}
|
||||
}
|
||||
flushBlanks();
|
||||
return out.join("\n");
|
||||
})();
|
||||
const verbose = !!p.flags.verbose;
|
||||
const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
|
||||
|
||||
console.log(
|
||||
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`,
|
||||
`FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
|
||||
);
|
||||
console.log(frameTrimmed);
|
||||
console.log(trimFrame(p.plainFrame));
|
||||
|
||||
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
||||
if (scope === "all" && (flags.styles || verbose)) {
|
||||
const styles = distinctStyles(spans);
|
||||
if (scope === "all" && (p.flags.styles || verbose)) {
|
||||
const styles = distinctStyles(p.spans);
|
||||
if (styles.length) {
|
||||
console.log("-- styles (top 20) --");
|
||||
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
|
||||
@@ -572,9 +611,9 @@ async function main() {
|
||||
const want = (k: string) => scope === "all" || scope === k;
|
||||
const compact = (obj: unknown): string =>
|
||||
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
||||
if (want("nav")) console.log("nav " + compact(state.nav));
|
||||
if (want("audio")) console.log("audio " + compact(state.audio));
|
||||
if (want("feed")) console.log("feed " + compact(state.feed));
|
||||
if (want("nav")) console.log("nav " + compact(p.state.nav));
|
||||
if (want("audio")) console.log("audio " + compact(p.state.audio));
|
||||
if (want("feed")) console.log("feed " + compact(p.state.feed));
|
||||
if (want("app")) console.log("app (not dumped in v1)");
|
||||
|
||||
// ── issues: terse ──
|
||||
@@ -586,12 +625,14 @@ async function main() {
|
||||
}
|
||||
|
||||
// Footer is identical every run — only print on init or --verbose.
|
||||
if (cmd === "init" || verbose) {
|
||||
if (p.cmd === "init" || verbose) {
|
||||
console.log(
|
||||
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function teardown(setup: any, audioControls: any): Promise<void> {
|
||||
// Tear down child processes (audio backend) before exit to avoid orphans.
|
||||
try {
|
||||
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
||||
@@ -606,6 +647,32 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
activateSandbox();
|
||||
captureIssues();
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0] ?? "frame";
|
||||
const { flags, positional } = parseFlags(argv.slice(1));
|
||||
|
||||
// Local-only commands that don't mount.
|
||||
if (runLocal(cmd, flags)) return;
|
||||
|
||||
const m = await mountApp(flags, cmd, positional);
|
||||
const state = await snapshotState(m.audioControls);
|
||||
emitOutput({
|
||||
spans: m.spans,
|
||||
plainFrame: m.plainFrame,
|
||||
state,
|
||||
actions: m.actions,
|
||||
cmd,
|
||||
flags,
|
||||
positional,
|
||||
});
|
||||
await teardown(m.setup, m.audioControls);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("HARNESS FAILED:", err?.stack || err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -50,6 +50,7 @@ export function App() {
|
||||
});
|
||||
|
||||
const backgroundColor = () =>
|
||||
themeContext.transparentBackground() ||
|
||||
themeContext.selected === "system"
|
||||
? "transparent"
|
||||
: themeContext.theme.surface;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { PodcastSource } from "../types/source"
|
||||
import { parseRSSFeed } from "@/api/rss-parser"
|
||||
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
|
||||
|
||||
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl)
|
||||
if (!response.ok) return []
|
||||
const xml = await response.text()
|
||||
return parseRSSFeed(xml, feedUrl).episodes
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchFeeds = async (
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[]
|
||||
): Promise<Feed[]> => {
|
||||
const active = sources.filter((source) => sourceIds.includes(source.id))
|
||||
const feeds: Feed[] = []
|
||||
|
||||
await Promise.all(
|
||||
active.map(async (source) => {
|
||||
try {
|
||||
if (source.type === "rss") {
|
||||
const rssFeeds = await handleRSSSource(source)
|
||||
feeds.push(...rssFeeds)
|
||||
} else if (source.type === "api") {
|
||||
const apiFeeds = await handleAPISource(source, "")
|
||||
feeds.push(...apiFeeds)
|
||||
} else {
|
||||
const customFeeds = await handleCustomSource(source, "")
|
||||
feeds.push(...customFeeds)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual source errors
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return feeds
|
||||
}
|
||||
|
||||
export const searchPodcasts = async (
|
||||
query: string,
|
||||
sources: PodcastSource[]
|
||||
): Promise<Podcast[]> => {
|
||||
const results: Podcast[] = []
|
||||
await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
try {
|
||||
if (source.type === "rss") {
|
||||
const feeds = await handleRSSSource(source)
|
||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||
} else if (source.type === "api") {
|
||||
const feeds = await handleAPISource(source, query)
|
||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||
} else {
|
||||
const feeds = await handleCustomSource(source, query)
|
||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
||||
}
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { PodcastSource } from "../types/source"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { parseRSSFeed } from "./rss-parser"
|
||||
|
||||
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
|
||||
return {
|
||||
id: `${sourceId}-${podcast.id}`,
|
||||
podcast,
|
||||
episodes: [],
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
|
||||
if (!source.baseUrl) return []
|
||||
const response = await fetch(source.baseUrl)
|
||||
if (!response.ok) return []
|
||||
const xml = await response.text()
|
||||
const parsed = parseRSSFeed(xml, source.baseUrl)
|
||||
return [
|
||||
{
|
||||
id: `${source.id}-${parsed.feedUrl}`,
|
||||
podcast: {
|
||||
id: parsed.id,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
feedUrl: parsed.feedUrl,
|
||||
author: parsed.author,
|
||||
categories: parsed.categories,
|
||||
lastUpdated: parsed.lastUpdated,
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: parsed.episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: source.id,
|
||||
lastUpdated: parsed.lastUpdated,
|
||||
isPinned: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const handleAPISource = async (
|
||||
source: PodcastSource,
|
||||
query: string
|
||||
): Promise<Feed[]> => {
|
||||
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
|
||||
url.searchParams.set("term", query || "podcast")
|
||||
url.searchParams.set("media", "podcast")
|
||||
url.searchParams.set("entity", "podcast")
|
||||
url.searchParams.set("country", source.country || "US")
|
||||
url.searchParams.set("lang", source.language || "en_us")
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) return []
|
||||
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
|
||||
const results = data.results ?? []
|
||||
|
||||
return results
|
||||
.filter((item) => item.collectionName && item.feedUrl)
|
||||
.map((item) => {
|
||||
const podcast: Podcast = {
|
||||
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
|
||||
title: item.collectionName || "Untitled Podcast",
|
||||
description: item.collectionName || "",
|
||||
feedUrl: item.feedUrl || "",
|
||||
author: item.artistName,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
}
|
||||
return buildFeedFromPodcast(podcast, source.id)
|
||||
})
|
||||
}
|
||||
|
||||
export const handleCustomSource = async (
|
||||
source: PodcastSource,
|
||||
query: string
|
||||
): Promise<Feed[]> => {
|
||||
if (!query) return []
|
||||
const podcast: Podcast = {
|
||||
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
title: `${query} Highlights`,
|
||||
description: `Curated results for ${query}`,
|
||||
feedUrl: source.baseUrl || "",
|
||||
author: source.name,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
}
|
||||
return [buildFeedFromPodcast(podcast, source.id)]
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
import { createSignal, createMemo, onCleanup } from "solid-js";
|
||||
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
//TODO: Watch for actual loading state (fetching feeds)
|
||||
export function LoadingIndicator() {
|
||||
const { theme } = useTheme();
|
||||
const [index, setIndex] = createSignal(0);
|
||||
/**
|
||||
* Animated braille spinner with an optional label (e.g. "Refreshing…").
|
||||
* The spinner is rendered in the theme primary color; the label in muted.
|
||||
*/
|
||||
export function LoadingIndicator(props: { label?: string }) {
|
||||
const { theme } = useTheme();
|
||||
const [index, setIndex] = createSignal(0);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setIndex((i) => (i + 1) % spinnerChars.length);
|
||||
}, 65);
|
||||
const interval = setInterval(() => {
|
||||
setIndex((i) => (i + 1) % spinnerChars.length);
|
||||
}, 65);
|
||||
|
||||
onCleanup(() => clearInterval(interval));
|
||||
onCleanup(() => clearInterval(interval));
|
||||
|
||||
const currentChar = createMemo(() => spinnerChars[index()]);
|
||||
const currentChar = createMemo(() => spinnerChars[index()]);
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
|
||||
<text fg={theme.primary} content={currentChar()} />
|
||||
</box>
|
||||
);
|
||||
return (
|
||||
<box flexDirection="row" gap={1} alignItems="flex-start">
|
||||
<text fg={theme.primary} content={currentChar()} />
|
||||
<Show when={props.label}>
|
||||
<text fg={theme.muted || theme.text} content={props.label} />
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
/**
|
||||
* PaneRow — 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
|
||||
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
||||
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
||||
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
||||
* the ratio is exact regardless of content width — a column's content can
|
||||
* never stretch its slot.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
||||
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
||||
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||
* header row: the CURRENT column's title renders top-left in the
|
||||
* parent's slot (the panes above current/preview were removed).
|
||||
* current — the current-depth list. The only focusable content column; it
|
||||
* carries the active-border focus ring when `focused` is truthy.
|
||||
* preview — detail of the hovered item in `current`; always muted border.
|
||||
* is the ONLY bordered column — left/right edges only, always
|
||||
* muted (no active-border highlight, focused or not).
|
||||
* preview — detail of the hovered item in `current`. Borderless, no header.
|
||||
*
|
||||
* The primitive is purely structural: callers pass their own JSX per column
|
||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
||||
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||
* (static elements or accessors) plus the current-column title. Theme colors
|
||||
* are resolved internally via `useTheme()`. Only the current column's
|
||||
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
|
||||
* stay in the current pane).
|
||||
*
|
||||
* Example:
|
||||
* <PaneRow
|
||||
* parent={parentList}
|
||||
* current={currentList}
|
||||
* preview={detail}
|
||||
* parentLabel="Up"
|
||||
* currentLabel="List · 42"
|
||||
* previewLabel="Detail"
|
||||
* focused={isActive}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { createMemo, Show } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import type { RGBA, BorderSides } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
@@ -43,19 +46,19 @@ type PaneLabel = string | (() => string);
|
||||
|
||||
export type PaneRowProps = {
|
||||
/** Parent column content (previous-depth list, or null for a muted
|
||||
* placeholder — the 1/7 slot is always preserved). */
|
||||
* placeholder — the 1/5 slot is always preserved). */
|
||||
parent?: PaneContent;
|
||||
/** Current column content (the focused list). */
|
||||
current?: PaneContent;
|
||||
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||
preview?: PaneContent;
|
||||
parentLabel?: PaneLabel;
|
||||
/** Title of the current column — rendered once, top-left in the parent
|
||||
* pane's header slot (the per-pane Up/Detail headers are gone). */
|
||||
currentLabel?: PaneLabel;
|
||||
previewLabel?: PaneLabel;
|
||||
/** Whether the current column carries the active-border focus ring. Defaults to
|
||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
||||
* preview columns always render muted borders. */
|
||||
/** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
|
||||
* true; pass `false` (or a signal) when the row is inactive. Does NOT change
|
||||
* border colors — the current column's border is always muted. */
|
||||
focused?: boolean | (() => boolean);
|
||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||
@@ -69,16 +72,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* We deliberately avoid Solid's `children()` helper: it flattens accessor
|
||||
* children into a stable resolved-nodes array and won't re-resolve on a
|
||||
* truthy→truthy root swap (e.g. the current pane switching between a
|
||||
* depth-1 list fragment and a depth-2 editor), freezing the previous
|
||||
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
|
||||
* expression — a tracked `insert` effect that disposes the old subtree and
|
||||
* mounts the new whenever the accessor returns a different element identity. */
|
||||
function normalizeContent(
|
||||
v: PaneContent | undefined,
|
||||
): () => JSX.Element | undefined {
|
||||
@@ -99,15 +99,15 @@ function Pane(props: {
|
||||
grow: number;
|
||||
label: () => string;
|
||||
content: () => JSX.Element | undefined;
|
||||
borderColor: () => RGBA;
|
||||
border: boolean | BorderSides[];
|
||||
scrollFocused: () => boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const themeContext = useTheme();
|
||||
const theme = themeContext.theme;
|
||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||
|
||||
// Memoize accessor results so the prop expressions below stay reactive
|
||||
// when the underlying signals (e.g. `focused`) change.
|
||||
const borderColor = createMemo(() => props.borderColor());
|
||||
// Memoize the scroll-focus accessor result so the prop expression below
|
||||
// stays reactive when the underlying signal (e.g. `focused`) changes.
|
||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||
|
||||
return (
|
||||
@@ -117,32 +117,39 @@ function Pane(props: {
|
||||
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 ────────────────────────────────────────────── */}
|
||||
{/* ── title row: rendered only when the pane carries a label ────────── */}
|
||||
<Show when={props.label() !== ""}>
|
||||
<box
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
backgroundColor={
|
||||
themeContext.transparentBackground()
|
||||
? "transparent"
|
||||
: theme.background
|
||||
}
|
||||
>
|
||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
{/* ── scrollbox; border always muted (focused or not) ──────────────── */}
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={scrollFocused()}
|
||||
border
|
||||
borderColor={borderColor()}
|
||||
backgroundColor={theme.background}
|
||||
border={props.border}
|
||||
// Only supply colors when a border is requested — opentui flips a
|
||||
// borderless box to bordered when borderColor/focusedBorderColor
|
||||
// are passed, which would frame the parent/preview panes too.
|
||||
borderColor={props.border === false ? undefined : theme.border}
|
||||
focusedBorderColor={
|
||||
props.border === false ? undefined : theme.border
|
||||
}
|
||||
backgroundColor={
|
||||
themeContext.transparentBackground()
|
||||
? "transparent"
|
||||
: theme.background
|
||||
}
|
||||
>
|
||||
{/*
|
||||
* 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} />}
|
||||
{props.content() ?? <Placeholder color={muted} />}
|
||||
</scrollbox>
|
||||
</box>
|
||||
);
|
||||
@@ -150,9 +157,7 @@ function Pane(props: {
|
||||
|
||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||
export function PaneRow(props: PaneRowProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
/** true → the current column gets the active-border focus ring. */
|
||||
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||
const focused = createMemo(() => {
|
||||
const f = props.focused;
|
||||
return typeof f === "function" ? f() : (f ?? true);
|
||||
@@ -164,9 +169,9 @@ export function PaneRow(props: PaneRowProps) {
|
||||
const currentContent = normalizeContent(props.current);
|
||||
const previewContent = normalizeContent(props.preview);
|
||||
|
||||
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
|
||||
// The single title: the CURRENT column's label, rendered in the parent
|
||||
// pane's header slot (top-left). Current/preview panes have no headers.
|
||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
||||
|
||||
// 2-pane mode (parent|current) grows the current column to fill the
|
||||
// preview slot. Defaults to 3 (parent|current|preview).
|
||||
@@ -179,29 +184,29 @@ export function PaneRow(props: PaneRowProps) {
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||
{/* ── parent (1/5) — previous-depth list; title row top-left ───────── */}
|
||||
<Pane
|
||||
grow={PANE_RATIO.parent}
|
||||
label={parentLabel}
|
||||
label={currentLabel}
|
||||
content={parentContent}
|
||||
borderColor={() => theme.border}
|
||||
border={false}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||
{/* ── current — the focused list; left/right borders only ─────────── */}
|
||||
<Pane
|
||||
grow={currentGrow()}
|
||||
label={currentLabel}
|
||||
label={() => ""}
|
||||
content={currentContent}
|
||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
||||
border={["left", "right"]}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||
{/* ── preview (2/5) — hovered-item detail; no border, no header ────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<Pane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
label={() => ""}
|
||||
content={previewContent}
|
||||
borderColor={() => theme.border}
|
||||
border={false}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
|
||||
backgroundColor={
|
||||
props.selected()
|
||||
? theme.primary
|
||||
: themeContext.selected === "system"
|
||||
: themeContext.transparentBackground() ||
|
||||
themeContext.selected === "system"
|
||||
? "transparent"
|
||||
: themeContext.theme.surface
|
||||
}
|
||||
|
||||
@@ -12,42 +12,47 @@
|
||||
*/
|
||||
|
||||
import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { useAudioNavStore } from "@/stores/audio-nav";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { emit } from "@/utils/event-bus";
|
||||
import { emit, on } from "@/utils/event-bus";
|
||||
import { LayerGraph } from "@/utils/layer-graph";
|
||||
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
import { createDispatcher } from "@/utils/dispatch";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
export function Shell() {
|
||||
const theme = useTheme();
|
||||
const t = theme.theme;
|
||||
const nav = useNavigation();
|
||||
const k = useKeybinds();
|
||||
const audio = useAudio();
|
||||
const renderer = useRenderer();
|
||||
const audioNav = useAudioNavStore();
|
||||
const toast = useToast();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
const [showHelp, setShowHelp] = createSignal(false);
|
||||
|
||||
// ── Auto jump to Player on podcast start ───────────────────────────────────
|
||||
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
|
||||
// "player.started" — distinct from "player.play", which also fires on
|
||||
// resume), switch to the Player tab and drop into its content pane.
|
||||
on("player.started", () => {
|
||||
const app = useAppStore();
|
||||
if (app.state().preferences.autoJumpToPlayer) {
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
|
||||
}
|
||||
});
|
||||
|
||||
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
||||
* within its feed's episode list. Updates audio-nav context accordingly. */
|
||||
function advanceEpisode(offset: number) {
|
||||
@@ -83,74 +88,60 @@ export function Shell() {
|
||||
}
|
||||
|
||||
// ── Command bar dispatch ────────────────────────────────────────────────────
|
||||
const COMMANDS: Record<string, (arg: string) => void> = {
|
||||
quit: () => process.exit(0),
|
||||
exit: () => process.exit(0),
|
||||
q: () => process.exit(0),
|
||||
refresh: () =>
|
||||
emit("nav.action", {
|
||||
action: "refresh",
|
||||
tab: nav.activeTab(),
|
||||
pane: nav.activePane(),
|
||||
mode: nav.mode(),
|
||||
}),
|
||||
r: () =>
|
||||
emit("nav.action", {
|
||||
action: "refresh",
|
||||
tab: nav.activeTab(),
|
||||
pane: nav.activePane(),
|
||||
mode: nav.mode(),
|
||||
}),
|
||||
play: () => audio.togglePlayback().catch(() => {}),
|
||||
pause: () => audio.togglePlayback().catch(() => {}),
|
||||
p: () => audio.togglePlayback().catch(() => {}),
|
||||
next: () => advanceEpisode(1),
|
||||
n: () => advanceEpisode(1),
|
||||
prev: () => advanceEpisode(-1),
|
||||
seek: (arg) => {
|
||||
const n = Number(arg) || 0;
|
||||
audio.seek(n).catch(() => {});
|
||||
},
|
||||
feed: () => nav.setActiveTab(TABS.FEED),
|
||||
f: () => nav.setActiveTab(TABS.FEED),
|
||||
shows: () => nav.setActiveTab(TABS.MYSHOWS),
|
||||
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
|
||||
discover: () => nav.setActiveTab(TABS.DISCOVER),
|
||||
d: () => nav.setActiveTab(TABS.DISCOVER),
|
||||
search: () => nav.setActiveTab(TABS.SEARCH),
|
||||
player: () => nav.setActiveTab(TABS.PLAYER),
|
||||
settings: () => nav.setActiveTab(TABS.SETTINGS),
|
||||
set: () => nav.setActiveTab(TABS.SETTINGS),
|
||||
help: () => setShowHelp((v) => !v),
|
||||
h: () => setShowHelp((v) => !v),
|
||||
};
|
||||
|
||||
function runCommand(raw: string) {
|
||||
const cmd = raw.trim();
|
||||
if (!cmd) return;
|
||||
const name = cmd.split(/\s+/)[0].toLowerCase();
|
||||
const arg = cmd.slice(name.length).trim();
|
||||
switch (name) {
|
||||
case "q":
|
||||
case "quit":
|
||||
case "exit":
|
||||
return process.exit(0);
|
||||
case "refresh":
|
||||
case "r":
|
||||
emit("nav.action", {
|
||||
action: "refresh",
|
||||
tab: nav.activeTab(),
|
||||
pane: nav.activePane(),
|
||||
mode: nav.mode(),
|
||||
});
|
||||
break;
|
||||
case "play":
|
||||
case "pause":
|
||||
case "p":
|
||||
audio.togglePlayback().catch(() => {});
|
||||
break;
|
||||
case "next":
|
||||
case "n":
|
||||
advanceEpisode(1);
|
||||
break;
|
||||
case "prev":
|
||||
advanceEpisode(-1);
|
||||
break;
|
||||
case "seek": {
|
||||
const n = Number(arg) || 0;
|
||||
audio.seek(n).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "feed":
|
||||
case "f":
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
break;
|
||||
case "shows":
|
||||
case "myshows":
|
||||
nav.setActiveTab(TABS.MYSHOWS);
|
||||
break;
|
||||
case "discover":
|
||||
case "d":
|
||||
nav.setActiveTab(TABS.DISCOVER);
|
||||
break;
|
||||
case "search":
|
||||
nav.setActiveTab(TABS.SEARCH);
|
||||
break;
|
||||
case "player":
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
break;
|
||||
case "settings":
|
||||
case "set":
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
break;
|
||||
case "help":
|
||||
case "h":
|
||||
setShowHelp((v) => !v);
|
||||
break;
|
||||
default:
|
||||
nav.setCommandError(`unknown command: ${name}`);
|
||||
// re-enter command mode so the user sees the error + can correct
|
||||
nav.enterCommand();
|
||||
nav.setCommandBuffer(cmd);
|
||||
}
|
||||
const unknownCommand = () => {
|
||||
nav.setCommandError(`unknown command: ${name}`);
|
||||
// re-enter command mode so the user sees the error + can correct
|
||||
nav.enterCommand();
|
||||
nav.setCommandBuffer(cmd);
|
||||
};
|
||||
(COMMANDS[name] ?? unknownCommand)(arg);
|
||||
}
|
||||
|
||||
// ── Command-mode key handling ───────────────────────────────────────────────
|
||||
@@ -206,6 +197,11 @@ export function Shell() {
|
||||
if (evt.name === "escape") {
|
||||
evt.preventDefault();
|
||||
nav.setInputFocused(false);
|
||||
// Actually blur the focused renderable too — setting the flag alone
|
||||
// leaves the opentui input owning keys, so nav keys would still be
|
||||
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
|
||||
// (and re-blurs the SearchPage input via its `focused` prop).
|
||||
renderer.currentFocusedRenderable?.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -236,11 +232,13 @@ export function Shell() {
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundColor={t.surface}
|
||||
>
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
theme.transparentBackground() ? "transparent" : t.surface
|
||||
}
|
||||
>
|
||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||
<box flexDirection="row" flexGrow={1} width="100%">
|
||||
<Show
|
||||
@@ -264,9 +262,7 @@ export function Shell() {
|
||||
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
||||
</box>
|
||||
}
|
||||
parentLabel="Up"
|
||||
currentLabel="Tabs"
|
||||
previewLabel=""
|
||||
focused
|
||||
/>
|
||||
</Show>
|
||||
@@ -276,7 +272,11 @@ export function Shell() {
|
||||
flexDirection="row"
|
||||
height={1}
|
||||
width="100%"
|
||||
backgroundColor={t.backgroundPanel ?? t.background}
|
||||
backgroundColor={
|
||||
theme.transparentBackground()
|
||||
? "transparent"
|
||||
: (t.backgroundPanel ?? t.background)
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={nav.mode() === NavMode.COMMAND}
|
||||
@@ -285,15 +285,6 @@ export function Shell() {
|
||||
<text fg={t.accent} paddingLeft={1}>
|
||||
{modeLabel()}
|
||||
</text>
|
||||
<text fg={t.textMuted} paddingLeft={1}>
|
||||
{nav.atRootTab()
|
||||
? "Tabs · root"
|
||||
: `${TAB_LABEL[nav.activeTab()]} · ${
|
||||
nav.isDepthTab()
|
||||
? `depth ${nav.currentDepth()}`
|
||||
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
||||
}`}
|
||||
</text>
|
||||
<Show when={nav.selectedIds().length > 0}>
|
||||
<text fg={t.warning} paddingLeft={1}>
|
||||
● {nav.selectedIds().length}
|
||||
@@ -458,18 +449,5 @@ function k_match_escape(evt: any): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
|
||||
* search) into the player tab. */
|
||||
export function playEpisodeAndSwitch(
|
||||
nav: ReturnType<typeof useNavigation>,
|
||||
audio: ReturnType<typeof useAudio>,
|
||||
episode: import("@/types/episode").Episode,
|
||||
) {
|
||||
audio.play(episode);
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
|
||||
useAudioNavStore().setSource(AudioSource.FEED);
|
||||
}
|
||||
|
||||
// Re-export Episode type for callers building pane trees.
|
||||
export type { Episode } from "@/types/episode";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import { shortcuts } from "@/config/shortcuts";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
/** Yazi-style keybind reference. The Shell has its own overlay; this component
|
||||
* is kept for embedding inside Settings or other surfaces. */
|
||||
export function ShortcutHelp() {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<box
|
||||
border
|
||||
title="Shortcuts"
|
||||
style={{ flexDirection: "column", padding: 1 }}
|
||||
>
|
||||
<box style={{ flexDirection: "column" }}>
|
||||
<For each={shortcuts}>
|
||||
{(s) => (
|
||||
<box style={{ flexDirection: "row" }} gap={2}>
|
||||
<text fg={theme.accent}>{s.keys}</text>
|
||||
<text fg={theme.text}>{s.action}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { TABS, TabsCount } from "@/utils/navigation";
|
||||
import { For } from "solid-js";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
|
||||
export const tabs: TabDefinition[] = [
|
||||
{ id: TABS.FEED, label: "Feed" },
|
||||
{ id: TABS.MYSHOWS, label: "My Shows" },
|
||||
{ id: TABS.DISCOVER, label: "Discover" },
|
||||
{ id: TABS.SEARCH, label: "Search" },
|
||||
{ id: TABS.PLAYER, label: "Player" },
|
||||
{ id: TABS.SETTINGS, label: "Settings" },
|
||||
];
|
||||
|
||||
export function TabNavigation() {
|
||||
const { theme } = useTheme();
|
||||
const { activeTab, setActiveTab, activeDepth } = useNavigation();
|
||||
return (
|
||||
<box
|
||||
border
|
||||
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
|
||||
backgroundColor={"transparent"}
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
width: 12,
|
||||
height: TabsCount * 3 + 2,
|
||||
}}
|
||||
>
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<SelectableBox
|
||||
border
|
||||
height={3}
|
||||
selected={() => tab.id == activeTab()}
|
||||
onMouseDown={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<SelectableText
|
||||
selected={() => tab.id == activeTab()}
|
||||
primary
|
||||
alignSelf="center"
|
||||
>
|
||||
{tab.label}
|
||||
</SelectableText>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export type TabDefinition = {
|
||||
id: TABS;
|
||||
label: string;
|
||||
};
|
||||
@@ -53,7 +53,11 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active() ? theme.surface : theme.text;
|
||||
t === cursor() && active()
|
||||
? theme.surface
|
||||
: t === cursor()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
@@ -77,6 +81,14 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
flexDirection="row"
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
onMouseDown={() => {
|
||||
// Click = hover + open, the yazi "open" of the row
|
||||
// (switches to the tab and enters its content), the same
|
||||
// as l/Enter. Restores mouse support the tab-strip
|
||||
// refactor dropped.
|
||||
nav.setTabCursor(tab);
|
||||
nav.activateTabCursor();
|
||||
}}
|
||||
>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//
|
||||
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
|
||||
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
|
||||
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl.
|
||||
// : / q command palette (q + Enter quits there), Q quick quit, ~ help.
|
||||
// Audio transport kept on shifted keys / ctrl.
|
||||
|
||||
// ── Movement (within a pane) ─────────────────────────────────────────────
|
||||
"move-down": ["j", "down"],
|
||||
@@ -50,9 +51,11 @@
|
||||
"tab-goto-5": ["5"],
|
||||
"tab-goto-6": ["6"],
|
||||
|
||||
// ── Command bar & help & quit ────────────────────────────────────────────
|
||||
"command": [":"],
|
||||
"quit": ["q", "ctrl-c"],
|
||||
// ── Command palette & help & quit ────────────────────────────────────────
|
||||
// q opens the command palette (neovim-style: type q + Enter to quit there).
|
||||
// Q (shift+q) is the instant quick quit. ctrl-c also quits.
|
||||
"command": [":", "q"],
|
||||
"quit": ["Q", "ctrl-c"],
|
||||
"help": ["~", "f1"],
|
||||
|
||||
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
|
||||
* Shown in help overlays; the canonical source remains keybinds.jsonc.
|
||||
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
|
||||
*/
|
||||
export const shortcuts = [
|
||||
{ keys: "j / k", action: "Move down / up (within pane)" },
|
||||
{ keys: "h / l", action: "Swipe to prev / next pane" },
|
||||
{ keys: "J / K", action: "Jump 5 lines down / up" },
|
||||
{ keys: "ctrl-d / u", action: "Half page down / up" },
|
||||
{ keys: "g g / G", action: "Go to top / bottom of list" },
|
||||
{ keys: "1-6", action: "Go to tab 1-6" },
|
||||
{ keys: "[ / ]", action: "Previous / next tab" },
|
||||
{ keys: "Enter", action: "Open / activate focused item" },
|
||||
{ keys: "Space", action: "Toggle selection on item" },
|
||||
{ keys: "v", action: "Enter visual (range) select mode" },
|
||||
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
|
||||
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
||||
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
||||
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
||||
{ keys: "x", action: "Unsubscribe focused show (My Shows)" },
|
||||
{ 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;
|
||||
@@ -1,12 +0,0 @@
|
||||
export const syncFormats = {
|
||||
json: {
|
||||
version: "1.0",
|
||||
extension: ".json",
|
||||
},
|
||||
xml: {
|
||||
version: "1.0",
|
||||
extension: ".xml",
|
||||
},
|
||||
}
|
||||
|
||||
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]
|
||||
@@ -72,20 +72,7 @@ export type KeybindActionName =
|
||||
| "audio-next"
|
||||
| "audio-prev"
|
||||
| "audio-seek-forward"
|
||||
| "audio-seek-backward"
|
||||
// legacy compat (kept so older callers don't crash)
|
||||
| "select"
|
||||
| "leader"
|
||||
| "inverseModifier"
|
||||
| "cycle"
|
||||
| "dive"
|
||||
| "out"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "audio-pause"
|
||||
| "audio-play";
|
||||
| "audio-seek-backward";
|
||||
|
||||
/** Resolved config: action -> list of alternative stroke-sequences. */
|
||||
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
||||
@@ -146,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
|
||||
}
|
||||
|
||||
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
||||
export function strokeFromEvent(evt: {
|
||||
function strokeFromEvent(evt: {
|
||||
name: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
@@ -154,7 +141,7 @@ export function strokeFromEvent(evt: {
|
||||
}): Stroke {
|
||||
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
||||
return {
|
||||
key: (evt.name ?? "").toLowerCase(),
|
||||
key: evt.name.toLowerCase(),
|
||||
ctrl: !!evt.ctrl,
|
||||
shift: !!evt.shift,
|
||||
meta: !!evt.meta,
|
||||
@@ -171,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
|
||||
}
|
||||
|
||||
/** A human label for a stroke, for the status bar / help. */
|
||||
export function strokeLabel(s: Stroke): string {
|
||||
function strokeLabel(s: Stroke): string {
|
||||
let out = "";
|
||||
if (s.ctrl) out += "C-";
|
||||
if (s.meta) out += "M-";
|
||||
@@ -180,7 +167,7 @@ export function strokeLabel(s: Stroke): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sequenceLabel(seq: Stroke[]): string {
|
||||
function sequenceLabel(seq: Stroke[]): string {
|
||||
return seq.map(strokeLabel).join(" ");
|
||||
}
|
||||
|
||||
@@ -338,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
||||
return best;
|
||||
}
|
||||
|
||||
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
|
||||
// so it always reports false. Migrated callers should use tryMatch().
|
||||
function isInverting(_evt: {
|
||||
name: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
shift?: boolean;
|
||||
}): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load().catch(() => {});
|
||||
});
|
||||
@@ -366,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
||||
pending,
|
||||
match,
|
||||
tryMatch,
|
||||
isInverting,
|
||||
print,
|
||||
save,
|
||||
load,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { useRenderer } from "@opentui/solid";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
generateSubtleSyntax,
|
||||
} from "../utils/syntax-highlighter";
|
||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
||||
import { detectModeFromBackground } from "../utils/system-theme";
|
||||
import { createSimpleContext } from "./helper";
|
||||
import {
|
||||
setupThemeSignalHandler,
|
||||
@@ -84,6 +86,8 @@ export type ThemeResolved = {
|
||||
muted?: RGBA;
|
||||
surface?: RGBA;
|
||||
selectedListItemText?: RGBA;
|
||||
/** Theme declares a transparent (terminal-bg-visible) background. */
|
||||
transparent?: boolean;
|
||||
layerBackgrounds?: {
|
||||
layer0: RGBA;
|
||||
layer1: RGBA;
|
||||
@@ -94,6 +98,61 @@ export type ThemeResolved = {
|
||||
thinkingOpacity?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A TerminalColors with no values — used to keep the "system" theme rendering
|
||||
* with default ANSI colors + the detected dark/light mode when the terminal
|
||||
* cannot answer OSC queries (e.g. inside tmux without OSC forwarding).
|
||||
*/
|
||||
const EMPTY_TERMINAL_COLORS: TerminalColors = {
|
||||
palette: Array.from({ length: 16 }, () => null),
|
||||
defaultForeground: null,
|
||||
defaultBackground: null,
|
||||
cursorColor: null,
|
||||
mouseForeground: null,
|
||||
mouseBackground: null,
|
||||
tekForeground: null,
|
||||
tekBackground: null,
|
||||
highlightBackground: null,
|
||||
highlightForeground: null,
|
||||
};
|
||||
|
||||
/** Cached macOS appearance (dark/light), independent of the terminal. */
|
||||
let cachedOsMode: "dark" | "light" | null = null;
|
||||
|
||||
/**
|
||||
* Detect the terminal's dark/light mode.
|
||||
*
|
||||
* Priority:
|
||||
* 1. The terminal's real background color (OSC 11 response) — terminal-specific.
|
||||
* 2. The macOS appearance via `defaults read -g AppleInterfaceStyle` — works
|
||||
* even inside tmux, where OSC queries are usually not forwarded.
|
||||
* An unset value means light mode (macOS defaults to light).
|
||||
* 3. null → keep whatever mode is currently active.
|
||||
*/
|
||||
function detectSystemMode(
|
||||
colors: TerminalColors | null,
|
||||
): "dark" | "light" | null {
|
||||
const fromBg = detectModeFromBackground(colors?.defaultBackground);
|
||||
if (fromBg) return fromBg;
|
||||
|
||||
if (process.platform === "darwin" && cachedOsMode === null) {
|
||||
let style: string | null = null;
|
||||
try {
|
||||
style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
})
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
} catch {
|
||||
// Unset → light appearance (macOS default).
|
||||
}
|
||||
cachedOsMode = style?.includes("dark") ? "dark" : "light";
|
||||
}
|
||||
|
||||
return cachedOsMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme context using the createSimpleContext pattern.
|
||||
*
|
||||
@@ -195,6 +254,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
// ── dark/light mode detection ─────────────────────────────────────────
|
||||
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
|
||||
// real one from the terminal's background color (OSC 11) or, when that
|
||||
// is unavailable (tmux without OSC forwarding), the OS appearance.
|
||||
const detectedMode = detectSystemMode(colors);
|
||||
if (detectedMode && detectedMode !== store.mode) {
|
||||
setStore("mode", detectedMode);
|
||||
emitThemeModeChanged(detectedMode);
|
||||
}
|
||||
|
||||
const hasPalette = Boolean(
|
||||
colors?.palette?.some((value) => Boolean(value)),
|
||||
);
|
||||
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
);
|
||||
|
||||
if (!hasPalette && !hasDefaultColors) {
|
||||
// No system colors available, fall back to default
|
||||
// This happens when the terminal doesn't support OSC palette queries
|
||||
// (e.g., running inside tmux, or on unsupported terminals)
|
||||
// No system colors available — the terminal can't answer OSC queries
|
||||
// (e.g. inside tmux, or unsupported terminals). Keep the "system"
|
||||
// theme anyway: the detected dark/light mode plus default ANSI colors
|
||||
// still produce a usable, mode-correct palette.
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "catppuccin";
|
||||
draft.system = colors ?? EMPTY_TERMINAL_COLORS;
|
||||
draft.ready = true;
|
||||
}),
|
||||
);
|
||||
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
mode() {
|
||||
return store.mode;
|
||||
},
|
||||
/** Whether the app background should be transparent (no solid fill):
|
||||
* either the global preference is on, or the selected theme declares
|
||||
* transparency (e.g. the system theme). */
|
||||
transparentBackground() {
|
||||
return (
|
||||
appStore.state().settings.transparentBackground ||
|
||||
values().transparent === true
|
||||
);
|
||||
},
|
||||
setMode(mode: "dark" | "light") {
|
||||
setStore("mode", mode);
|
||||
emitThemeModeChanged(mode);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* parent | current | preview
|
||||
*
|
||||
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
|
||||
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
|
||||
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||
* nav model — which column is focused and where its list cursor lives. The
|
||||
* parent/preview columns are always derived, never focused.
|
||||
@@ -267,6 +267,9 @@ export function createNavigation() {
|
||||
/** The tab the root's cursor is hovering (independent of activeTab). */
|
||||
const tabCursor = (): TABS => tabCursorSignal();
|
||||
|
||||
/** Directly set the root's tab cursor (e.g. a mouse click on a tab row). */
|
||||
const setTabCursorTo = (tab: TABS) => setTabCursor(tab);
|
||||
|
||||
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
||||
const moveTabCursor = (dir: -1 | 1) => {
|
||||
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
||||
@@ -469,6 +472,7 @@ export function createNavigation() {
|
||||
enterTabContent,
|
||||
backToTabRoot,
|
||||
tabCursor,
|
||||
setTabCursor: setTabCursorTo,
|
||||
moveTabCursor,
|
||||
activateTabCursor,
|
||||
// pane focus
|
||||
@@ -488,11 +492,7 @@ export function createNavigation() {
|
||||
exitVisual,
|
||||
// modes
|
||||
setActiveTabSignal: setActiveTab,
|
||||
setActiveDepth: setPane, // legacy alias
|
||||
activeDepth: activePane, // legacy alias
|
||||
setInputFocused,
|
||||
nextPane: () => {}, // legacy noop; swipe() replaces this
|
||||
prevPane: () => {},
|
||||
setMode,
|
||||
enterCommand,
|
||||
enterInput,
|
||||
|
||||
@@ -138,7 +138,6 @@ function startPolling(): void {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
|
||||
// Update platform media position
|
||||
const media = useMediaRegistry();
|
||||
media.setPosition(pos);
|
||||
}
|
||||
@@ -215,6 +214,9 @@ async function play(episode: Episode): Promise<void> {
|
||||
|
||||
startPolling();
|
||||
emit("player.play", { episodeId: episode.id });
|
||||
// Distinct from "player.play" (which also fires on resume): signals a
|
||||
// fresh episode start so Shell can honor the auto-jump-to-player pref.
|
||||
emit("player.started", { episodeId: episode.id });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed");
|
||||
setIsPlaying(false);
|
||||
@@ -285,7 +287,6 @@ async function stop(): Promise<void> {
|
||||
stopPolling();
|
||||
emit("player.stop", {});
|
||||
|
||||
// Clear platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
} catch (err) {
|
||||
@@ -332,12 +333,8 @@ async function doSetSpeed(spd: number): Promise<void> {
|
||||
setSpeed(clamped);
|
||||
|
||||
// Sync back to app store
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ playbackSpeed: clamped });
|
||||
} catch {
|
||||
// Store may not be available
|
||||
}
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ playbackSpeed: clamped });
|
||||
}
|
||||
|
||||
async function switchBackend(name: BackendName): Promise<void> {
|
||||
@@ -347,14 +344,12 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
const vol = volume();
|
||||
const spd = speed();
|
||||
|
||||
// Stop current backend
|
||||
if (backend) {
|
||||
stopPolling();
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
backend = createAudioBackend(name);
|
||||
setBackendName(backend.name);
|
||||
setAvailablePlayers(detectPlayers());
|
||||
@@ -388,14 +383,10 @@ export function useAudio(): AudioControls {
|
||||
|
||||
// Sync initial speed from app store
|
||||
if (refCount === 0) {
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
} catch {
|
||||
// Store may not be available yet
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
type CacheOptions<T> = {
|
||||
fetcher: () => Promise<T>
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export const useCachedData = <T,>(options: CacheOptions<T>) => {
|
||||
const [data, setData] = createSignal<T | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const value = await options.fetcher()
|
||||
setData(() => value)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load data")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
|
||||
if (options.intervalMs) {
|
||||
const interval = setInterval(refresh, options.intervalMs)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
}
|
||||
|
||||
return { data, loading, error, refresh }
|
||||
}
|
||||
65
src/hooks/useInputFocusNav.ts
Normal file
65
src/hooks/useInputFocusNav.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
|
||||
* focusable renderable) that holds the navigation store's `inputFocused`
|
||||
* flag true while the renderable has focus.
|
||||
*
|
||||
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
|
||||
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
|
||||
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
|
||||
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
|
||||
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
|
||||
* flag to each input's real focus/blur state fixes that.
|
||||
*
|
||||
* A module-level counter guards the blur→focus ordering gap that occurs when
|
||||
* tabbing between two inputs in the same form (the old input blurs before the
|
||||
* new one focuses) so the flag never flickers off mid-handoff.
|
||||
*/
|
||||
|
||||
import { onCleanup } from "solid-js";
|
||||
import { RenderableEvents } from "@opentui/core";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
|
||||
// Inputs (managed by this hook) currently holding focus.
|
||||
let focusedCount = 0;
|
||||
|
||||
export function useInputFocusNav() {
|
||||
const nav = useNavigation();
|
||||
let current: any | undefined;
|
||||
|
||||
const onFocused = () => {
|
||||
focusedCount++;
|
||||
nav.setInputFocused(true);
|
||||
};
|
||||
const onBlurred = () => {
|
||||
focusedCount = Math.max(0, focusedCount - 1);
|
||||
if (focusedCount === 0) nav.setInputFocused(false);
|
||||
};
|
||||
|
||||
const detach = (el: any) => {
|
||||
el.off(RenderableEvents.FOCUSED, onFocused);
|
||||
el.off(RenderableEvents.BLURRED, onBlurred);
|
||||
// Treat a focused element being torn down as a blur so the counter
|
||||
// doesn't leak and leave inputFocused stuck on.
|
||||
if (el.focused) onBlurred();
|
||||
};
|
||||
|
||||
const ref = (el: any) => {
|
||||
if (current && current !== el) detach(current);
|
||||
current = el;
|
||||
if (el) {
|
||||
el.on(RenderableEvents.FOCUSED, onFocused);
|
||||
el.on(RenderableEvents.BLURRED, onBlurred);
|
||||
// If the renderable is already focused when attached, count it.
|
||||
if (el.focused) onFocused();
|
||||
}
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
if (current) {
|
||||
detach(current);
|
||||
current = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -21,20 +21,6 @@ export type MediaKeyAction =
|
||||
| "media.seekBackward"
|
||||
| "media.speedCycle";
|
||||
|
||||
/** Key-to-action mappings for multimedia controls */
|
||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||
// Common terminal media keys — these overlap with Player.tsx local
|
||||
// bindings, but Player guards on `props.focused` so the global
|
||||
// handler fires independently when the player tab is *not* active.
|
||||
//
|
||||
// When Player IS focused both handlers fire, but since the audio
|
||||
// actions are idempotent (toggle = toggle, seek = additive) having
|
||||
// them called twice for the same keypress is avoided by the event
|
||||
// bus approach — the audio hook only processes event-bus events, and
|
||||
// Player.tsx calls audio methods directly. We therefore guard with
|
||||
// a "playerFocused" flag passed via options.
|
||||
};
|
||||
|
||||
export interface MultimediaKeysOptions {
|
||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||
playerFocused?: () => boolean;
|
||||
|
||||
308
src/index.tsx
308
src/index.tsx
@@ -1,4 +1,7 @@
|
||||
const VERSION = "0.2.1";
|
||||
import type { Feed } from "./types/feed"
|
||||
import type { Episode } from "./types/episode"
|
||||
|
||||
const VERSION = "0.3.1";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
@@ -37,160 +40,173 @@ if (cliArgs.version) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Find the most recent episode across all feeds */
|
||||
function findLatestEpisode(
|
||||
feeds: Feed[],
|
||||
): { feed: Feed; episode: Episode } | null {
|
||||
let latest: { feed: Feed; episode: Episode } | null = null
|
||||
let latestDate = 0
|
||||
|
||||
for (const feed of feeds) {
|
||||
if (feed.episodes.length === 0) continue
|
||||
const ep = feed.episodes[0]
|
||||
const epDate =
|
||||
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
|
||||
if (epDate > latestDate) {
|
||||
latestDate = epDate
|
||||
latest = { feed, episode: ep }
|
||||
}
|
||||
}
|
||||
|
||||
return latest
|
||||
}
|
||||
|
||||
/** Search feeds by title and print matching shows */
|
||||
function handleQuery(feeds: Feed[], query: string): void {
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
|
||||
const matches = feeds.filter((feed) => {
|
||||
const title = feed.podcast.title.toLowerCase()
|
||||
return title.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log(`No shows found matching: ${query}`)
|
||||
if (feeds.length > 0) {
|
||||
console.log("\nAvailable shows:")
|
||||
feeds.slice(0, 5).forEach((feed) => {
|
||||
console.log(` - ${feed.podcast.title}`)
|
||||
})
|
||||
if (feeds.length > 5) {
|
||||
console.log(` ... and ${feeds.length - 5} more`)
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
const feed = matches[0]
|
||||
console.log(`\n${feed.podcast.title}`)
|
||||
if (feed.podcast.description) {
|
||||
console.log(
|
||||
feed.podcast.description.substring(0, 200) +
|
||||
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||
)
|
||||
}
|
||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
const date =
|
||||
ep.pubDate instanceof Date
|
||||
? ep.pubDate.toLocaleDateString()
|
||||
: String(ep.pubDate)
|
||||
console.log(` ${idx + 1}. ${ep.title} (${date})`)
|
||||
})
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`\nClosest matches for "${query}":`)
|
||||
matches.slice(0, 5).forEach((feed, idx) => {
|
||||
console.log(` ${idx + 1}. ${feed.podcast.title}`)
|
||||
})
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
/** Resolve and play an episode from `arg` (title path or "latest") */
|
||||
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||
const normalizedArg = arg.toLowerCase()
|
||||
|
||||
let feedResult: Feed | null = null
|
||||
let episodeResult: Episode | null = null
|
||||
|
||||
if (normalizedArg === "latest") {
|
||||
const latest = findLatestEpisode(feeds)
|
||||
if (latest) {
|
||||
feedResult = latest.feed
|
||||
episodeResult = latest.episode
|
||||
}
|
||||
} else {
|
||||
const parts = normalizedArg.split("/")
|
||||
const showQuery = parts[0]
|
||||
const episodeQuery = parts[1]
|
||||
|
||||
const matchingFeeds = feeds.filter((feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||
)
|
||||
|
||||
if (matchingFeeds.length === 0) {
|
||||
console.log(`No show found matching: ${showQuery}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const feed = matchingFeeds[0]
|
||||
|
||||
if (!episodeQuery) {
|
||||
if (feed.episodes.length > 0) {
|
||||
feedResult = feed
|
||||
episodeResult = feed.episodes[0]
|
||||
} else {
|
||||
console.log(`No episodes available for: ${feed.podcast.title}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (episodeQuery === "latest") {
|
||||
feedResult = feed
|
||||
episodeResult = feed.episodes[0]
|
||||
} else {
|
||||
const matchingEpisode = feed.episodes.find((ep) =>
|
||||
ep.title.toLowerCase().includes(episodeQuery),
|
||||
)
|
||||
|
||||
if (matchingEpisode) {
|
||||
feedResult = feed
|
||||
episodeResult = matchingEpisode
|
||||
} else {
|
||||
console.log(`Episode not found: ${episodeQuery}`)
|
||||
console.log(`Available episodes for ${feed.podcast.title}:`)
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
console.log(` ${idx + 1}. ${ep.title}`)
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!feedResult || !episodeResult) {
|
||||
console.log("Could not find episode to play")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\nPlaying: ${episodeResult.title}`)
|
||||
console.log(`Show: ${feedResult.podcast.title}`)
|
||||
|
||||
try {
|
||||
const { createAudioBackend } = await import("./utils/audio-player")
|
||||
const backend = createAudioBackend()
|
||||
if (episodeResult.audioUrl) {
|
||||
await backend.play(episodeResult.audioUrl)
|
||||
console.log("Playback started (use the UI to control)")
|
||||
} else {
|
||||
console.log("No audio URL available for this episode")
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Playback error:", err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||
import("./utils/feeds-persistence")
|
||||
.then(async ({ loadFeedsFromFile }) => {
|
||||
const feeds = await loadFeedsFromFile();
|
||||
|
||||
if (cliArgs.query !== null) {
|
||||
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);
|
||||
handleQuery(feeds, cliArgs.query)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await handlePlay(feeds, cliArgs.play)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||
*
|
||||
* depth 0 (current) — category list. Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* placeholder (1/5 slot kept).
|
||||
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||
* pane = the categories list.
|
||||
* preview — detail of the hovered item (category summary, or
|
||||
@@ -30,6 +30,7 @@ import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const DiscoverPaneCount = 1;
|
||||
@@ -146,7 +147,11 @@ function DiscoverPage() {
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
i === lf && active
|
||||
? theme.surface
|
||||
: i === lf
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
@@ -154,7 +159,6 @@ function DiscoverPage() {
|
||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||
// mounts/unmounts cleanly on depth change.
|
||||
const parentContent = () => (
|
||||
@@ -223,7 +227,14 @@ function DiscoverPage() {
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||
<Show
|
||||
when={discoverStore.isLoading()}
|
||||
fallback={
|
||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||
}
|
||||
>
|
||||
<LoadingIndicator label="Discovering…" />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -271,6 +282,11 @@ function DiscoverPage() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={discoverStore.isLoading()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator label="Refreshing…" />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
@@ -373,9 +389,7 @@ function DiscoverPage() {
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* PodcastCard component - Reusable card for displaying podcast info
|
||||
*/
|
||||
|
||||
import { Show, For } from "solid-js";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
|
||||
type PodcastCardProps = {
|
||||
podcast: Podcast;
|
||||
selected: boolean;
|
||||
compact?: boolean;
|
||||
onSelect?: () => void;
|
||||
onSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export function PodcastCard(props: PodcastCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const handleSubscribeClick = () => {
|
||||
props.onSubscribe?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectableBox
|
||||
selected={() => props.selected}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
onMouseDown={props.onSelect}
|
||||
>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<SelectableText selected={() => props.selected} primary>
|
||||
<strong>{props.podcast.title}</strong>
|
||||
</SelectableText>
|
||||
|
||||
<Show when={props.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>[+]</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Author */}
|
||||
<Show when={props.podcast.author && !props.compact}>
|
||||
<SelectableText
|
||||
selected={() => props.selected}
|
||||
tertiary
|
||||
>
|
||||
by {props.podcast.author}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
|
||||
{/* Description */}
|
||||
<Show when={props.podcast.description && !props.compact}>
|
||||
<SelectableText
|
||||
selected={() => props.selected}
|
||||
tertiary
|
||||
>
|
||||
{props.podcast.description!.length > 80
|
||||
? props.podcast.description!.slice(0, 80) + "..."
|
||||
: props.podcast.description}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
|
||||
{/**<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
marginTop={props.compact ? 0 : 1}
|
||||
/>**/}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
||||
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show when={props.selected}>
|
||||
<box onMouseDown={handleSubscribeClick}>
|
||||
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
|
||||
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</SelectableBox>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Feed detail view component for PodTUI
|
||||
* Shows podcast info and episode list
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
|
||||
interface FeedDetailProps {
|
||||
feed: Feed;
|
||||
focused?: boolean;
|
||||
onBack?: () => void;
|
||||
onPlayEpisode?: (episode: Episode) => void;
|
||||
}
|
||||
|
||||
export function FeedDetail(props: FeedDetailProps) {
|
||||
const { theme } = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [showInfo, setShowInfo] = createSignal(true);
|
||||
|
||||
const episodes = () => {
|
||||
// Sort episodes by publication date (newest first)
|
||||
return [...props.feed.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) {
|
||||
return `${hrs}h ${mins % 60}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
const eps = episodes();
|
||||
|
||||
if (key.name === "escape" && props.onBack) {
|
||||
props.onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "i") {
|
||||
setShowInfo((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "v") {
|
||||
props.feed.podcast.onToggleVisibility?.(props.feed.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
||||
} else if (key.name === "return") {
|
||||
const episode = eps[selectedIndex()];
|
||||
if (episode && props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode);
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(eps.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return;
|
||||
handleKeyPress(key);
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with back button */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
|
||||
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
|
||||
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
|
||||
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Podcast info section */}
|
||||
<Show when={showInfo()}>
|
||||
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
||||
<SelectableText selected={() => false} primary>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</SelectableText>
|
||||
{props.feed.podcast.author && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>by</SelectableText>
|
||||
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
|
||||
</box>
|
||||
)}
|
||||
<box height={1} />
|
||||
<SelectableText selected={() => false} tertiary>
|
||||
{props.feed.podcast.description?.slice(0, 200)}
|
||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||
</SelectableText>
|
||||
<box height={1} />
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
|
||||
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
|
||||
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
|
||||
</box>
|
||||
<SelectableText selected={() => false} tertiary>
|
||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||
</SelectableText>
|
||||
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Episodes header */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<SelectableText selected={() => false} primary>
|
||||
<strong>Episodes</strong>
|
||||
</SelectableText>
|
||||
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
|
||||
</box>
|
||||
|
||||
{/* Episode list */}
|
||||
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
||||
<For each={episodes()}>
|
||||
{(episode, index) => (
|
||||
<SelectableBox
|
||||
selected={() => index() === selectedIndex()}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={1}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
if (props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{index() === selectedIndex() ? ">" : " "}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||
{episode.title}
|
||||
</SelectableText>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
|
||||
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
|
||||
</box>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
|
||||
{/* Help text */}
|
||||
<text fg={theme.textMuted}>
|
||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Feed filter component for PodTUI
|
||||
* Toggle and filter options for feed list
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||
import type { FeedFilter } from "@/types/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
interface FeedFilterProps {
|
||||
filter: FeedFilter;
|
||||
focused?: boolean;
|
||||
onFilterChange: (filter: FeedFilter) => void;
|
||||
}
|
||||
|
||||
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
|
||||
|
||||
export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
||||
const [searchValue, setSearchValue] = createSignal(
|
||||
props.filter.searchQuery || "",
|
||||
);
|
||||
|
||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return") {
|
||||
if (focusField() === "visibility") {
|
||||
cycleVisibility();
|
||||
} else if (focusField() === "sort") {
|
||||
cycleSort();
|
||||
} else if (focusField() === "pinned") {
|
||||
togglePinned();
|
||||
} else if (focusField() === "private") {
|
||||
togglePrivate();
|
||||
}
|
||||
} else if (key.name === "space") {
|
||||
if (focusField() === "pinned") {
|
||||
togglePinned();
|
||||
} else if (focusField() === "private") {
|
||||
togglePrivate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cycleVisibility = () => {
|
||||
const current = props.filter.visibility;
|
||||
let next: FeedVisibility | "all";
|
||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
||||
else next = "all";
|
||||
props.onFilterChange({ ...props.filter, visibility: next });
|
||||
};
|
||||
|
||||
const cycleSort = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
];
|
||||
const currentIndex = sortOptions.indexOf(
|
||||
props.filter.sortBy as FeedSortField,
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % sortOptions.length;
|
||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
|
||||
};
|
||||
|
||||
const togglePinned = () => {
|
||||
props.onFilterChange({
|
||||
...props.filter,
|
||||
pinnedOnly: !props.filter.pinnedOnly,
|
||||
});
|
||||
};
|
||||
|
||||
const togglePrivate = () => {
|
||||
props.onFilterChange({
|
||||
...props.filter,
|
||||
showPrivate: !props.filter.showPrivate,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInput = (value: string) => {
|
||||
setSearchValue(value);
|
||||
props.onFilterChange({ ...props.filter, searchQuery: value });
|
||||
};
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = props.filter.visibility;
|
||||
if (vis === "all") return "All";
|
||||
if (vis === "public") return "Public";
|
||||
return "Private";
|
||||
};
|
||||
|
||||
const visibilityColor = () => {
|
||||
const vis = props.filter.visibility;
|
||||
if (vis === "public") return theme.success;
|
||||
if (vis === "private") return theme.warning;
|
||||
return theme.text;
|
||||
};
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = props.filter.sortBy;
|
||||
switch (sort) {
|
||||
case "title":
|
||||
return "Title";
|
||||
case "episodeCount":
|
||||
return "Episodes";
|
||||
case "latestEpisode":
|
||||
return "Latest";
|
||||
case "updated":
|
||||
default:
|
||||
return "Updated";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
|
||||
<text fg={theme.text}>
|
||||
<strong>Filter Feeds</strong>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||
{/* Visibility filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
|
||||
Show:
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Sort filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
|
||||
<text fg={theme.text}>{sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Pinned filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
|
||||
Pinned:
|
||||
</text>
|
||||
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
|
||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Private filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
|
||||
Private:
|
||||
</text>
|
||||
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
|
||||
{props.filter.showPrivate ? "Yes" : "No"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Search box */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
|
||||
<input
|
||||
value={searchValue()}
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Filter by name..."
|
||||
focused={props.focused && focusField() === "search"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Feed item component for PodTUI
|
||||
* Displays a single feed/podcast in the list
|
||||
*/
|
||||
|
||||
import type { Feed, FeedVisibility } from "@/types/feed";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
|
||||
interface FeedItemProps {
|
||||
feed: Feed;
|
||||
isSelected: boolean;
|
||||
showEpisodeCount?: boolean;
|
||||
showLastUpdated?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function FeedItem(props: FeedItemProps) {
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d");
|
||||
};
|
||||
|
||||
const episodeCount = () => props.feed.episodes.length;
|
||||
const unplayedCount = () => {
|
||||
// This would be calculated based on episode status
|
||||
return props.feed.episodes.length;
|
||||
};
|
||||
|
||||
const visibilityIcon = () => {
|
||||
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
||||
};
|
||||
|
||||
const visibilityColor = () => {
|
||||
return props.feed.visibility === "public" ? theme.success : theme.warning;
|
||||
};
|
||||
|
||||
const pinnedIndicator = () => {
|
||||
return props.feed.isPinned ? "*" : " ";
|
||||
};
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (props.compact) {
|
||||
// Compact single-line view
|
||||
return (
|
||||
<SelectableBox
|
||||
selected={() => props.isSelected}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
onMouseDown={() => {}}
|
||||
>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
primary
|
||||
>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
tertiary
|
||||
>
|
||||
{visibilityIcon()}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
primary
|
||||
>
|
||||
{props.feed.customName || props.feed.podcast.title}
|
||||
</SelectableText>
|
||||
{props.showEpisodeCount && (
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
tertiary
|
||||
>
|
||||
({episodeCount()})
|
||||
</SelectableText>
|
||||
)}
|
||||
</SelectableBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Full view with details
|
||||
return (
|
||||
<SelectableBox
|
||||
selected={() => props.isSelected}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={1}
|
||||
onMouseDown={() => {}}
|
||||
>
|
||||
{/* Title row */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
primary
|
||||
>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
tertiary
|
||||
>
|
||||
{visibilityIcon()}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
secondary
|
||||
>
|
||||
{pinnedIndicator()}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
primary
|
||||
>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</SelectableText>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||
{props.showEpisodeCount && (
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
tertiary
|
||||
>
|
||||
{episodeCount()} episodes ({unplayedCount()} new)
|
||||
</SelectableText>
|
||||
)}
|
||||
{props.showLastUpdated && (
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
tertiary
|
||||
>
|
||||
Updated: {formatDate(props.feed.lastUpdated)}
|
||||
</SelectableText>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{props.feed.podcast.description && (
|
||||
<SelectableText
|
||||
selected={() => props.isSelected}
|
||||
paddingLeft={4}
|
||||
paddingTop={0}
|
||||
tertiary
|
||||
>
|
||||
{props.feed.podcast.description.slice(0, 60)}
|
||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||
</SelectableText>
|
||||
)}
|
||||
</SelectableBox>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Feed list component for PodTUI
|
||||
* Scrollable list of feeds with keyboard navigation and mouse support
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { FeedItem } from "./FeedItem";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
interface FeedListProps {
|
||||
focused?: boolean;
|
||||
compact?: boolean;
|
||||
showEpisodeCount?: boolean;
|
||||
showLastUpdated?: boolean;
|
||||
onSelectFeed?: (feed: Feed) => void;
|
||||
onOpenFeed?: (feed: Feed) => void;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeedList(props: FeedListProps) {
|
||||
const { theme } = useTheme();
|
||||
const feedStore = useFeedStore();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const filteredFeeds = () => feedStore.getFilteredFeeds();
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
if (key.name === "escape") {
|
||||
props.onFocusChange?.(false);
|
||||
return;
|
||||
}
|
||||
const feeds = filteredFeeds();
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
||||
} else if (key.name === "return") {
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed && props.onOpenFeed) {
|
||||
props.onOpenFeed(feed);
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(feeds.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 5));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
|
||||
} else if (key.name === "p") {
|
||||
// Toggle pin on selected feed
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed) {
|
||||
feedStore.togglePinned(feed.id);
|
||||
}
|
||||
} else if (key.name === "v") {
|
||||
// Toggle visibility on selected feed
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed) {
|
||||
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
|
||||
feedStore.updateFeed(feed.id, { visibility: newVisibility });
|
||||
}
|
||||
} else if (key.name === "f") {
|
||||
// Cycle visibility filter
|
||||
cycleVisibilityFilter();
|
||||
} else if (key.name === "s") {
|
||||
// Cycle sort
|
||||
cycleSortField();
|
||||
}
|
||||
|
||||
// Notify selection change
|
||||
const selectedFeed = feeds[selectedIndex()];
|
||||
if (selectedFeed && props.onSelectFeed) {
|
||||
props.onSelectFeed(selectedFeed);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return;
|
||||
handleKeyPress(key);
|
||||
});
|
||||
|
||||
const cycleVisibilityFilter = () => {
|
||||
const current = feedStore.filter().visibility;
|
||||
let next: FeedVisibility | "all";
|
||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
||||
else next = "all";
|
||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
|
||||
};
|
||||
|
||||
const cycleSortField = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
];
|
||||
const current = feedStore.filter().sortBy as FeedSortField;
|
||||
const idx = sortOptions.indexOf(current);
|
||||
const next = sortOptions[(idx + 1) % sortOptions.length];
|
||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
|
||||
};
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = feedStore.filter().visibility;
|
||||
if (vis === "all") return "All";
|
||||
if (vis === "public") return "Public";
|
||||
return "Private";
|
||||
};
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = feedStore.filter().sortBy;
|
||||
switch (sort) {
|
||||
case "title":
|
||||
return "Title";
|
||||
case "episodeCount":
|
||||
return "Episodes";
|
||||
case "latestEpisode":
|
||||
return "Latest";
|
||||
default:
|
||||
return "Updated";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedClick = (feed: Feed, index: number) => {
|
||||
setSelectedIndex(index);
|
||||
if (props.onSelectFeed) {
|
||||
props.onSelectFeed(feed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedDoubleClick = (feed: Feed) => {
|
||||
if (props.onOpenFeed) {
|
||||
props.onOpenFeed(feed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with filter controls */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||
<text fg={theme.text}>
|
||||
<strong>My Feeds</strong>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
|
||||
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
|
||||
<text fg={theme.primary}>[s] {sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Feed list in scrollbox */}
|
||||
<Show
|
||||
when={filteredFeeds().length > 0}
|
||||
fallback={
|
||||
<box border padding={2} borderColor={theme.border}>
|
||||
<text fg={theme.textMuted}>
|
||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height={15} focused={props.focused}>
|
||||
<For each={filteredFeeds()}>
|
||||
{(feed, index) => (
|
||||
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
compact={props.compact}
|
||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||
showLastUpdated={props.showLastUpdated ?? true}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
{/* Navigation help */}
|
||||
<box paddingTop={0}>
|
||||
<text fg={theme.textMuted}>
|
||||
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,10 @@
|
||||
* everything over `nav.action`; this page only handles list/preview data.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
@@ -56,18 +57,48 @@ function FeedPage() {
|
||||
const episodes = createMemo<EpItem[]>(
|
||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||
);
|
||||
|
||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||
// reaching the bottom row fetches automatically (see the effect below).
|
||||
const app = useAppStore();
|
||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
||||
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||
// Total navigable rows: episodes + the optional Fetch More row.
|
||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||
const focus = () => nav.depthFocus(0);
|
||||
const focusedRow = () =>
|
||||
rowCount() === 0 ? 0 : Math.min(focus(), rowCount() - 1);
|
||||
const focusedOnMore = () =>
|
||||
showFetchMore() && focusedRow() === episodes().length;
|
||||
// -1 while the Fetch More row is focused so no episode row renders the
|
||||
// cursor/highlight (the button is the focused row, not the last episode).
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
|
||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||
const curLen = () => episodes().length;
|
||||
focusedOnMore()
|
||||
? -1
|
||||
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||
const focusedItem = (): EpItem | undefined =>
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const curLen = () => rowCount();
|
||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (episodes().length > 0 && focus() >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 0);
|
||||
if (rowCount() > 0 && focus() >= rowCount())
|
||||
nav.setDepthFocus(rowCount() - 1, 0);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
// Auto mode: reaching the bottom row loads the next batch. Guarded by
|
||||
// isLoadingMore so concurrent loads never stack.
|
||||
createEffect(() => {
|
||||
if (fetchMoreMode() !== "auto") return;
|
||||
if (!showFetchMore()) return;
|
||||
if (feedStore.isLoadingMore()) return;
|
||||
if (focusedRow() < rowCount() - 1) return;
|
||||
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||
@@ -118,6 +149,10 @@ function FeedPage() {
|
||||
|
||||
// ── open ───────────────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (focusedOnMore()) {
|
||||
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||
return;
|
||||
}
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
|
||||
@@ -168,7 +203,11 @@ function FeedPage() {
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
i === listFocus && active
|
||||
? theme.surface
|
||||
: i === listFocus
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
|
||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||
|
||||
@@ -181,7 +220,16 @@ function FeedPage() {
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
|
||||
<Show
|
||||
when={feedStore.isLoadingFeeds()}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<LoadingIndicator label="Refreshing…" />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -236,60 +284,106 @@ function FeedPage() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={showFetchMore()}>
|
||||
<box
|
||||
ref={moreRef}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{focusedOnMore() ? "❯" : " "}
|
||||
</text>
|
||||
<Show
|
||||
when={!feedStore.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
[Fetch More]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
<LoadingIndicator label="Refreshing…" />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
||||
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||
const previewContent = () => (
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
{feedStore.isLoadingMore()
|
||||
? "Loading the next batch of episodes…"
|
||||
: fetchMoreMode() === "auto"
|
||||
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||
: "Load the next batch of older episodes across all feeds (Enter)."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
||||
<text fg={muted()}>enter: load more · h back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -297,9 +391,7 @@ function FeedPage() {
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel="Up"
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||
*
|
||||
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* placeholder (1/5 slot kept).
|
||||
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||
* preview — detail of the hovered item in the current column.
|
||||
*
|
||||
@@ -199,7 +199,11 @@ export function MyShowsPage() {
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
i === lf && active
|
||||
? theme.surface
|
||||
: i === lf
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||
|
||||
const currentLabel = () =>
|
||||
@@ -208,7 +212,6 @@ export function MyShowsPage() {
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||
// mounts/unmounts cleanly on depth change.
|
||||
const parentContent = () => (
|
||||
@@ -343,7 +346,7 @@ export function MyShowsPage() {
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
<LoadingIndicator label="Loading more…" />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -429,9 +432,7 @@ export function MyShowsPage() {
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -119,7 +119,6 @@ export function PlayerPage() {
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
parentLabel="Up"
|
||||
currentLabel="Player"
|
||||
panes={2}
|
||||
focused={isActive}
|
||||
|
||||
@@ -116,7 +116,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
}
|
||||
reader.start(position, speed);
|
||||
|
||||
// Start render loop
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
};
|
||||
|
||||
@@ -140,11 +139,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||
|
||||
// Read available PCM samples from the stream
|
||||
const count = reader.read(sampleBuffer);
|
||||
if (count === 0) return;
|
||||
|
||||
// Feed samples to cavacore → get frequency bars
|
||||
const input =
|
||||
count < sampleBuffer.length
|
||||
? sampleBuffer.subarray(0, count)
|
||||
@@ -198,7 +195,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopVisualization();
|
||||
if (reader) {
|
||||
@@ -222,7 +218,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const bars = barData();
|
||||
const count = numBars();
|
||||
|
||||
// If no data yet, show empty placeholder
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(count);
|
||||
return (
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { SourceBadge } from "./SourceBadge";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
|
||||
type ResultCardProps = {
|
||||
result: SearchResult;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export function ResultCard(props: ResultCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const podcast = () => props.result.podcast;
|
||||
|
||||
return (
|
||||
<SelectableBox
|
||||
selected={() => props.selected}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
onMouseDown={props.onSelect}
|
||||
>
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<SelectableText
|
||||
selected={() => props.selected}
|
||||
primary
|
||||
>
|
||||
<strong>{podcast().title}</strong>
|
||||
</SelectableText>
|
||||
<SourceBadge
|
||||
sourceId={props.result.sourceId}
|
||||
sourceName={props.result.sourceName}
|
||||
sourceType={props.result.sourceType}
|
||||
/>
|
||||
</box>
|
||||
<Show when={podcast().isSubscribed}>
|
||||
<text fg={theme.success}>[Subscribed]</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show when={podcast().author}>
|
||||
<SelectableText
|
||||
selected={() => props.selected}
|
||||
tertiary
|
||||
>
|
||||
by {podcast().author}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
|
||||
<Show when={podcast().description}>
|
||||
{(description) => (
|
||||
<SelectableText
|
||||
selected={() => props.selected}
|
||||
tertiary
|
||||
>
|
||||
{description().length > 120
|
||||
? description().slice(0, 120) + "..."
|
||||
: description()}
|
||||
</SelectableText>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={(podcast().categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
||||
<text fg={theme.warning}>[{category}]</text>
|
||||
))}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!podcast().isSubscribed}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={18}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation?.();
|
||||
props.onSubscribe?.();
|
||||
}}
|
||||
>
|
||||
<text fg={theme.primary}>[+] Add to Feeds</text>
|
||||
</box>
|
||||
</Show>
|
||||
</SelectableBox>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Show } from "solid-js";
|
||||
import { format } from "date-fns";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { SourceBadge } from "./SourceBadge";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
type ResultDetailProps = {
|
||||
result?: SearchResult;
|
||||
onSubscribe?: (result: SearchResult) => void;
|
||||
};
|
||||
|
||||
export function ResultDetail(props: ResultDetailProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
|
||||
<Show
|
||||
when={props.result}
|
||||
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
|
||||
>
|
||||
{(result) => (
|
||||
<>
|
||||
<text fg={theme.text}>
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
|
||||
<SourceBadge
|
||||
sourceId={result().sourceId}
|
||||
sourceName={result().sourceName}
|
||||
sourceType={result().sourceType}
|
||||
/>
|
||||
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg={theme.textMuted}>by {result().podcast.author}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textMuted}>{result().podcast.description}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
{(result().podcast.categories ?? []).map((category) => (
|
||||
<text fg={theme.warning}>[{category}]</text>
|
||||
))}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
|
||||
|
||||
<text fg={theme.textMuted}>
|
||||
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
||||
</text>
|
||||
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={18}
|
||||
onMouseDown={() => props.onSubscribe?.(result())}
|
||||
>
|
||||
<text fg={theme.primary}>[+] Add to Feeds</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* SearchHistory component for displaying and managing search history
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
import { useTheme } from "@/context/ThemeContext"
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable"
|
||||
|
||||
type SearchHistoryProps = {
|
||||
history: string[]
|
||||
focused: boolean
|
||||
selectedIndex: number
|
||||
onSelect?: (query: string) => void
|
||||
onRemove?: (query: string) => void
|
||||
onClear?: () => void
|
||||
onChange?: (index: number) => void
|
||||
}
|
||||
|
||||
export function SearchHistory(props: SearchHistoryProps) {
|
||||
const { theme } = useTheme();
|
||||
const handleSearchClick = (index: number, query: string) => {
|
||||
props.onChange?.(index)
|
||||
props.onSelect?.(query)
|
||||
}
|
||||
|
||||
const handleRemoveClick = (query: string) => {
|
||||
props.onRemove?.(query)
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Recent Searches</text>
|
||||
<Show when={props.history.length > 0}>
|
||||
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
||||
<text fg={theme.error}>[Clear All]</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={props.history.length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={theme.textMuted}>No recent searches</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height={10}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.history}>
|
||||
{(query, index) => {
|
||||
const isSelected = () => index() === props.selectedIndex && props.focused
|
||||
|
||||
return (
|
||||
<SelectableBox
|
||||
selected={isSelected}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
onMouseDown={() => handleSearchClick(index(), query)}
|
||||
>
|
||||
<SelectableText
|
||||
selected={isSelected}
|
||||
tertiary
|
||||
>
|
||||
{">"}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={isSelected}
|
||||
primary
|
||||
>
|
||||
{query}
|
||||
</SelectableText>
|
||||
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
||||
<text fg={theme.error}>[x]</text>
|
||||
</box>
|
||||
</SelectableBox>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const SearchPaneCount = 1;
|
||||
@@ -205,7 +206,11 @@ function SearchPage() {
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
i === listFocus && active
|
||||
? theme.surface
|
||||
: i === listFocus
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
|
||||
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||||
const parentContent = () => (
|
||||
@@ -237,7 +242,7 @@ function SearchPage() {
|
||||
/>
|
||||
</box>
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text fg={theme.warning}>Searching...</text>
|
||||
<LoadingIndicator label="Searching…" />
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text fg={theme.error}>{searchStore.error()}</text>
|
||||
@@ -294,11 +299,18 @@ function SearchPage() {
|
||||
when={results().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
<Show
|
||||
when={searchStore.isSearching()}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<LoadingIndicator label="Searching…" />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -385,8 +397,7 @@ function SearchPage() {
|
||||
</Show>
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{result().podcast.description!.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{result().podcast.description!.slice(0, 400)}
|
||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
@@ -428,9 +439,7 @@ function SearchPage() {
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* SearchResults component for displaying podcast search results
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { ResultCard } from "./ResultCard";
|
||||
import { ResultDetail } from "./ResultDetail";
|
||||
|
||||
type SearchResultsProps = {
|
||||
results: SearchResult[];
|
||||
selectedIndex: number;
|
||||
focused: boolean;
|
||||
onSelect?: (result: SearchResult) => void;
|
||||
onChange?: (index: number) => void;
|
||||
isSearching?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export function SearchResults(props: SearchResultsProps) {
|
||||
const handleSelect = (index: number) => {
|
||||
props.onChange?.(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!props.isSearching}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="yellow">Searching...</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!props.error}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="red">{props.error}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">
|
||||
No results found. Try a different search term.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={1} height="100%">
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<scrollbox height="100%">
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={props.results}>
|
||||
{(result, index) => (
|
||||
<ResultCard
|
||||
result={result}
|
||||
selected={index() === props.selectedIndex}
|
||||
onSelect={() => handleSelect(index())}
|
||||
onSubscribe={() => props.onSelect?.(result)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box width={36}>
|
||||
<ResultDetail
|
||||
result={props.results[props.selectedIndex]}
|
||||
onSubscribe={(result) => props.onSelect?.(result)}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { SourceType } from "@/types/source";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
type SourceBadgeProps = {
|
||||
sourceId: string;
|
||||
sourceName?: string;
|
||||
sourceType?: SourceType;
|
||||
};
|
||||
|
||||
const typeLabel = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return "API";
|
||||
if (sourceType === SourceType.RSS) return "RSS";
|
||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
||||
return "Source";
|
||||
};
|
||||
|
||||
// No module-level typeColor here — it needs the theme from the component.
|
||||
// The correct definition lives inside SourceBadge below.
|
||||
export function SourceBadge(props: SourceBadgeProps) {
|
||||
const { theme } = useTheme();
|
||||
const label = () => props.sourceName || props.sourceId;
|
||||
|
||||
const typeColor = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return theme.primary;
|
||||
if (sourceType === SourceType.RSS) return theme.success;
|
||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||
return theme.textMuted;
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={1} padding={0}>
|
||||
<text fg={typeColor(props.sourceType)}>
|
||||
[{typeLabel(props.sourceType)}]
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{label()}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,55 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
}
|
||||
let current = value;
|
||||
return [
|
||||
() => current,
|
||||
(next) => {
|
||||
current = next;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
import { SyncStatus } from "./SyncStatus"
|
||||
import { useTheme } from "@/context/ThemeContext"
|
||||
import { SyncStatus } from "./SyncStatus";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
|
||||
export function ExportDialog() {
|
||||
const { theme } = useTheme();
|
||||
const filename = createSignal("podcast-sync.json")
|
||||
const format = createSignal<"json" | "xml">("json")
|
||||
const { theme } = useTheme();
|
||||
const filename = createSignal("podcast-sync.json");
|
||||
const format = createSignal<"json" | "xml">("json");
|
||||
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||
const filenameRef = useInputFocusNav();
|
||||
|
||||
return (
|
||||
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text fg={theme.text}>File:</text>
|
||||
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
||||
</box>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text fg={theme.text}>Format:</text>
|
||||
<tab_select
|
||||
options={[
|
||||
{ name: "JSON", description: "Portable" },
|
||||
{ name: "XML", description: "Structured" },
|
||||
]}
|
||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||
/>
|
||||
</box>
|
||||
<box border borderColor={theme.border}>
|
||||
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
</box>
|
||||
)
|
||||
return (
|
||||
<box
|
||||
border
|
||||
title="Export"
|
||||
style={{ padding: 1, flexDirection: "column", gap: 1 }}
|
||||
>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text fg={theme.text}>File:</text>
|
||||
<input
|
||||
ref={filenameRef}
|
||||
value={filename[0]()}
|
||||
onInput={filename[1]}
|
||||
style={{ width: 30 }}
|
||||
/>
|
||||
</box>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text fg={theme.text}>Format:</text>
|
||||
<tab_select
|
||||
options={[
|
||||
{ name: "JSON", description: "Portable" },
|
||||
{ name: "XML", description: "Structured" },
|
||||
]}
|
||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||
/>
|
||||
</box>
|
||||
<box border borderColor={theme.border}>
|
||||
<text fg={theme.text}>
|
||||
Export {format[0]()} to {filename[0]()}
|
||||
</text>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { detectFormat } from "@/utils/file-detector";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
|
||||
type FilePickerProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function FilePicker(props: FilePickerProps) {
|
||||
const { theme } = useTheme();
|
||||
const format = detectFormat(props.value);
|
||||
const { theme } = useTheme();
|
||||
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||
const inputRef = useInputFocusNav();
|
||||
const format = detectFormat(props.value);
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
<input
|
||||
value={props.value}
|
||||
onInput={props.onChange}
|
||||
placeholder="/path/to/sync-file.json"
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<text fg={theme.text}>Format: {format}</text>
|
||||
</box>
|
||||
);
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={props.value}
|
||||
onInput={props.onChange}
|
||||
placeholder="/path/to/sync-file.json"
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<text fg={theme.text}>Format: {format}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,19 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
app.setTheme(THEME_LABELS[next].value);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "transparentBackground",
|
||||
label: "Transparent Background",
|
||||
kind: "toggle",
|
||||
display: () =>
|
||||
settings().transparentBackground ? "On" : "Off",
|
||||
help: () =>
|
||||
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updateSettings({
|
||||
transparentBackground: !settings().transparentBackground,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font Size",
|
||||
@@ -90,5 +103,31 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
autoDownload: !prefs().autoDownload,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "autoJumpToPlayer",
|
||||
label: "Auto Jump to Player",
|
||||
kind: "toggle",
|
||||
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updatePreferences({
|
||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "fetchMore",
|
||||
label: "Fetch More",
|
||||
kind: "select",
|
||||
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||
help: () =>
|
||||
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
||||
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||
app.updatePreferences({ fetchMoreMode: next });
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Renders entirely through `<PaneRow>` (parent | current | preview):
|
||||
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
||||
* blank placeholder at depth 0 (1/7 slot kept).
|
||||
* blank placeholder at depth 0 (1/5 slot kept).
|
||||
* current = the current-depth list (or editor at depth 2); the only
|
||||
* focusable column.
|
||||
* preview = help/preview text for the hovered item in current.
|
||||
@@ -267,12 +267,6 @@ export function SettingsPage() {
|
||||
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
||||
return editorItem()?.label ?? "Editor";
|
||||
};
|
||||
const parentLabel = () => {
|
||||
const d = depth();
|
||||
if (d === 1) return "Sections";
|
||||
if (d === 2) return sectionForDepth1()?.label ?? "";
|
||||
return "Up";
|
||||
};
|
||||
|
||||
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
||||
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
||||
@@ -384,9 +378,7 @@ export function SettingsPage() {
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={parentLabel}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
@@ -424,7 +416,12 @@ function Row(props: {
|
||||
: props.focused
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () => (props.focused && props.active ? theme.surface : theme.text);
|
||||
const fg = () =>
|
||||
props.focused && props.active
|
||||
? theme.surface
|
||||
: props.focused
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
const ref = useScrollIntoView(() => props.focused);
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
import { SourceType } from "@/types/source";
|
||||
import type { PodcastSource } from "@/types/source";
|
||||
import type { SettingItem } from "./types";
|
||||
@@ -61,6 +62,9 @@ function AddSourceForm() {
|
||||
const [name, setName] = createSignal("");
|
||||
const [url, setUrl] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
// Yield navigation keybinds to the Shell router while either input is focused.
|
||||
const nameRef = useInputFocusNav();
|
||||
const urlRef = useInputFocusNav();
|
||||
|
||||
const submit = () => {
|
||||
const u = url().trim();
|
||||
@@ -94,6 +98,7 @@ function AddSourceForm() {
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>Name:</text>
|
||||
<input
|
||||
ref={nameRef}
|
||||
value={name()}
|
||||
onInput={setName}
|
||||
placeholder="My Custom Feed"
|
||||
@@ -103,6 +108,7 @@ function AddSourceForm() {
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>URL:</text>
|
||||
<input
|
||||
ref={urlRef}
|
||||
value={url()}
|
||||
onInput={(v) => {
|
||||
setUrl(v);
|
||||
|
||||
@@ -3,21 +3,13 @@
|
||||
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
// closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
|
||||
// (the syncEditor signal) was removed as dead — nothing ever read it.
|
||||
export function closeSyncEditor() {}
|
||||
|
||||
export function useSyncItems(): SettingItem[] {
|
||||
return [
|
||||
@@ -49,9 +41,3 @@ export function useSyncItems(): SettingItem[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Renders the live sync status block (used by the Settings page header for the
|
||||
* Sync section, when relevant). */
|
||||
export function SyncStatusBlock() {
|
||||
return <SyncStatus />;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,15 @@ const defaultSettings: AppSettings = {
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
transparentBackground: false,
|
||||
visualizer: defaultVisualizerSettings,
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
autoJumpToPlayer: true,
|
||||
fetchMoreMode: "manual",
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
@@ -43,7 +46,7 @@ const defaultState: AppState = {
|
||||
customTheme: DEFAULT_THEME,
|
||||
};
|
||||
|
||||
export function createAppStore() {
|
||||
function createAppStore() {
|
||||
// Start with defaults; async load will update once ready
|
||||
const [state, setState] = createSignal<AppState>(defaultState);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const defaultNavState: AudioNavState = {
|
||||
};
|
||||
|
||||
/** Create audio navigation store */
|
||||
export function createAudioNavStore() {
|
||||
function createAudioNavStore() {
|
||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||
|
||||
/** Persist current navigation state to file (fire-and-forget) */
|
||||
|
||||
@@ -127,7 +127,6 @@ export function createDiscoverStore() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the podcast list from the manifest entries
|
||||
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||
cachedAt = now;
|
||||
setPodcasts(fetched);
|
||||
@@ -173,7 +172,6 @@ export function createDiscoverStore() {
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
// Remove the feed from the feed store
|
||||
const feedStore = useFeedStore();
|
||||
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ interface QueueItem {
|
||||
}
|
||||
|
||||
/** Create download store */
|
||||
export function createDownloadStore() {
|
||||
function createDownloadStore() {
|
||||
const [downloads, setDownloads] = createSignal<
|
||||
Map<string, DownloadedEpisode>
|
||||
>(new Map());
|
||||
@@ -48,7 +48,6 @@ export function createDownloadStore() {
|
||||
/** Active AbortControllers keyed by episodeId */
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
// Load persisted downloads on init
|
||||
(async () => {
|
||||
const loaded = await loadDownloads();
|
||||
if (loaded.size > 0) setDownloads(loaded);
|
||||
@@ -153,7 +152,6 @@ export function createDownloadStore() {
|
||||
const slotsAvailable = MAX_CONCURRENT - current;
|
||||
const toStart = q.slice(0, slotsAvailable);
|
||||
|
||||
// Remove started items from queue
|
||||
if (toStart.length > 0) {
|
||||
setQueue((prev) => prev.slice(toStart.length));
|
||||
}
|
||||
@@ -250,7 +248,6 @@ export function createDownloadStore() {
|
||||
return; // Already downloading or queued
|
||||
}
|
||||
|
||||
// Create download entry
|
||||
const entry: DownloadedEpisode = {
|
||||
episodeId: episode.id,
|
||||
feedId,
|
||||
@@ -269,7 +266,6 @@ export function createDownloadStore() {
|
||||
return next;
|
||||
});
|
||||
|
||||
// Add to queue
|
||||
const queueItem: QueueItem = {
|
||||
episodeId: episode.id,
|
||||
feedId,
|
||||
@@ -291,10 +287,8 @@ export function createDownloadStore() {
|
||||
abortControllers.delete(episodeId);
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||
|
||||
// Update status
|
||||
updateDownload(episodeId, {
|
||||
status: DownloadStatus.NONE,
|
||||
progress: 0,
|
||||
|
||||
@@ -43,7 +43,7 @@ function saveSources(sources: PodcastSource[]): void {
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
export function createFeedStore() {
|
||||
function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
@@ -62,22 +62,18 @@ export function createFeedStore() {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
|
||||
// Filter by visibility
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
@@ -88,7 +84,6 @@ export function createFeedStore() {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
@@ -111,7 +106,6 @@ export function createFeedStore() {
|
||||
}
|
||||
});
|
||||
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
@@ -224,25 +218,21 @@ export function createFeedStore() {
|
||||
newEpisodes: Episode[],
|
||||
count: number,
|
||||
) => {
|
||||
try {
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
const sorted = [...newEpisodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
// count = 0 means download all new episodes
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
const sorted = [...newEpisodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
// count = 0 means download all new episodes
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
} catch {
|
||||
// Download store may not be available yet
|
||||
}
|
||||
};
|
||||
|
||||
@@ -409,52 +399,79 @@ export function createFeedStore() {
|
||||
return loaded < cached.length;
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for one feed from the cache.
|
||||
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
}
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for a feed from the cache.
|
||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
await loadMoreEpisodesForFeed(feedId);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
/** True if any feed still has cached episodes beyond its loaded window. */
|
||||
const hasMoreAcrossAll = (): boolean => {
|
||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||
};
|
||||
|
||||
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
||||
* still has cached episodes — powers the Feed page's "[Fetch More]". */
|
||||
const loadMoreAllFeeds = async () => {
|
||||
if (isLoadingMore()) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
||||
for (const feed of pending) {
|
||||
await loadMoreEpisodesForFeed(feed.id);
|
||||
}
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
@@ -497,6 +514,8 @@ export function createFeedStore() {
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
loadMoreAllFeeds,
|
||||
hasMoreAcrossAll,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { searchPodcasts } from "../utils/search";
|
||||
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
||||
import { useFeedStore } from "./feed";
|
||||
import type { SearchResult } from "../types/source";
|
||||
|
||||
@@ -80,10 +80,18 @@ export function createSearchStore() {
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
|
||||
// Add to history
|
||||
addToHistory(q);
|
||||
|
||||
try {
|
||||
// A query that is a direct RSS feed URL (e.g. a private feed that
|
||||
// isn't in any public directory) resolves to that feed directly,
|
||||
// independent of enabled search sources.
|
||||
const urlResults = await searchByFeedUrl(q);
|
||||
if (urlResults.length > 0) {
|
||||
setResults(applySubscribedStatus(urlResults));
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = feedStore.sources();
|
||||
const enabledSourceIds = sources
|
||||
.filter((s) => s.enabled)
|
||||
@@ -122,7 +130,6 @@ export function createSearchStore() {
|
||||
/** Add query to history */
|
||||
const addToHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
// Remove duplicates and add to front
|
||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(updated);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import type {
|
||||
DesktopTheme,
|
||||
ThemeColors,
|
||||
ThemeDefinition,
|
||||
ThemeName,
|
||||
ThemeToken,
|
||||
ThemeVariant,
|
||||
} from "../types/settings"
|
||||
import type { ColorValue } from "./theme-schema"
|
||||
import type { ThemeColors } from "../types/settings"
|
||||
|
||||
// Base theme colors
|
||||
export const BASE_THEME_COLORS: ThemeColors = {
|
||||
@@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
|
||||
layer2: "#161b22",
|
||||
layer3: "#0d1117",
|
||||
}
|
||||
|
||||
// Theme tokens
|
||||
export const BASE_THEME_TOKENS: ThemeToken = {
|
||||
"background": "transparent",
|
||||
"surface": "#1b1f27",
|
||||
"primary": "#6fa8ff",
|
||||
"secondary": "#a9b1d6",
|
||||
"accent": "#f6c177",
|
||||
"text": "#e6edf3",
|
||||
"muted": "#7d8590",
|
||||
"warning": "#f0b429",
|
||||
"error": "#f47067",
|
||||
"success": "#3fb950",
|
||||
"layer0": "transparent",
|
||||
"layer1": "#1e222e",
|
||||
"layer2": "#161b22",
|
||||
"layer3": "#0d1117",
|
||||
}
|
||||
|
||||
// Desktop theme structure
|
||||
export const THEMES_DESKTOP: DesktopTheme = {
|
||||
name: "PodTUI",
|
||||
variants: [
|
||||
{
|
||||
name: "catppuccin",
|
||||
colors: {
|
||||
background: "transparent",
|
||||
surface: "#1e1e2e",
|
||||
primary: "#89b4fa",
|
||||
secondary: "#cba6f7",
|
||||
accent: "#f9e2af",
|
||||
text: "#cdd6f4",
|
||||
textPrimary: "#cdd6f4",
|
||||
textSecondary: "#cba6f7",
|
||||
textTertiary: "#7f849c",
|
||||
textSelectedPrimary: "#1e1e2e",
|
||||
textSelectedSecondary: "#cdd6f4",
|
||||
textSelectedTertiary: "#cba6f7",
|
||||
muted: "#7f849c",
|
||||
warning: "#fab387",
|
||||
error: "#f38ba8",
|
||||
success: "#a6e3a1",
|
||||
layerBackgrounds: {
|
||||
layer0: "transparent",
|
||||
layer1: "#181825",
|
||||
layer2: "#11111b",
|
||||
layer3: "#0a0a0f",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gruvbox",
|
||||
colors: {
|
||||
background: "transparent",
|
||||
surface: "#282828",
|
||||
primary: "#fabd2f",
|
||||
secondary: "#83a598",
|
||||
accent: "#fe8019",
|
||||
text: "#ebdbb2",
|
||||
textPrimary: "#ebdbb2",
|
||||
textSecondary: "#83a598",
|
||||
textTertiary: "#928374",
|
||||
textSelectedPrimary: "#282828",
|
||||
textSelectedSecondary: "#ebdbb2",
|
||||
textSelectedTertiary: "#83a598",
|
||||
muted: "#928374",
|
||||
warning: "#fabd2f",
|
||||
error: "#fb4934",
|
||||
success: "#b8bb26",
|
||||
layerBackgrounds: {
|
||||
layer0: "transparent",
|
||||
layer1: "#32302a",
|
||||
layer2: "#1d2021",
|
||||
layer3: "#0d0c0c",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tokyo",
|
||||
colors: {
|
||||
background: "transparent",
|
||||
surface: "#1a1b26",
|
||||
primary: "#7aa2f7",
|
||||
secondary: "#bb9af7",
|
||||
accent: "#e0af68",
|
||||
text: "#c0caf5",
|
||||
textPrimary: "#c0caf5",
|
||||
textSecondary: "#bb9af7",
|
||||
textTertiary: "#565f89",
|
||||
textSelectedPrimary: "#1a1b26",
|
||||
textSelectedSecondary: "#c0caf5",
|
||||
textSelectedTertiary: "#bb9af7",
|
||||
muted: "#565f89",
|
||||
warning: "#e0af68",
|
||||
error: "#f7768e",
|
||||
success: "#9ece6a",
|
||||
layerBackgrounds: {
|
||||
layer0: "transparent",
|
||||
layer1: "#16161e",
|
||||
layer2: "#0f0f15",
|
||||
layer3: "#08080b",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nord",
|
||||
colors: {
|
||||
background: "transparent",
|
||||
surface: "#2e3440",
|
||||
primary: "#88c0d0",
|
||||
secondary: "#81a1c1",
|
||||
accent: "#ebcb8b",
|
||||
text: "#eceff4",
|
||||
textPrimary: "#eceff4",
|
||||
textSecondary: "#81a1c1",
|
||||
textTertiary: "#4c566a",
|
||||
textSelectedPrimary: "#2e3440",
|
||||
textSelectedSecondary: "#eceff4",
|
||||
textSelectedTertiary: "#81a1c1",
|
||||
muted: "#4c566a",
|
||||
warning: "#ebcb8b",
|
||||
error: "#bf616a",
|
||||
success: "#a3be8c",
|
||||
layerBackgrounds: {
|
||||
layer0: "transparent",
|
||||
layer1: "#3b4252",
|
||||
layer2: "#242933",
|
||||
layer3: "#1a1c23",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultVariant: "catppuccin",
|
||||
tokens: BASE_THEME_TOKENS,
|
||||
}
|
||||
|
||||
// Helper function to get theme by name
|
||||
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
|
||||
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
|
||||
}
|
||||
|
||||
// Helper function to get default theme
|
||||
export function getDefaultTheme(): ThemeVariant {
|
||||
return THEMES_DESKTOP.variants.find(
|
||||
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
|
||||
)!
|
||||
}
|
||||
|
||||
export type ThemeJsonFile = ThemeDefinition
|
||||
|
||||
export function isColorReference(value: ColorValue): value is string {
|
||||
return typeof value === "string" && !value.startsWith("#")
|
||||
}
|
||||
|
||||
@@ -97,14 +97,6 @@ export interface FeedListOptions {
|
||||
compact: boolean
|
||||
}
|
||||
|
||||
/** Default feed list options */
|
||||
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
|
||||
showEpisodeCount: true,
|
||||
showLastUpdated: true,
|
||||
showSource: false,
|
||||
compact: false,
|
||||
}
|
||||
|
||||
/** Feed statistics */
|
||||
export interface FeedStats {
|
||||
/** Total feed count */
|
||||
|
||||
@@ -79,12 +79,21 @@ export type AppSettings = {
|
||||
fontSize: number;
|
||||
playbackSpeed: number;
|
||||
downloadPath: string;
|
||||
/** Render the app background transparent (let the terminal's own bg show). */
|
||||
transparentBackground: boolean;
|
||||
visualizer: VisualizerSettings;
|
||||
};
|
||||
|
||||
/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
|
||||
export type FetchMoreMode = "manual" | "auto";
|
||||
|
||||
export type UserPreferences = {
|
||||
showExplicit: boolean;
|
||||
autoDownload: boolean;
|
||||
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||
autoJumpToPlayer: boolean;
|
||||
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
||||
fetchMoreMode: FetchMoreMode;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export type SyncData = {
|
||||
version: string
|
||||
lastSyncedAt: string
|
||||
feeds: {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
isPrivate: boolean
|
||||
}[]
|
||||
sources: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
}[]
|
||||
settings: {
|
||||
theme: string
|
||||
playbackSpeed: number
|
||||
downloadPath: string
|
||||
}
|
||||
preferences: {
|
||||
showExplicit: boolean
|
||||
autoDownload: boolean
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export type SyncDataXML = {
|
||||
version: string
|
||||
lastSyncedAt: string
|
||||
feeds: {
|
||||
feed: {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
isPrivate: boolean
|
||||
}[]
|
||||
}
|
||||
sources: {
|
||||
source: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
settings: {
|
||||
theme: string
|
||||
playbackSpeed: number
|
||||
downloadPath: string
|
||||
}
|
||||
preferences: {
|
||||
showExplicit: boolean
|
||||
autoDownload: boolean
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@ export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Record<string, ColorValue> & {
|
||||
theme: Record<string, ColorValue | boolean> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
/** Render the app background transparent (let the terminal's own bg show). */
|
||||
transparent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ function init() {
|
||||
);
|
||||
const suspended = () => suspendCount() > 0;
|
||||
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
@@ -180,9 +179,8 @@ export function CommandProvider(props: ParentProps) {
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
// 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.
|
||||
// Open the command palette via the `command` keybind (bound to `:` or `q`
|
||||
// in keybinds.jsonc; the Shell router owns the action and runs it first).
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
@@ -258,7 +256,6 @@ function CommandDialog(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name);
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,7 @@ export type DialogSize = "medium" | "large"
|
||||
/**
|
||||
* Dialog component that renders a modal overlay with content.
|
||||
*/
|
||||
export function Dialog(
|
||||
function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: DialogSize
|
||||
onClose: () => void
|
||||
|
||||
@@ -32,12 +32,15 @@ const defaultSettings: AppSettings = {
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
transparentBackground: false,
|
||||
visualizer: defaultVisualizerSettings,
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
autoJumpToPlayer: true,
|
||||
fetchMoreMode: "manual",
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
|
||||
@@ -135,10 +135,8 @@ export class AudioStreamReader {
|
||||
this.writePos = 0;
|
||||
this.totalSamplesWritten = 0;
|
||||
|
||||
// Capture generation for this run
|
||||
const myGeneration = this.generation;
|
||||
|
||||
// Start async reading loop
|
||||
this.readLoop(myGeneration);
|
||||
|
||||
// Detect process exit
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Audio waveform analysis for PodTUI
|
||||
*
|
||||
* Extracts amplitude data from audio files using ffmpeg (when available)
|
||||
* Results are cache in-memory keyed by audio URL.
|
||||
*/
|
||||
|
||||
/** Number of amplitude data points to generate */
|
||||
const DEFAULT_RESOLUTION = 128;
|
||||
|
||||
/** In-memory cache: audioUrl -> amplitude data */
|
||||
const waveformCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Try to extract real waveform data from an audio URL using ffmpeg.
|
||||
* Returns null if ffmpeg is not available or the extraction fails.
|
||||
*/
|
||||
async function extractWithFfmpeg(
|
||||
audioUrl: string,
|
||||
resolution: number,
|
||||
): Promise<number[] | null> {
|
||||
try {
|
||||
if (!Bun.which("ffmpeg")) return null;
|
||||
|
||||
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
|
||||
// -t 300: read at most 5 minutes (enough data to fill the waveform)
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
audioUrl,
|
||||
"-t",
|
||||
"300",
|
||||
"-ac",
|
||||
"1", // mono
|
||||
"-ar",
|
||||
"8000", // low sample rate to keep data small
|
||||
"-f",
|
||||
"s16le", // raw signed 16-bit PCM
|
||||
"-v",
|
||||
"quiet",
|
||||
"-",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "ignore" },
|
||||
);
|
||||
|
||||
const output = await new Response(proc.stdout).arrayBuffer();
|
||||
await proc.exited;
|
||||
|
||||
if (output.byteLength === 0) return null;
|
||||
|
||||
const samples = new Int16Array(output);
|
||||
if (samples.length === 0) return null;
|
||||
|
||||
// Downsample to `resolution` buckets by taking the max absolute amplitude
|
||||
// in each bucket.
|
||||
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
|
||||
const data: number[] = [];
|
||||
|
||||
for (let i = 0; i < resolution; i++) {
|
||||
const start = i * bucketSize;
|
||||
const end = Math.min(start + bucketSize, samples.length);
|
||||
let maxAbs = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(samples[j]);
|
||||
if (abs > maxAbs) maxAbs = abs;
|
||||
}
|
||||
// Normalise to 0-1
|
||||
data.push(Number((maxAbs / 32768).toFixed(3)));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get waveform data for an audio URL.
|
||||
*
|
||||
* Returns cached data if available, otherwise attempts ffmpeg extraction
|
||||
*/
|
||||
export async function getWaveformData(
|
||||
audioUrl: string,
|
||||
resolution: number = DEFAULT_RESOLUTION,
|
||||
): Promise<number[]> {
|
||||
const cacheKey = `${audioUrl}:${resolution}`;
|
||||
const cached = waveformCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const real = await extractWithFfmpeg(audioUrl, resolution);
|
||||
if (real) {
|
||||
waveformCache.set(cacheKey, real);
|
||||
return real;
|
||||
} else {
|
||||
console.error("generation failure");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWaveformCache(): void {
|
||||
waveformCache.clear();
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
type CacheEntry<T> = {
|
||||
value: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const CACHE_KEY = "podtui_cache"
|
||||
const DEFAULT_TTL = 1000 * 60 * 60
|
||||
|
||||
const loadCache = (): Record<string, CacheEntry<unknown>> => {
|
||||
if (typeof localStorage === "undefined") return {}
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const cache = loadCache()
|
||||
|
||||
export const cacheValue = <T,>(key: string, value: T) => {
|
||||
cache[key] = { value, timestamp: Date.now() }
|
||||
saveCache(cache)
|
||||
}
|
||||
|
||||
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
|
||||
const entry = cache[key] as CacheEntry<T> | undefined
|
||||
if (!entry) return null
|
||||
if (Date.now() - entry.timestamp > ttl) {
|
||||
delete cache[key]
|
||||
saveCache(cache)
|
||||
return null
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
|
||||
export const invalidateCache = (prefix?: string) => {
|
||||
if (!prefix) {
|
||||
Object.keys(cache).forEach((key) => delete cache[key])
|
||||
saveCache(cache)
|
||||
return
|
||||
}
|
||||
|
||||
Object.keys(cache)
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.forEach((key) => delete cache[key])
|
||||
saveCache(cache)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export namespace Clipboard {
|
||||
/**
|
||||
* Read text from the clipboard.
|
||||
*/
|
||||
export async function readText(): Promise<string | undefined> {
|
||||
async function readText(): Promise<string | undefined> {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin") {
|
||||
|
||||
@@ -13,7 +13,7 @@ import path from "path"
|
||||
const APP_DIR_NAME = "podtui"
|
||||
|
||||
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
||||
export function getXdgConfigHome(): string {
|
||||
function getXdgConfigHome(): string {
|
||||
const xdg = process.env.XDG_CONFIG_HOME
|
||||
if (xdg) return xdg
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
|
||||
}
|
||||
|
||||
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
||||
export function getXdgDataHome(): string {
|
||||
function getXdgDataHome(): string {
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
if (xdg) return xdg
|
||||
|
||||
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
|
||||
}
|
||||
|
||||
/** Get the application-specific data directory path */
|
||||
export function getDataDir(): string {
|
||||
function getDataDir(): string {
|
||||
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
||||
}
|
||||
|
||||
/** Get the downloads directory path */
|
||||
export function getDownloadsDir(): string {
|
||||
function getDownloadsDir(): string {
|
||||
return path.join(getDataDir(), "downloads")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Validates JSON structure of config files, handles corrupted files
|
||||
* gracefully (falling back to defaults), and provides a single
|
||||
*/
|
||||
|
||||
import { getConfigFilePath } from "./config-dir";
|
||||
// --- Validation helpers ---
|
||||
|
||||
/** Check that a value is a non-null object */
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** Validate AppState JSON structure */
|
||||
export function validateAppState(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(data)) {
|
||||
return { valid: false, errors: ["app-state.json is not an object"] };
|
||||
}
|
||||
|
||||
// settings
|
||||
if (data.settings !== undefined) {
|
||||
if (!isObject(data.settings)) {
|
||||
errors.push("settings must be an object");
|
||||
} else {
|
||||
const s = data.settings as Record<string, unknown>;
|
||||
if (s.theme !== undefined && typeof s.theme !== "string")
|
||||
errors.push("settings.theme must be a string");
|
||||
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
|
||||
errors.push("settings.fontSize must be a number");
|
||||
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
|
||||
errors.push("settings.playbackSpeed must be a number");
|
||||
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
|
||||
errors.push("settings.downloadPath must be a string");
|
||||
}
|
||||
}
|
||||
|
||||
// preferences
|
||||
if (data.preferences !== undefined) {
|
||||
if (!isObject(data.preferences)) {
|
||||
errors.push("preferences must be an object");
|
||||
} else {
|
||||
const p = data.preferences as Record<string, unknown>;
|
||||
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
|
||||
errors.push("preferences.showExplicit must be a boolean");
|
||||
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
|
||||
errors.push("preferences.autoDownload must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
// customTheme
|
||||
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
||||
errors.push("customTheme must be an object");
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Validate feeds JSON structure */
|
||||
export function validateFeeds(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(data)) {
|
||||
return { valid: false, errors: ["feeds.json is not an array"] };
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const feed = data[i];
|
||||
if (!isObject(feed)) {
|
||||
errors.push(`feeds[${i}] is not an object`);
|
||||
continue;
|
||||
}
|
||||
if (typeof feed.id !== "string")
|
||||
errors.push(`feeds[${i}].id must be a string`);
|
||||
if (!isObject(feed.podcast))
|
||||
errors.push(`feeds[${i}].podcast must be an object`);
|
||||
if (!Array.isArray(feed.episodes))
|
||||
errors.push(`feeds[${i}].episodes must be an array`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Validate progress JSON structure */
|
||||
export function validateProgress(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(data)) {
|
||||
return { valid: false, errors: ["progress.json is not an object"] };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (!isObject(value)) {
|
||||
errors.push(`progress["${key}"] is not an object`);
|
||||
continue;
|
||||
}
|
||||
const p = value as Record<string, unknown>;
|
||||
if (typeof p.episodeId !== "string")
|
||||
errors.push(`progress["${key}"].episodeId must be a string`);
|
||||
if (typeof p.position !== "number")
|
||||
errors.push(`progress["${key}"].position must be a number`);
|
||||
if (typeof p.duration !== "number")
|
||||
errors.push(`progress["${key}"].duration must be a number`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// --- Safe config file reading ---
|
||||
|
||||
/**
|
||||
* Safely read and validate a config file.
|
||||
* Returns the parsed data if valid, or null if the file is missing/corrupt.
|
||||
*/
|
||||
export async function safeReadConfigFile<T>(
|
||||
filename: string,
|
||||
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
||||
): Promise<{ data: T | null; errors: string[] }> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(filename);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return { data: null, errors: [] };
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { data: null, errors: [`${filename}: invalid JSON`] };
|
||||
}
|
||||
|
||||
const result = validator(parsed);
|
||||
if (!result.valid) {
|
||||
return { data: null, errors: result.errors };
|
||||
}
|
||||
|
||||
return { data: parsed as T, errors: [] };
|
||||
} catch (err) {
|
||||
return { data: null, errors: [`${filename}: ${String(err)}`] };
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,6 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Await all pending config writes (used by sync/export flows). */
|
||||
export async function flushConfig(): Promise<void> {
|
||||
await writeChain;
|
||||
}
|
||||
|
||||
/** Guards so migration runs exactly once per process. */
|
||||
let migrationDone = false;
|
||||
let migrationPromise: Promise<void> | null = null;
|
||||
@@ -98,7 +93,7 @@ async function migrateOnce(): Promise<void> {
|
||||
* Safe to call on every startup — no-op once config.json exists (except for
|
||||
* backup cleanup, which runs unconditionally since those files are now dead).
|
||||
*/
|
||||
export async function migrateLegacyConfig(): Promise<void> {
|
||||
async function migrateLegacyConfig(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
const dir = getConfigDir();
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { cacheValue, getCachedValue } from "./cache"
|
||||
import { fetchEpisodes } from "@/api/client"
|
||||
|
||||
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
|
||||
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
|
||||
const searchKey = (query: string) => `search:${query.toLowerCase()}`
|
||||
|
||||
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
|
||||
const cached = getCachedValue<Feed>(feedKey(feedUrl))
|
||||
if (cached) return cached
|
||||
try {
|
||||
const episodes = await fetchEpisodes(feedUrl)
|
||||
const feed: Feed = {
|
||||
id: feedUrl,
|
||||
podcast: {
|
||||
id: feedUrl,
|
||||
title: feedUrl,
|
||||
description: "",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
}
|
||||
cacheValue(feedKey(feedUrl), feed)
|
||||
return feed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
|
||||
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
|
||||
if (cached) return cached
|
||||
const episodes = await fetchEpisodes(feedUrl)
|
||||
cacheValue(episodesKey(feedUrl), episodes)
|
||||
return episodes
|
||||
}
|
||||
|
||||
export const searchWithCache = async (
|
||||
query: string,
|
||||
fetcher: () => Promise<Podcast[]>
|
||||
): Promise<Podcast[]> => {
|
||||
const cached = getCachedValue<Podcast[]>(searchKey(query))
|
||||
if (cached) return cached
|
||||
const results = await fetcher()
|
||||
cacheValue(searchKey(query), results)
|
||||
return results
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
]);
|
||||
|
||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||
export function tabByDigit(action: KeybindActionName): TABS | null {
|
||||
function tabByDigit(action: KeybindActionName): TABS | null {
|
||||
if (action.startsWith("tab-goto-")) {
|
||||
const n = Number(action.slice("tab-goto-".length));
|
||||
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||
|
||||
@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
|
||||
}
|
||||
|
||||
// Singleton event bus instance
|
||||
export const EventBus = createEventBus();
|
||||
const EventBus = createEventBus();
|
||||
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { TABS } from "@/utils/navigation";
|
||||
@@ -105,6 +105,8 @@ export type AppEvents = {
|
||||
"player.play": { episodeId: string };
|
||||
"player.pause": { episodeId: string };
|
||||
"player.stop": {};
|
||||
// Emitted when a NEW episode begins playback (not on resume).
|
||||
"player.started": { episodeId: string };
|
||||
"toast.show": {
|
||||
message: string;
|
||||
variant: "info" | "success" | "warning" | "error";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/**
|
||||
* Remove JSONC comments from a string
|
||||
*/
|
||||
export function stripComments(jsonString: string): string {
|
||||
function stripComments(jsonString: string): string {
|
||||
const comments = [
|
||||
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
||||
|
||||
@@ -53,9 +53,10 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||
"tab-goto-4": ["4"],
|
||||
"tab-goto-5": ["5"],
|
||||
"tab-goto-6": ["6"],
|
||||
// command / help / quit
|
||||
command: [":"],
|
||||
quit: ["q", "ctrl-c"],
|
||||
// command palette / help / quit
|
||||
// q opens the palette (type q + Enter to quit there); Q is the quick quit.
|
||||
command: [":", "q"],
|
||||
quit: ["Q", "ctrl-c"],
|
||||
help: ["~", "f1"],
|
||||
// list ops
|
||||
search: ["s"],
|
||||
@@ -77,7 +78,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||
try {
|
||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||
|
||||
// Check if file already exists
|
||||
const targetFile = Bun.file(targetPath);
|
||||
if (await targetFile.exists()) return;
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export function rootFrameFor(
|
||||
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||
//
|
||||
// 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.
|
||||
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
|
||||
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
|
||||
// give `current` the combined 4/5.
|
||||
export const PANE_RATIO = {
|
||||
parent: 1,
|
||||
current: 3,
|
||||
preview: 3,
|
||||
current: 2,
|
||||
preview: 2,
|
||||
} as const;
|
||||
|
||||
// Number of *focusable* content panes per tab. The three visible columns
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { searchSourceByType } from "./source-searcher";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import { SourceType } from "../types/source";
|
||||
import type { PodcastSource, SearchResult } from "../types/source";
|
||||
import type { Episode } from "../types/episode";
|
||||
|
||||
type SearchCacheEntry = {
|
||||
timestamp: number;
|
||||
@@ -56,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const FEED_URL_RE = /^https?:\/\/.+/i;
|
||||
|
||||
/**
|
||||
* If the query is a direct RSS feed URL (useful for private feeds that aren't
|
||||
* in public directories), fetch and parse it into a single search result.
|
||||
* Returns an empty array when the query is not a URL so normal search proceeds.
|
||||
*/
|
||||
export const searchByFeedUrl = async (
|
||||
query: string,
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!FEED_URL_RE.test(trimmed)) return [];
|
||||
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const xml = await response.text();
|
||||
const podcast = parseRSSFeed(xml, trimmed);
|
||||
|
||||
return [
|
||||
{
|
||||
sourceId: "direct-rss",
|
||||
sourceName: "RSS Feed",
|
||||
sourceType: SourceType.RSS,
|
||||
// parseRSSFeed marks feeds subscribed; a search result should start
|
||||
// unsubscribed so the store can flag it correctly if already added.
|
||||
podcast: { ...podcast, isSubscribed: false },
|
||||
score: 1,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const searchPodcasts = async (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
@@ -114,61 +156,4 @@ export const searchPodcasts = async (
|
||||
return sorted;
|
||||
};
|
||||
|
||||
type ItunesEpisodeResult = {
|
||||
trackId?: number;
|
||||
trackName?: string;
|
||||
description?: string;
|
||||
shortDescription?: string;
|
||||
releaseDate?: string;
|
||||
trackTimeMillis?: number;
|
||||
episodeUrl?: string;
|
||||
previewUrl?: string;
|
||||
trackViewUrl?: string;
|
||||
};
|
||||
|
||||
type ItunesEpisodeResponse = {
|
||||
resultCount: number;
|
||||
results: ItunesEpisodeResult[];
|
||||
};
|
||||
|
||||
export const searchEpisodes = async (
|
||||
query: string,
|
||||
feedId: string,
|
||||
): Promise<Episode[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const url = new URL("https://itunes.apple.com/search");
|
||||
url.searchParams.set("term", trimmed);
|
||||
url.searchParams.set("media", "podcast");
|
||||
url.searchParams.set("entity", "podcastEpisode");
|
||||
url.searchParams.set("country", "US");
|
||||
url.searchParams.set("lang", "en_us");
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = (await response.json()) as ItunesEpisodeResponse;
|
||||
return data.results
|
||||
.map((item) => {
|
||||
if (!item.trackName) return null;
|
||||
const id = item.trackId
|
||||
? `episode-${item.trackId}`
|
||||
: `episode-${item.trackName}`;
|
||||
const audioUrl =
|
||||
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
||||
|
||||
return {
|
||||
id,
|
||||
podcastId: feedId,
|
||||
title: item.trackName,
|
||||
description: item.description || item.shortDescription || "",
|
||||
audioUrl,
|
||||
duration: item.trackTimeMillis
|
||||
? Math.round(item.trackTimeMillis / 1000)
|
||||
: 0,
|
||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||
};
|
||||
})
|
||||
.filter((item): item is Episode => Boolean(item));
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
|
||||
})
|
||||
}
|
||||
|
||||
export const searchRSSSource = async (
|
||||
const searchRSSSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
|
||||
}
|
||||
}
|
||||
|
||||
export const searchAPISource = async (
|
||||
const searchAPISource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
@@ -173,7 +173,7 @@ export const searchAPISource = async (
|
||||
}))
|
||||
}
|
||||
|
||||
export const searchCustomSource = async (
|
||||
const searchCustomSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { SyncData } from "../types/sync-json"
|
||||
import type { SyncDataXML } from "../types/sync-xml"
|
||||
import { syncFormats } from "../constants/sync-formats"
|
||||
|
||||
const isObject = (value: unknown): value is { [key: string]: unknown } =>
|
||||
typeof value === "object" && value !== null
|
||||
|
||||
const hasVersion = (value: unknown): value is { version: string } =>
|
||||
isObject(value) && typeof value.version === "string"
|
||||
|
||||
export function validateJSONSync(data: unknown): SyncData {
|
||||
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
|
||||
throw { message: "Unsupported sync format" }
|
||||
}
|
||||
|
||||
return data as SyncData
|
||||
}
|
||||
|
||||
export function validateXMLSync(data: unknown): SyncDataXML {
|
||||
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
|
||||
throw { message: "Unsupported sync format" }
|
||||
}
|
||||
|
||||
return data as SyncDataXML
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { SyncData } from "../types/sync-json"
|
||||
import type { SyncDataXML } from "../types/sync-xml"
|
||||
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
||||
import { syncFormats } from "../constants/sync-formats"
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
|
||||
export function exportToJSON(data: SyncData): string {
|
||||
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
|
||||
}
|
||||
|
||||
export function importFromJSON(json: string): SyncData {
|
||||
const data = json
|
||||
return validateJSONSync(data as unknown)
|
||||
}
|
||||
|
||||
export function exportToXML(data: SyncDataXML): string {
|
||||
const feedItems = ""
|
||||
const sourceItems = ""
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<podcastSync version="${syncFormats.xml.version}">\n` +
|
||||
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
|
||||
` <feeds>\n` +
|
||||
feedItems +
|
||||
` </feeds>\n` +
|
||||
` <sources>\n` +
|
||||
sourceItems +
|
||||
` </sources>\n` +
|
||||
` <settings>\n` +
|
||||
` <theme>${data.settings.theme}</theme>\n` +
|
||||
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
|
||||
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
|
||||
` </settings>\n` +
|
||||
` <preferences>\n` +
|
||||
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
|
||||
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
|
||||
` </preferences>\n` +
|
||||
`</podcastSync>`
|
||||
}
|
||||
|
||||
export function importFromXML(xml: string): SyncDataXML {
|
||||
const version = syncFormats.xml.version
|
||||
const data = {
|
||||
version,
|
||||
lastSyncedAt: "",
|
||||
feeds: { feed: [] },
|
||||
sources: { source: [] },
|
||||
settings: {
|
||||
theme: "system",
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
},
|
||||
preferences: {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
},
|
||||
} as SyncDataXML
|
||||
|
||||
return validateXMLSync(data)
|
||||
}
|
||||
@@ -13,13 +13,21 @@ export function clearPaletteCache() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export function detectSystemTheme(colors: TerminalColors) {
|
||||
const bg = RGBA.fromHex(
|
||||
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
|
||||
);
|
||||
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
|
||||
const mode = luminance > 0.5 ? "light" : "dark";
|
||||
return { mode, background: bg };
|
||||
/** Relative luminance of a hex color (0 = black, 1 = white). */
|
||||
function luminance(hex: string): number {
|
||||
const c = RGBA.fromHex(hex);
|
||||
return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the terminal's dark/light mode from its default background color
|
||||
* (the OSC 11 query response). Returns null when no background is available.
|
||||
*/
|
||||
export function detectModeFromBackground(
|
||||
background: string | null | undefined,
|
||||
): "dark" | "light" | null {
|
||||
if (!background) return null;
|
||||
return luminance(background) < 0.5 ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function generateSystemTheme(
|
||||
@@ -27,14 +35,18 @@ export function generateSystemTheme(
|
||||
mode: "dark" | "light",
|
||||
): ThemeJson {
|
||||
cached = colors;
|
||||
const isDark = mode === "dark";
|
||||
const bg = RGBA.fromHex(
|
||||
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
|
||||
colors.defaultBackground ??
|
||||
colors.palette[0] ??
|
||||
(isDark ? "#000000" : "#ffffff"),
|
||||
);
|
||||
const fg = RGBA.fromHex(
|
||||
colors.defaultForeground ?? colors.palette[7] ?? "#ffffff",
|
||||
colors.defaultForeground ??
|
||||
colors.palette[7] ??
|
||||
(isDark ? "#ffffff" : "#000000"),
|
||||
);
|
||||
const transparent = RGBA.fromInts(0, 0, 0, 0);
|
||||
const isDark = mode === "dark";
|
||||
|
||||
const col = (i: number) => {
|
||||
const value = colors.palette[i];
|
||||
@@ -96,6 +108,7 @@ export function generateSystemTheme(
|
||||
textSelectedTertiary: selectedTertiary,
|
||||
selectedListItemText: bg,
|
||||
background: transparent,
|
||||
transparent: true,
|
||||
backgroundPanel: grays[2],
|
||||
backgroundElement: grays[3],
|
||||
backgroundMenu: grays[3],
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import type { ColorValue } from "../types/theme-schema"
|
||||
|
||||
const toCss = (value: ColorValue | RGBA) => {
|
||||
if (value instanceof RGBA) {
|
||||
const r = Math.round(value.r * 255)
|
||||
const g = Math.round(value.g * 255)
|
||||
const b = Math.round(value.b * 255)
|
||||
return `rgba(${r}, ${g}, ${b}, ${value.a})`
|
||||
}
|
||||
if (typeof value === "number") return `var(--ansi-${value})`
|
||||
if (typeof value === "string") return value
|
||||
return value.dark
|
||||
}
|
||||
|
||||
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === "layerBackgrounds" && typeof value === "object") {
|
||||
const layers = value as Record<string, RGBA | ColorValue>
|
||||
for (const [layer, color] of Object.entries(layers)) {
|
||||
root.style.setProperty(`--color-${layer}`, toCss(color))
|
||||
}
|
||||
} else {
|
||||
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute("data-theme", themeName)
|
||||
}
|
||||
|
||||
export function resolveColorReference(value: ColorValue) {
|
||||
return toCss(value)
|
||||
}
|
||||
@@ -1,42 +1,4 @@
|
||||
import path from "path"
|
||||
import type { ThemeJson } from "../types/theme-schema"
|
||||
import { THEME_JSON } from "../constants/themes"
|
||||
|
||||
export async function loadTheme(name: string) {
|
||||
if (THEME_JSON[name]) return THEME_JSON[name]
|
||||
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
|
||||
return loadThemeFromPath(file)
|
||||
}
|
||||
|
||||
export async function loadThemeFromPath(file: string) {
|
||||
const json = (await Bun.file(file).json()) as ThemeJson
|
||||
validateTheme(json, file)
|
||||
return json
|
||||
}
|
||||
|
||||
export async function getAllThemes() {
|
||||
return { ...THEME_JSON, ...(await getCustomThemes()) }
|
||||
}
|
||||
|
||||
export async function getCustomThemes() {
|
||||
const dirs = [
|
||||
path.join(process.env.HOME ?? "", ".config/podtui/themes"),
|
||||
path.resolve(process.cwd(), ".podtui/themes"),
|
||||
path.resolve(process.cwd(), "themes"),
|
||||
]
|
||||
|
||||
const result: Record<string, ThemeJson> = {}
|
||||
for (const dir of dirs) {
|
||||
const glob = new Bun.Glob("*.json")
|
||||
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
|
||||
const name = path.basename(item, ".json")
|
||||
const json = (await Bun.file(item).json()) as ThemeJson
|
||||
validateTheme(json, item)
|
||||
result[name] = json
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function validateTheme(theme: ThemeJson, source?: string) {
|
||||
if (!theme || typeof theme !== "object") {
|
||||
|
||||
@@ -7,40 +7,12 @@
|
||||
* - Tracking theme change state
|
||||
*/
|
||||
|
||||
import { emit, on, off, type EventHandler } from "./event-bus"
|
||||
|
||||
/**
|
||||
* Subscribe to theme reload events.
|
||||
* These are triggered by SIGUSR2 signals.
|
||||
*/
|
||||
export function onThemeReload(handler: EventHandler<{}>): () => void {
|
||||
return on("theme.reload", handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to theme changed events.
|
||||
* These are triggered when the theme selection changes.
|
||||
*/
|
||||
export function onThemeChanged(
|
||||
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
|
||||
): () => void {
|
||||
return on("theme.changed", handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to theme mode changed events.
|
||||
* These are triggered when switching between dark/light mode.
|
||||
*/
|
||||
export function onThemeModeChanged(
|
||||
handler: EventHandler<{ mode: "dark" | "light" }>
|
||||
): () => void {
|
||||
return on("theme.mode.changed", handler)
|
||||
}
|
||||
import { emit } from "./event-bus"
|
||||
|
||||
/**
|
||||
* Emit a theme reload event.
|
||||
*/
|
||||
export function emitThemeReload(): void {
|
||||
function emitThemeReload(): void {
|
||||
emit("theme.reload", {})
|
||||
}
|
||||
|
||||
@@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void {
|
||||
process.off("SIGUSR2", handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debounced theme change handler to prevent rapid consecutive updates.
|
||||
*
|
||||
* @param handler - The handler to debounce
|
||||
* @param delay - Delay in milliseconds (default: 100ms)
|
||||
*/
|
||||
export function createDebouncedThemeHandler<T>(
|
||||
handler: (event: T) => void,
|
||||
delay: number = 100
|
||||
): (event: T) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
return (event: T) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
handler(event)
|
||||
timeout = null
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
|
||||
if (value.startsWith("#")) return RGBA.fromHex(value)
|
||||
if (defs[value] != null) return resolveColor(defs[value])
|
||||
const ref = theme.theme[value]
|
||||
if (ref != null) return resolveColor(ref)
|
||||
if (ref != null && typeof ref !== "boolean") return resolveColor(ref)
|
||||
throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(value[mode])
|
||||
@@ -26,8 +26,15 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value)])
|
||||
.filter(
|
||||
(entry): entry is [string, ColorValue] =>
|
||||
entry[0] !== "selectedListItemText" &&
|
||||
entry[0] !== "backgroundMenu" &&
|
||||
entry[0] !== "thinkingOpacity" &&
|
||||
entry[0] !== "transparent" &&
|
||||
typeof entry[1] !== "boolean",
|
||||
)
|
||||
.map(([key, value]) => [key, resolveColor(value)]),
|
||||
) as Record<string, RGBA>
|
||||
|
||||
const hasSelected = theme.theme.selectedListItemText !== undefined
|
||||
@@ -40,6 +47,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
|
||||
: resolved.backgroundElement
|
||||
|
||||
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
|
||||
const transparent = theme.theme.transparent === true
|
||||
|
||||
const background = resolved.background
|
||||
const backgroundPanel = resolved.backgroundPanel ?? background
|
||||
@@ -58,5 +66,6 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
|
||||
},
|
||||
_hasSelectedListItemText: hasSelected,
|
||||
thinkingOpacity,
|
||||
transparent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,67 +3,13 @@
|
||||
* Handles dynamic theme switching by updating CSS custom properties
|
||||
*/
|
||||
|
||||
import { RGBA, type TerminalColors } from "@opentui/core";
|
||||
import type { ThemeColors } from "../types/settings";
|
||||
import type { ColorValue, ThemeJson } from "../types/theme-schema";
|
||||
import type { TerminalColors } from "@opentui/core";
|
||||
import type { ThemeJson } from "../types/theme-schema";
|
||||
import { THEME_JSON } from "../constants/themes";
|
||||
import { getCustomThemes } from "./custom-themes";
|
||||
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
||||
import { generateSystemTheme } from "./system-theme";
|
||||
|
||||
const toCss = (value: ColorValue | RGBA) => {
|
||||
if (value instanceof RGBA) {
|
||||
const r = Math.round(value.r * 255);
|
||||
const g = Math.round(value.g * 255);
|
||||
const b = Math.round(value.b * 255);
|
||||
return `rgba(${r}, ${g}, ${b}, ${value.a})`;
|
||||
}
|
||||
if (typeof value === "number") return `var(--ansi-${value})`;
|
||||
if (typeof value === "string") return value;
|
||||
return value.dark;
|
||||
};
|
||||
|
||||
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty(
|
||||
"--color-background",
|
||||
toCss(theme.background as ColorValue),
|
||||
);
|
||||
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue));
|
||||
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue));
|
||||
root.style.setProperty(
|
||||
"--color-secondary",
|
||||
toCss(theme.secondary as ColorValue),
|
||||
);
|
||||
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue));
|
||||
root.style.setProperty("--color-text", toCss(theme.text as ColorValue));
|
||||
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue));
|
||||
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue));
|
||||
root.style.setProperty("--color-error", toCss(theme.error as ColorValue));
|
||||
root.style.setProperty("--color-success", toCss(theme.success as ColorValue));
|
||||
|
||||
const layers = theme.layerBackgrounds as
|
||||
| Record<string, ColorValue>
|
||||
| undefined;
|
||||
if (layers) {
|
||||
root.style.setProperty("--color-layer0", toCss(layers.layer0));
|
||||
root.style.setProperty("--color-layer1", toCss(layers.layer1));
|
||||
root.style.setProperty("--color-layer2", toCss(layers.layer2));
|
||||
root.style.setProperty("--color-layer3", toCss(layers.layer3));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme mode from system preference
|
||||
*/
|
||||
export function getSystemThemeMode(): "dark" | "light" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
return prefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSS variable data-theme attribute
|
||||
*/
|
||||
@@ -77,15 +23,6 @@ export async function loadThemes() {
|
||||
return await getCustomThemes();
|
||||
}
|
||||
|
||||
export async function loadTheme(name: string) {
|
||||
const themes = await loadThemes();
|
||||
return themes[name];
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
return resolveThemeJson(theme, mode);
|
||||
}
|
||||
|
||||
export function resolveTerminalTheme(
|
||||
themes: Record<string, ThemeJson>,
|
||||
name: string,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# 01. Rearchitect nav model — remove the sidebar pane
|
||||
|
||||
meta:
|
||||
id: yazi-remake-01
|
||||
feature: yazi-remake
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, nav-model, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Remove the always-on `SIDEBAR_PANE` concept from the navigation context so `activeTab` is plain tab state (not a pane), establishing clean parent|current|preview semantics for the yazi remake.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/context/NavigationContext.tsx` — delete `SIDEBAR_PANE` constant and all references; `activeTab` is no longer a pane
|
||||
- `src/utils/navigation.ts` — update `TabPaneCount` semantics; depth-tabs = 1 focusable pane (current), the 3 visible columns are a render concern not 3 panes
|
||||
- Updated header/comment block describing the parent|current|preview model
|
||||
- `swipe()` / `popDepth()` reworked: depth-tabs `l`=drill (`open`), `h`=pop (noop at depth 0); fixed-pane tabs `h/l` move between parent/current/preview
|
||||
- Tab-enter resets focus to `DEPTH_CENTER_PANE` (current pane), not a sidebar
|
||||
|
||||
steps:
|
||||
|
||||
- Audit every reference to `SIDEBAR_PANE` across the codebase (grep)
|
||||
- In `NavigationContext.tsx`: delete the `SIDEBAR_PANE = -1` export and the `focusedIndex`/`setFocusedIndex` SIDEBAR_PANE branch added previously
|
||||
- Set the initial `activePane` signal and the tab-switch createEffect to reset to `DEPTH_CENTER_PANE` (the current pane), not `SIDEBAR_PANE`
|
||||
- Rework `swipe()` to clamp to `[0, paneCount-1]` for fixed-pane tabs (the sidebar is no longer in the chain); depth-tabs don't use `swipe` for drill/pop (that lives in Shell dispatch)
|
||||
- In `utils/navigation.ts`: confirm `TabPaneCount` reflects focusable content panes only (depth-tabs = 1, Search = 3, Player = 1); update `PANE_RATIO` leave-behind note (ratio change happens in task 02)
|
||||
- Update the file header comment block to describe parent|current|preview
|
||||
- Run `lens_diagnostics` on the two files
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: `focusedIndex(DEPTH_CENTER_PANE)` on a depth-tab returns the top frame's focus; `setFocusedIndex` writes to the top frame (Arrange a tab with a 2-frame stack, Act by calling setFocusedIndex, Assert topFrame.focus updated)
|
||||
- Integration: tab-switch effect sets `activePane` to `DEPTH_CENTER_PANE` (not -1); `swipe(-1, 3)` on a fixed tab clamps to 0 not -1
|
||||
- e2e (harness): app boots with `nav.state.pane === 0` (current), not -1
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- No symbol `SIDEBAR_PANE` exists anywhere in `src/`
|
||||
- Initial `activePane` === `DEPTH_CENTER_PANE` (0)
|
||||
- Tab-enter sets `activePane` to `DEPTH_CENTER_PANE`
|
||||
- `swipe()` lower bound is 0 (no `-1`)
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -rn "SIDEBAR_PANE" src/` returns nothing
|
||||
- `bun run build` passes
|
||||
- `lens_diagnostics` paths=[`src/context/NavigationContext.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
|
||||
|
||||
notes:
|
||||
|
||||
- This task unblocks 03/04/05/06. It must not delete `DEPTH_CENTER_PANE` — that constant is generalised to "the current pane" and retained
|
||||
- `SIDEBAR_ACTIONS` (added in Shell in a prior turn) is removed in task 06 (the keybind rewrite), not here — but Shell will temporarily fail to compile after this task until 05/06 land; that's expected and the build command ignores type errors, so gate success on grep + targeted diagnostics, not the full build
|
||||
@@ -1,56 +0,0 @@
|
||||
# 02. Build the reusable 3-pane layout primitive (1:3:3 ratio, stable parent slot)
|
||||
|
||||
meta:
|
||||
id: yazi-remake-02
|
||||
feature: yazi-remake
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [implementation, layout, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Create one reusable `<YaziPaneRow>` primitive that renders three bordered columns (parent | current | preview) at a 1:3:3 grow ratio with a stable 1/7 parent slot even when blank, so every list tab shares an identical, layout-stable shell.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/components/YaziPaneRow.tsx` — new component: props `parent`, `current`, `preview` (Solid JSX/accessors), `parentLabel`, `currentLabel`, `previewLabel`, `focused` (boolean, defaults to current)
|
||||
- `src/utils/navigation.ts` — `PANE_RATIO` updated to `{ parent: 1, current: 3, preview: 3 }` (was `{ parent: 1, current: 4, preview: 3 }`)
|
||||
- Each pane: bordered `scrollbox` + slim header label row (height=1)
|
||||
- Parent pane keeps its 1/7 `flexGrow` slot even when empty (renders a muted placeholder, never `width:0`)
|
||||
- Focus ring (border color = accent on current; muted `border` on parent & preview)
|
||||
|
||||
steps:
|
||||
|
||||
- Set `PANE_RATIO = { parent: 1, current: 3, preview: 3 }` in `utils/navigation.ts`
|
||||
- Create `YaziPaneRow.tsx` exporting a component that lays out three `<box flexGrow={PANE_RATIO.x}>` columns in a row
|
||||
- Each column: a height-1 header `<box>` with the label text, then a `<scrollbox height="100%" border borderColor=…>` rendering the passed children
|
||||
- Thread a `theme` via `useTheme()` inside the primitive (don't require callers to pass colors)
|
||||
- `focused` prop controls which column gets the accent border — default current; parent & preview always muted
|
||||
- Ensure the parent column renders a muted placeholder box (e.g. a single `<text fg={muted}>—</text>` or empty) when its children are null, but critically keeps `flexGrow={PANE_RATIO.parent}` so width never collapses
|
||||
- Add a JSDoc header describing the yazi 1:3:3 contract
|
||||
- Run diagnostics on the new file
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: the primitive renders three boxes with flexGrow 1/3/3 regardless of null children (Arrange null parent, render, Assert three columns present with correct flexGrow)
|
||||
- Integration: toggling `focused` swaps the accent border onto the requested column
|
||||
- e2e (harness): a page using the primitive shows three equal-ratio columns with the parent column visibly non-zero width even when blank
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- `PANE_RATIO` is `{ parent: 1, current: 3, preview: 3 }`
|
||||
- `YaziPaneRow` accepts parent/current/preview children + labels + focused
|
||||
- Parent column width never collapses to 0 (stable 1/7 slot)
|
||||
- Only the focused column shows the accent border
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -n "PANE_RATIO" src/utils/navigation.ts` shows the new 1:3:3 values
|
||||
- `lens_diagnostics` paths=[`src/components/YaziPaneRow.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
|
||||
- Harness: render a throwaway page using `<YaziPaneRow>`; confirm 3 columns at 1:3:3 via the frame
|
||||
|
||||
notes:
|
||||
|
||||
- Independent of task 01 (no nav-state dependency) — can be built in parallel
|
||||
- Callers (tasks 03/04) pass their own parent/current/preview JSX; the primitive is purely structural
|
||||
- opentui scrollbox: use `focused` only on the current pane so scroll focus follows the cursor
|
||||
@@ -1,58 +0,0 @@
|
||||
# 03. Convert Feed/MyShows/Discover/Settings to the shared parent|current|preview primitive
|
||||
|
||||
meta:
|
||||
id: yazi-remake-03
|
||||
feature: yazi-remake
|
||||
priority: P2
|
||||
depends_on: [yazi-remake-01, yazi-remake-02]
|
||||
tags: [implementation, pages, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Rewrite the four depth-stack list tabs to render through `<YaziPaneRow>`, with the previous-depth list now visible in the parent pane (blank at depth 0), the current-depth list in current, and the hovered item in preview — eliminating per-page bespoke 3-column JSX.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/pages/Feed/FeedPage.tsx` — rewritten to use `<YaziPaneRow>`; parent = previous-depth list, current = current-depth list, preview = hovered item detail
|
||||
- `src/pages/MyShows/MyShowsPage.tsx` — same conversion
|
||||
- `src/pages/Discover/DiscoverPage.tsx` — same conversion
|
||||
- `src/pages/Settings/SettingsPage.tsx` — same conversion (sections → items → editor)
|
||||
- Each page's `nav.action` handler retained but only acts on the current pane
|
||||
- All per-page bespoke row/flexbox 3-column JSX removed
|
||||
|
||||
steps:
|
||||
|
||||
- For each of the four pages, read the current implementation to extract the parent/current/preview content builders
|
||||
- Wrap the page body in `<YaziPaneRow parent={…} current={…} preview={…} focused={isActive} />`
|
||||
- Parent pane: render the previous-depth frame's list (depth-1). At depth 0 the parent receives null/placeholder (the primitive keeps the slot)
|
||||
- Current pane: the current-depth list, focusable, with `onMouseDown` row handlers calling `nav.setActivePane(DEPTH_CENTER_PANE)` + `nav.setDepthFocus(i, depth)`
|
||||
- Preview pane: hovered-item detail derived from `focusedIndex(DEPTH_CENTER_PANE)` (unchanged logic, just relocated into the preview slot)
|
||||
- Keep `pushDepth`/`popDepth` calls in the `open` action (drill) — behaviour unchanged, only layout changes
|
||||
- Remove the old inline `<box flexGrow={PANE_RATIO.parent/current/preview}>` columns in favour of the primitive
|
||||
- Verify each page's `nav.action` handler guards on `data.pane === DEPTH_CENTER_PANE && nav.activePane() === DEPTH_CENTER_PANE`
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: each page's `open` action pushes a frame and the parent pane switches from blank to the previous list (Arrange depth 0, Act open, Assert stack length 2 and parent renders the old list)
|
||||
- Integration: `h` (pop) returns parent to blank at depth 0; `l` (drill) populates parent with the previous list
|
||||
- e2e (harness): Feed depth 0→1→2 shows parent blank → previous feeds list → previous episodes list; Settings sections→items→editor shows the chain in the parent pane
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- All four pages render via `<YaziPaneRow>` (no bespoke 3-column JSX remains)
|
||||
- Parent pane is blank at depth 0, populated at depth ≥ 1
|
||||
- Drilling (l/Enter) populates the parent with the previous-depth list
|
||||
- Popping (h) empties the parent back to blank at depth 0
|
||||
- j/k move focus only within the current pane
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -rn "YaziPaneRow" src/pages/` returns 4 files
|
||||
- `lens_diagnostics` paths over the four page files severity=error → 0 findings
|
||||
- Harness walk: `init` → navigate Feed → `l` (drill) → `l` (drill) → `h` (pop) → `h` (pop); confirm parent slot transitions blank→list→list→blank
|
||||
|
||||
notes:
|
||||
|
||||
- Depends on 01 (pane model) and 02 (the primitive) being merged
|
||||
- The already-working `<Show when={item}>{(item) => (… item() …)}</Show>` accessor pattern for opentui `<Show>` callbacks must be preserved in preview panes
|
||||
- Keep `LoadingIndicator` usages where they exist
|
||||
@@ -1,52 +0,0 @@
|
||||
# 04. Fit Search and Player into the 3-pane (1:3:3) model
|
||||
|
||||
meta:
|
||||
id: yazi-remake-04
|
||||
feature: yazi-remake
|
||||
priority: P2
|
||||
depends_on: [yazi-remake-01, yazi-remake-02]
|
||||
tags: [implementation, pages, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Bring the two fixed-layout tabs (Search, Player) into the same 1:3:3 parent|current|preview shell, deciding per-page whether to adopt the depth-stack or stay fixed-3-pane, while applying the new ratios throughout.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`; parent = query input + recent-search history, current = results list, preview = focused-result detail
|
||||
- `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`; current = now-playing transport, preview = episode description/notes, parent = blank placeholder (or compact episode list if available)
|
||||
- Decision recorded in each file's header comment: depth-stack vs fixed-3-pane
|
||||
|
||||
steps:
|
||||
|
||||
- Read both pages to understand their current pane semantics
|
||||
- Search: map INPUT→parent, RESULTS→current, DETAIL→preview inside `<YaziPaneRow>`. If the 1/7 parent slot is too narrow for the input box, widen parent for Search only by passing an override ratio OR move the query into current and results into parent — pick the option that keeps the input usable and document it
|
||||
- Search: keep the `inputFocused` effect (Shell yields keys to `<input>` when current-pane focus is on the query) — adapt to whichever pane the input lives in
|
||||
- Player: single content pane; parent = blank/placeholder (1/7), current = transport + progress + controls (3/7), preview = episode art/description/notes (3/7). If no preview data, render a muted placeholder but keep the slot
|
||||
- Confirm fixed-pane tab swipe (h/l between parent/current/preview) still routes correctly for Search
|
||||
- Run diagnostics
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: Search's `handleSubmit` swipes to the results pane and sets focus index 0 (Arrange empty results, Act submit, Assert activePane === results pane & focusedIndex 0)
|
||||
- Integration: Player renders with parent blank and the transport in current
|
||||
- e2e (harness): Search shows query | results | detail at 1:3:3; Player shows blank | transport | notes at 1:3:3
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- Both pages render via `<YaziPaneRow>` at 1:3:3
|
||||
- Search input remains typeable (Shell yields keys when the query pane is focused)
|
||||
- Player's transport is in the current pane with focus
|
||||
- No layout collapse: parent & preview keep their slots even if blank
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` returns 2 files
|
||||
- `lens_diagnostics` paths over both files severity=error → 0 findings
|
||||
- Harness: navigate to Search, type a query, press Enter, see results in current + detail in preview; navigate to Player, see transport + notes
|
||||
|
||||
notes:
|
||||
|
||||
- Depends on 01 (pane model — though Search is fixed-pane, the model cleanup affects `swipe` bounds) and 02 (the primitive)
|
||||
- If Search input at 1/7 is genuinely too tight (~14 cols at 100w), prefer moving the query into the current pane for Search only and the results into parent — but confirm width with the harness before committing
|
||||
- Player is single-content; the 1:3:3 with blanks is mostly cosmetic but keeps the layout globally consistent
|
||||
@@ -1,56 +0,0 @@
|
||||
# 05. Rebuild Shell chrome — drop sidebar, add yazi bottom status/tab bar
|
||||
|
||||
meta:
|
||||
id: yazi-remake-05
|
||||
feature: yazi-remake
|
||||
priority: P1
|
||||
depends_on: [yazi-remake-01]
|
||||
tags: [implementation, shell-chrome, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Remove the always-on left tab sidebar entirely and replace it with a full-width page area above a slim yazi-style bottom bar that surfaces the active tab, depth/counts, selection, now-playing, and a discoverable tab strip.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/components/Shell.tsx` — sidebar JSX deleted; render `LayerGraph[tab]()` full-width + a rebuilt bottom status/command bar
|
||||
- Bottom bar (normal mode): mode label, `TAB_LABEL[tab] · depth N · i/len` (or `pane i/n` for fixed tabs), selection count `●N`, now-playing `♪ title`, pending-keybind hint, and a compact tab strip `[1]Feed [2]MyShows …` with the active tab marked
|
||||
- Bottom bar (command mode): `:` prompt + buffer + error (unchanged, just relocated if needed)
|
||||
- Help overlay kept; now-playing relocated from the old sidebar footer into the status bar
|
||||
|
||||
steps:
|
||||
|
||||
- Read `Shell.tsx` and delete the entire left tab sidebar `<box flexDirection="column" width={14}>…` block
|
||||
- Replace the middle row with a single full-width `<box flexGrow={1}>{LayerGraph[nav.activeTab()]()}</box>`
|
||||
- Rebuild the bottom bar: a height-1 `<box flexDirection="row">` with the fragments described above
|
||||
- Tab strip: render `Object.values(TABS)` filtered to numbers; for each tab show `[N] Label` with the active tab inverted/highlighted (accent bg or `≡` marker)
|
||||
- Status fragment: `nav.activePane() === DEPTH_CENTER_PANE ? (isDepthTab ? \`depth ${currentDepth()}\` : \`pane ${activePane()+1}/${count}\`) : 'tabs'` — but since the sidebar is gone, default to the depth/pane string (focus starts on current)
|
||||
- Relocate `nowPlaying()` text from the sidebar footer into the bottom bar
|
||||
- Keep `runCommand`, `handleCommandKey`, the help overlay, and `playEpisodeAndSwitch` untouched
|
||||
- Run diagnostics
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: `nowPlaying()` formats `♪ <truncated title>` (Arrange a current episode, Assert the string)
|
||||
- Integration: switching tabs updates the tab strip's active marker and the status tab label
|
||||
- e2e (harness): `init` shows no left sidebar, a full-width page, and a bottom bar containing the tab strip + `Feed · depth 0`; cycling tabs moves the strip's active marker
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- No `width={14}` sidebar `<box>` remains in `Shell.tsx`
|
||||
- The active page fills the full content width
|
||||
- The bottom bar shows the active tab, depth, counts, selection, now-playing, and the tab strip
|
||||
- The active tab is visually marked in the strip
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -n "width={14}" src/components/Shell.tsx` returns nothing
|
||||
- `grep -n "LayerGraph" src/components/Shell.tsx` shows the full-width render
|
||||
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
|
||||
- Harness: `init` frame has no sidebar column and shows the tab strip in the last row
|
||||
|
||||
notes:
|
||||
|
||||
- Depends on 01 (the pane model: focus starts on current, so the status fragment no longer needs the `SIDEBAR_PANE` branch)
|
||||
- Task 06 rewrites the dispatch keybinds in this same file; do the chrome here and leave the dispatch `SIDEBAR_ACTIONS` branch for 06 to remove (or remove it here if 01 already deleted the constant — coordinate with 01)
|
||||
- `playEpisodeAndSwitch` and the command bar must keep working
|
||||
@@ -1,56 +0,0 @@
|
||||
# 06. Rewire keybinds — h/l drill+pop, digits switch tabs, focus starts on current
|
||||
|
||||
meta:
|
||||
id: yazi-remake-06
|
||||
feature: yazi-remake
|
||||
priority: P1
|
||||
depends_on: [yazi-remake-01, yazi-remake-05]
|
||||
tags: [implementation, keybinds, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Rewire the Shell dispatch so the sidebar's special-cased j/k branch is gone, h/l drill/pop on depth-tabs and swipe on fixed tabs, digit keys + `[ ]` are the sole tab switcher, and app focus starts on the current pane.
|
||||
|
||||
deliverables:
|
||||
|
||||
- `src/components/Shell.tsx` (dispatch) — `SIDEBAR_ACTIONS` set + the `if (nav.activePane() === SIDEBAR_PANE)` branch deleted
|
||||
- `h`/`l` unified: depth-tabs `l`=current-drills (`open` emit), `h`=current-pops (noop at depth 0); fixed-pane tabs `h/l`=`swipe(∓1, count)`
|
||||
- `1`-`6` / `tab-goto-*`, `tab-next`/`tab-prev` (`[`/`]`) — the only tab switchers
|
||||
- Initial focus + tab-enter land on `DEPTH_CENTER_PANE`
|
||||
- `keybinds.jsonc` reviewed (update labels/help only if needed)
|
||||
|
||||
steps:
|
||||
|
||||
- Read the current `dispatch()` (post task 01 it references a deleted `SIDEBAR_PANE` — fix the compile here)
|
||||
- Remove the `SIDEBAR_ACTIONS` constant and its branch
|
||||
- In the `default` case, implement: digit/tab-goto → `setActiveTab`; `swipe-prev` → (depth-tab & current & depth>0) `popDepth` else (depth-tab & current & depth==0) noop else `swipe(-1, count)`; `swipe-next` → (depth-tab & current) emit `open` else `swipe(1, count)`
|
||||
- Move/list actions (`move-down/up`, `jump-*`, `page-*`, `goto-top/bottom`) flow to `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only
|
||||
- Confirm `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches unchanged
|
||||
- Verify the app boot path sets focus to current (task 01 set the signal; confirm dispatch doesn't override)
|
||||
- Run diagnostics + harness key sequence
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: `dispatch("move-down")` on a depth-tab current pane emits `nav.action {action:"move-down"}` (Arrange current pane, Act, Assert emit)
|
||||
- Integration: `dispatch("swipe-next")` on a depth-tab at depth 0 emits `open` (drill); `dispatch("swipe-prev")` at depth 1 pops to depth 0; at depth 0 `swipe-prev` is a noop
|
||||
- e2e (harness): `l` drills (depth 0→1, parent populates), `h` pops (1→0, parent blanks), `1`/`2`/`3` switch tabs, `j`/`k` move the current list cursor without changing depth
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- No `SIDEBAR_PANE` or `SIDEBAR_ACTIONS` references in `Shell.tsx`
|
||||
- `h` at depth 0 is a noop (does not error, does not change pane)
|
||||
- `l` at current on a depth-tab drills (depth+1)
|
||||
- Digit keys switch tabs; focus lands on current pane
|
||||
- `j`/`k` move within current only
|
||||
|
||||
validation:
|
||||
|
||||
- `grep -n "SIDEBAR" src/components/Shell.tsx` returns nothing
|
||||
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
|
||||
- Harness: `init` (focus on current) → `l` (depth 1, parent filled) → `l` (depth 2) → `h` (depth 1) → `h` (depth 0, parent blank) → `3` (Discover tab, focus on current) → `j`/`k` move
|
||||
|
||||
notes:
|
||||
|
||||
- Depends on 01 (pane model: `swipe` bounds, no SIDEBAR) and 05 (dispatch lives in the rebuilt Shell)
|
||||
- If `keybinds.jsonc` has a `tab-next`/`tab-prev` mapping conflict, resolve here
|
||||
- The noop `h` at depth 0 should feel inert (yazi: at root, `h` does nothing)
|
||||
@@ -1,98 +0,0 @@
|
||||
# 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
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user