Compare commits
88 Commits
v0.2.1
...
df4701957b
| Author | SHA1 | Date | |
|---|---|---|---|
| df4701957b | |||
| 4b44623891 | |||
| 4ef9ab7e59 | |||
| 9df8eebf6c | |||
| badbc6a037 | |||
| 878d1e01ab | |||
| 42c48e59fb | |||
| 91d4acca90 | |||
| 20d5b57cb6 | |||
| d7aec4e810 | |||
| 77531ce41d | |||
| 26729fa5e6 | |||
| 4127fd1181 | |||
| 6c99b96b12 | |||
| acbaf2ed1c | |||
| e7ed89056e | |||
| b5432e3e5f | |||
| 13664c3cec | |||
| 0b4a551744 | |||
| ed75c2fff7 | |||
| bd7d988741 | |||
| deac6081ca | |||
| e09ae15e32 | |||
| 33af131b77 | |||
| 3d6d4918bc | |||
| 35ad858d0d | |||
| af827a9a96 | |||
| 2cf9559b0b | |||
| 20336ea716 | |||
| 8b7b38276e | |||
| 8496922aaf | |||
| 5e3ad48a2d | |||
| 005ac8fde3 | |||
| 1f0b9de456 | |||
| 3388757185 | |||
| 8049d02457 | |||
| 1b55b7117c | |||
| 15f8a098b5 | |||
| 2d7d49b91c | |||
| df9c519439 | |||
| 2bf1c229c7 | |||
| b2e9e5c16c | |||
| 41c0002090 | |||
| c0252fc9b8 | |||
| 0c3506beb5 | |||
| ef9fc13aaa | |||
| 0b0637b9dc | |||
| e73e608b9f | |||
| ebed49237c | |||
| dc2b22eaa5 | |||
| 2bb612ee07 | |||
| dc855ab8a0 | |||
| f976bdc2b7 | |||
| 1cf3361e59 | |||
| ada441300a | |||
| 6134dea044 | |||
| 93d5925dfd | |||
| de6d0ccbf6 | |||
| cda29bcb95 | |||
| 3775a9801d | |||
| a9589e7686 | |||
| c52fa14e42 | |||
| 116d095ad5 | |||
| b280af484c | |||
| 5dce21c038 | |||
| d2c46631ef | |||
| 67032460ff | |||
| 0f3ffcf934 | |||
| c813949d48 | |||
| eb220386ce | |||
| 19eae4fd5a | |||
| e70469b1ec | |||
| 30b7ff57d3 | |||
| fd689aba04 | |||
| 8eaca82ce9 | |||
| 8ac1ec1162 | |||
| 4a94ff5910 | |||
| 2e69868ffc | |||
| 491a736c32 | |||
| 12bd6be4bc | |||
| d2f6c5c525 | |||
| f758b53336 | |||
| b0bfa41028 | |||
| 25307f83e9 | |||
| db285530b6 | |||
| e1cdd6b2a5 | |||
| 2abdbaa4e9 | |||
| 1d06156b8b |
@@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
|
||||||
env: {
|
|
||||||
es2022: true,
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ["dist", "node_modules"],
|
|
||||||
}
|
|
||||||
18
.github/workflows/release.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
plat: darwin
|
plat: darwin
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -66,17 +66,19 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||||
run: |
|
run: |
|
||||||
# The embedded runtime reads the launching process's CWD bunfig.toml.
|
# The binary is compiled with bunfig autoload disabled
|
||||||
# This repo's bunfig lists a preload the standalone can't resolve
|
# (autoloadBunfig: false in build.ts), so it must boot even from a
|
||||||
# ("preload not found"), so kicking the binary from the workspace root
|
# directory holding a bunfig.toml with a top-level preload the
|
||||||
# would falsely fail every build. cd into a clean dir first.
|
# standalone can't resolve. Plant one to make this a real regression
|
||||||
|
# test for "preload not found".
|
||||||
SMOKE_DIR=$(mktemp -d)
|
SMOKE_DIR=$(mktemp -d)
|
||||||
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
||||||
|
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
||||||
cd "$SMOKE_DIR"
|
cd "$SMOKE_DIR"
|
||||||
./podtui-*/podtui --version
|
./podtui-*/podtui --version
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
||||||
path: dist/podtui-*.tar.gz
|
path: dist/podtui-*.tar.gz
|
||||||
@@ -87,12 +89,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Download all binaries
|
- name: Download all binaries
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -34,3 +34,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.harness/
|
.harness/
|
||||||
.ralpi
|
.ralpi
|
||||||
|
notes.md
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
- `bun run lint` - Run ESLint with TypeScript rules
|
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
|
|||||||
| `bun run lint` | Type-check |
|
| `bun run lint` | Type-check |
|
||||||
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
| `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` | 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/` |
|
| `make clean` | Remove `dist/` |
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
@@ -90,19 +91,21 @@ Cavacore smoke test: `bun tests/cavacore-smoke.ts`
|
|||||||
|
|
||||||
## Gotchas (read before touching anything)
|
## Gotchas (read before touching anything)
|
||||||
|
|
||||||
1. **Never add a top-level `preload` to `bunfig.toml`.**
|
1. **The compiled binary must keep bunfig autoload disabled.**
|
||||||
A compiled PodTui binary's embedded runtime reads the *launching process's*
|
`build.ts` compiles the standalone with `autoloadBunfig: false`, so its
|
||||||
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
|
embedded runtime *never* reads the launching CWD's `bunfig.toml`. Without
|
||||||
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
|
that flag, a top-level `preload` in the CWD bunfig (common in Bun project
|
||||||
`preload not found`. This is why `bunfig.toml` has **no** top-level
|
dirs) resolves against the CWD rather than the binary and kills startup
|
||||||
`preload`; dev-mode preloading happens via explicit `--preload` flags in
|
with `preload not found`. Don't remove the flag. Preloads for dev/test
|
||||||
`package.json`. The `[test]` section *does* keep a preload — that only
|
belong in the explicit `--preload` flags in `package.json` and the
|
||||||
affects `bun test`.
|
`[test]` section of `bunfig.toml` — not as a top-level entry.
|
||||||
|
|
||||||
2. **Smoke-test the compiled binary from a bunfig-free dir.**
|
2. **Smoke-test the binary from a dir with a poisoned bunfig.**
|
||||||
Because of (1), `./dist/podtui --version` run from the repo root launched
|
The CI smoke test unpacks the tarball into a `mktemp` dir, drops a
|
||||||
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
|
`bunfig.toml` containing an unresolvable top-level `preload` next to it,
|
||||||
before booting. Do the same when testing a release build locally.
|
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.**
|
3. **Homebrew's dylib-repair warning is benign.**
|
||||||
`brew install` may print “load commands do not fit in the header … needs
|
`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
|
4. A release is auto-created with all 4 tarballs attached. `brew` never
|
||||||
sees the new version: the **tap self-updates**: the
|
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
|
polls GitHub releases, and when a new tag appears, rewrites
|
||||||
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||||
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
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
|
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
|
||||||
staged, not yet published (AUR account registrations are closed; see the
|
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`
|
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
|
||||||
entries, keep the `LICENSE` asset source (the workflow above uploads
|
entries, keep the `LICENSE` asset source (the workflow above uploads
|
||||||
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
|
`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):
|
If you ever need to sync the tap by hand (or before the hourly job runs):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd <clone of mikefreno/homebrew-podtui>
|
cd <clone of mikefreno/homebrew-tap>
|
||||||
./scripts/sync-formula.sh 0.2.0
|
./scripts/sync-formula.sh 0.2.0
|
||||||
git commit -am 'podtui 0.2.0' && git push
|
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.
|
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
|
## 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
|
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||||
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||||
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||||
|
|||||||
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,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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
@@ -47,9 +47,8 @@ native:
|
|||||||
scripts/build-cavacore.sh
|
scripts/build-cavacore.sh
|
||||||
|
|
||||||
## Standalone binary + native-libs tarball for the current platform.
|
## Standalone binary + native-libs tarball for the current platform.
|
||||||
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
|
## Built with bunfig autoload disabled (build.ts sets autoloadBunfig: false),
|
||||||
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
|
## so the embedded runtime ignores any bunfig.toml in the launching directory.
|
||||||
## from a bunfig-free dir (see release.yml).
|
|
||||||
dist:
|
dist:
|
||||||
bun run build.ts --compile
|
bun run build.ts --compile
|
||||||
|
|
||||||
|
|||||||
236
README.md
@@ -1,7 +1,6 @@
|
|||||||
# PodTui
|
# PodTui
|
||||||
|
|
||||||
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
|
A keyboard-first, terminal podcast client built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
||||||
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
|
browse episodes in a three-pane file-manager layout, and play audio through an
|
||||||
external player with full transport control — all from your terminal.
|
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:
|
`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
|
at launch it fills the current pane, and drilling into a tab's contents slides
|
||||||
it into the parent pane.
|
it into the parent pane.
|
||||||
- **Three-pane view** — parent / current / preview (Up | Current | Preview),
|
- **Three-pane view** — parent / current / preview (Up | Current | Preview).
|
||||||
mirroring yazi's pane model.
|
|
||||||
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
||||||
(My Shows, Discover, Feed tabs).
|
(My Shows, Discover, Feed tabs).
|
||||||
- **Search** across your subscribed shows.
|
- **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
|
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||||
a system audio player.
|
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
|
## Requirements
|
||||||
|
|
||||||
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
|
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
|
||||||
tmux, GNOME Terminal, etc.).
|
Ghostty, tmux etc.).
|
||||||
- An **audio player** on `PATH`. PodTui auto-detects in priority order:
|
- **mpv** on `PATH` for audio playback. PodTui drives mpv over JSON IPC, so
|
||||||
|
seek, speed, and position tracking all work. Without `mpv` on `PATH`,
|
||||||
| Player | Platforms | Seek | Speed | Position tracking |
|
playback is a silent no-op (the `none` backend) — see
|
||||||
|----------|----------------|:----:|:-----:|:------------------|
|
[Troubleshooting](#troubleshooting).
|
||||||
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
|
|
||||||
| `ffplay` | any | ✔ | ✘ | ✘ |
|
|
||||||
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
|
|
||||||
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
|
|
||||||
|
|
||||||
Install `mpv` for the best experience (`brew install mpv`,
|
|
||||||
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
|
|
||||||
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -47,13 +51,9 @@ Linux (arm64/x64). Pick whichever fits your platform.
|
|||||||
### 1. Homebrew (macOS)
|
### 1. Homebrew (macOS)
|
||||||
|
|
||||||
```sh
|
```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)
|
### 2. Standalone tarball (all platforms)
|
||||||
|
|
||||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
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
|
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||||
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||||
> binary alone), or the native FFI libraries won't load.
|
> 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)
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Status: PKGBUILD ready, not yet on the AUR (see note below)
|
|
||||||
yay -S podtui-bin # once published
|
yay -S podtui-bin # once published
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
Requires an AUR helper ([paru](https://github.com/morgan/paru)); the package
|
||||||
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and
|
pulls in `mpv` as a dependency.
|
||||||
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.
|
|
||||||
|
|
||||||
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready
|
> **Not yet on the AUR.** The `podtui-bin` package is staged and awaiting
|
||||||
> in `packaging/aur/` and can be built locally today:
|
> publication (AUR account registrations are currently suspended). Until it
|
||||||
>
|
> lands, use the standalone tarball above.
|
||||||
> ```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.
|
|
||||||
|
|
||||||
### 4. From source
|
### 4. From source
|
||||||
|
|
||||||
Requires [Bun](https://bun.sh) ≥ 1.2.
|
PodTui is written in TypeScript and runs on [Bun](https://bun.sh). To build
|
||||||
|
from source (development, distro packaging, unreleased versions), see
|
||||||
```bash
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
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.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~`
|
Launch `podtui`. Press `~` for the in-app help.
|
||||||
for the in-app help.
|
|
||||||
|
|
||||||
### Command-line flags
|
### Command-line flags
|
||||||
|
|
||||||
@@ -145,30 +105,73 @@ for the in-app help.
|
|||||||
|
|
||||||
### Keybindings
|
### 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 |
|
| Keys | Action |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `j` / `k` | Move cursor down / up |
|
| `j` / `k` (or `down` / `up`) | Move down / up |
|
||||||
| `J` / `K` | Jump 5 lines |
|
| `J` / `K` | Jump 5 lines down / up |
|
||||||
| `ctrl-d` / `ctrl-u` | Page down / up |
|
| `ctrl-d` / `ctrl-u` | Page down / up |
|
||||||
|
| `ctrl-f` / `ctrl-b` | Full page down / up |
|
||||||
| `gg` / `G` | Go to top / bottom |
|
| `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…) |
|
| `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) |
|
| `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) |
|
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
||||||
| `[` / `]` | Previous / next tab |
|
| `[` / `]` | Previous / next tab |
|
||||||
| `P` (shift) | Play / pause |
|
|
||||||
|
**Commands, help, quit**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `:` or `q` | Open the command palette (type `q` + `Enter` there to quit) |
|
||||||
|
| `Q` or `ctrl-c` | Quit |
|
||||||
|
| `~` or `f1` | In-app help |
|
||||||
|
|
||||||
|
**Lists**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `s` | Search |
|
||||||
|
| `f` | Filter |
|
||||||
|
| `,` | Sort |
|
||||||
|
| `.` | Toggle hidden |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `x` | Unsubscribe the focused show (My Shows) |
|
||||||
|
| `d` | Download the focused episode (Feed / My Shows detail pane) |
|
||||||
|
| `D` | Delete the focused episode's download (if one exists) |
|
||||||
|
| `w` | Toggle the focused show in/out of the auto-download whitelist (My Shows, whitelist scope) |
|
||||||
|
|
||||||
|
**Audio**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `P` | Play / pause |
|
||||||
| `N` / `B` | Next / previous episode |
|
| `N` / `B` | Next / previous episode |
|
||||||
| `shift-.` / `shift-,` | Seek forward / backward |
|
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||||
| `s` | Search (in a list) |
|
|
||||||
| `f` | Filter |
|
|
||||||
| `r` | Refresh |
|
|
||||||
| `:` | Command bar |
|
|
||||||
| `~`, `f1` | Help |
|
|
||||||
| `q`, `ctrl-c` | Quit |
|
|
||||||
| `Esc` | Escape / cancel |
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -177,56 +180,49 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) |
|
| `config.json` | Unified settings (theme, playback speed, download path), feeds, and custom feed sources |
|
||||||
| `sources.json` | Custom feed sources |
|
|
||||||
| `downloads.json` | Downloaded episode metadata |
|
| `downloads.json` | Downloaded episode metadata |
|
||||||
| `keybinds.jsonc` | Keybinding remaps (see above) |
|
| `keybinds.jsonc` | Keybinding remaps (see above) |
|
||||||
| `themes/` | Optional custom theme files |
|
| `themes/` | Optional custom theme files |
|
||||||
|
|
||||||
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
|
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
|
||||||
the same OpenTUI environment variables.
|
into `config.json` on first run.
|
||||||
|
|
||||||
## Development
|
**Auto-download** — in Settings → Preferences: `Auto Download` (master
|
||||||
|
toggle) downloads the `Auto Download Count` most recent episodes (default 2,
|
||||||
|
any positive integer — type it in the editor) of every show in the `Auto
|
||||||
|
Download Scope` (all / none / whitelist, default all). With the whitelist
|
||||||
|
scope, a search field appears under the setting to pick shows (Space toggles
|
||||||
|
a suggestion in/out), and `w` in My Shows adds/removes the focused show.
|
||||||
|
|
||||||
```bash
|
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Releasing
|
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed and per-show "Fetch More" rows). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
|
||||||
|
|
||||||
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs
|
## Troubleshooting
|
||||||
to your GitHub Release automatically:
|
|
||||||
|
|
||||||
```bash
|
**`preload not found` at startup** — this used to happen when the binary was
|
||||||
make dist # build the standalone binary + tarball for THIS platform
|
launched from a Bun project directory whose `bunfig.toml` had a `preload`
|
||||||
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
|
entry. Releases are compiled with bunfig autoload disabled
|
||||||
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
|
(`autoloadBunfig: false`), so current binaries ignore the CWD's `bunfig.toml`
|
||||||
```
|
entirely. If you still hit it, you're on an old release — upgrade.
|
||||||
|
|
||||||
`make dist` emits a config-independent binary: Bun does not bake bunfig
|
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
|
||||||
settings into `--compile` output, and the solid JSX transform is registered in
|
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
|
||||||
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
|
|
||||||
it from any normal directory.
|
|
||||||
|
|
||||||
## Packaging model
|
**Homebrew prints a dylib warning** — “load commands do not fit in the header
|
||||||
|
… needs `-headerpad`” is benign: the app loads its libraries by path, the
|
||||||
|
install completes, and the app boots normally.
|
||||||
|
|
||||||
A release tarball is three files sitting side by side:
|
**The app won't start / no spectrum after moving files** — `podtui` loads its
|
||||||
|
two native libraries relative to the binary, so keep `podtui`,
|
||||||
|
`libopentui.*`, and `libcavacore.*` together in the same directory (the
|
||||||
|
tarball unpacks them side by side).
|
||||||
|
|
||||||
```
|
## Building from source / contributing
|
||||||
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
|
Development setup, the test suite, packaging, and the release process are
|
||||||
the same directory**. The compiled binary embeds the Bun runtime, so it runs
|
documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
|
|
||||||
is no cross-compilation.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="400" xmlns="http://www.w3.org/2000/svg" height="125.714" id="screenshot-993fe4cb-279d-80e0-8008-769a7b1374b8" viewBox="0 0 400 125.714" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-769a7b1374b8" rx="0" ry="0"><g id="shape-993fe4cb-279d-80e0-8008-769a7a62c77d"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a62c77d"><rect rx="6.857142857142833" ry="6.857142857142833" x="0" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a89c956"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a89c956"><rect rx="15" ry="15" x="45.71428571428572" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><rect rx="15" ry="15" x="91.42857142857144" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ab46172"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ab46172"><rect rx="15" ry="15" x="137.1428571428571" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ac82b71"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ac82b71"><rect rx="15" ry="15" x="182.85714285714283" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="125.71428571428578" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><rect rx="15" ry="15" x="228.57142857142856" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7aea70cd"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7aea70cd"><rect rx="15" ry="15" x="274.28571428571433" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7af77904"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7af77904"><rect rx="15" ry="15" x="320" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7b04705e"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7b04705e"><rect rx="6.857142857142833" ry="6.857142857142833" x="365.7142857142858" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
1
assets/App Icon/App Icon.icon/Assets/Terminal Cursor.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="80" xmlns="http://www.w3.org/2000/svg" height="256" id="screenshot-993fe4cb-279d-80e0-8008-76a1e45d35f1" viewBox="0 0 80 256" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-76a1e45d35f1"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-76a1e45d35f1"><rect rx="10" ry="10" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="80" height="256" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 526 B |
51
assets/App Icon/App Icon.icon/icon.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"solid" : "display-p3:0.18481,0.20325,0.24683,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "Podcast Waveform.svg",
|
||||||
|
"name" : "Podcast Waveform",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 2,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode" : "normal",
|
||||||
|
"fill" : "automatic",
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "Terminal Cursor.svg",
|
||||||
|
"name" : "Terminal Cursor",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 2,
|
||||||
|
"translation-in-points" : [
|
||||||
|
320.00000000000006,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
assets/App Icon/App Icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
assets/App Icon/App Icon.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/App Icon/App Icon_2x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/App Icon/App Icon_4x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
assets/App Icon/App Icon_6x.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
21
build.ts
@@ -82,6 +82,12 @@ if (COMPILE) {
|
|||||||
plugins: [solidPlugin],
|
plugins: [solidPlugin],
|
||||||
compile: {
|
compile: {
|
||||||
outfile,
|
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}`);
|
console.log(`Compiled standalone binary: ${outfile}`);
|
||||||
@@ -110,6 +116,21 @@ if (COMPILE) {
|
|||||||
const s = join("dist", lib);
|
const s = join("dist", lib);
|
||||||
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
|
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App icon: bundled into every platform tarball; Linux also gets the
|
||||||
|
// desktop entry so the AUR package can install both system-wide
|
||||||
|
// (icon to hicolor, entry to applications/).
|
||||||
|
const iconSrc = join("assets", "App Icon", "App Icon.png");
|
||||||
|
if (existsSync(iconSrc)) {
|
||||||
|
copyFileSync(iconSrc, join(tarRoot, "podtui.png"));
|
||||||
|
}
|
||||||
|
if (platform === "linux") {
|
||||||
|
const desktopSrc = join("packaging", "podtui.desktop");
|
||||||
|
if (existsSync(desktopSrc)) {
|
||||||
|
copyFileSync(desktopSrc, join(tarRoot, "podtui.desktop"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tar = Bun.spawnSync([
|
const tar = Bun.spawnSync([
|
||||||
"tar",
|
"tar",
|
||||||
"-czf",
|
"-czf",
|
||||||
|
|||||||
11
bunfig.toml
@@ -1,9 +1,8 @@
|
|||||||
# NO top-level `preload` here — intentional. A compiled PodTUI binary's
|
# No top-level `preload` here — dev/test get the solid JSX transform via the
|
||||||
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a
|
# explicit `--preload` flags in package.json and the [test] section below.
|
||||||
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the
|
# Releases don't read this file at all: build.ts compiles the standalone with
|
||||||
# standalone cannot resolve) makes the binary die at startup with
|
# `autoloadBunfig: false`, so its embedded runtime ignores any bunfig.toml in
|
||||||
# "preload not found". Dev/test still get the solid transform via explicit
|
# the launching directory — no more "preload not found" from CWD bunfigs.
|
||||||
# `--preload` flags in package.json and the [test] section below.
|
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
preload = "@opentui/solid/preload"
|
preload = "@opentui/solid/preload"
|
||||||
|
|||||||
12
package.json
@@ -18,21 +18,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@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"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
|
||||||
"@opentui/core": "^0.1.77",
|
"@opentui/core": "^0.1.77",
|
||||||
"@opentui/solid": "^0.1.77",
|
"@opentui/solid": "^0.1.77",
|
||||||
"babel-preset-solid": "1.9.9",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"solid-js": "^1.9.9",
|
"effect": "^3",
|
||||||
"uuid": "^13.0.0",
|
"solid-js": "^1.9.9"
|
||||||
"zustand": "^5.0.11"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,13 @@ package() {
|
|||||||
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
||||||
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
||||||
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
|
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
|
||||||
|
|
||||||
|
# App icon + desktop entry ship inside the release tarball; Terminal=true
|
||||||
|
# makes launchers drop the TUI into a terminal window.
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/podtui.png" \
|
||||||
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/podtui.png"
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/podtui.desktop" \
|
||||||
|
"${pkgdir}/usr/share/applications/podtui.desktop"
|
||||||
|
|
||||||
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
}
|
}
|
||||||
11
packaging/podtui.desktop
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=PodTui
|
||||||
|
GenericName=Podcast Player
|
||||||
|
Comment=Terminal podcast and audio player with waveform visualization
|
||||||
|
Exec=podtui
|
||||||
|
Icon=podtui
|
||||||
|
Terminal=true
|
||||||
|
Categories=Audio;AudioVideo;Player;
|
||||||
|
Keywords=podcast;audio;player;terminal;
|
||||||
|
StartupNotify=false
|
||||||
@@ -132,7 +132,7 @@ for r in $REMOTES; do
|
|||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
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 ""
|
echo ""
|
||||||
read -p "Proceed? (y/n) " -n 1 -r
|
read -p "Proceed? (y/n) " -n 1 -r
|
||||||
echo ""
|
echo ""
|
||||||
@@ -236,5 +236,5 @@ echo ""
|
|||||||
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||||
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
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 -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."
|
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
|
|||||||
} else if (a === "--from") {
|
} else if (a === "--from") {
|
||||||
flags.from = rest[++i];
|
flags.from = rest[++i];
|
||||||
} else {
|
} else {
|
||||||
flags[a.slice(2)] = rest[++i] ?? true;
|
throw new Error(`unknown flag: ${a}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
positional.push(a);
|
positional.push(a);
|
||||||
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
|
|||||||
return mods;
|
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 mods = parseMods(positional);
|
||||||
const first = positional[0];
|
return mods.length ? mods : undefined;
|
||||||
switch (cmd) {
|
};
|
||||||
case "key":
|
|
||||||
if (!first) throw new Error("key requires a <key> argument");
|
const BUILDERS: Record<string, (positional: string[]) => Action> = {
|
||||||
return { t: "key", k: first, mods: mods.length ? mods : undefined };
|
key: (p) => {
|
||||||
case "arrow":
|
if (!p[0]) throw new Error("key requires a <key> argument");
|
||||||
if (!first || !["up", "down", "left", "right"].includes(first))
|
return { t: "key", k: p[0], mods: modsOrUndefined(p) };
|
||||||
throw new Error("arrow requires up|down|left|right");
|
},
|
||||||
return {
|
arrow: (p) => {
|
||||||
t: "arrow",
|
if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
|
||||||
d: first as any,
|
throw new Error("arrow requires up|down|left|right");
|
||||||
mods: mods.length ? mods : undefined,
|
return {
|
||||||
};
|
t: "arrow",
|
||||||
case "enter":
|
d: p[0] as "up" | "down" | "left" | "right",
|
||||||
case "escape":
|
mods: modsOrUndefined(p),
|
||||||
case "tab":
|
};
|
||||||
case "space":
|
},
|
||||||
case "backspace":
|
enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
|
||||||
return { t: cmd, mods: mods.length ? mods : undefined };
|
escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
|
||||||
case "type":
|
tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
|
||||||
if (first === undefined) throw new Error("type requires <text>");
|
space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
|
||||||
// Re-join the rest in case text had spaces; positional[0] already is first token,
|
backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
|
||||||
// caller should quote. We join all positional as the text.
|
type: (p) => {
|
||||||
return { t: "type", s: positional.join(" ") };
|
if (p[0] === undefined) throw new Error("type requires <text>");
|
||||||
case "wait":
|
// Re-join the rest in case text had spaces; p[0] already is first token,
|
||||||
if (!first) throw new Error("wait requires <ms>");
|
// caller should quote. We join all positional as the text.
|
||||||
return { t: "wait", ms: parseInt(first, 10) || 0 };
|
return { t: "type", s: p.join(" ") };
|
||||||
case "resize":
|
},
|
||||||
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
|
wait: (p) => {
|
||||||
return {
|
if (!p[0]) throw new Error("wait requires <ms>");
|
||||||
t: "resize",
|
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
|
||||||
w: parseInt(first, 10) || 100,
|
},
|
||||||
h: parseInt(positional[1], 10) || 30,
|
resize: (p) => {
|
||||||
};
|
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
|
||||||
case "frame":
|
return {
|
||||||
case "state":
|
t: "resize",
|
||||||
case "reset":
|
w: parseInt(p[0], 10) || 100,
|
||||||
case "actions":
|
h: parseInt(p[1], 10) || 30,
|
||||||
case "init":
|
};
|
||||||
case "seed":
|
},
|
||||||
return null;
|
};
|
||||||
default:
|
|
||||||
throw new Error(`unknown command: ${cmd}`);
|
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 ──────────────────────────────
|
// ── 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));
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────────────
|
// ── Mount, snapshot & output (extracted from main) ─────────────────────────
|
||||||
async function main() {
|
// A line is "visually empty" if it's either fully blank OR contains only
|
||||||
activateSandbox();
|
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
||||||
captureIssues();
|
// "│ │"). 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);
|
function trimFrame(plainFrame: string): string {
|
||||||
const cmd = argv[0] ?? "frame";
|
const lines = plainFrame
|
||||||
const { flags, positional } = parseFlags(argv.slice(1));
|
.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") {
|
if (cmd === "reset") {
|
||||||
saveActions([]);
|
saveActions([]);
|
||||||
console.log("✔ actions log cleared.");
|
console.log("✔ actions log cleared.");
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd === "actions") {
|
if (cmd === "actions") {
|
||||||
const a = loadActions();
|
const a = loadActions();
|
||||||
console.log(`Action log (${a.length}):`);
|
console.log(`Action log (${a.length}):`);
|
||||||
console.log(JSON.stringify(a, null, 2));
|
console.log(JSON.stringify(a, null, 2));
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd === "seed") {
|
if (cmd === "seed") {
|
||||||
const from = String(
|
const from = String(
|
||||||
@@ -349,9 +392,29 @@ async function main() {
|
|||||||
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
||||||
cpSync(from, dest, { recursive: true });
|
cpSync(from, dest, { recursive: true });
|
||||||
console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
|
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.
|
// Size settings.
|
||||||
let width = 100;
|
let width = 100;
|
||||||
let height = 30;
|
let height = 30;
|
||||||
@@ -448,12 +511,7 @@ async function main() {
|
|||||||
// Final settle + capture.
|
// Final settle + capture.
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
await new Promise((r) => setTimeout(r, 60));
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
const spans = setup.captureSpans() as {
|
const spans = setup.captureSpans() as FrameCapture;
|
||||||
lines: { spans: Span[] }[];
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
cursor: [number, number];
|
|
||||||
};
|
|
||||||
const plainFrame = setup.captureCharFrame();
|
const plainFrame = setup.captureCharFrame();
|
||||||
|
|
||||||
// Dump structured spans + plain frame.
|
// Dump structured spans + plain frame.
|
||||||
@@ -462,6 +520,10 @@ async function main() {
|
|||||||
writeFileSync(FRAME_TXT, plainFrame);
|
writeFileSync(FRAME_TXT, plainFrame);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
return { setup, spans, plainFrame, audioControls, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
|
||||||
// Store state snapshot.
|
// Store state snapshot.
|
||||||
const state: Record<string, unknown> = {};
|
const state: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
@@ -514,54 +576,31 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
writeFileSync(STATE_JSON, JSON.stringify(state));
|
writeFileSync(STATE_JSON, JSON.stringify(state));
|
||||||
} catch {}
|
} 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
|
// Compact by default: trimmed frame, one-line state per section, no styles
|
||||||
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
||||||
const verbose = !!flags.verbose;
|
const verbose = !!p.flags.verbose;
|
||||||
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
|
const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
|
||||||
|
|
||||||
// A line is "visually empty" if it's either fully blank OR contains only
|
|
||||||
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
|
||||||
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
|
||||||
// 24-row pane costs 1 line, not 18.
|
|
||||||
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
|
||||||
const isVisuallyEmpty = (l: string): boolean =>
|
|
||||||
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
|
||||||
const frameTrimmed = (() => {
|
|
||||||
const lines = plainFrame
|
|
||||||
.replace(/\n+$/, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.replace(/\s+$/, ""));
|
|
||||||
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
|
||||||
lines.pop();
|
|
||||||
const out: string[] = [];
|
|
||||||
let blank = 0;
|
|
||||||
const flushBlanks = () => {
|
|
||||||
if (blank >= 3) out.push(` …${blank} empty`);
|
|
||||||
else for (let i = 0; i < blank; i++) out.push("");
|
|
||||||
blank = 0;
|
|
||||||
};
|
|
||||||
for (const l of lines) {
|
|
||||||
if (isVisuallyEmpty(l)) {
|
|
||||||
blank++;
|
|
||||||
} else {
|
|
||||||
flushBlanks();
|
|
||||||
out.push(l);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushBlanks();
|
|
||||||
return out.join("\n");
|
|
||||||
})();
|
|
||||||
|
|
||||||
console.log(
|
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) ──
|
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
||||||
if (scope === "all" && (flags.styles || verbose)) {
|
if (scope === "all" && (p.flags.styles || verbose)) {
|
||||||
const styles = distinctStyles(spans);
|
const styles = distinctStyles(p.spans);
|
||||||
if (styles.length) {
|
if (styles.length) {
|
||||||
console.log("-- styles (top 20) --");
|
console.log("-- styles (top 20) --");
|
||||||
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
|
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 want = (k: string) => scope === "all" || scope === k;
|
||||||
const compact = (obj: unknown): string =>
|
const compact = (obj: unknown): string =>
|
||||||
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
||||||
if (want("nav")) console.log("nav " + compact(state.nav));
|
if (want("nav")) console.log("nav " + compact(p.state.nav));
|
||||||
if (want("audio")) console.log("audio " + compact(state.audio));
|
if (want("audio")) console.log("audio " + compact(p.state.audio));
|
||||||
if (want("feed")) console.log("feed " + compact(state.feed));
|
if (want("feed")) console.log("feed " + compact(p.state.feed));
|
||||||
if (want("app")) console.log("app (not dumped in v1)");
|
if (want("app")) console.log("app (not dumped in v1)");
|
||||||
|
|
||||||
// ── issues: terse ──
|
// ── issues: terse ──
|
||||||
@@ -586,12 +625,14 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Footer is identical every run — only print on init or --verbose.
|
// Footer is identical every run — only print on init or --verbose.
|
||||||
if (cmd === "init" || verbose) {
|
if (p.cmd === "init" || verbose) {
|
||||||
console.log(
|
console.log(
|
||||||
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
`(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.
|
// Tear down child processes (audio backend) before exit to avoid orphans.
|
||||||
try {
|
try {
|
||||||
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
||||||
@@ -606,6 +647,32 @@ async function main() {
|
|||||||
process.exit(0);
|
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) => {
|
main().catch((err) => {
|
||||||
console.error("HARNESS FAILED:", err?.stack || err);
|
console.error("HARNESS FAILED:", err?.stack || err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -18,22 +18,21 @@ const DEBUG = import.meta.env.DEBUG;
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const audio = useAudio();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
const themeContext = useTheme();
|
const themeContext = useTheme();
|
||||||
const theme = themeContext.theme;
|
const theme = themeContext.theme;
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
// Multimedia keys (physical play/seek keys) still feed the audio backend
|
// Multimedia keys (physical play/volume/speed keys) still feed the audio
|
||||||
// regardless of the on-screen yazi keybinds.
|
// backend regardless of the on-screen yazi keybinds. Seek lives on the
|
||||||
|
// keybind router (< / > = shift+, / shift+.), so arrows stay on navigation.
|
||||||
useMultimediaKeys({
|
useMultimediaKeys({
|
||||||
playerFocused: () =>
|
playerFocused: () =>
|
||||||
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
||||||
? true
|
? true
|
||||||
: false,
|
: false,
|
||||||
inputFocused: () => nav.inputFocused(),
|
inputFocused: () => nav.inputFocused(),
|
||||||
hasEpisode: () => !!audio.currentEpisode(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mouse text-selection → clipboard (unchanged from the old shell).
|
// Mouse text-selection → clipboard (unchanged from the old shell).
|
||||||
@@ -50,6 +49,7 @@ export function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const backgroundColor = () =>
|
const backgroundColor = () =>
|
||||||
|
themeContext.transparentBackground() ||
|
||||||
themeContext.selected === "system"
|
themeContext.selected === "system"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: themeContext.theme.surface;
|
: 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
|
|
||||||
}
|
|
||||||
@@ -74,6 +74,114 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions
|
||||||
|
* (unlike Bun.hash) — used to derive stable episode ids from audio URLs so
|
||||||
|
* a feed's episode ids never change between refreshes. */
|
||||||
|
const fnv1a = (input: string): number => {
|
||||||
|
let hash = 0x811c9dc5
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i)
|
||||||
|
hash = Math.imul(hash, 0x01000193)
|
||||||
|
}
|
||||||
|
return hash >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable per-episode identity. The old positional id (`feedUrl#index`) was
|
||||||
|
* invalidated by ANY feed change: a new episode or a pruned one shifted
|
||||||
|
* every episode's index, so progress/downloads saved under `feedUrl#5`
|
||||||
|
* attached to whatever episode now sat at index 5 — new episodes resumed
|
||||||
|
* minutes in. Identity derives from stable content instead:
|
||||||
|
* 1. `<guid>` — the canonical per-episode identifier (required by Apple
|
||||||
|
* Podcasts; nearly universal).
|
||||||
|
* 2. The enclosure URL, hashed to keep the id compact (hosts serve
|
||||||
|
* permanent per-episode URLs; guids can be absent in hand-rolled feeds).
|
||||||
|
* 3. Positional index as a last resort: no guid AND no audio URL means
|
||||||
|
* the episode cannot be played, so nothing persistent keys off it.
|
||||||
|
*/
|
||||||
|
const stableEpisodeId = (
|
||||||
|
feedUrl: string,
|
||||||
|
item: string,
|
||||||
|
audioUrl: string,
|
||||||
|
index: number,
|
||||||
|
): string => {
|
||||||
|
const guid = getTagValue(item, "guid")
|
||||||
|
if (guid) return `${feedUrl}#guid:${guid}`
|
||||||
|
if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}`
|
||||||
|
return `${feedUrl}#${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||||
|
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
||||||
|
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||||
|
* RSS has no items outside <channel>). */
|
||||||
|
export const getRSSItems = (xml: string): string[] => {
|
||||||
|
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
|
||||||
|
* `<image><url>`. Works on the full XML — channel-level tags precede
|
||||||
|
* <item> blocks in RSS, so the first match is the channel image. */
|
||||||
|
export const parseChannelCoverUrl = (xml: string): string | undefined => {
|
||||||
|
const itunesHref = getAttr(xml, "itunes:image", "href")
|
||||||
|
if (itunesHref) return itunesHref
|
||||||
|
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
|
||||||
|
return url?.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single `<item>` into an Episode. Exported so the feed store can
|
||||||
|
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||||
|
* chunks) instead of one synchronous block. */
|
||||||
|
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
|
||||||
|
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||||
|
const epDescription = cleanField(getTagValue(item, "description"))
|
||||||
|
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||||
|
|
||||||
|
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||||
|
const audioUrl = enclosure?.[1] ?? ""
|
||||||
|
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||||
|
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||||
|
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||||
|
|
||||||
|
const durationRaw = getTagValue(item, "itunes:duration")
|
||||||
|
const duration = parseDuration(durationRaw)
|
||||||
|
|
||||||
|
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||||
|
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||||
|
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||||
|
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||||
|
|
||||||
|
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||||
|
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||||
|
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||||
|
|
||||||
|
// Episode image (itunes:image has href attribute)
|
||||||
|
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||||
|
|
||||||
|
const ep: Episode = {
|
||||||
|
id: stableEpisodeId(feedUrl, item, audioUrl, index),
|
||||||
|
podcastId: feedUrl,
|
||||||
|
title: epTitle,
|
||||||
|
description: epDescription,
|
||||||
|
audioUrl,
|
||||||
|
duration,
|
||||||
|
pubDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||||
|
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||||
|
if (episodeType) ep.episodeType = episodeType
|
||||||
|
if (explicit !== undefined) ep.explicit = explicit
|
||||||
|
if (imageUrl) ep.imageUrl = imageUrl
|
||||||
|
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||||
|
if (mimeType) ep.mimeType = mimeType
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a full RSS document (channel metadata + all episodes). The sync
|
||||||
|
* whole-feed variant — callers that parse potentially huge feeds on a UI
|
||||||
|
* thread should prefer the store's chunked incremental parse instead. */
|
||||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||||
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||||
@@ -81,58 +189,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||||
const lastUpdated = new Date()
|
const lastUpdated = new Date()
|
||||||
|
|
||||||
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
const items = getRSSItems(xml)
|
||||||
const episodes = items.map((item, index) => {
|
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
|
||||||
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
|
||||||
const epDescription = cleanField(getTagValue(item, "description"))
|
|
||||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
|
||||||
|
|
||||||
// Audio URL + file size + MIME type from <enclosure>
|
|
||||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
|
||||||
const audioUrl = enclosure?.[1] ?? ""
|
|
||||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
|
||||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
|
||||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
|
||||||
|
|
||||||
// Duration from <itunes:duration>
|
|
||||||
const durationRaw = getTagValue(item, "itunes:duration")
|
|
||||||
const duration = parseDuration(durationRaw)
|
|
||||||
|
|
||||||
// Episode & season numbers
|
|
||||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
|
||||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
|
||||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
|
||||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
|
||||||
|
|
||||||
// Episode type & explicit
|
|
||||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
|
||||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
|
||||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
|
||||||
|
|
||||||
// Episode image (itunes:image has href attribute)
|
|
||||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
|
||||||
|
|
||||||
const ep: Episode = {
|
|
||||||
id: `${feedUrl}#${index}`,
|
|
||||||
podcastId: feedUrl,
|
|
||||||
title: epTitle,
|
|
||||||
description: epDescription,
|
|
||||||
audioUrl,
|
|
||||||
duration,
|
|
||||||
pubDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set optional fields if present
|
|
||||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
|
||||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
|
||||||
if (episodeType) ep.episodeType = episodeType
|
|
||||||
if (explicit !== undefined) ep.explicit = explicit
|
|
||||||
if (imageUrl) ep.imageUrl = imageUrl
|
|
||||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
|
||||||
if (mimeType) ep.mimeType = mimeType
|
|
||||||
|
|
||||||
return ep
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: feedUrl,
|
id: feedUrl,
|
||||||
@@ -142,6 +200,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
feedUrl,
|
feedUrl,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
coverUrl: parseChannelCoverUrl(channel),
|
||||||
episodes,
|
episodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
|
||||||
}
|
|
||||||
246
src/components/EpisodeList.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Shared list-row and preview components for the Feed and My Shows pages.
|
||||||
|
*
|
||||||
|
* Both pages render the same episode rows (marker + title, optional subtitle
|
||||||
|
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
|
||||||
|
* hovered-episode / fetch-more preview panes; the pages differ only in the
|
||||||
|
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
|
||||||
|
* so the previously 3-4-level-nested render blocks run as flat named
|
||||||
|
* components.
|
||||||
|
*
|
||||||
|
* Anything that can change at runtime arrives as a signal getter: Solid
|
||||||
|
* components do not re-render, so only props that are called inside the
|
||||||
|
* component's own JSX stay reactive (focus, selection, download state).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { NF_ICONS } from "@/utils/nerd-fonts";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
|
||||||
|
// ── formatting helpers ──────────────────────────────────────────────────────
|
||||||
|
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
export const formatDuration = (s: number) => {
|
||||||
|
const mins = Math.floor(s / 60);
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── EpisodeRow ──────────────────────────────────────────────────────────────
|
||||||
|
export function EpisodeRow(props: {
|
||||||
|
/** The episode this row renders. */
|
||||||
|
episode: Episode;
|
||||||
|
/** Optional second line under the title (podcast/show name). */
|
||||||
|
subtitle?: () => string | undefined;
|
||||||
|
/** For index signal (row position). */
|
||||||
|
index: () => number;
|
||||||
|
/** Focused row index in this list (-1 while the Fetch More row is
|
||||||
|
* focused, so no episode row draws the cursor). */
|
||||||
|
focused: () => number;
|
||||||
|
/** Whether the current pane has keyboard focus. */
|
||||||
|
active: () => boolean;
|
||||||
|
/** Whether this episode is selection-marked. */
|
||||||
|
selected: () => boolean;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
marker: () => string;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
|
||||||
|
{props.episode.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
{/* podcast name on its own row — readable at a glance; the 50%
|
||||||
|
current pane fits it in full for typical names, and truncate
|
||||||
|
keeps the row one line tall either way */}
|
||||||
|
<Show when={props.subtitle?.()}>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{props.subtitle?.()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
|
||||||
|
{formatDate(props.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||||
|
{formatDuration(props.episode.duration)}
|
||||||
|
</text>
|
||||||
|
<Show when={props.selected()}>
|
||||||
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text flexShrink={0} fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FetchMoreRow ────────────────────────────────────────────────────────────
|
||||||
|
export function FetchMoreRow(props: {
|
||||||
|
/** Row index of the Fetch More button within the list. */
|
||||||
|
index: () => number;
|
||||||
|
/** Focused row index. */
|
||||||
|
focused: () => number;
|
||||||
|
/** True while the Fetch More row itself is focused. */
|
||||||
|
onMore: () => boolean;
|
||||||
|
/** Whether the current pane has keyboard focus. */
|
||||||
|
active: () => boolean;
|
||||||
|
isLoadingMore: () => boolean;
|
||||||
|
nerd: boolean;
|
||||||
|
marker: () => string;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useScrollIntoView(props.onMore);
|
||||||
|
const bg = () =>
|
||||||
|
props.index() === props.focused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: props.index() === props.focused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
props.index() === props.focused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: props.index() === props.focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
|
||||||
|
{props.nerd && (
|
||||||
|
<text fg={fg()}>{NF_ICONS.more}</text>
|
||||||
|
)}
|
||||||
|
<Show
|
||||||
|
when={!props.isLoadingMore()}
|
||||||
|
fallback={<LoadingIndicator label="Fetching…" />}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>[Fetch More]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EpisodePreview ──────────────────────────────────────────────────────────
|
||||||
|
export function EpisodePreview(props: {
|
||||||
|
episode: () => Episode;
|
||||||
|
/** Optional line under the meta row (podcast/show name). */
|
||||||
|
subtitle?: () => string | undefined;
|
||||||
|
author: () => string | undefined;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
/** Page-specific action-hint line. */
|
||||||
|
hint: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
|
||||||
|
{props.episode().title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={props.subtitle?.()}>
|
||||||
|
<text fg={muted()}>{props.subtitle?.()}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.author()}>
|
||||||
|
<text fg={muted()}>by {props.author()}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{props.episode().description?.slice(0, 400) ?? "No description available."}
|
||||||
|
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{props.hint()}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FetchMorePreview ────────────────────────────────────────────────────────
|
||||||
|
export function FetchMorePreview(props: {
|
||||||
|
isLoadingMore: () => boolean;
|
||||||
|
fetchMoreMode: () => string;
|
||||||
|
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
|
||||||
|
manualText: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>[Fetch More]</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{props.isLoadingMore()
|
||||||
|
? "Loading the next batch of episodes…"
|
||||||
|
: props.fetchMoreMode() === "auto"
|
||||||
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
|
: props.manualText()}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/GlobalActivityIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useSearchStore } from "@/stores/search";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useActivityStore } from "@/stores/activity";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalActivityIndicator — one global top-right signal that ANY feed
|
||||||
|
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
|
||||||
|
* Per-page spinners are unchanged; this overlays the content row and status
|
||||||
|
* bar as a single app-wide "something is happening" indicator.
|
||||||
|
*/
|
||||||
|
export function GlobalActivityIndicator() {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const searchStore = useSearchStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
/** True while any tracked activity is in flight */
|
||||||
|
const isActive = () =>
|
||||||
|
feedStore.isLoadingFeeds() ||
|
||||||
|
feedStore.isLoadingMore() ||
|
||||||
|
searchStore.isSearching() ||
|
||||||
|
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||||
|
activity.isActive();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isActive()}>
|
||||||
|
<box position="absolute" top={0} right={0} paddingRight={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
import { createSignal, createMemo, onCleanup } from "solid-js";
|
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
//TODO: Watch for actual loading state (fetching feeds)
|
/**
|
||||||
export function LoadingIndicator() {
|
* Animated braille spinner with an optional label (e.g. "Refreshing…").
|
||||||
const { theme } = useTheme();
|
* The spinner is rendered in the theme primary color; the label in muted.
|
||||||
const [index, setIndex] = createSignal(0);
|
*/
|
||||||
|
export function LoadingIndicator(props: { label?: string }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [index, setIndex] = createSignal(0);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setIndex((i) => (i + 1) % spinnerChars.length);
|
setIndex((i) => (i + 1) % spinnerChars.length);
|
||||||
}, 65);
|
}, 65);
|
||||||
|
|
||||||
onCleanup(() => clearInterval(interval));
|
onCleanup(() => clearInterval(interval));
|
||||||
|
|
||||||
const currentChar = createMemo(() => spinnerChars[index()]);
|
const currentChar = createMemo(() => spinnerChars[index()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
|
<box flexDirection="row" gap={1} alignItems="flex-start">
|
||||||
<text fg={theme.primary} content={currentChar()} />
|
<text fg={theme.primary} content={currentChar()} />
|
||||||
</box>
|
<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.
|
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||||
*
|
*
|
||||||
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
|
* Implements yazi's `mgr.ratio` contract: three columns grow at
|
||||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
|
||||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
* so every list tab renders an identical, layout-stable shell. Columns use
|
||||||
* so the ratio is exact regardless of content width — a column's content can
|
* `flexBasis={0}` so the ratio is exact regardless of content width — a
|
||||||
* never stretch its slot.
|
* column's content can never stretch its slot.
|
||||||
*
|
*
|
||||||
* Column semantics (per the yazi depth model):
|
* Column semantics (per the yazi depth model):
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||||
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
* KEEPS its 20% slot when blank (never collapses to width 0).
|
||||||
|
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||||
|
* header row: the CURRENT column's title renders top-left in the
|
||||||
|
* parent's slot (the panes above current/preview were removed).
|
||||||
* current — the current-depth list. The only focusable content column; it
|
* current — the current-depth list. The only focusable content column; it
|
||||||
* carries the active-border focus ring when `focused` is truthy.
|
* is the ONLY bordered column — left/right edges only, always
|
||||||
* preview — detail of the hovered item in `current`; always muted border.
|
* 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
|
* The primitive is purely structural: callers pass their own JSX per column
|
||||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
* (static elements or accessors) plus the current-column title. Theme colors
|
||||||
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
* are resolved internally via `useTheme()`. Only the current column's
|
||||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
|
||||||
|
* stay in the current pane).
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* <PaneRow
|
* <PaneRow
|
||||||
* parent={parentList}
|
* parent={parentList}
|
||||||
* current={currentList}
|
* current={currentList}
|
||||||
* preview={detail}
|
* preview={detail}
|
||||||
* parentLabel="Up"
|
|
||||||
* currentLabel="List · 42"
|
* currentLabel="List · 42"
|
||||||
* previewLabel="Detail"
|
|
||||||
* focused={isActive}
|
* focused={isActive}
|
||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, Show } from "solid-js";
|
import { createMemo, Show } from "solid-js";
|
||||||
import type { JSX } 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 { useTheme } from "@/context/ThemeContext";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
@@ -43,23 +46,26 @@ type PaneLabel = string | (() => string);
|
|||||||
|
|
||||||
export type PaneRowProps = {
|
export type PaneRowProps = {
|
||||||
/** Parent column content (previous-depth list, or null for a muted
|
/** 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;
|
parent?: PaneContent;
|
||||||
/** Current column content (the focused list). */
|
/** Current column content (the focused list). */
|
||||||
current?: PaneContent;
|
current?: PaneContent;
|
||||||
/** Preview column content (detail of the hovered item). Omit/undefined
|
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||||
* together with `panes={2}` to render a 2-pane parent|current row. */
|
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||||
preview?: PaneContent;
|
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;
|
currentLabel?: PaneLabel;
|
||||||
previewLabel?: PaneLabel;
|
/** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
|
||||||
/** Whether the current column carries the active-border focus ring. Defaults to
|
* true; pass `false` (or a signal) when the row is inactive. Does NOT change
|
||||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
* border colors — the current column's border is always muted. */
|
||||||
* preview columns always render muted borders. */
|
|
||||||
focused?: boolean | (() => boolean);
|
focused?: boolean | (() => boolean);
|
||||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
panes?: 2 | 3;
|
panes?: 2 | 3;
|
||||||
|
/** Which sides of the current column's border render. Defaults to
|
||||||
|
* `["left", "right"]` (the standard focused-list frame). */
|
||||||
|
currentBorder?: boolean | BorderSides[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -69,16 +75,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
||||||
* We deliberately do NOT use Solid's `children()` helper here: that helper
|
* We deliberately avoid Solid's `children()` helper: it flattens accessor
|
||||||
* flattens accessor children into a stable resolved-nodes array and is the
|
* children into a stable resolved-nodes array and won't re-resolve on a
|
||||||
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
|
* truthy→truthy root swap (e.g. the current pane switching between a
|
||||||
* switching between a depth-1 list fragment and a depth-2 editor — both
|
* depth-1 list fragment and a depth-2 editor), freezing the previous
|
||||||
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
|
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
|
||||||
* root swap, freezing the previous subtree in place. Instead we hand the
|
* expression — a tracked `insert` effect that disposes the old subtree and
|
||||||
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
|
* mounts the new whenever the accessor returns a different element identity. */
|
||||||
* which Solid compiles into a tracked `insert` effect that disposes the old
|
|
||||||
* subtree and mounts the new whenever the accessor returns a different
|
|
||||||
* element identity. */
|
|
||||||
function normalizeContent(
|
function normalizeContent(
|
||||||
v: PaneContent | undefined,
|
v: PaneContent | undefined,
|
||||||
): () => JSX.Element | undefined {
|
): () => JSX.Element | undefined {
|
||||||
@@ -99,15 +102,15 @@ function Pane(props: {
|
|||||||
grow: number;
|
grow: number;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
content: () => JSX.Element | undefined;
|
content: () => JSX.Element | undefined;
|
||||||
borderColor: () => RGBA;
|
border: boolean | BorderSides[];
|
||||||
scrollFocused: () => boolean;
|
scrollFocused: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const themeContext = useTheme();
|
||||||
|
const theme = themeContext.theme;
|
||||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||||
|
|
||||||
// Memoize accessor results so the prop expressions below stay reactive
|
// Memoize the scroll-focus accessor result so the prop expression below
|
||||||
// when the underlying signals (e.g. `focused`) change.
|
// stays reactive when the underlying signal (e.g. `focused`) changes.
|
||||||
const borderColor = createMemo(() => props.borderColor());
|
|
||||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,32 +120,39 @@ function Pane(props: {
|
|||||||
flexBasis={0}
|
flexBasis={0}
|
||||||
height="100%"
|
height="100%"
|
||||||
>
|
>
|
||||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
{/* ── title row: rendered only when the pane carries a label ────────── */}
|
||||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
<Show when={props.label() !== ""}>
|
||||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
<box
|
||||||
</box>
|
height={1}
|
||||||
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
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
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={scrollFocused()}
|
focused={scrollFocused()}
|
||||||
border
|
border={props.border}
|
||||||
borderColor={borderColor()}
|
// Only supply colors when a border is requested — opentui flips a
|
||||||
backgroundColor={theme.background}
|
// borderless box to bordered when borderColor/focusedBorderColor
|
||||||
|
// are passed, which would frame the parent/preview panes too.
|
||||||
|
borderColor={props.border === false ? undefined : theme.border}
|
||||||
|
focusedBorderColor={
|
||||||
|
props.border === false ? undefined : theme.border
|
||||||
|
}
|
||||||
|
backgroundColor={
|
||||||
|
themeContext.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: theme.background
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/*
|
{props.content() ?? <Placeholder color={muted} />}
|
||||||
* Render the content accessor directly via a reactive expression.
|
|
||||||
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
|
|
||||||
* effect that re-runs whenever the accessor's tracked signals
|
|
||||||
* change (e.g. `depth()` swapping the root from a list fragment to
|
|
||||||
* an editor). Solid disposes the previously-rendered subtree and
|
|
||||||
* mounts the new element identity. `null`/`undefined` falls back
|
|
||||||
* to the muted placeholder so the parent pane keeps its 1/7 slot
|
|
||||||
* visibly blank at depth 0. This is the correct tool for root
|
|
||||||
* swapping — unlike Solid's `children()` / `<Show>`-children,
|
|
||||||
* which only react to truthiness flips, not truthy<@->truthy root
|
|
||||||
* identity changes.
|
|
||||||
*/}
|
|
||||||
{props.content() ?? <Placeholder color={muted} />}
|
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -150,9 +160,7 @@ function Pane(props: {
|
|||||||
|
|
||||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||||
export function PaneRow(props: PaneRowProps) {
|
export function PaneRow(props: PaneRowProps) {
|
||||||
const { theme } = useTheme();
|
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||||
|
|
||||||
/** true → the current column gets the active-border focus ring. */
|
|
||||||
const focused = createMemo(() => {
|
const focused = createMemo(() => {
|
||||||
const f = props.focused;
|
const f = props.focused;
|
||||||
return typeof f === "function" ? f() : (f ?? true);
|
return typeof f === "function" ? f() : (f ?? true);
|
||||||
@@ -164,9 +172,9 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
const currentContent = normalizeContent(props.current);
|
const currentContent = normalizeContent(props.current);
|
||||||
const previewContent = normalizeContent(props.preview);
|
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 currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
|
||||||
|
|
||||||
// 2-pane mode (parent|current) grows the current column to fill the
|
// 2-pane mode (parent|current) grows the current column to fill the
|
||||||
// preview slot. Defaults to 3 (parent|current|preview).
|
// preview slot. Defaults to 3 (parent|current|preview).
|
||||||
@@ -176,32 +184,35 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
? PANE_RATIO.current + PANE_RATIO.preview
|
? PANE_RATIO.current + PANE_RATIO.preview
|
||||||
: PANE_RATIO.current,
|
: PANE_RATIO.current,
|
||||||
);
|
);
|
||||||
|
const currentBorder = createMemo<boolean | BorderSides[]>(
|
||||||
|
() => props.currentBorder ?? ["left", "right"],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={parentLabel}
|
label={currentLabel}
|
||||||
content={parentContent}
|
content={parentContent}
|
||||||
borderColor={() => theme.border}
|
border={false}
|
||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
{/* ── current — the focused list; left/right borders only ─────────── */}
|
||||||
<Pane
|
<Pane
|
||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={currentLabel}
|
label={() => ""}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
border={currentBorder()}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
label={previewLabel}
|
label={() => ""}
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
borderColor={() => theme.border}
|
border={false}
|
||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
|
|||||||
backgroundColor={
|
backgroundColor={
|
||||||
props.selected()
|
props.selected()
|
||||||
? theme.primary
|
? theme.primary
|
||||||
: themeContext.selected === "system"
|
: themeContext.transparentBackground() ||
|
||||||
|
themeContext.selected === "system"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: themeContext.theme.surface
|
: themeContext.theme.surface
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,30 +11,23 @@
|
|||||||
* event bus. There is no sidebar pane.
|
* event bus. There is no sidebar pane.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, Show, For } from "solid-js";
|
import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore } from "@/stores/audio-nav";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useToast } from "@/ui/toast";
|
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 { LayerGraph } from "@/utils/layer-graph";
|
||||||
import { TABS, TabPaneCount } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
|
||||||
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() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -42,12 +35,25 @@ export function Shell() {
|
|||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const k = useKeybinds();
|
const k = useKeybinds();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
const renderer = useRenderer();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
const [showHelp, setShowHelp] = createSignal(false);
|
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,
|
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
||||||
* within its feed's episode list. Updates audio-nav context accordingly. */
|
* within its feed's episode list. Updates audio-nav context accordingly. */
|
||||||
function advanceEpisode(offset: number) {
|
function advanceEpisode(offset: number) {
|
||||||
@@ -83,74 +89,60 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Command bar dispatch ────────────────────────────────────────────────────
|
// ── 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) {
|
function runCommand(raw: string) {
|
||||||
const cmd = raw.trim();
|
const cmd = raw.trim();
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
const name = cmd.split(/\s+/)[0].toLowerCase();
|
const name = cmd.split(/\s+/)[0].toLowerCase();
|
||||||
const arg = cmd.slice(name.length).trim();
|
const arg = cmd.slice(name.length).trim();
|
||||||
switch (name) {
|
const unknownCommand = () => {
|
||||||
case "q":
|
nav.setCommandError(`unknown command: ${name}`);
|
||||||
case "quit":
|
// re-enter command mode so the user sees the error + can correct
|
||||||
case "exit":
|
nav.enterCommand();
|
||||||
return process.exit(0);
|
nav.setCommandBuffer(cmd);
|
||||||
case "refresh":
|
};
|
||||||
case "r":
|
(COMMANDS[name] ?? unknownCommand)(arg);
|
||||||
emit("nav.action", {
|
|
||||||
action: "refresh",
|
|
||||||
tab: nav.activeTab(),
|
|
||||||
pane: nav.activePane(),
|
|
||||||
mode: nav.mode(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "play":
|
|
||||||
case "pause":
|
|
||||||
case "p":
|
|
||||||
audio.togglePlayback().catch(() => {});
|
|
||||||
break;
|
|
||||||
case "next":
|
|
||||||
case "n":
|
|
||||||
advanceEpisode(1);
|
|
||||||
break;
|
|
||||||
case "prev":
|
|
||||||
advanceEpisode(-1);
|
|
||||||
break;
|
|
||||||
case "seek": {
|
|
||||||
const n = Number(arg) || 0;
|
|
||||||
audio.seek(n).catch(() => {});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "feed":
|
|
||||||
case "f":
|
|
||||||
nav.setActiveTab(TABS.FEED);
|
|
||||||
break;
|
|
||||||
case "shows":
|
|
||||||
case "myshows":
|
|
||||||
nav.setActiveTab(TABS.MYSHOWS);
|
|
||||||
break;
|
|
||||||
case "discover":
|
|
||||||
case "d":
|
|
||||||
nav.setActiveTab(TABS.DISCOVER);
|
|
||||||
break;
|
|
||||||
case "search":
|
|
||||||
nav.setActiveTab(TABS.SEARCH);
|
|
||||||
break;
|
|
||||||
case "player":
|
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
|
||||||
break;
|
|
||||||
case "settings":
|
|
||||||
case "set":
|
|
||||||
nav.setActiveTab(TABS.SETTINGS);
|
|
||||||
break;
|
|
||||||
case "help":
|
|
||||||
case "h":
|
|
||||||
setShowHelp((v) => !v);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nav.setCommandError(`unknown command: ${name}`);
|
|
||||||
// re-enter command mode so the user sees the error + can correct
|
|
||||||
nav.enterCommand();
|
|
||||||
nav.setCommandBuffer(cmd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command-mode key handling ───────────────────────────────────────────────
|
// ── Command-mode key handling ───────────────────────────────────────────────
|
||||||
@@ -177,7 +169,6 @@ export function Shell() {
|
|||||||
nav.backspaceCommand();
|
nav.backspaceCommand();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// printable char
|
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.appendCommand(evt.name);
|
nav.appendCommand(evt.name);
|
||||||
@@ -206,6 +197,11 @@ export function Shell() {
|
|||||||
if (evt.name === "escape") {
|
if (evt.name === "escape") {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.setInputFocused(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -220,11 +216,18 @@ export function Shell() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Status bar fragments ──────────────────────────────────────────────────
|
// ── Status bar fragments ──────────────────────────────────────────────────
|
||||||
const nowPlaying = () => {
|
// Now-playing text carries the podcast name (custom name when set) when
|
||||||
|
// the episode's feed is resolvable, mirroring advanceEpisode's lookup.
|
||||||
|
const nowPlayingText = () => {
|
||||||
const ep = audio.currentEpisode();
|
const ep = audio.currentEpisode();
|
||||||
if (!ep) return null;
|
if (!ep) return null;
|
||||||
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
|
const feeds = feedStore.getFilteredFeeds();
|
||||||
return `♪ ${title}`;
|
const feed =
|
||||||
|
feeds.find((f) => f.podcast.id === ep.podcastId) ??
|
||||||
|
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
|
||||||
|
return feed
|
||||||
|
? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}`
|
||||||
|
: `♪ ${ep.title}`;
|
||||||
};
|
};
|
||||||
const modeLabel = () =>
|
const modeLabel = () =>
|
||||||
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
||||||
@@ -234,13 +237,77 @@ export function Shell() {
|
|||||||
.map((s) => s.key)
|
.map((s) => s.key)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
// ── Now-playing marquee ────────────────────────────────────────────────────
|
||||||
|
// The now-playing segment takes the full remaining status-bar width and
|
||||||
|
// marquee-scrolls when its text overflows; when it fits (or the bar is too
|
||||||
|
// narrow to show anything) it renders statically. Each pass scrolls at
|
||||||
|
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
||||||
|
// before scrolling again.
|
||||||
|
const dims = useTerminalDimensions();
|
||||||
|
const GAP = 3;
|
||||||
|
const SCROLL_STEP_MS = 150;
|
||||||
|
const SCROLL_HOLD_MS = 10_000;
|
||||||
|
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||||
|
const leftFixed = () =>
|
||||||
|
modeLabel().length +
|
||||||
|
(nav.selectedIds().length > 0
|
||||||
|
? 4 + String(nav.selectedIds().length).length
|
||||||
|
: 0);
|
||||||
|
const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3;
|
||||||
|
const availableWidth = () =>
|
||||||
|
Math.max(0, dims().width - leftFixed() - rightFixed() - 2);
|
||||||
|
const visible = () => {
|
||||||
|
const text = nowPlayingText();
|
||||||
|
const avail = availableWidth();
|
||||||
|
if (!text || avail <= 0) return "";
|
||||||
|
if (text.length <= avail) return text;
|
||||||
|
// Double the text with a gap so the wrap is seamless: the window
|
||||||
|
// slides over text + gap + text without ever hitting the tail.
|
||||||
|
return (text + " ".repeat(GAP) + text).slice(
|
||||||
|
scrollOffset(),
|
||||||
|
scrollOffset() + avail,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
createEffect(() => {
|
||||||
|
const text = nowPlayingText();
|
||||||
|
const avail = availableWidth();
|
||||||
|
setScrollOffset(0);
|
||||||
|
if (!text || avail <= 0 || text.length <= avail) return;
|
||||||
|
const cycle = text.length + GAP - avail;
|
||||||
|
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
||||||
|
// then hold again before the next pass.
|
||||||
|
let holdId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let scrollId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const startHold = () => {
|
||||||
|
setScrollOffset(0);
|
||||||
|
holdId = setTimeout(() => {
|
||||||
|
scrollId = setInterval(() => {
|
||||||
|
const next = scrollOffset() + 1;
|
||||||
|
if (next >= cycle) {
|
||||||
|
clearInterval(scrollId!);
|
||||||
|
startHold();
|
||||||
|
} else {
|
||||||
|
setScrollOffset(next);
|
||||||
|
}
|
||||||
|
}, SCROLL_STEP_MS);
|
||||||
|
}, SCROLL_HOLD_MS);
|
||||||
|
};
|
||||||
|
startHold();
|
||||||
|
onCleanup(() => {
|
||||||
|
if (holdId) clearTimeout(holdId);
|
||||||
|
if (scrollId) clearInterval(scrollId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={t.surface}
|
backgroundColor={
|
||||||
>
|
theme.transparentBackground() ? "transparent" : t.surface
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||||
<box flexDirection="row" flexGrow={1} width="100%">
|
<box flexDirection="row" flexGrow={1} width="100%">
|
||||||
<Show
|
<Show
|
||||||
@@ -264,9 +331,7 @@ export function Shell() {
|
|||||||
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel="Tabs"
|
currentLabel="Tabs"
|
||||||
previewLabel=""
|
|
||||||
focused
|
focused
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -276,7 +341,11 @@ export function Shell() {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
height={1}
|
height={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
backgroundColor={t.backgroundPanel ?? t.background}
|
backgroundColor={
|
||||||
|
theme.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: (t.backgroundPanel ?? t.background)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={nav.mode() === NavMode.COMMAND}
|
when={nav.mode() === NavMode.COMMAND}
|
||||||
@@ -285,26 +354,19 @@ export function Shell() {
|
|||||||
<text fg={t.accent} paddingLeft={1}>
|
<text fg={t.accent} paddingLeft={1}>
|
||||||
{modeLabel()}
|
{modeLabel()}
|
||||||
</text>
|
</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}>
|
<Show when={nav.selectedIds().length > 0}>
|
||||||
<text fg={t.warning} paddingLeft={1}>
|
<text fg={t.warning} paddingLeft={1}>
|
||||||
● {nav.selectedIds().length}
|
● {nav.selectedIds().length}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={nowPlaying()}>
|
<Show when={nowPlayingText()}>
|
||||||
<text fg={t.primary} paddingLeft={1}>
|
<box flexGrow={1} paddingLeft={1}>
|
||||||
{nowPlaying()}
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
</text>
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
|
<text fg={t.primary} content={visible()} />
|
||||||
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<box flexGrow={1} />
|
|
||||||
<text fg={t.textMuted} paddingRight={1}>
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
{pendingLabel()}
|
{pendingLabel()}
|
||||||
</text>
|
</text>
|
||||||
@@ -334,6 +396,8 @@ export function Shell() {
|
|||||||
theme={t as any}
|
theme={t as any}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
|
||||||
|
<GlobalActivityIndicator />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -385,6 +449,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
|||||||
["enter", "open"],
|
["enter", "open"],
|
||||||
["r", "refresh"],
|
["r", "refresh"],
|
||||||
["s", "search"],
|
["s", "search"],
|
||||||
|
[p("search-scope-toggle"), "shows/episodes"],
|
||||||
["f", "filter"],
|
["f", "filter"],
|
||||||
[",", "sort"],
|
[",", "sort"],
|
||||||
[".", "hidden"],
|
[".", "hidden"],
|
||||||
@@ -458,18 +523,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.
|
// Re-export Episode type for callers building pane trees.
|
||||||
export type { Episode } from "@/types/episode";
|
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;
|
|
||||||
};
|
|
||||||
@@ -19,74 +19,105 @@ import { For } from "solid-js";
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
|
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[TABS.FEED]: "Feed",
|
||||||
[TABS.MYSHOWS]: "My Shows",
|
[TABS.MYSHOWS]: "My Shows",
|
||||||
[TABS.DISCOVER]: "Discover",
|
[TABS.DISCOVER]: "Discover",
|
||||||
[TABS.SEARCH]: "Search",
|
[TABS.SEARCH]: "Search",
|
||||||
[TABS.PLAYER]: "Player",
|
[TABS.PLAYER]: "Player",
|
||||||
[TABS.SETTINGS]: "Settings",
|
[TABS.SETTINGS]: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
|
||||||
|
const TAB_ICON: Record<TABS, string> = {
|
||||||
|
[TABS.FEED]: NF_ICONS.feed,
|
||||||
|
[TABS.MYSHOWS]: NF_ICONS.shows,
|
||||||
|
[TABS.DISCOVER]: NF_ICONS.discover,
|
||||||
|
[TABS.SEARCH]: NF_ICONS.search,
|
||||||
|
[TABS.PLAYER]: NF_ICONS.player,
|
||||||
|
[TABS.SETTINGS]: NF_ICONS.settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||||
const TAB_ORDER = Object.values(TABS).filter(
|
const TAB_ORDER = Object.values(TABS).filter(
|
||||||
(v): v is TABS => typeof v === "number",
|
(v): v is TABS => typeof v === "number",
|
||||||
) as TABS[];
|
) as TABS[];
|
||||||
|
|
||||||
export function TabListPane(props: { muted?: boolean }) {
|
export function TabListPane(props: { muted?: boolean }) {
|
||||||
const { theme } = useTheme();
|
// Static: detection never changes mid-session.
|
||||||
const nav = useNavigation();
|
const nerd = supportsNerdFonts();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const cursor = () => nav.tabCursor();
|
const cursor = () => nav.tabCursor();
|
||||||
const activeTab = () => nav.activeTab();
|
const activeTab = () => nav.activeTab();
|
||||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||||
const active = () => !props.muted;
|
const active = () => !props.muted;
|
||||||
|
|
||||||
// Same focus-bg / focus-fg contract every other pane uses.
|
// Same focus-bg / focus-fg contract every other pane uses.
|
||||||
const focusBg = (t: TABS) =>
|
const focusBg = (t: TABS) =>
|
||||||
t === cursor() && active()
|
t === cursor() && active()
|
||||||
? theme.primary
|
? theme.primary
|
||||||
: t === cursor()
|
: t === cursor()
|
||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (t: TABS) =>
|
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 (
|
return (
|
||||||
<For each={TAB_ORDER}>
|
<For each={TAB_ORDER}>
|
||||||
{(tab) => {
|
{(tab) => {
|
||||||
const isCursor = () => cursor() === tab;
|
const isCursor = () => cursor() === tab;
|
||||||
const isActive = () => activeTab() === tab;
|
const isActive = () => activeTab() === tab;
|
||||||
// The active tab is only accented in the Up/parent position — when this
|
// The active tab is only accented in the Up/parent position — when this
|
||||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||||
const labelFg = () =>
|
const labelFg = () =>
|
||||||
isCursor()
|
isCursor()
|
||||||
? focusFg(tab)
|
? focusFg(tab)
|
||||||
: isActive() && !active()
|
: isActive() && !active()
|
||||||
? theme.accent
|
? theme.accent
|
||||||
: theme.text;
|
: theme.text;
|
||||||
const ref = useScrollIntoView(isCursor);
|
const ref = useScrollIntoView(isCursor);
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
width="100%"
|
width="100%"
|
||||||
height={1}
|
height={1}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(tab)}
|
backgroundColor={focusBg(tab)}
|
||||||
>
|
onMouseDown={() => {
|
||||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
// Click = hover + open, the yazi "open" of the row
|
||||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
// (switches to the tab and enters its content), the same
|
||||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
// as l/Enter. Restores mouse support the tab-strip
|
||||||
<text fg={labelFg()} paddingLeft={1}>
|
// refactor dropped.
|
||||||
{TAB_LABEL[tab]}
|
nav.setTabCursor(tab);
|
||||||
</text>
|
nav.activateTabCursor();
|
||||||
</box>
|
}}
|
||||||
);
|
>
|
||||||
}}
|
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||||
</For>
|
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||||
);
|
{nerd && (
|
||||||
|
<text fg={focusFg(tab)} paddingRight={1}>
|
||||||
|
{TAB_ICON[tab]}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={labelFg()} paddingLeft={1}>
|
||||||
|
{TAB_LABEL[tab]}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
//
|
//
|
||||||
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
|
// 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,
|
// 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) ─────────────────────────────────────────────
|
// ── Movement (within a pane) ─────────────────────────────────────────────
|
||||||
"move-down": ["j", "down"],
|
"move-down": ["j", "down"],
|
||||||
@@ -50,25 +51,36 @@
|
|||||||
"tab-goto-5": ["5"],
|
"tab-goto-5": ["5"],
|
||||||
"tab-goto-6": ["6"],
|
"tab-goto-6": ["6"],
|
||||||
|
|
||||||
// ── Command bar & help & quit ────────────────────────────────────────────
|
// ── Command palette & help & quit ────────────────────────────────────────
|
||||||
"command": [":"],
|
// q opens the command palette (neovim-style: type q + Enter to quit there).
|
||||||
"quit": ["q", "ctrl-c"],
|
// Q (shift+q) is the instant quick quit. ctrl-c also quits.
|
||||||
|
"command": [":", "q"],
|
||||||
|
"quit": ["Q", "ctrl-c"],
|
||||||
"help": ["~", "f1"],
|
"help": ["~", "f1"],
|
||||||
|
|
||||||
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
||||||
"search": ["s"],
|
"search": ["s"],
|
||||||
"filter": ["f"],
|
// tab toggles the Search page between show and episode scope (re-runs the
|
||||||
|
// current query when viewing results)
|
||||||
|
"search-scope-toggle": ["tab"],
|
||||||
|
"filter": ["f"],
|
||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
|
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
|
||||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||||
|
|
||||||
|
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||||
|
"download": ["d"], // download the focused episode (detail pane)
|
||||||
|
"delete-download": ["D"], // delete the focused episode's download (if any)
|
||||||
|
"whitelist-toggle": ["w"], // add/remove the focused show from the auto-download whitelist (My Shows)
|
||||||
|
|
||||||
// ── Audio transport (preserved) ──────────────────────────────────────────
|
// ── Audio transport (preserved) ──────────────────────────────────────────
|
||||||
// Kept on shifted single keys so they never collide with the yazi core
|
// Kept on shifted single keys so they never collide with the yazi core
|
||||||
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
|
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
|
||||||
"audio-toggle": ["P"], // play / pause (shift+p)
|
"audio-toggle": ["P"], // play / pause (shift+p)
|
||||||
"audio-next": ["N"], // next episode (shift+n)
|
"audio-next": ["N"], // next episode (shift+n)
|
||||||
"audio-prev": ["B"], // prev episode (shift+b)
|
"audio-prev": ["B"], // prev episode (shift+b)
|
||||||
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
|
"audio-seek-forward": ["shift-."], // seek forward (> = shift+.)
|
||||||
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
|
"audio-seek-backward": ["shift-,"] // seek backward (< = shift+,)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -63,29 +63,21 @@ export type KeybindActionName =
|
|||||||
| "quit"
|
| "quit"
|
||||||
| "help"
|
| "help"
|
||||||
| "search"
|
| "search"
|
||||||
|
| "search-scope-toggle"
|
||||||
| "filter"
|
| "filter"
|
||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "subscribe"
|
||||||
| "unsubscribe"
|
| "unsubscribe"
|
||||||
|
| "download"
|
||||||
|
| "delete-download"
|
||||||
|
| "whitelist-toggle"
|
||||||
| "audio-toggle"
|
| "audio-toggle"
|
||||||
| "audio-next"
|
| "audio-next"
|
||||||
| "audio-prev"
|
| "audio-prev"
|
||||||
| "audio-seek-forward"
|
| "audio-seek-forward"
|
||||||
| "audio-seek-backward"
|
| "audio-seek-backward";
|
||||||
// legacy compat (kept so older callers don't crash)
|
|
||||||
| "select"
|
|
||||||
| "leader"
|
|
||||||
| "inverseModifier"
|
|
||||||
| "cycle"
|
|
||||||
| "dive"
|
|
||||||
| "out"
|
|
||||||
| "up"
|
|
||||||
| "down"
|
|
||||||
| "left"
|
|
||||||
| "right"
|
|
||||||
| "audio-pause"
|
|
||||||
| "audio-play";
|
|
||||||
|
|
||||||
/** Resolved config: action -> list of alternative stroke-sequences. */
|
/** Resolved config: action -> list of alternative stroke-sequences. */
|
||||||
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
||||||
@@ -146,7 +138,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
||||||
export function strokeFromEvent(evt: {
|
function strokeFromEvent(evt: {
|
||||||
name: string;
|
name: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
@@ -154,7 +146,7 @@ export function strokeFromEvent(evt: {
|
|||||||
}): Stroke {
|
}): Stroke {
|
||||||
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
||||||
return {
|
return {
|
||||||
key: (evt.name ?? "").toLowerCase(),
|
key: evt.name.toLowerCase(),
|
||||||
ctrl: !!evt.ctrl,
|
ctrl: !!evt.ctrl,
|
||||||
shift: !!evt.shift,
|
shift: !!evt.shift,
|
||||||
meta: !!evt.meta,
|
meta: !!evt.meta,
|
||||||
@@ -171,7 +163,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A human label for a stroke, for the status bar / help. */
|
/** A human label for a stroke, for the status bar / help. */
|
||||||
export function strokeLabel(s: Stroke): string {
|
function strokeLabel(s: Stroke): string {
|
||||||
let out = "";
|
let out = "";
|
||||||
if (s.ctrl) out += "C-";
|
if (s.ctrl) out += "C-";
|
||||||
if (s.meta) out += "M-";
|
if (s.meta) out += "M-";
|
||||||
@@ -180,7 +172,7 @@ export function strokeLabel(s: Stroke): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sequenceLabel(seq: Stroke[]): string {
|
function sequenceLabel(seq: Stroke[]): string {
|
||||||
return seq.map(strokeLabel).join(" ");
|
return seq.map(strokeLabel).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,17 +330,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
return best;
|
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(() => {
|
onMount(() => {
|
||||||
load().catch(() => {});
|
load().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -366,7 +347,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
pending,
|
pending,
|
||||||
match,
|
match,
|
||||||
tryMatch,
|
tryMatch,
|
||||||
isInverting,
|
|
||||||
print,
|
print,
|
||||||
save,
|
save,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
||||||
import { createStore, produce } from "solid-js/store";
|
import { createStore, produce } from "solid-js/store";
|
||||||
import { useRenderer } from "@opentui/solid";
|
import { useRenderer } from "@opentui/solid";
|
||||||
@@ -9,7 +10,9 @@ import {
|
|||||||
generateSyntax,
|
generateSyntax,
|
||||||
generateSubtleSyntax,
|
generateSubtleSyntax,
|
||||||
} from "../utils/syntax-highlighter";
|
} from "../utils/syntax-highlighter";
|
||||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
import { resolveTerminalTheme } from "../utils/theme";
|
||||||
|
import { getCustomThemes } from "../utils/custom-themes";
|
||||||
|
import { detectModeFromBackground } from "../utils/system-theme";
|
||||||
import { createSimpleContext } from "./helper";
|
import { createSimpleContext } from "./helper";
|
||||||
import {
|
import {
|
||||||
setupThemeSignalHandler,
|
setupThemeSignalHandler,
|
||||||
@@ -84,6 +87,8 @@ export type ThemeResolved = {
|
|||||||
muted?: RGBA;
|
muted?: RGBA;
|
||||||
surface?: RGBA;
|
surface?: RGBA;
|
||||||
selectedListItemText?: RGBA;
|
selectedListItemText?: RGBA;
|
||||||
|
/** Theme declares a transparent (terminal-bg-visible) background. */
|
||||||
|
transparent?: boolean;
|
||||||
layerBackgrounds?: {
|
layerBackgrounds?: {
|
||||||
layer0: RGBA;
|
layer0: RGBA;
|
||||||
layer1: RGBA;
|
layer1: RGBA;
|
||||||
@@ -94,6 +99,61 @@ export type ThemeResolved = {
|
|||||||
thinkingOpacity?: number;
|
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.
|
* Theme context using the createSimpleContext pattern.
|
||||||
*
|
*
|
||||||
@@ -116,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme();
|
resolveSystemTheme();
|
||||||
loadThemes()
|
getCustomThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -128,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
setStore("active", "catppuccin");
|
setStore("active", "catppuccin");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Only set ready if not waiting for system theme
|
|
||||||
if (store.active !== "system") {
|
if (store.active !== "system") {
|
||||||
setStore("ready", true);
|
setStore("ready", true);
|
||||||
}
|
}
|
||||||
@@ -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(
|
const hasPalette = Boolean(
|
||||||
colors?.palette?.some((value) => Boolean(value)),
|
colors?.palette?.some((value) => Boolean(value)),
|
||||||
);
|
);
|
||||||
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!hasPalette && !hasDefaultColors) {
|
if (!hasPalette && !hasDefaultColors) {
|
||||||
// No system colors available, fall back to default
|
// No system colors available — the terminal can't answer OSC queries
|
||||||
// This happens when the terminal doesn't support OSC palette queries
|
// (e.g. inside tmux, or unsupported terminals). Keep the "system"
|
||||||
// (e.g., running inside tmux, or on unsupported terminals)
|
// theme anyway: the detected dark/light mode plus default ANSI colors
|
||||||
|
// still produce a usable, mode-correct palette.
|
||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.active = "catppuccin";
|
draft.system = colors ?? EMPTY_TERMINAL_COLORS;
|
||||||
draft.ready = true;
|
draft.ready = true;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
mode() {
|
mode() {
|
||||||
return store.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") {
|
setMode(mode: "dark" | "light") {
|
||||||
setStore("mode", mode);
|
setStore("mode", mode);
|
||||||
emitThemeModeChanged(mode);
|
emitThemeModeChanged(mode);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
*
|
*
|
||||||
* parent | current | preview
|
* parent | current | preview
|
||||||
*
|
*
|
||||||
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
|
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
|
||||||
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||||
* nav model — which column is focused and where its list cursor lives. The
|
* nav model — which column is focused and where its list cursor lives. The
|
||||||
* parent/preview columns are always derived, never focused.
|
* 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). */
|
/** The tab the root's cursor is hovering (independent of activeTab). */
|
||||||
const tabCursor = (): TABS => tabCursorSignal();
|
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). */
|
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
||||||
const moveTabCursor = (dir: -1 | 1) => {
|
const moveTabCursor = (dir: -1 | 1) => {
|
||||||
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
||||||
@@ -469,6 +472,7 @@ export function createNavigation() {
|
|||||||
enterTabContent,
|
enterTabContent,
|
||||||
backToTabRoot,
|
backToTabRoot,
|
||||||
tabCursor,
|
tabCursor,
|
||||||
|
setTabCursor: setTabCursorTo,
|
||||||
moveTabCursor,
|
moveTabCursor,
|
||||||
activateTabCursor,
|
activateTabCursor,
|
||||||
// pane focus
|
// pane focus
|
||||||
@@ -488,11 +492,7 @@ export function createNavigation() {
|
|||||||
exitVisual,
|
exitVisual,
|
||||||
// modes
|
// modes
|
||||||
setActiveTabSignal: setActiveTab,
|
setActiveTabSignal: setActiveTab,
|
||||||
setActiveDepth: setPane, // legacy alias
|
|
||||||
activeDepth: activePane, // legacy alias
|
|
||||||
setInputFocused,
|
setInputFocused,
|
||||||
nextPane: () => {}, // legacy noop; swipe() replaces this
|
|
||||||
prevPane: () => {},
|
|
||||||
setMode,
|
setMode,
|
||||||
enterCommand,
|
enterCommand,
|
||||||
enterInput,
|
enterInput,
|
||||||
|
|||||||
85
src/effects/feed-refresh.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Feed-refresh batch as an Effect program.
|
||||||
|
*
|
||||||
|
* Replaces the hand-rolled worker pool (mapWithConcurrency) + per-feed
|
||||||
|
* fetch/apply plumbing in stores/feed.ts with Effect's structured
|
||||||
|
* concurrency:
|
||||||
|
* - `Effect.forEach(..., { concurrency })` bounds in-flight fetches to
|
||||||
|
* `concurrency` (starts exactly that many fibers; each completion pulls
|
||||||
|
* the next feed — identical semantics to the old shared-counter pool).
|
||||||
|
* - `Effect.timeout` bounds each feed's fetch to `timeoutMs`. It runs
|
||||||
|
* through the `Clock` service, so under `TestContext` the TestClock
|
||||||
|
* drives it deterministically (no real 20s wait in tests).
|
||||||
|
* - Failures are folded to a null result: a failed or timed-out feed is
|
||||||
|
* left untouched instead of failing the batch.
|
||||||
|
* - The apply callback runs inside each feed's own fiber, so a feed's
|
||||||
|
* refreshed episodes land AS ITS OWN FETCH COMPLETES — the
|
||||||
|
* per-feed-apply-as-it-lands contract, no Promise.all barrier.
|
||||||
|
*
|
||||||
|
* The store boundary (stores/feed.ts) supplies the real fetch and apply
|
||||||
|
* closures and runs the program with Effect.runPromise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Duration, Effect } from "effect"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
|
||||||
|
/** Result of fetching one feed's RSS. `episodes: null` means the fetch
|
||||||
|
* failed or timed out — callers must leave that feed untouched. */
|
||||||
|
export interface RefreshFetchResult {
|
||||||
|
episodes: Episode[] | null
|
||||||
|
coverUrl: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result guaranteed to have parsed episodes (the apply path only). */
|
||||||
|
export interface RefreshSuccess {
|
||||||
|
episodes: Episode[]
|
||||||
|
coverUrl: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshBatchOptions {
|
||||||
|
/** Max simultaneous in-flight fetches. */
|
||||||
|
concurrency: number
|
||||||
|
/** Per-feed fetch timeout in milliseconds. */
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold any failure (network error, timeout, rejection) to a null result so
|
||||||
|
* one bad feed can never fail the batch. */
|
||||||
|
const failedResult: RefreshFetchResult = { episodes: null, coverUrl: undefined }
|
||||||
|
|
||||||
|
/** Fetch one feed with a timeout, applying its result as its own fetch
|
||||||
|
* lands. A failed or timed-out fetch yields null — the feed is untouched. */
|
||||||
|
const refreshOne = (
|
||||||
|
feed: Feed,
|
||||||
|
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||||
|
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Effect.Effect<void> =>
|
||||||
|
Effect.tryPromise(() => fetchOne(feed)).pipe(
|
||||||
|
Effect.timeout(Duration.millis(timeoutMs)),
|
||||||
|
Effect.catchAll(() => Effect.succeed(failedResult)),
|
||||||
|
Effect.flatMap((result) => {
|
||||||
|
if (result.episodes === null) return Effect.void
|
||||||
|
// Capture the narrowed array before the closure — TS drops the
|
||||||
|
// `episodes !== null` narrowing inside Effect.sync's callback.
|
||||||
|
const episodes = result.episodes
|
||||||
|
return Effect.sync(() => applyOne(feed, { episodes, coverUrl: result.coverUrl }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Refresh every feed with bounded concurrency. Each feed's refreshed
|
||||||
|
* episodes are applied as its own fetch lands (no barrier); a failed or
|
||||||
|
* timed-out feed is left untouched. The program never fails — failures
|
||||||
|
* are folded to per-feed no-ops. */
|
||||||
|
export const refreshFeedsBatch = (
|
||||||
|
feeds: readonly Feed[],
|
||||||
|
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||||
|
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||||
|
options: RefreshBatchOptions,
|
||||||
|
): Effect.Effect<void> =>
|
||||||
|
Effect.forEach(
|
||||||
|
feeds,
|
||||||
|
(feed) => refreshOne(feed, fetchOne, applyOne, options.timeoutMs),
|
||||||
|
{ concurrency: options.concurrency, discard: true },
|
||||||
|
)
|
||||||
@@ -12,21 +12,52 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
|
import {
|
||||||
|
cachedCoverPath,
|
||||||
|
fetchCoverArt,
|
||||||
|
} from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
|
PlayerRestartedError,
|
||||||
type AudioBackend,
|
type AudioBackend,
|
||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
type DetectedPlayer,
|
||||||
} from "../utils/audio-player";
|
} from "../utils/audio-player";
|
||||||
|
import {
|
||||||
|
isPlaying,
|
||||||
|
setIsPlaying,
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
duration,
|
||||||
|
setDuration,
|
||||||
|
volume,
|
||||||
|
setVolume,
|
||||||
|
speed,
|
||||||
|
setSpeed,
|
||||||
|
backendName,
|
||||||
|
setBackendName,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
currentEpisode,
|
||||||
|
setCurrentEpisode,
|
||||||
|
availablePlayers,
|
||||||
|
setAvailablePlayers,
|
||||||
|
} from "../utils/audio-signals";
|
||||||
import { emit, on } from "../utils/event-bus";
|
import { emit, on } from "../utils/event-bus";
|
||||||
import { useAppStore } from "../stores/app";
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress";
|
import { useProgressStore } from "../stores/progress";
|
||||||
import { useMediaRegistry } from "../utils/media-registry";
|
import { useMediaRegistry } from "../utils/media-registry";
|
||||||
import type { Episode } from "../types/episode";
|
import {
|
||||||
|
loadLastPlayerFromFile,
|
||||||
|
saveLastPlayerToFile,
|
||||||
|
saveLastPlayerSync,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
|
import type { Episode, Progress } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -43,6 +74,8 @@ export interface AudioControls {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (episode: Episode) => Promise<void>;
|
play: (episode: Episode) => Promise<void>;
|
||||||
|
/** Load an episode into the player WITHOUT starting playback. */
|
||||||
|
load: (episode: Episode) => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
resume: () => Promise<void>;
|
resume: () => Promise<void>;
|
||||||
togglePlayback: () => Promise<void>;
|
togglePlayback: () => Promise<void>;
|
||||||
@@ -62,17 +95,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|||||||
let refCount = 0;
|
let refCount = 0;
|
||||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
||||||
const [position, setPosition] = createSignal(0);
|
// so non-component consumers (the visualizer store) can subscribe without
|
||||||
const [duration, setDuration] = createSignal(0);
|
// mounting a useAudio() owner.
|
||||||
const [volume, setVolume] = createSignal(0.7);
|
|
||||||
const [speed, setSpeed] = createSignal(1);
|
/** True once the current episode has been handed to the backend (play
|
||||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
* restored at boot) and the first play action must start the backend
|
||||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
* instead of unpausing it. */
|
||||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
let startedPlayback = false;
|
||||||
[],
|
|
||||||
);
|
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
||||||
|
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
||||||
|
|
||||||
|
/** True when saved progress is below the restore cutoff. Episodes with no
|
||||||
|
* progress (never reached the persist threshold) or unknown duration count
|
||||||
|
* as eligible — they restore from the start. */
|
||||||
|
function isRestoreEligible(progress: Progress | undefined): boolean {
|
||||||
|
if (!progress || progress.duration <= 0) return true;
|
||||||
|
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureBackend(): AudioBackend {
|
function ensureBackend(): AudioBackend {
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
@@ -99,6 +141,17 @@ function registerExitTeardown(): void {
|
|||||||
exitTeardownRegistered = true;
|
exitTeardownRegistered = true;
|
||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
// Persist "what's loaded in the player right now" synchronously —
|
||||||
|
// process.exit(0) runs this handler synchronously and an async write
|
||||||
|
// would never land. The next launch restores this episode paused.
|
||||||
|
try {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
backend?.dispose();
|
backend?.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -119,46 +172,118 @@ function registerExitTeardown(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
|
||||||
|
* UI believes playback is paused we only need to catch an external
|
||||||
|
* resume (AirPod play tap, lock-screen/media-center play); checking every
|
||||||
|
* tick would just hammer mpv IPC for nothing. */
|
||||||
|
const PAUSE_WATCH_TICKS = 7;
|
||||||
|
|
||||||
|
/** The player process died while we believed playback was live — track
|
||||||
|
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||||
|
* position and stop polling. */
|
||||||
|
function finalizeTrackEnd(): void {
|
||||||
|
setIsPlaying(false);
|
||||||
|
stopPolling();
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||||
|
* device swap, OS media keys, the Now Playing center. Bring the UI in
|
||||||
|
* sync; the poll stays armed so an external resume is caught too. */
|
||||||
|
function reconcileExternalPause(): void {
|
||||||
|
setIsPlaying(false);
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
|
emit("player.pause", { episodeId: ep.id });
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setPlaybackState(false);
|
||||||
|
media.setPosition(position());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
|
||||||
|
* media-center play, OS media keys). Bring the UI back to "playing". */
|
||||||
|
function reconcileExternalResume(): void {
|
||||||
|
setIsPlaying(true);
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
emit("player.play", { episodeId: ep.id });
|
||||||
|
useMediaRegistry().setPlaybackState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling(): void {
|
function startPolling(): void {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollCount = 0;
|
pollCount = 0;
|
||||||
|
// Guard against overlapping ticks if a socket read ever outlives the
|
||||||
|
// interval (getPosition opens a fresh mpv IPC connection per call).
|
||||||
|
let pollInFlight = false;
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
if (!backend || !isPlaying()) return;
|
if (!backend || pollInFlight) return;
|
||||||
|
pollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const pos = await backend.getPosition();
|
|
||||||
const dur = await backend.getDuration();
|
|
||||||
setPosition(pos);
|
|
||||||
if (dur > 0) setDuration(dur);
|
|
||||||
|
|
||||||
// Save progress every ~5 seconds (10 ticks * 500ms)
|
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (pollCount % 10 === 0) {
|
if (isPlaying()) {
|
||||||
const ep = currentEpisode();
|
// Track ended (eof-reached observed) or process died. Check
|
||||||
if (ep) {
|
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||||
const progressStore = useProgressStore();
|
// and reports pause=true there, which would otherwise be
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
// mistaken for an external pause and never finalize.
|
||||||
|
if (!backend.isPlaying()) {
|
||||||
// Update platform media position
|
finalizeTrackEnd();
|
||||||
const media = useMediaRegistry();
|
return;
|
||||||
media.setPosition(pos);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if backend stopped playing (track ended)
|
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||||
if (!backend.isPlaying() && isPlaying()) {
|
// staying stuck on "playing" with a frozen waveform
|
||||||
setIsPlaying(false);
|
// (getPosition would just re-read the same frozen time-pos).
|
||||||
stopPolling();
|
const paused = await backend.getPauseState();
|
||||||
// Save final position on track end
|
if (paused === true) {
|
||||||
const ep = currentEpisode();
|
reconcileExternalPause();
|
||||||
if (ep) {
|
return;
|
||||||
const progressStore = useProgressStore();
|
}
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
|
||||||
|
const pos = await backend.getPosition();
|
||||||
|
const dur = await backend.getDuration();
|
||||||
|
setPosition(pos);
|
||||||
|
if (dur > 0) setDuration(dur);
|
||||||
|
|
||||||
|
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||||
|
if (pollCount % 33 === 0) {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setPosition(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||||
|
// Paused — watch for playback restarted from outside (AirPods,
|
||||||
|
// lock-screen/media-center play). Only while the player is
|
||||||
|
// still alive: a dead player while we thought we were paused
|
||||||
|
// means the track ended (mpv quits at EOF) or it crashed.
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
finalizeTrackEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paused = await backend.getPauseState();
|
||||||
|
if (paused === false) {
|
||||||
|
reconcileExternalResume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Backend may have been disposed
|
// Backend may have been disposed
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false;
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(): void {
|
function stopPolling(): void {
|
||||||
@@ -168,6 +293,39 @@ function stopPolling(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cover art for system Now Playing ─────────────────────────────────────────
|
||||||
|
// macOS shows the media session's albumart in the audio center; mpv reads it
|
||||||
|
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
||||||
|
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
||||||
|
|
||||||
|
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
|
||||||
|
* call site's latency budget:
|
||||||
|
* "cache" — disk cache only (sync): resume paths must never wait on the
|
||||||
|
* network, so a miss plays artless and warms for next time.
|
||||||
|
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
|
||||||
|
* at file LOAD, but a slow cover server must not stall audio.
|
||||||
|
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
|
||||||
|
* while feeds/progress load anyway, so the wait is free and the
|
||||||
|
* cover must be present when the file loads.
|
||||||
|
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
|
||||||
|
* nothing on a warm cache. */
|
||||||
|
async function resolveCoverArt(
|
||||||
|
coverUrl: string | undefined,
|
||||||
|
mode: "cache" | "bounded" | "await",
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!coverUrl) return null;
|
||||||
|
if (mode === "cache") return cachedCoverPath(coverUrl);
|
||||||
|
if (mode === "bounded") {
|
||||||
|
const cached = cachedCoverPath(coverUrl);
|
||||||
|
if (cached) return cached;
|
||||||
|
return Promise.race([
|
||||||
|
fetchCoverArt(coverUrl),
|
||||||
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return fetchCoverArt(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
async function play(episode: Episode): Promise<void> {
|
async function play(episode: Episode): Promise<void> {
|
||||||
const b = ensureBackend();
|
const b = ensureBackend();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -184,6 +342,24 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const vol = volume();
|
const vol = volume();
|
||||||
const spd = storeSpeed || speed();
|
const spd = storeSpeed || speed();
|
||||||
|
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
// Play the downloaded file when present (offline + no network stalls);
|
||||||
|
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||||
|
// episode's own image (feeds added by URL may lack a channel cover).
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
|
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||||
|
// never becomes an albumart track), so a cold-cache play must wait for
|
||||||
|
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||||
|
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
|
||||||
|
// past the 1.2s cap, play bare and let the fetch warm the cache.
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"bounded",
|
||||||
|
);
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
const savedProgress = progressStore.get(episode.id);
|
||||||
let startPos = 0;
|
let startPos = 0;
|
||||||
@@ -191,10 +367,12 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
startPos = savedProgress.position;
|
startPos = savedProgress.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
await b.play(episode.audioUrl, {
|
await b.play(url, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
|
mediaTitle: episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
@@ -202,12 +380,17 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
setSpeed(spd);
|
setSpeed(spd);
|
||||||
if (episode.duration) setDuration(episode.duration);
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
startedPlayback = true;
|
||||||
|
|
||||||
|
// Remember this episode as "loaded in the player" so the next launch
|
||||||
|
// can restore it paused (cleared by stop()).
|
||||||
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
|
||||||
// Register with platform media controls
|
// Register with platform media controls
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.setNowPlaying({
|
media.setNowPlaying({
|
||||||
title: episode.title,
|
title: episode.title,
|
||||||
artist: episode.podcastId,
|
artist: podcastTitle || episode.podcastId,
|
||||||
duration: episode.duration,
|
duration: episode.duration,
|
||||||
});
|
});
|
||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
@@ -215,18 +398,94 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
|
|
||||||
startPolling();
|
startPolling();
|
||||||
emit("player.play", { episodeId: episode.id });
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Playback failed");
|
setError(err instanceof Error ? err.message : "Playback failed");
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an episode into the player WITHOUT starting playback. The player tab
|
||||||
|
* renders it paused at its saved position; the first play action starts the
|
||||||
|
* backend from there (see togglePlayback). Used to restore the last player
|
||||||
|
* session at boot.
|
||||||
|
*/
|
||||||
|
async function load(episode: Episode): Promise<void> {
|
||||||
|
ensureBackend();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setCurrentEpisode(episode);
|
||||||
|
setIsPlaying(false);
|
||||||
|
startedPlayback = false;
|
||||||
|
|
||||||
|
// Show the saved position so the player tab reflects where playback
|
||||||
|
// will resume; episodes at/above the completion threshold start from 0.
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
const saved = progressStore.get(episode.id);
|
||||||
|
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
|
||||||
|
setPosition(pos);
|
||||||
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
|
setSpeed(storeSpeed || speed());
|
||||||
|
|
||||||
|
// Surface the loaded-but-paused track to the OS media controls.
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setNowPlaying({
|
||||||
|
title: episode.title,
|
||||||
|
artist: podcastTitle || episode.podcastId,
|
||||||
|
duration: episode.duration,
|
||||||
|
});
|
||||||
|
media.setPlaybackState(false);
|
||||||
|
if (pos > 0) media.setPosition(pos);
|
||||||
|
|
||||||
|
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||||
|
// fills its demuxer cache while parked, so the user's first Play flips
|
||||||
|
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||||
|
// — a failed preload just makes the first play take the cold path.
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
|
if (episode.audioUrl && backend) {
|
||||||
|
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||||
|
// applies when the file loads, and the runtime video-add fallback
|
||||||
|
// never becomes an albumart track (verified). Restore already waits
|
||||||
|
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||||
|
// 8s worst case) is free. Falls back to the episode's own image when
|
||||||
|
// the feed has no channel cover.
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"await",
|
||||||
|
);
|
||||||
|
const backendSnap = backend;
|
||||||
|
backendSnap
|
||||||
|
.preload(url, {
|
||||||
|
volume: volume(),
|
||||||
|
speed: storeSpeed || speed(),
|
||||||
|
startPosition: pos > 0 ? pos : undefined,
|
||||||
|
mediaTitle: episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
async function pause(): Promise<void> {
|
async function pause(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
await backend.pause();
|
await backend.pause();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
stopPolling();
|
// Polling stays armed (paused-watch mode): playback can be resumed
|
||||||
|
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
|
||||||
|
// and the poll must be live to catch it.
|
||||||
const ep = currentEpisode();
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
// Save progress on pause
|
// Save progress on pause
|
||||||
@@ -244,8 +503,25 @@ async function pause(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** mpv was killed/crashed: respawn it and restart playback from the saved
|
||||||
|
* position via the full play path (fresh loadfile, cover art, media
|
||||||
|
* registry). A bare unpause would target a dead — or freshly-idle —
|
||||||
|
* daemon and silently do nothing. */
|
||||||
|
async function recoverPlayback(): Promise<void> {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep && ep.audioUrl) {
|
||||||
|
await play(ep);
|
||||||
|
} else {
|
||||||
|
setError("Player is not running");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resume(): Promise<void> {
|
async function resume(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
await recoverPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await backend.resume();
|
await backend.resume();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
@@ -257,6 +533,13 @@ async function resume(): Promise<void> {
|
|||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Race: the daemon died between the liveness check above and the
|
||||||
|
// unpause — backend.resume() respawned it and threw
|
||||||
|
// PlayerRestartedError (the fresh daemon has no file loaded).
|
||||||
|
if (err instanceof PlayerRestartedError) {
|
||||||
|
await recoverPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : "Resume failed");
|
setError(err instanceof Error ? err.message : "Resume failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +548,15 @@ async function togglePlayback(): Promise<void> {
|
|||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await pause();
|
await pause();
|
||||||
} else if (currentEpisode()) {
|
} else if (currentEpisode()) {
|
||||||
await resume();
|
if (startedPlayback) {
|
||||||
|
await resume();
|
||||||
|
} else {
|
||||||
|
// Episode is only LOADED (e.g. restored at boot) — the backend
|
||||||
|
// was never started, so unpausing a dead player would fail
|
||||||
|
// silently. Start playback from the saved position instead.
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) await play(ep);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,10 +573,13 @@ async function stop(): Promise<void> {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setPosition(0);
|
setPosition(0);
|
||||||
setCurrentEpisode(null);
|
setCurrentEpisode(null);
|
||||||
|
startedPlayback = false;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
// Clear platform media controls
|
// Player is empty again — nothing to restore on the next launch.
|
||||||
|
saveLastPlayerToFile({ episodeId: null, timestamp: null });
|
||||||
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -318,6 +612,10 @@ async function doSetVolume(vol: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVolume(clamped);
|
setVolume(clamped);
|
||||||
|
|
||||||
|
// Sync back to app store (persisted to config.json for the next launch).
|
||||||
|
const appStore = useAppStore();
|
||||||
|
appStore.updateSettings({ volume: clamped });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSetSpeed(spd: number): Promise<void> {
|
async function doSetSpeed(spd: number): Promise<void> {
|
||||||
@@ -332,12 +630,8 @@ async function doSetSpeed(spd: number): Promise<void> {
|
|||||||
setSpeed(clamped);
|
setSpeed(clamped);
|
||||||
|
|
||||||
// Sync back to app store
|
// Sync back to app store
|
||||||
try {
|
const appStore = useAppStore();
|
||||||
const appStore = useAppStore();
|
appStore.updateSettings({ playbackSpeed: clamped });
|
||||||
appStore.updateSettings({ playbackSpeed: clamped });
|
|
||||||
} catch {
|
|
||||||
// Store may not be available
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchBackend(name: BackendName): Promise<void> {
|
async function switchBackend(name: BackendName): Promise<void> {
|
||||||
@@ -347,14 +641,12 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
const vol = volume();
|
const vol = volume();
|
||||||
const spd = speed();
|
const spd = speed();
|
||||||
|
|
||||||
// Stop current backend
|
|
||||||
if (backend) {
|
if (backend) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
backend.dispose();
|
backend.dispose();
|
||||||
backend = null;
|
backend = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new backend
|
|
||||||
backend = createAudioBackend(name);
|
backend = createAudioBackend(name);
|
||||||
setBackendName(backend.name);
|
setBackendName(backend.name);
|
||||||
setAvailablePlayers(detectPlayers());
|
setAvailablePlayers(detectPlayers());
|
||||||
@@ -362,12 +654,26 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
// Resume playback if we were playing
|
// Resume playback if we were playing
|
||||||
if (wasPlaying && ep && ep.audioUrl) {
|
if (wasPlaying && ep && ep.audioUrl) {
|
||||||
try {
|
try {
|
||||||
await backend.play(ep.audioUrl, {
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore
|
||||||
|
.feeds()
|
||||||
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const url =
|
||||||
|
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||||
|
"cache",
|
||||||
|
);
|
||||||
|
await backend.play(url, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
|
mediaTitle: ep.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
startedPlayback = true;
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||||
@@ -376,6 +682,46 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serialized restore chain: the boot-triggered restore and any explicit
|
||||||
|
* call run one after another, so a late-finishing earlier restore can never
|
||||||
|
* overwrite state changed by a later one (and callers can await the latest
|
||||||
|
* attempt deterministically). */
|
||||||
|
let restoreChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time session restore: reload the episode that was loaded in the
|
||||||
|
* player when the previous run ended (persisted on play/load and at exit),
|
||||||
|
* paused at its saved position — never autostarted. Episodes at/above the
|
||||||
|
* completion threshold are skipped. Silently no-ops when there is nothing
|
||||||
|
* to restore (empty player, unsubscribed show, or completed episode).
|
||||||
|
*/
|
||||||
|
export async function restoreLastSession(): Promise<void> {
|
||||||
|
const attempt = restoreChain.then(async () => {
|
||||||
|
const marker = await loadLastPlayerFromFile();
|
||||||
|
if (!marker?.episodeId) return;
|
||||||
|
|
||||||
|
// Feeds and progress load asynchronously at boot; wait for both
|
||||||
|
// before looking the episode up.
|
||||||
|
await Promise.all([
|
||||||
|
useProgressStore().whenReady(),
|
||||||
|
useFeedStore().whenReady(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const episode = useFeedStore().findEpisode(marker.episodeId);
|
||||||
|
if (!episode) return;
|
||||||
|
|
||||||
|
// Only restore episodes below the completion threshold.
|
||||||
|
const saved = useProgressStore().get(episode.id);
|
||||||
|
if (!isRestoreEligible(saved)) return;
|
||||||
|
|
||||||
|
await load(episode);
|
||||||
|
});
|
||||||
|
// Keep the chain alive even when an attempt fails; the caller awaiting
|
||||||
|
// this attempt still observes its own outcome.
|
||||||
|
restoreChain = attempt.catch(() => {});
|
||||||
|
await attempt;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive audio controls hook.
|
* Reactive audio controls hook.
|
||||||
*
|
*
|
||||||
@@ -386,17 +732,29 @@ export function useAudio(): AudioControls {
|
|||||||
// Initialize backend on first use
|
// Initialize backend on first use
|
||||||
ensureBackend();
|
ensureBackend();
|
||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed/volume from app store (reuse the previous session's
|
||||||
|
// playback levels; defaults are 1x and 100%).
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
try {
|
const appStore = useAppStore();
|
||||||
const appStore = useAppStore();
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
if (storeSpeed && storeSpeed !== speed()) {
|
setSpeed(storeSpeed);
|
||||||
setSpeed(storeSpeed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Store may not be available yet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume re-syncs once settings finish loading (async config read)
|
||||||
|
// so a level persisted last session is applied at boot.
|
||||||
|
appStore
|
||||||
|
.whenReady()
|
||||||
|
.then(() => {
|
||||||
|
const storeVolume = appStore.state().settings.volume;
|
||||||
|
if (storeVolume !== undefined && storeVolume !== volume()) {
|
||||||
|
setVolume(storeVolume);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Restore the last player session once at boot (loaded, not playing).
|
||||||
|
restoreLastSession().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
refCount++;
|
refCount++;
|
||||||
@@ -430,14 +788,6 @@ export function useAudio(): AudioControls {
|
|||||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
|
||||||
await seekRelative(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
|
||||||
await seekRelative(-10);
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||||
await doSetSpeed(next);
|
await doSetSpeed(next);
|
||||||
@@ -524,8 +874,6 @@ export function useAudio(): AudioControls {
|
|||||||
unsubMediaToggle();
|
unsubMediaToggle();
|
||||||
unsubMediaVolUp();
|
unsubMediaVolUp();
|
||||||
unsubMediaVolDown();
|
unsubMediaVolDown();
|
||||||
unsubMediaSeekFwd();
|
|
||||||
unsubMediaSeekBack();
|
|
||||||
unsubMediaSpeed();
|
unsubMediaSpeed();
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
@@ -554,6 +902,7 @@ export function useAudio(): AudioControls {
|
|||||||
availablePlayers,
|
availablePlayers,
|
||||||
|
|
||||||
play,
|
play,
|
||||||
|
load,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Global multimedia key handler hook.
|
* Global multimedia key handler hook.
|
||||||
*
|
*
|
||||||
* Captures media-related key events (play/pause, volume, seek, speed)
|
* Captures media-related key events (play/pause, volume, speed)
|
||||||
* regardless of which component is focused. Uses the event bus to
|
* regardless of which component is focused. Uses the event bus to
|
||||||
* decouple key detection from audio control logic.
|
* decouple key detection from audio control logic.
|
||||||
*
|
*
|
||||||
* Volume and speed are app-level settings — adjustable with or without
|
* Volume and speed are app-level settings — adjustable with or without
|
||||||
* an episode loaded (they apply to the next playback and persist). Seek
|
* an episode loaded (they apply to the next playback and persist). Seek
|
||||||
* is playback-dependent, so it still requires a loaded episode.
|
* is NOT handled here: it lives on the yazi keybind router (`<` / `>` =
|
||||||
|
* shift+, / shift+.), so the arrow keys stay free for navigation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard } from "@opentui/solid";
|
||||||
@@ -17,31 +18,13 @@ export type MediaKeyAction =
|
|||||||
| "media.toggle"
|
| "media.toggle"
|
||||||
| "media.volumeUp"
|
| "media.volumeUp"
|
||||||
| "media.volumeDown"
|
| "media.volumeDown"
|
||||||
| "media.seekForward"
|
|
||||||
| "media.seekBackward"
|
|
||||||
| "media.speedCycle";
|
| "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 {
|
export interface MultimediaKeysOptions {
|
||||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||||
playerFocused?: () => boolean;
|
playerFocused?: () => boolean;
|
||||||
/** When true, skip handling (text input has focus) */
|
/** When true, skip handling (text input has focus) */
|
||||||
inputFocused?: () => boolean;
|
inputFocused?: () => boolean;
|
||||||
/** Whether an episode is currently loaded */
|
|
||||||
hasEpisode?: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,17 +56,9 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
|||||||
emit("media.volumeDown", {});
|
emit("media.volumeDown", {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "left":
|
|
||||||
if (!options.hasEpisode?.()) return;
|
|
||||||
emit("media.seekBackward", {});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "right":
|
|
||||||
if (!options.hasEpisode?.()) return;
|
|
||||||
emit("media.seekForward", {});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "s":
|
case "s":
|
||||||
|
// Speed is shift+s (S) so plain `s` stays free for search.
|
||||||
|
if (!key.shift) return;
|
||||||
emit("media.speedCycle", {});
|
emit("media.speedCycle", {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
16
src/hooks/useSelectionMarker.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* useSelectionMarker — reactive accessor for the row-selection marker glyph.
|
||||||
|
*
|
||||||
|
* When the `showSelectionMarker` setting is on, the focused row of every list
|
||||||
|
* renders `❯`; when off (the default), it renders a space so column alignment
|
||||||
|
* is preserved. Every list pane in the app reads the marker through this hook
|
||||||
|
* so the setting applies consistently everywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
|
export function useSelectionMarker(): () => string {
|
||||||
|
const app = useAppStore();
|
||||||
|
return () =>
|
||||||
|
app.state().settings.showSelectionMarker ? "❯" : " ";
|
||||||
|
}
|
||||||
320
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.6.2";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
@@ -37,160 +40,185 @@ if (cliArgs.version) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function findLatestEpisode(
|
||||||
|
feeds: Feed[],
|
||||||
|
): { feed: Feed; episode: Episode } | null {
|
||||||
|
let latest: { feed: Feed; episode: Episode } | null = null
|
||||||
|
let latestDate = 0
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (feed.episodes.length === 0) continue
|
||||||
|
const ep = feed.episodes[0]
|
||||||
|
const epDate =
|
||||||
|
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
|
||||||
|
if (epDate > latestDate) {
|
||||||
|
latestDate = epDate
|
||||||
|
latest = { feed, episode: ep }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search feeds by title and print matching shows */
|
||||||
|
function handleQuery(feeds: Feed[], query: string): void {
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
const matches = feeds.filter((feed) => {
|
||||||
|
const title = feed.podcast.title.toLowerCase()
|
||||||
|
return title.includes(normalizedQuery)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
console.log(`No shows found matching: ${query}`)
|
||||||
|
if (feeds.length > 0) {
|
||||||
|
console.log("\nAvailable shows:")
|
||||||
|
feeds.slice(0, 5).forEach((feed) => {
|
||||||
|
console.log(` - ${feed.podcast.title}`)
|
||||||
|
})
|
||||||
|
if (feeds.length > 5) {
|
||||||
|
console.log(` ... and ${feeds.length - 5} more`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const feed = matches[0]
|
||||||
|
console.log(`\n${feed.podcast.title}`)
|
||||||
|
if (feed.podcast.description) {
|
||||||
|
console.log(
|
||||||
|
feed.podcast.description.substring(0, 200) +
|
||||||
|
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
const date =
|
||||||
|
ep.pubDate instanceof Date
|
||||||
|
? ep.pubDate.toLocaleDateString()
|
||||||
|
: String(ep.pubDate)
|
||||||
|
console.log(` ${idx + 1}. ${ep.title} (${date})`)
|
||||||
|
})
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nClosest matches for "${query}":`)
|
||||||
|
matches.slice(0, 5).forEach((feed, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${feed.podcast.title}`)
|
||||||
|
})
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve and play an episode from `arg` (title path or "latest") */
|
||||||
|
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||||
|
const normalizedArg = arg.toLowerCase()
|
||||||
|
|
||||||
|
let feedResult: Feed | null = null
|
||||||
|
let episodeResult: Episode | null = null
|
||||||
|
|
||||||
|
if (normalizedArg === "latest") {
|
||||||
|
const latest = findLatestEpisode(feeds)
|
||||||
|
if (latest) {
|
||||||
|
feedResult = latest.feed
|
||||||
|
episodeResult = latest.episode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parts = normalizedArg.split("/")
|
||||||
|
const showQuery = parts[0]
|
||||||
|
const episodeQuery = parts[1]
|
||||||
|
|
||||||
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingFeeds.length === 0) {
|
||||||
|
console.log(`No show found matching: ${showQuery}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = matchingFeeds[0]
|
||||||
|
|
||||||
|
if (!episodeQuery) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = feed.episodes[0]
|
||||||
|
} else {
|
||||||
|
console.log(`No episodes available for: ${feed.podcast.title}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else if (episodeQuery === "latest") {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = feed.episodes[0]
|
||||||
|
} else {
|
||||||
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
|
ep.title.toLowerCase().includes(episodeQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingEpisode) {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = matchingEpisode
|
||||||
|
} else {
|
||||||
|
console.log(`Episode not found: ${episodeQuery}`)
|
||||||
|
console.log(`Available episodes for ${feed.podcast.title}:`)
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ep.title}`)
|
||||||
|
})
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedResult || !episodeResult) {
|
||||||
|
console.log("Could not find episode to play")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nPlaying: ${episodeResult.title}`)
|
||||||
|
console.log(`Show: ${feedResult.podcast.title}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAudioBackend } = await import("./utils/audio-player")
|
||||||
|
const { fetchCoverArt } = await import("./utils/cover-art")
|
||||||
|
const backend = createAudioBackend()
|
||||||
|
if (episodeResult.audioUrl) {
|
||||||
|
// Stage the podcast cover so the system Now Playing shows
|
||||||
|
// artwork (mpv --cover-art-files), like the UI path does. Falls
|
||||||
|
// back to the episode's own image when the feed has no channel
|
||||||
|
// cover (URL-added feeds).
|
||||||
|
const coverUrl =
|
||||||
|
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
|
||||||
|
const coverArtPath = coverUrl
|
||||||
|
? await fetchCoverArt(coverUrl)
|
||||||
|
: null
|
||||||
|
await backend.play(episodeResult.audioUrl, {
|
||||||
|
mediaTitle: episodeResult.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
|
})
|
||||||
|
console.log("Playback started (use the UI to control)")
|
||||||
|
} else {
|
||||||
|
console.log("No audio URL available for this episode")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Playback error:", err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cliArgs.query !== null || cliArgs.play !== null) {
|
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||||
import("./utils/feeds-persistence")
|
import("./utils/feeds-persistence")
|
||||||
.then(async ({ loadFeedsFromFile }) => {
|
.then(async ({ loadFeedsFromFile }) => {
|
||||||
const feeds = await loadFeedsFromFile();
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
if (cliArgs.query !== null) {
|
if (cliArgs.query !== null) {
|
||||||
const query = cliArgs.query;
|
handleQuery(feeds, cliArgs.query)
|
||||||
const normalizedQuery = query.toLowerCase();
|
|
||||||
|
|
||||||
const matches = feeds.filter((feed) => {
|
|
||||||
const title = feed.podcast.title.toLowerCase();
|
|
||||||
return title.includes(normalizedQuery);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
console.log(`No shows found matching: ${query}`);
|
|
||||||
if (feeds.length > 0) {
|
|
||||||
console.log("\nAvailable shows:");
|
|
||||||
feeds.slice(0, 5).forEach((feed) => {
|
|
||||||
console.log(` - ${feed.podcast.title}`);
|
|
||||||
});
|
|
||||||
if (feeds.length > 5) {
|
|
||||||
console.log(` ... and ${feeds.length - 5} more`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length === 1) {
|
|
||||||
const feed = matches[0];
|
|
||||||
console.log(`\n${feed.podcast.title}`);
|
|
||||||
if (feed.podcast.description) {
|
|
||||||
console.log(
|
|
||||||
feed.podcast.description.substring(0, 200) +
|
|
||||||
(feed.podcast.description.length > 200 ? "..." : ""),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
|
|
||||||
);
|
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
|
||||||
const date =
|
|
||||||
ep.pubDate instanceof Date
|
|
||||||
? ep.pubDate.toLocaleDateString()
|
|
||||||
: String(ep.pubDate);
|
|
||||||
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nClosest matches for "${query}":`);
|
|
||||||
matches.slice(0, 5).forEach((feed, idx) => {
|
|
||||||
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliArgs.play !== null) {
|
if (cliArgs.play !== null) {
|
||||||
const playArg = cliArgs.play;
|
await handlePlay(feeds, cliArgs.play)
|
||||||
const normalizedArg = playArg.toLowerCase();
|
|
||||||
|
|
||||||
let feedResult: (typeof feeds)[0] | null = null;
|
|
||||||
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
|
|
||||||
|
|
||||||
if (normalizedArg === "latest") {
|
|
||||||
let latestFeed: (typeof feeds)[0] | null = null;
|
|
||||||
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
|
|
||||||
let latestDate = 0;
|
|
||||||
|
|
||||||
for (const feed of feeds) {
|
|
||||||
if (feed.episodes.length > 0) {
|
|
||||||
const ep = feed.episodes[0];
|
|
||||||
const epDate =
|
|
||||||
ep.pubDate instanceof Date
|
|
||||||
? ep.pubDate.getTime()
|
|
||||||
: Number(ep.pubDate);
|
|
||||||
if (epDate > latestDate) {
|
|
||||||
latestDate = epDate;
|
|
||||||
latestFeed = feed;
|
|
||||||
latestEpisode = ep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
feedResult = latestFeed;
|
|
||||||
episodeResult = latestEpisode;
|
|
||||||
} else {
|
|
||||||
const parts = normalizedArg.split("/");
|
|
||||||
const showQuery = parts[0];
|
|
||||||
const episodeQuery = parts[1];
|
|
||||||
|
|
||||||
const matchingFeeds = feeds.filter((feed) =>
|
|
||||||
feed.podcast.title.toLowerCase().includes(showQuery),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingFeeds.length === 0) {
|
|
||||||
console.log(`No show found matching: ${showQuery}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const feed = matchingFeeds[0];
|
|
||||||
|
|
||||||
if (!episodeQuery) {
|
|
||||||
if (feed.episodes.length > 0) {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = feed.episodes[0];
|
|
||||||
} else {
|
|
||||||
console.log(`No episodes available for: ${feed.podcast.title}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else if (episodeQuery === "latest") {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = feed.episodes[0];
|
|
||||||
} else {
|
|
||||||
const matchingEpisode = feed.episodes.find((ep) =>
|
|
||||||
ep.title.toLowerCase().includes(episodeQuery),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingEpisode) {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = matchingEpisode;
|
|
||||||
} else {
|
|
||||||
console.log(`Episode not found: ${episodeQuery}`);
|
|
||||||
console.log(`Available episodes for ${feed.podcast.title}:`);
|
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
|
||||||
console.log(` ${idx + 1}. ${ep.title}`);
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!feedResult || !episodeResult) {
|
|
||||||
console.log("Could not find episode to play");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nPlaying: ${episodeResult.title}`);
|
|
||||||
console.log(`Show: ${feedResult.podcast.title}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { createAudioBackend } = await import("./utils/audio-player");
|
|
||||||
const backend = createAudioBackend();
|
|
||||||
if (episodeResult.audioUrl) {
|
|
||||||
await backend.play(episodeResult.audioUrl);
|
|
||||||
console.log("Playback started (use the UI to control)");
|
|
||||||
} else {
|
|
||||||
console.log("No audio URL available for this episode");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Playback error:", err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -2,21 +2,31 @@
|
|||||||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — category list. Parent pane shows the muted
|
* 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
|
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||||
* pane = the categories list.
|
* pane = the categories list.
|
||||||
* preview — detail of the hovered item (category summary, or
|
* depth 2 (current) — episodes of the drilled show, fetched on demand
|
||||||
* podcast detail + subscribe action).
|
* WITHOUT subscribing. Parent pane = the results list.
|
||||||
|
* preview — detail of the hovered item (category summary,
|
||||||
|
* podcast detail, or episode detail).
|
||||||
*
|
*
|
||||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
* remains. `l`/Enter drills in (category → results → episodes); `a`
|
||||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
* subscribes the focused show (enter/l never subscribe — they open the
|
||||||
* column. Moving through categories at depth 0 updates the store's selected
|
* episode list); `h` pops a depth (noop at 0). j/k move only within the
|
||||||
* category so the preview follows.
|
* current column. Moving through categories at depth 0 updates the store's
|
||||||
|
* selected category so the preview follows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
import type { Podcast } from "@/types/podcast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -27,19 +37,31 @@ import {
|
|||||||
type DepthFrame,
|
type DepthFrame,
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const DiscoverPaneCount = 1;
|
export const DiscoverPaneCount = 1;
|
||||||
|
|
||||||
function DiscoverPage() {
|
function DiscoverPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const discoverStore = useDiscoverStore();
|
const discoverStore = useDiscoverStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
|
|
||||||
@@ -54,14 +76,37 @@ function DiscoverPage() {
|
|||||||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||||||
|
|
||||||
|
// depth-2 frame ctx = the drilled podcast id (episode preview, no
|
||||||
|
// subscription). Episodes come from the discover store's session cache.
|
||||||
|
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
|
||||||
|
const drilledPodcast = (): Podcast | undefined =>
|
||||||
|
podcasts().find((p) => p.id === drilledPodcastId());
|
||||||
|
const episodes = createMemo<Episode[]>(() => {
|
||||||
|
if (depth() < 2) return [];
|
||||||
|
return discoverStore.episodesForPodcast(drilledPodcastId());
|
||||||
|
});
|
||||||
|
const episodesLoading = () =>
|
||||||
|
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
|
||||||
|
const episodesError = () =>
|
||||||
|
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
|
||||||
|
const focusedEpIdx = () =>
|
||||||
|
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
|
||||||
|
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
const curLen = () =>
|
const curLen = () =>
|
||||||
depth() === 0 ? categories().length : podcasts().length;
|
depth() === 0
|
||||||
|
? categories().length
|
||||||
|
: depth() === 1
|
||||||
|
? podcasts().length
|
||||||
|
: episodes().length;
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (categories().length > 0 && focus(0) >= categories().length)
|
if (categories().length > 0 && focus(0) >= categories().length)
|
||||||
nav.setDepthFocus(categories().length - 1, 0);
|
nav.setDepthFocus(categories().length - 1, 0);
|
||||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||||
|
if (episodes().length > 0 && focus(2) >= episodes().length)
|
||||||
|
nav.setDepthFocus(episodes().length - 1, 2);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
@@ -74,13 +119,56 @@ function DiscoverPage() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return categories()[i]?.id;
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
return podcasts()[i]?.id;
|
if (depth() === 1) return podcasts()[i]?.id;
|
||||||
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
/** The subscribed feed backing a podcast, if any (matched by directory id
|
||||||
|
* or feed URL — a Discover show may already be subscribed). */
|
||||||
|
const feedForPodcast = (p: Podcast) =>
|
||||||
|
feedStore.feeds().find(
|
||||||
|
(f) =>
|
||||||
|
f.podcast.id === p.id ||
|
||||||
|
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const playEpisode = (ep: Episode) => {
|
||||||
|
audio.play(ep).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
@@ -91,9 +179,19 @@ function DiscoverPage() {
|
|||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) discoverStore.toggleSubscription(pod.id);
|
if (!pod) return;
|
||||||
|
// Drill into the show's episode list WITHOUT subscribing — `l`,
|
||||||
|
// right, and Enter open the episodes; `a` is the subscribe key.
|
||||||
|
discoverStore.openEpisodes(pod).catch(() => {});
|
||||||
|
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) playEpisode(ep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +207,59 @@ function DiscoverPage() {
|
|||||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
open: () => open(),
|
open: () => open(),
|
||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) nav.toggleSelected(pod.id);
|
if (pod) nav.toggleSelected(pod.id);
|
||||||
}
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
download: () => {
|
||||||
|
if (depth() !== 2) return;
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!pod || !ep) return;
|
||||||
|
// Under its subscribed feed when already subscribed, otherwise as
|
||||||
|
// an "unsubscribed show" download (mirrors Search).
|
||||||
|
const feed = feedForPodcast(pod);
|
||||||
|
if (feed) downloadStore.startDownload(ep, feed.id);
|
||||||
|
else downloadStore.startUnsubscribedDownload(ep, pod);
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() !== 2) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!ep) return;
|
||||||
|
const id = ep.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
|
||||||
|
// the episode list, so subscribing moved off open).
|
||||||
|
subscribe: () => {
|
||||||
|
if (depth() === 1) {
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
|
||||||
},
|
},
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
discoverStore.refresh().catch(() => {});
|
discoverStore.refresh().catch(() => {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -146,43 +291,87 @@ function DiscoverPage() {
|
|||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
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 = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? "Categories"
|
? "Categories"
|
||||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
: depth() === 1
|
||||||
|
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
|
||||||
|
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
|
||||||
// mounts/unmounts cleanly on depth change.
|
// children toggle on depth change, so the old subtree is disposed instead
|
||||||
|
// of left orphaned next to the new one (single <Show with fallback> and
|
||||||
|
// ternary root swaps both leak the previous root).
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
<>
|
||||||
<For each={categories()}>
|
<Show when={depth() === 0}>
|
||||||
{(cat, index) => {
|
<TabListPane muted />
|
||||||
const lf = () => nav.depthFocus(0);
|
</Show>
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
<Show when={depth() === 1}>
|
||||||
return (
|
<For each={categories()}>
|
||||||
<box
|
{(cat, index) => {
|
||||||
ref={ref}
|
const lf = () => nav.depthFocus(0);
|
||||||
flexDirection="row"
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
gap={1}
|
return (
|
||||||
paddingLeft={1}
|
<box
|
||||||
paddingRight={1}
|
ref={ref}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
flexDirection="row"
|
||||||
>
|
gap={1}
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
paddingRight={1}
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
</text>
|
>
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
{cat.name}
|
{index() === nav.depthFocus(0) ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
{nerd && (
|
||||||
);
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
}}
|
{cat.icon}
|
||||||
</For>
|
</text>
|
||||||
</Show>
|
)}
|
||||||
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
|
{cat.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
<Show when={depth() >= 2}>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(podcast, index) => {
|
||||||
|
const lf = () => nav.depthFocus(1);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(index(), lf(), false)}>
|
||||||
|
{index() === lf() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
|
||||||
|
{podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={podcast.isSubscribed}>
|
||||||
|
<text flexShrink={0} fg={muted()}>[+]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane ───────────────────────────────────────────────────────────
|
// ── current pane ───────────────────────────────────────────────────────────
|
||||||
@@ -199,7 +388,6 @@ function DiscoverPage() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -209,8 +397,13 @@ function DiscoverPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
|
{nerd && (
|
||||||
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
|
{cat.icon}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -218,12 +411,19 @@ function DiscoverPage() {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: results */}
|
{/* depth ≥1: results */}
|
||||||
<Show when={depth() >= 1}>
|
<Show when={depth() === 1}>
|
||||||
<Show
|
<Show
|
||||||
when={podcasts().length > 0}
|
when={podcasts().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<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>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -236,7 +436,6 @@ function DiscoverPage() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -246,7 +445,7 @@ function DiscoverPage() {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{podcast.title}
|
{podcast.title}
|
||||||
@@ -271,6 +470,59 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={discoverStore.isLoading()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
|
||||||
|
<Show when={depth() >= 2}>
|
||||||
|
<Show when={episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<LoadingIndicator label="Loading episodes…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={episodesError() && !episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.error}>{episodesError()}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>r: retry · h: back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes found. :refresh</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={episodes()}>
|
||||||
|
{(ep, index) => (
|
||||||
|
<EpisodeRow
|
||||||
|
episode={ep}
|
||||||
|
index={index}
|
||||||
|
focused={focusedEpIdx}
|
||||||
|
active={isActive}
|
||||||
|
selected={() => nav.isSelected(ep.id)}
|
||||||
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
|
marker={marker}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 2);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
@@ -321,8 +573,8 @@ function DiscoverPage() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : depth() === 1 ? (
|
||||||
// depth ≥1 preview: hovered podcast + subscribe
|
// depth 1 preview: hovered podcast + episode-list hint
|
||||||
<Show
|
<Show
|
||||||
when={focusedPodcast()}
|
when={focusedPodcast()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -340,10 +592,10 @@ function DiscoverPage() {
|
|||||||
<text fg={muted()}>by {pod().author}</text>
|
<text fg={muted()}>by {pod().author}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={pod().isSubscribed}>
|
<Show when={pod().isSubscribed}>
|
||||||
<text fg={theme.success}>✓ Subscribed</text>
|
<text fg={theme.success}>✓ Subscribed · x: unsubscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!pod().isSubscribed}>
|
<Show when={!pod().isSubscribed}>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>a: subscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
@@ -362,10 +614,67 @@ function DiscoverPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
<text fg={muted()}>enter/l: episodes · h: back · r: refresh</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
) : (
|
||||||
|
// depth ≥2 preview: hovered episode (or loading/error/empty)
|
||||||
|
<>
|
||||||
|
<Show when={episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<LoadingIndicator label="Loading episodes…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={episodesError() && !episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.error}>{episodesError()}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>r: retry · h: back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes found.</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() &&
|
||||||
|
!episodesError() &&
|
||||||
|
episodes().length > 0 &&
|
||||||
|
focusedEpisode()
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<EpisodePreview
|
||||||
|
episode={() => ep()}
|
||||||
|
author={() => drilledPodcast()?.author}
|
||||||
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
|
hint={() =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(ep().id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
|
||||||
|
} · h: back`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -373,9 +682,7 @@ function DiscoverPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
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,11 +16,12 @@
|
|||||||
* everything over `nav.action`; this page only handles list/preview data.
|
* 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 { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { prefetchCoverArt } from "@/utils/cover-art";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -31,19 +32,28 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
type EpItem = { episode: Episode; feed: Feed };
|
type EpItem = { episode: Episode; feed: Feed };
|
||||||
|
|
||||||
function FeedPage() {
|
function FeedPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
@@ -51,23 +61,92 @@ function FeedPage() {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||||
const episodes = createMemo<EpItem[]>(
|
const episodes = createMemo<EpItem[]>(
|
||||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Cover warm-up ────────────────────────────────────────────────────────
|
||||||
|
// Prefetch covers for episodes around the focus (plus the top of the
|
||||||
|
// list) so plays land on a warm cache: cover-art-files only applies at
|
||||||
|
// file load, and there is no working runtime fallback. Single-flight +
|
||||||
|
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
|
||||||
|
createEffect(() => {
|
||||||
|
const list = episodes();
|
||||||
|
const focusIdx = focusedEpIdx();
|
||||||
|
const start = Math.max(0, focusIdx - 10);
|
||||||
|
const end = Math.min(list.length, focusIdx + 11);
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const item = list[i];
|
||||||
|
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
|
// loaded window by 50 episodes. 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 ?? "auto";
|
||||||
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focus = () => nav.depthFocus(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 = () =>
|
const focusedEpIdx = () =>
|
||||||
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
|
focusedOnMore()
|
||||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
? -1
|
||||||
const curLen = () => episodes().length;
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
|
const focusedItem = (): EpItem | undefined =>
|
||||||
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
const curLen = () => rowCount();
|
||||||
|
|
||||||
|
// ── Render window ────────────────────────────────────────────────────────
|
||||||
|
// The union grows to thousands of episodes after repeated fetch-more
|
||||||
|
// presses; rendering every row per frame froze the UI. Render only a
|
||||||
|
// bounded slice around the focus (real indexes preserved) — the scrollbox
|
||||||
|
// still keeps the focused row in view. Spacers above/below the window
|
||||||
|
// restore the full content height so the scrollbar tracks the real list.
|
||||||
|
// Each EpisodeRow is 3 lines tall (title, subtitle, date).
|
||||||
|
const LIST_WINDOW = 30;
|
||||||
|
const ROW_HEIGHT = 3;
|
||||||
|
const listWindow = createMemo<[number, number]>(() => {
|
||||||
|
const len = episodes().length;
|
||||||
|
// Focusing the Fetch More button keeps the window anchored at the
|
||||||
|
// last episode — no jump when the focus crosses onto the button.
|
||||||
|
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||||
|
return [
|
||||||
|
Math.max(0, f - LIST_WINDOW),
|
||||||
|
Math.min(len, f + LIST_WINDOW + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const visibleEpisodes = createMemo(() => {
|
||||||
|
const [start, end] = listWindow();
|
||||||
|
return episodes().slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (episodes().length > 0 && focus() >= episodes().length)
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
nav.setDepthFocus(episodes().length - 1, 0);
|
nav.setDepthFocus(rowCount() - 1, 0);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
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(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(
|
nav.registerResolver(
|
||||||
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||||
@@ -76,12 +155,6 @@ function FeedPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
||||||
const formatDuration = (s: number) => {
|
|
||||||
const mins = Math.floor(s / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
||||||
};
|
|
||||||
const downloadLabel = (id: string) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -118,6 +191,10 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── open ───────────────────────────────────────────────────────────────────
|
// ── open ───────────────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
|
if (focusedOnMore()) {
|
||||||
|
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
playEpisode(focusedItem());
|
playEpisode(focusedItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +213,18 @@ function FeedPage() {
|
|||||||
const item = focusedItem();
|
const item = focusedItem();
|
||||||
if (item) nav.toggleSelected(item.episode.id);
|
if (item) nav.toggleSelected(item.episode.id);
|
||||||
},
|
},
|
||||||
|
download: () => {
|
||||||
|
const item = focusedItem();
|
||||||
|
if (item) downloadStore.startDownload(item.episode, item.feed.id);
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
const item = focusedItem();
|
||||||
|
if (!item) return;
|
||||||
|
const id = item.episode.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
feedStore.refreshAllFeeds().catch(() => {});
|
feedStore.refreshAllFeeds().catch(() => {});
|
||||||
},
|
},
|
||||||
@@ -160,15 +249,6 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
// Row highlight within the list. `active=true` only for the current pane.
|
|
||||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
|
||||||
i === listFocus && active
|
|
||||||
? theme.primary
|
|
||||||
: i === listFocus
|
|
||||||
? theme.border
|
|
||||||
: undefined;
|
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
|
||||||
i === listFocus && active ? theme.surface : theme.text;
|
|
||||||
|
|
||||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
@@ -180,116 +260,113 @@ function FeedPage() {
|
|||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1} alignItems="center">
|
||||||
<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 />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
{/* Spacers keep the scrollbox content at the FULL list height so
|
||||||
{(item, index) => {
|
the scrollbar reflects the real list, not the render window. */}
|
||||||
const fi = () => focusedEpIdx();
|
<Show when={listWindow()[0] > 0}>
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||||
return (
|
</Show>
|
||||||
<box
|
<For each={visibleEpisodes()}>
|
||||||
ref={ref}
|
{(item, index) => (
|
||||||
flexDirection="column"
|
<EpisodeRow
|
||||||
gap={0}
|
episode={item.episode}
|
||||||
paddingLeft={1}
|
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||||
paddingRight={1}
|
index={() => listWindow()[0] + index()}
|
||||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
focused={focusedEpIdx}
|
||||||
onMouseDown={() => {
|
active={isActive}
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
selected={() => nav.isSelected(item.episode.id)}
|
||||||
nav.setDepthFocus(index(), 0);
|
downloadLabel={() => downloadLabel(item.episode.id)}
|
||||||
}}
|
downloadColor={() => downloadColor(item.episode.id)}
|
||||||
>
|
marker={marker}
|
||||||
<box flexDirection="row" gap={1}>
|
onMouseDown={() => {
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
{index() === fi() ? "❯" : " "}
|
nav.setDepthFocus(listWindow()[0] + index(), 0);
|
||||||
</text>
|
}}
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
/>
|
||||||
{item.episode.episodeNumber
|
)}
|
||||||
? `#${item.episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{item.episode.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text fg={index() === fi() ? theme.surface : theme.info}>
|
|
||||||
{formatDate(item.episode.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
|
||||||
{formatDuration(item.episode.duration)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
|
||||||
{item.feed.customName || item.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(item.episode.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(item.episode.id)}>
|
|
||||||
<text fg={downloadColor(item.episode.id)}>
|
|
||||||
{downloadLabel(item.episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||||
|
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||||
|
</Show>
|
||||||
|
<Show when={showFetchMore()}>
|
||||||
|
<FetchMoreRow
|
||||||
|
index={() => episodes().length}
|
||||||
|
focused={focusedRow}
|
||||||
|
onMore={focusedOnMore}
|
||||||
|
active={isActive}
|
||||||
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
|
nerd={nerd}
|
||||||
|
marker={marker}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(episodes().length, 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box alignItems="center" paddingTop={1}>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||||
|
const episodeHint = (item: EpItem) =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
} · space: select · h back`;
|
||||||
|
|
||||||
const previewContent = () => (
|
const previewContent = () => (
|
||||||
<Show
|
<>
|
||||||
when={focusedItem()}
|
<Show when={focusedOnMore()}>
|
||||||
fallback={
|
<FetchMorePreview
|
||||||
<box padding={1}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<text fg={muted()}>No episode focused</text>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</box>
|
manualText={() =>
|
||||||
}
|
"Load the next batch of older episodes across all feeds (Enter)."
|
||||||
>
|
}
|
||||||
{(item) => (
|
/>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
</Show>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
<Show when={!focusedOnMore()}>
|
||||||
<strong>
|
<Show
|
||||||
{item().episode.episodeNumber
|
when={focusedItem()}
|
||||||
? `#${item().episode.episodeNumber} `
|
fallback={
|
||||||
: ""}
|
<box padding={1}>
|
||||||
{item().episode.title}
|
<text fg={muted()}>No episode focused</text>
|
||||||
</strong>
|
</box>
|
||||||
</text>
|
}
|
||||||
<box flexDirection="row" gap={2}>
|
>
|
||||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
{(item) => (
|
||||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
<EpisodePreview
|
||||||
<Show when={downloadLabel(item().episode.id)}>
|
episode={() => item().episode}
|
||||||
<text fg={downloadColor(item().episode.id)}>
|
subtitle={() =>
|
||||||
{downloadLabel(item().episode.id)}
|
item().feed.customName || item().feed.podcast.title
|
||||||
</text>
|
}
|
||||||
</Show>
|
author={() => item().feed.podcast.author}
|
||||||
</box>
|
downloadLabel={() => downloadLabel(item().episode.id)}
|
||||||
<text fg={muted()}>
|
downloadColor={() => downloadColor(item().episode.id)}
|
||||||
{item().feed.customName || item().feed.podcast.title}
|
hint={() => episodeHint(item())}
|
||||||
</text>
|
/>
|
||||||
<Show when={item().feed.podcast.author}>
|
)}
|
||||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
</>
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{item().episode.description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -297,9 +374,7 @@ function FeedPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,20 +2,24 @@
|
|||||||
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
* 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.
|
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||||
* preview — detail of the hovered item in the current column.
|
* preview — detail of the hovered item in the current column.
|
||||||
*
|
*
|
||||||
|
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
|
||||||
|
* as the Feed tab) that loads the next batch of episodes for that show.
|
||||||
|
*
|
||||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||||
* 0). j/k move only within the current column.
|
* 0). j/k move only within the current column.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -27,24 +31,232 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
formatDate,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
|
// ── render components ────────────────────────────────────────────────────────
|
||||||
|
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
|
||||||
|
// preview panes are My Shows-specific; episode rows/previews are shared with
|
||||||
|
// the Feed page (see EpisodeList.tsx).
|
||||||
|
|
||||||
|
/** A subscribed-show row (depth 0). */
|
||||||
|
function ShowRow(props: {
|
||||||
|
feed: Feed;
|
||||||
|
title: string;
|
||||||
|
index: () => number;
|
||||||
|
focused: () => number;
|
||||||
|
active: () => boolean;
|
||||||
|
marker: () => string;
|
||||||
|
wlScope: () => boolean;
|
||||||
|
wlInList: () => boolean;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
{/* Long titles truncate with middle-ellipsis instead of wrapping —
|
||||||
|
a wrapped title grows the row to 2+ lines and shifts every row
|
||||||
|
below (see EpisodeList for the same guard). The episode-count
|
||||||
|
and watchlist cells are flexShrink=0 so they never shrink or
|
||||||
|
wrap; the flexible title takes the remaining width. */}
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.title}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||||
|
({props.feed.episodes.length})
|
||||||
|
</text>
|
||||||
|
<Show when={props.wlScope()}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={
|
||||||
|
isFocused()
|
||||||
|
? theme.surface
|
||||||
|
: props.wlInList()
|
||||||
|
? theme.warning
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.wlInList() ? "●" : "○"}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unsubscribed-show download row (depth 0, below the shows list). */
|
||||||
|
function UnsubscribedRow(props: {
|
||||||
|
d: DownloadedEpisode;
|
||||||
|
index: () => number;
|
||||||
|
focused: () => number;
|
||||||
|
active: () => boolean;
|
||||||
|
marker: () => string;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.d.episodeTitle ?? props.d.episodeId}
|
||||||
|
</text>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text flexShrink={0} fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{props.d.podcastTitle ?? props.d.feedId}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-0 preview: the hovered subscribed show. */
|
||||||
|
function ShowPreview(props: {
|
||||||
|
show: () => Feed;
|
||||||
|
title: () => string;
|
||||||
|
hint: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const show = props.show;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{props.title()}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={show().podcast.author}>
|
||||||
|
<text fg={muted()}>by {show().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{props.hint()}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-0 preview: the hovered unsubscribed-show download. */
|
||||||
|
function UnsubscribedPreview(props: {
|
||||||
|
d: () => DownloadedEpisode;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const d = props.d;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<Show when={d().pubDate}>
|
||||||
|
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Downloaded from episode search — the show is not subscribed.
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · D: delete download · h: back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const MyShowsPaneCount = 1;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
|
const app = useAppStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const stack = nav.depthStack;
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
@@ -52,9 +264,27 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
|
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||||
|
// listed as their own section under the shows list. Reads feeds() so an
|
||||||
|
// entry drops out the moment the user subscribes to its show.
|
||||||
|
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||||
|
|
||||||
|
const depth0Count = () => shows().length + unsubs().length;
|
||||||
|
|
||||||
const focusedShowIdx = () =>
|
const focusedShowIdx = () =>
|
||||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||||
|
* row (past the shows list). */
|
||||||
|
const focusedOnUnsub = () =>
|
||||||
|
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
|
||||||
|
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||||
|
if (!focusedOnUnsub()) return undefined;
|
||||||
|
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
|
||||||
|
};
|
||||||
|
const selectedShow = (): Feed | undefined => {
|
||||||
|
if (focusedOnUnsub()) return undefined;
|
||||||
|
return shows()[focusedShowIdx()];
|
||||||
|
};
|
||||||
|
|
||||||
// depth-1 frame ctx = the drilled feed id
|
// depth-1 frame ctx = the drilled feed id
|
||||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||||
@@ -67,34 +297,87 @@ export function MyShowsPage() {
|
|||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
|
// A "[Fetch More]" row at the bottom of a drilled show's episode list
|
||||||
|
// advances that show's loaded window by 50 episodes — the per-show
|
||||||
|
// counterpart to the Feed page's row (which loads every feed). manual
|
||||||
|
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
||||||
|
// automatically (see the effect below).
|
||||||
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
|
const showFetchMore = () =>
|
||||||
|
depth() >= 1 &&
|
||||||
|
!!drilledShowId() &&
|
||||||
|
feedStore.hasMoreEpisodes(drilledShowId());
|
||||||
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
|
const focusedRow = () =>
|
||||||
|
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
||||||
|
const focusedOnMore = () =>
|
||||||
|
showFetchMore() && focusedRow() === episodes().length;
|
||||||
|
// -1 while the Fetch More row is focused so no episode row renders the
|
||||||
|
// cursor/highlight (the button is the focused row, not the last episode).
|
||||||
const focusedEpIdx = () =>
|
const focusedEpIdx = () =>
|
||||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
focusedOnMore()
|
||||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
? -1
|
||||||
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
|
const focusedEpisode = () =>
|
||||||
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
// ── Render window ────────────────────────────────────────────────────────
|
||||||
|
// The drilled show's list grows deep after repeated fetch-more presses;
|
||||||
|
// rendering every row per frame froze the UI. Render only a bounded slice
|
||||||
|
// around the focus (real indexes preserved) — the scrollbox still keeps
|
||||||
|
// the focused row in view. Spacers above/below the window restore the
|
||||||
|
// full content height so the scrollbar tracks the real list.
|
||||||
|
// Each episode row is 2 lines tall (title, date) — no subtitle here.
|
||||||
|
const LIST_WINDOW = 30;
|
||||||
|
const ROW_HEIGHT = 2;
|
||||||
|
const listWindow = createMemo<[number, number]>(() => {
|
||||||
|
const len = episodes().length;
|
||||||
|
// Focusing the Fetch More button keeps the window anchored at the
|
||||||
|
// last episode — no jump when the focus crosses onto the button.
|
||||||
|
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||||
|
return [
|
||||||
|
Math.max(0, f - LIST_WINDOW),
|
||||||
|
Math.min(len, f + LIST_WINDOW + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const visibleEpisodes = createMemo(() => {
|
||||||
|
const [start, end] = listWindow();
|
||||||
|
return episodes().slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (shows().length > 0 && focus(0) >= shows().length)
|
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||||
nav.setDepthFocus(shows().length - 1, 0);
|
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||||
nav.setDepthFocus(episodes().length - 1, 1);
|
nav.setDepthFocus(rowCount() - 1, 1);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return shows()[i]?.id;
|
if (depth() === 0) {
|
||||||
|
if (i < shows().length) return shows()[i]?.id;
|
||||||
|
return unsubs()[i - shows().length]?.episodeId;
|
||||||
|
}
|
||||||
return episodes()[i]?.id;
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto mode: reaching the bottom of a drilled show's list loads its next
|
||||||
|
// batch. Guarded by isLoadingMore so concurrent loads never stack.
|
||||||
|
createEffect(() => {
|
||||||
|
if (depth() < 1) return;
|
||||||
|
if (fetchMoreMode() !== "auto") return;
|
||||||
|
if (!showFetchMore()) return;
|
||||||
|
if (feedStore.isLoadingMore()) return;
|
||||||
|
if (focusedRow() < rowCount() - 1) return;
|
||||||
|
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
||||||
const formatDuration = (s: number) => {
|
|
||||||
const mins = Math.floor(s / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
||||||
};
|
|
||||||
const downloadLabel = (id: string) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -128,9 +411,31 @@ export function MyShowsPage() {
|
|||||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stream an unsubscribed-show download. The record carries only what was
|
||||||
|
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||||
|
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||||
|
audio
|
||||||
|
.play({
|
||||||
|
id: d.episodeId,
|
||||||
|
podcastId: d.feedId,
|
||||||
|
title: d.episodeTitle ?? d.episodeId,
|
||||||
|
description: "",
|
||||||
|
audioUrl: d.audioUrl ?? "",
|
||||||
|
duration: 0,
|
||||||
|
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
playUnsubscribedDownload(d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||||
@@ -139,6 +444,10 @@ export function MyShowsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() >= 1) {
|
||||||
|
if (focusedOnMore()) {
|
||||||
|
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ep = focusedEpisode();
|
const ep = focusedEpisode();
|
||||||
if (ep) playEpisode(ep);
|
if (ep) playEpisode(ep);
|
||||||
}
|
}
|
||||||
@@ -161,6 +470,41 @@ export function MyShowsPage() {
|
|||||||
if (ep) nav.toggleSelected(ep.id);
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
download: () => {
|
||||||
|
if (depth() < 1) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
downloadStore.cancelDownload(d.episodeId);
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() < 1) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!ep) return;
|
||||||
|
const id = ep.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
"whitelist-toggle": () => {
|
||||||
|
const prefs = app.state().preferences;
|
||||||
|
if (prefs.autoDownloadScope !== "whitelist") return;
|
||||||
|
// depth 0: the focused show; depth ≥1: the drilled show.
|
||||||
|
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
|
||||||
|
if (!id) return;
|
||||||
|
const cur = prefs.autoDownloadWhitelist ?? [];
|
||||||
|
const next = cur.includes(id)
|
||||||
|
? cur.filter((x) => x !== id)
|
||||||
|
: [...cur, id];
|
||||||
|
app.updatePreferences({ autoDownloadWhitelist: next });
|
||||||
|
feedStore.runAutoDownload();
|
||||||
|
},
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||||
@@ -196,19 +540,16 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
|
||||||
i === lf && active ? theme.surface : theme.text;
|
|
||||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Shows (${shows().length})`
|
? `Shows (${shows().length})${
|
||||||
|
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||||
|
}`
|
||||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
|
||||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
// mounts/unmounts cleanly on depth change.
|
// mounts/unmounts cleanly on depth change.
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
@@ -217,20 +558,30 @@ export function MyShowsPage() {
|
|||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
const focused = () => index() === lf();
|
||||||
|
const fg = () =>
|
||||||
|
focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
backgroundColor={focused() ? theme.border : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), false)}>
|
<text flexShrink={0} fg={fg()}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{focused() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
{/* 20%-wide parent pane truncates hard — same
|
||||||
|
middle-ellipsis guard as the depth-0 rows. */}
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{showTitle(feed)}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={muted()}>
|
||||||
|
({feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -244,7 +595,7 @@ export function MyShowsPage() {
|
|||||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={depth0Count() > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
@@ -254,35 +605,53 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => (
|
||||||
const lf = () => focusedShowIdx();
|
<ShowRow
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
feed={feed}
|
||||||
return (
|
title={showTitle(feed)}
|
||||||
<box
|
index={index}
|
||||||
ref={ref}
|
focused={focusedShowIdx}
|
||||||
flexDirection="row"
|
active={isActive}
|
||||||
gap={1}
|
marker={marker}
|
||||||
paddingLeft={1}
|
wlScope={() =>
|
||||||
paddingRight={1}
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
}
|
||||||
|
wlInList={() =>
|
||||||
|
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
|
feed.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={unsubs().length > 0}>
|
||||||
|
<box paddingLeft={1} paddingTop={1}>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
Unsubscribed Show Downloads
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<For each={unsubs()}>
|
||||||
|
{(d, index) => (
|
||||||
|
<UnsubscribedRow
|
||||||
|
d={d}
|
||||||
|
index={() => shows().length + index()}
|
||||||
|
focused={() => nav.depthFocus(0)}
|
||||||
|
active={isActive}
|
||||||
|
marker={marker}
|
||||||
|
downloadLabel={() => downloadLabel(d.episodeId)}
|
||||||
|
downloadColor={() => downloadColor(d.episodeId)}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(shows().length + index(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
)}
|
||||||
{index() === lf() ? "❯" : " "}
|
</For>
|
||||||
</text>
|
</Show>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
|
||||||
{showTitle(feed)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
|
||||||
({feed.episodes.length})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: episodes */}
|
{/* depth ≥1: episodes */}
|
||||||
@@ -295,56 +664,47 @@ export function MyShowsPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
{/* Spacers keep the scrollbox content at the FULL list
|
||||||
{(ep, index) => {
|
height so the scrollbar reflects the real list, not the
|
||||||
const lf = () => focusedEpIdx();
|
render window. */}
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
<Show when={listWindow()[0] > 0}>
|
||||||
return (
|
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||||
<box
|
</Show>
|
||||||
ref={ref}
|
<For each={visibleEpisodes()}>
|
||||||
flexDirection="column"
|
{(ep, index) => (
|
||||||
gap={0}
|
<EpisodeRow
|
||||||
paddingLeft={1}
|
episode={ep}
|
||||||
paddingRight={1}
|
index={() => listWindow()[0] + index()}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
focused={focusedEpIdx}
|
||||||
onMouseDown={() => {
|
active={isActive}
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
selected={() => nav.isSelected(ep.id)}
|
||||||
nav.setDepthFocus(index(), 1);
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
}}
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
>
|
marker={marker}
|
||||||
<box flexDirection="row" gap={1}>
|
onMouseDown={() => {
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
{index() === lf() ? "❯" : " "}
|
nav.setDepthFocus(listWindow()[0] + index(), 1);
|
||||||
</text>
|
}}
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
/>
|
||||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
)}
|
||||||
{ep.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text fg={index() === lf() ? theme.surface : theme.info}>
|
|
||||||
{formatDate(ep.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
|
||||||
{formatDuration(ep.duration)}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(ep.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(ep.id)}>
|
|
||||||
<text fg={downloadColor(ep.id)}>
|
|
||||||
{downloadLabel(ep.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||||
<LoadingIndicator />
|
</Show>
|
||||||
</box>
|
<Show when={showFetchMore()}>
|
||||||
|
<FetchMoreRow
|
||||||
|
index={() => episodes().length}
|
||||||
|
focused={focusedRow}
|
||||||
|
onMore={focusedOnMore}
|
||||||
|
active={isActive}
|
||||||
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
|
nerd={nerd}
|
||||||
|
marker={marker}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(episodes().length, 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -352,76 +712,96 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
|
const episodeHint = (epId: string) =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
|
drilledShowId(),
|
||||||
|
)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""
|
||||||
|
} · space: select · h: back`;
|
||||||
|
|
||||||
|
const showHint = (show: Feed) =>
|
||||||
|
`enter/l: open · h: back · x: unsubscribe${
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered show
|
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||||
|
// hovered show.
|
||||||
<Show
|
<Show
|
||||||
when={selectedShow()}
|
when={focusedUnsub()}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<Show
|
||||||
<text fg={muted()}>No show focused</text>
|
when={selectedShow()}
|
||||||
</box>
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No show focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(show) => (
|
||||||
|
<ShowPreview
|
||||||
|
show={() => show()}
|
||||||
|
title={() => showTitle(show())}
|
||||||
|
hint={() => showHint(show())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(show) => (
|
{(d) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<UnsubscribedPreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
d={() => d()}
|
||||||
<strong>{showTitle(show())}</strong>
|
downloadLabel={() => downloadLabel(d().episodeId)}
|
||||||
</text>
|
downloadColor={() => downloadColor(d().episodeId)}
|
||||||
<Show when={show().podcast.author}>
|
/>
|
||||||
<text fg={muted()}>by {show().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{show().episodes.length} episodes
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : (
|
||||||
// depth ≥1 preview: hovered episode
|
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||||
<Show
|
<>
|
||||||
when={focusedEpisode()}
|
<Show when={focusedOnMore()}>
|
||||||
fallback={
|
<FetchMorePreview
|
||||||
<box padding={1}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<text fg={muted()}>No episode focused</text>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</box>
|
manualText={() =>
|
||||||
}
|
"Load the next batch of older episodes for this show (Enter)."
|
||||||
>
|
}
|
||||||
{(ep) => (
|
/>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
</Show>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
<Show when={!focusedOnMore()}>
|
||||||
<strong>
|
<Show
|
||||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
when={focusedEpisode()}
|
||||||
{ep().title}
|
fallback={
|
||||||
</strong>
|
<box padding={1}>
|
||||||
</text>
|
<text fg={muted()}>No episode focused</text>
|
||||||
<box flexDirection="row" gap={2}>
|
</box>
|
||||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
}
|
||||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
>
|
||||||
<Show when={downloadLabel(ep().id)}>
|
{(ep) => (
|
||||||
<text fg={downloadColor(ep().id)}>
|
<EpisodePreview
|
||||||
{downloadLabel(ep().id)}
|
episode={() => ep()}
|
||||||
</text>
|
author={() => selectedShow()?.podcast.author}
|
||||||
</Show>
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
</box>
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
<Show when={selectedShow()?.podcast.author}>
|
hint={() => episodeHint(ep().id)}
|
||||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
/>
|
||||||
</Show>
|
)}
|
||||||
<box height={1} />
|
</Show>
|
||||||
<text fg={theme.textSecondary}>
|
</Show>
|
||||||
{ep().description?.slice(0, 400) ?? "No description available."}
|
</>
|
||||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -429,9 +809,7 @@ export function MyShowsPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,70 +14,81 @@ type PlaybackControlsProps = {
|
|||||||
onSpeedChange: (value: number) => void;
|
onSpeedChange: (value: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
|
||||||
mpv: "mpv",
|
|
||||||
none: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
|
flexWrap="wrap"
|
||||||
gap={1}
|
gap={1}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
borderColor={theme.border}
|
borderColor={theme.border}
|
||||||
>
|
>
|
||||||
<box
|
{/* transport buttons — wrap as a unit, centered on their own line */}
|
||||||
border
|
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
||||||
padding={0}
|
<box
|
||||||
onMouseDown={props.onPrev}
|
border
|
||||||
borderColor={theme.border}
|
padding={0}
|
||||||
>
|
onMouseDown={props.onPrev}
|
||||||
<text fg={theme.primary}>[Prev]</text>
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary} wrapMode="none">[Prev]</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onToggle}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary} wrapMode="none">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onNext}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
|
<text fg={theme.primary} wrapMode="none">[Next]</text>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
{/* status group — always follows the buttons; wrap point is here */}
|
||||||
<box
|
<box
|
||||||
border
|
flexDirection="row"
|
||||||
padding={0}
|
gap={1}
|
||||||
onMouseDown={props.onToggle}
|
alignItems="center"
|
||||||
borderColor={theme.border}
|
marginLeft={2}
|
||||||
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
|
||||||
</box>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
onMouseDown={props.onNext}
|
|
||||||
borderColor={theme.border}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[Next]</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
|
||||||
<text fg={theme.textMuted}>Vol</text>
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
<text fg={theme.textMuted}>↑↓</text>
|
<text fg={theme.textMuted}>↑↓</text>
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
|
||||||
<text fg={theme.textMuted}>s</text>
|
|
||||||
</box>
|
|
||||||
{props.backendName && props.backendName !== "none" && (
|
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>via</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
<text fg={theme.textMuted}>S</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
</box>
|
||||||
{props.backendName === "none" && (
|
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||||
<box marginLeft={2}>
|
{(props.backendName === "none" || props.hasAudioUrl === false) && (
|
||||||
<text fg={theme.warning}>No audio player found</text>
|
<box
|
||||||
</box>
|
flexDirection="row"
|
||||||
)}
|
gap={1}
|
||||||
{props.hasAudioUrl === false && (
|
alignItems="center"
|
||||||
<box marginLeft={2}>
|
flexShrink={0}
|
||||||
<text fg={theme.warning}>No audio URL</text>
|
>
|
||||||
|
{props.backendName === "none" && (
|
||||||
|
<box marginLeft={2}>
|
||||||
|
<text fg={theme.warning}>No audio player found</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
{props.hasAudioUrl === false && (
|
||||||
|
<box marginLeft={2}>
|
||||||
|
<text fg={theme.warning}>No audio URL</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
* tab root.
|
* tab root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show, onMount, onCleanup } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
@@ -26,7 +28,19 @@ export function PlayerPage() {
|
|||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const viz = useVisualizer();
|
||||||
|
const app = useAppStore();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
|
// Settings master switch: off hides the waveform entirely (the store
|
||||||
|
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
||||||
|
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
||||||
|
|
||||||
|
// The page is mounted exactly while the Player tab is in focus (Shell
|
||||||
|
// renders only the active tab), so mount ⇔ focused. Report it to the
|
||||||
|
// visualizer store: losing focus starts the unload grace timer instead
|
||||||
|
// of killing the pipeline with the page; regaining focus restarts it.
|
||||||
|
onMount(() => viz.setFocused(true));
|
||||||
|
onCleanup(() => viz.setFocused(false));
|
||||||
|
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
@@ -79,18 +93,11 @@ export function PlayerPage() {
|
|||||||
{ep().description?.slice(0, 500) ?? "No description available."}
|
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<RealtimeWaveform
|
<ProgressBar />
|
||||||
visualizerConfig={(() => {
|
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
<Show when={vizEnabled()}>
|
||||||
// bars is width-derived in RealtimeWaveform; pass only the
|
<RealtimeWaveform />
|
||||||
// audio-processing params here.
|
</Show>
|
||||||
return {
|
|
||||||
noiseReduction: viz.noiseReduction,
|
|
||||||
lowCutOff: viz.lowCutOff,
|
|
||||||
highCutOff: viz.highCutOff,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -109,9 +116,13 @@ export function PlayerPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
{"P play/pause N next B prev ◀▶ seek h back"}
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
</text>
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
|
<text
|
||||||
|
fg={muted()}
|
||||||
|
content={"P play/pause N next B prev < > seek h back"}
|
||||||
|
/>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,10 +130,10 @@ export function PlayerPage() {
|
|||||||
<PaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel="Player"
|
currentLabel="Player"
|
||||||
panes={2}
|
panes={2}
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
|
currentBorder={["left"]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/pages/Player/ProgressBar.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* ProgressBar — one-row, click-to-seek playback progress bar for the
|
||||||
|
* player pane. Played portion renders as full blocks (█) in the theme's
|
||||||
|
* primary color, the remainder as light shade blocks (░) in the muted
|
||||||
|
* color. The header time/percent text lives in PlayerPage — this is only
|
||||||
|
* the bar itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
|
import type { Renderable } from "@opentui/core";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProgressBar() {
|
||||||
|
const audio = useAudio();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
|
||||||
|
// The bar's renderable, captured for its absolute left edge: MouseEvent.x
|
||||||
|
// is terminal-absolute (not bar-relative), so local x needs the offset
|
||||||
|
// of the bar inside the 2-pane row (parent pane ≈ 20% of the width).
|
||||||
|
let bar: Renderable | undefined;
|
||||||
|
|
||||||
|
// Full content width of the player pane: the player is a 2-pane row
|
||||||
|
// (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars
|
||||||
|
// of border/padding chrome (same math as RealtimeWaveform's numBars).
|
||||||
|
const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8);
|
||||||
|
|
||||||
|
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
|
||||||
|
|
||||||
|
const playedChars = () => {
|
||||||
|
const duration = audio.duration();
|
||||||
|
if (duration <= 0) return 0;
|
||||||
|
return Math.round(clamp01(audio.position() / duration) * width());
|
||||||
|
};
|
||||||
|
|
||||||
|
const remainingColor = theme.muted || theme.text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
padding={0}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={0}
|
||||||
|
// The bar's block-char texts are non-selectable below: a drag
|
||||||
|
// over the bar is a seek gesture, not a text selection — otherwise
|
||||||
|
// mouse-up would copy █/░ to the clipboard via the global
|
||||||
|
// selection handler.
|
||||||
|
ref={(el) => {
|
||||||
|
bar = el;
|
||||||
|
}}
|
||||||
|
onMouseDown={(e: { x: number }) => {
|
||||||
|
const duration = audio.duration();
|
||||||
|
if (duration <= 0 || !bar) return;
|
||||||
|
// localX = 0 is the box border; content starts at localX = 1.
|
||||||
|
const localX = e.x - bar.x;
|
||||||
|
const ratio = Math.max(0, Math.min(1, (localX - 1) / width()));
|
||||||
|
void audio.seek(ratio * duration);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playedChars() > 0 && (
|
||||||
|
<text fg={theme.primary} selectable={false}>
|
||||||
|
{"\u2588".repeat(playedChars())}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={remainingColor} selectable={false}>
|
||||||
|
{"\u2591".repeat(width() - playedChars())}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||||
*
|
*
|
||||||
* Spawns an independent ffmpeg
|
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||||
* for FFT analysis, and renders frequency bars as colored terminal
|
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||||
* characters at ~30fps.
|
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
|
||||||
|
*
|
||||||
|
* This component only subscribes to store state, reports the width-derived
|
||||||
|
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||||
|
* a braille spinner while the pipeline is loading its first frames, the
|
||||||
|
* frequency bars once frames arrive, and a dotted placeholder when idle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createEffect, on } from "solid-js";
|
||||||
import { useTerminalDimensions } from "@opentui/solid";
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
import {
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
loadCavaCore,
|
|
||||||
type CavaCore,
|
|
||||||
type CavaCoreConfig,
|
|
||||||
} from "@/utils/cavacore";
|
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type RealtimeWaveformProps = {
|
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
|
||||||
const BARS = [
|
|
||||||
" ",
|
|
||||||
"\u2581",
|
|
||||||
"\u2582",
|
|
||||||
"\u2583",
|
|
||||||
"\u2584",
|
|
||||||
"\u2585",
|
|
||||||
"\u2586",
|
|
||||||
"\u2587",
|
|
||||||
"\u2588",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Target frame interval in ms (~30 fps) */
|
|
||||||
const FRAME_INTERVAL = 33;
|
|
||||||
|
|
||||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
|
||||||
const SAMPLES_PER_FRAME = 512;
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const audio = useAudio();
|
const viz = useVisualizer();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
|
||||||
let reader: AudioStreamReader | null = null;
|
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let sampleBuffer: Float64Array | null = null;
|
|
||||||
|
|
||||||
// Bar count scales with terminal width so the waveform fills its pane.
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
// The player is a 2-pane row: current column = (current+preview) of
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
@@ -75,202 +43,49 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// Keep the store's bar count in sync with the terminal width; the store
|
||||||
|
// re-inits the running pipeline when it changes (terminal resize).
|
||||||
const initCava = () => {
|
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||||
if (cava) return true;
|
|
||||||
|
|
||||||
cava = loadCavaCore();
|
|
||||||
if (!cava) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
|
||||||
stopVisualization();
|
|
||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides.
|
|
||||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
|
||||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
|
||||||
const config: CavaCoreConfig = {
|
|
||||||
bars: numBars(),
|
|
||||||
sampleRate: 44100,
|
|
||||||
channels: 1,
|
|
||||||
...props.visualizerConfig,
|
|
||||||
};
|
|
||||||
cava.init(config);
|
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
|
||||||
|
|
||||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
|
||||||
if (!reader || reader.url !== url) {
|
|
||||||
if (reader) reader.stop();
|
|
||||||
reader = new AudioStreamReader({ url });
|
|
||||||
}
|
|
||||||
reader.start(position, speed);
|
|
||||||
|
|
||||||
// Start render loop
|
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopVisualization = () => {
|
|
||||||
if (frameTimer) {
|
|
||||||
clearInterval(frameTimer);
|
|
||||||
frameTimer = null;
|
|
||||||
}
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
// Don't null reader — we reuse it across start/stop cycles
|
|
||||||
}
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
sampleBuffer = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
|
||||||
|
|
||||||
const renderFrame = () => {
|
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
: sampleBuffer;
|
|
||||||
const output = cava.execute(input);
|
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
|
||||||
setBarData(Array.from(output as Float64Array));
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
[
|
|
||||||
audio.isPlaying,
|
|
||||||
() => audio.currentEpisode()?.audioUrl ?? "",
|
|
||||||
audio.speed,
|
|
||||||
numBars,
|
|
||||||
],
|
|
||||||
([playing, url, speed]) => {
|
|
||||||
if (playing && url) {
|
|
||||||
const pos = untrack(audio.position);
|
|
||||||
startVisualization(url, pos, speed);
|
|
||||||
} else {
|
|
||||||
stopVisualization();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
|
||||||
//
|
|
||||||
// Watches position and restarts the reader (not the whole pipeline)
|
|
||||||
// only on significant jumps (>2s), which indicate a user seek.
|
|
||||||
// This is intentionally a separate effect — it should NOT trigger a
|
|
||||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
|
||||||
createEffect(
|
|
||||||
on(audio.position, (pos) => {
|
|
||||||
if (!audio.isPlaying || !reader?.running) {
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
|
|
||||||
if (delta > 2) {
|
|
||||||
reader.restart(pos, audio.speed() ?? 1);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onCleanup(() => {
|
|
||||||
stopVisualization();
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const playedRatio = () =>
|
|
||||||
audio.duration() <= 0
|
|
||||||
? 0
|
|
||||||
: Math.min(1, audio.position() / audio.duration());
|
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = viz.barData();
|
||||||
const count = numBars();
|
const count = numBars();
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
// Loading state: the braille spinner shows while the pipeline warms
|
||||||
|
// up — but only when there are no bars to render yet (first play /
|
||||||
|
// after an unload). On resume/seek the last bars stay on screen
|
||||||
|
// until fresh frames arrive, so the waveform never blanks out for
|
||||||
|
// the (multi-second, network-bound) cold start.
|
||||||
|
if (bars.length === 0 && viz.isLoading()) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(count);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const played = Math.floor(count * playedRatio());
|
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
|
||||||
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
const top = pairs.map((pair) => pair.top).join("");
|
||||||
const futureColor = "#3b4252";
|
const bottom = pairs.map((pair) => pair.bottom).join("");
|
||||||
|
|
||||||
const playedChars = bars
|
|
||||||
.slice(0, played)
|
|
||||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const futureChars = bars
|
|
||||||
.slice(played)
|
|
||||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={playedColor}>{playedChars || " "}</text>
|
<text fg={theme.primary}>{top}</text>
|
||||||
<text fg={futureColor}>{futureChars || " "}</text>
|
<text fg={theme.primary}>{bottom}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (event: { x: number }) => {
|
|
||||||
const count = numBars();
|
|
||||||
const ratio = event.x / count;
|
|
||||||
const next = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
|
||||||
);
|
|
||||||
audio.seek(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box border borderColor={theme.border} padding={1}>
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={handleClick}
|
|
||||||
>
|
|
||||||
{renderLine()}
|
{renderLine()}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
* query (muted, read-only); preview shows the detail of
|
* query (muted, read-only); preview shows the detail of
|
||||||
* the focused result.
|
* the focused result.
|
||||||
*
|
*
|
||||||
|
* Search scope: `tab` (search-scope-toggle) flips between shows and episodes
|
||||||
|
* (clickable pills on the query depth too); toggling while viewing results
|
||||||
|
* re-runs the current query in the new scope.
|
||||||
|
*
|
||||||
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||||||
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||||
@@ -26,6 +30,11 @@ import {
|
|||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
|
import { useToast } from "@/ui/toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -37,20 +46,28 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { SearchResult } from "@/types/source";
|
import type { SearchResult, SearchScope } from "@/types/source";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
export const SearchPaneCount = 1;
|
export const SearchPaneCount = 1;
|
||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
const toast = useToast();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const stack = nav.depthStack;
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
@@ -60,25 +77,21 @@ function SearchPage() {
|
|||||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||||
|
|
||||||
// ── input focusing ────────────────────────────────────────────────────────
|
// ── input focusing ────────────────────────────────────────────────────────
|
||||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
// `inputFocused` tells the Shell router to yield keys to the query input.
|
||||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
// The input's REAL focus is the source of truth: useInputFocusNav flips
|
||||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
|
||||||
//
|
// input drops it and the router resumes j/k/h — no stuck "typing" state.
|
||||||
// Typing is the default only on the query depth (0); the results depth
|
// The depth stack only SEEDS it on transitions (re-entering depth 0
|
||||||
// (1) is always list-navigation. Drive `inputFocused` straight off
|
// focuses the input; mounting at depth 1 stays list-nav), gated on the
|
||||||
// `depth()` rather than seeding it `true` on mount and patching on change:
|
// depth VALUE via a memo because setDepthFocus also writes the stack
|
||||||
// the depth stack persists across tab switches, so re-mounting this page
|
// signal — without the memo every j/k at query depth re-focuses the input
|
||||||
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
|
// and strands the recents list.
|
||||||
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
|
|
||||||
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
|
||||||
// scroll responds.
|
|
||||||
//
|
|
||||||
// The effect only re-runs on a depth transition, so Escape (defocus) and
|
|
||||||
// `s` (refocus) at the same depth are not clobbered.
|
|
||||||
onMount(() => nav.setInputFocused(depth() === 0));
|
onMount(() => nav.setInputFocused(depth() === 0));
|
||||||
onCleanup(() => nav.setInputFocused(false));
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
|
const focusNavRef = useInputFocusNav();
|
||||||
|
const isQueryDepth = createMemo(() => depth() === 0);
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
nav.setInputFocused(depth() === 0);
|
nav.setInputFocused(isQueryDepth());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||||
@@ -104,12 +117,44 @@ function SearchPage() {
|
|||||||
// Register a visual-mode resolver for the results list (depth 1).
|
// Register a visual-mode resolver for the results list (depth 1).
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
||||||
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
|
nav.registerResolver(key, (i) => {
|
||||||
|
const r = results()[i];
|
||||||
|
return r?.kind === "episode" ? r.episode.id : r?.podcast.id;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runSearch = (query: string) => {
|
const runSearch = (query: string) => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -129,10 +174,89 @@ function SearchPage() {
|
|||||||
runSearch(query);
|
runSearch(query);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscribe = (result: SearchResult) => {
|
/** Set show/episode scope; when viewing results, re-run the current query
|
||||||
// Actually add the feed to the feed store, then mark the result subscribed
|
* so the list switches immediately (the toggle is otherwise invisible on
|
||||||
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
* a list of results). */
|
||||||
searchStore.markSubscribed(result.podcast.id);
|
const applyScope = (next: SearchScope) => {
|
||||||
|
searchStore.setScope(next);
|
||||||
|
if (depth() >= 1) {
|
||||||
|
const q = submittedQuery() || inputValue().trim();
|
||||||
|
if (q) searchStore.search(q).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toggleScope = () =>
|
||||||
|
applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast");
|
||||||
|
|
||||||
|
const handleSubscribe = async (result: SearchResult) => {
|
||||||
|
// Actually add the feed to the feed store, then mark the result
|
||||||
|
// subscribed. addFeed returns null when a feedless directory stub
|
||||||
|
// (delisted show) can't be resolved — tell the user why.
|
||||||
|
const feed = await feedStore
|
||||||
|
.addFeed(result.podcast, result.sourceId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!feed && !result.podcast.feedUrl) {
|
||||||
|
toast.show({
|
||||||
|
title: "Can't subscribe",
|
||||||
|
message:
|
||||||
|
"No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.",
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feed) searchStore.markSubscribed(result.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The subscribed feed backing a search result, if any (matched by
|
||||||
|
* directory id or feed URL). */
|
||||||
|
const feedForResult = (r: SearchResult) =>
|
||||||
|
feedStore.feeds().find(
|
||||||
|
(f) =>
|
||||||
|
f.podcast.id === r.podcast.id ||
|
||||||
|
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Download the focused episode: under its subscribed feed when the show
|
||||||
|
* is subscribed, otherwise as an "unsubscribed show" download (listed
|
||||||
|
* under Unsubscribed Show Downloads in My Shows / the download manager). */
|
||||||
|
const downloadFocusedEpisode = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
const feed = feedForResult(r);
|
||||||
|
if (feed) downloadStore.startDownload(r.episode, feed.id);
|
||||||
|
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playFocusedEpisode = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
audio.play(r.episode).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribeFocused = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || !r.podcast.isSubscribed) return;
|
||||||
|
const feed = feedForResult(r);
|
||||||
|
if (feed) {
|
||||||
|
feedStore.removeFeed(feed.id);
|
||||||
|
downloadStore
|
||||||
|
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
|
||||||
|
.catch(() => {});
|
||||||
|
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribe the focused result's show in place (episode or podcast
|
||||||
|
* result). `enter` plays episodes regardless of subscription, so an
|
||||||
|
* unsubscribed show's episode needs this explicit path. */
|
||||||
|
const subscribeFocused = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.podcast.isSubscribed) return;
|
||||||
|
handleSubscribe(r);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
@@ -149,13 +273,29 @@ function SearchPage() {
|
|||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
const r = focusedResult();
|
||||||
if (r) nav.toggleSelected(r.podcast.id);
|
if (r)
|
||||||
|
nav.toggleSelected(
|
||||||
|
r.kind === "episode" ? r.episode.id : r.podcast.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
download: () => downloadFocusedEpisode(),
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
const id = r.episode.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
unsubscribe: () => unsubscribeFocused(),
|
||||||
|
subscribe: () => subscribeFocused(),
|
||||||
search: () => {
|
search: () => {
|
||||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||||
if (depth() === 0) nav.setInputFocused(true);
|
if (depth() === 0) nav.setInputFocused(true);
|
||||||
},
|
},
|
||||||
|
"search-scope-toggle": () => toggleScope(),
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
const q = submittedQuery() || inputValue().trim();
|
const q = submittedQuery() || inputValue().trim();
|
||||||
if (q) searchStore.search(q).catch(() => {});
|
if (q) searchStore.search(q).catch(() => {});
|
||||||
@@ -176,7 +316,15 @@ function SearchPage() {
|
|||||||
}
|
}
|
||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
const r = focusedResult();
|
||||||
if (r) handleSubscribe(r);
|
if (!r) return;
|
||||||
|
if (r.kind === "episode") {
|
||||||
|
// Any episode result streams directly — subscribed or not
|
||||||
|
// (matches Feed/My Shows). `a` subscribes an unsubscribed
|
||||||
|
// show's episode in place.
|
||||||
|
playFocusedEpisode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSubscribe(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,18 +353,35 @@ function SearchPage() {
|
|||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
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) ──────────────
|
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||||||
|
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||||
|
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
|
||||||
|
// children toggle on depth change, so the old subtree is disposed instead
|
||||||
|
// of left orphaned next to the new one (single <Show with fallback> and
|
||||||
|
// ternary root swaps both leak the previous root).
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
<>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<Show when={depth() === 0}>
|
||||||
<text fg={theme.textSecondary}>Query</text>
|
<TabListPane muted />
|
||||||
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
</Show>
|
||||||
<box height={1} />
|
<Show when={depth() >= 1}>
|
||||||
<text fg={muted()}>h: back to query</text>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
</box>
|
<text fg={theme.textSecondary}>Query</text>
|
||||||
</Show>
|
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"}
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>h: back to query</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane ────────────────────────────────────────────────────────────
|
// ── current pane ────────────────────────────────────────────────────────────
|
||||||
@@ -228,16 +393,80 @@ function SearchPage() {
|
|||||||
<box flexDirection="row" gap={1} alignItems="center">
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
<text fg={muted()}>Query:</text>
|
<text fg={muted()}>Query:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={focusNavRef}
|
||||||
value={inputValue()}
|
value={inputValue()}
|
||||||
onInput={setInputValue}
|
onInput={setInputValue}
|
||||||
onSubmit={() => handleSubmit()}
|
onSubmit={() => handleSubmit()}
|
||||||
placeholder="Enter podcast name..."
|
onMouseDown={(evt) => {
|
||||||
|
// Clicking the input must focus it (typing mode).
|
||||||
|
// preventDefault stops opentui's click auto-focus from
|
||||||
|
// grabbing the pane scrollbox instead; setting the flag
|
||||||
|
// drives the `focused` prop → renderable focus → the
|
||||||
|
// useInputFocusNav FOCUSED handler.
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.setInputFocused(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
// While the input owns keys the Shell router never sees
|
||||||
|
// Tab, so the scope toggle must be handled here (the
|
||||||
|
// pills and the tab keybind cover the defocused cases).
|
||||||
|
if (evt.name === "tab") {
|
||||||
|
evt.preventDefault();
|
||||||
|
toggleScope();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
searchStore.scope() === "episode"
|
||||||
|
? "Enter episode, guest, topic..."
|
||||||
|
: "Enter podcast name..."
|
||||||
|
}
|
||||||
focused={inputActive()}
|
focused={inputActive()}
|
||||||
width={28}
|
width={28}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={theme.textSecondary}>Scope:</text>
|
||||||
|
<box
|
||||||
|
backgroundColor={
|
||||||
|
searchStore.scope() === "podcast" ? theme.primary : undefined
|
||||||
|
}
|
||||||
|
onMouseDown={() => applyScope("podcast")}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
searchStore.scope() === "podcast"
|
||||||
|
? theme.surface
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Shows{" "}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
backgroundColor={
|
||||||
|
searchStore.scope() === "episode" ? theme.primary : undefined
|
||||||
|
}
|
||||||
|
onMouseDown={() => applyScope("episode")}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
searchStore.scope() === "episode"
|
||||||
|
? theme.surface
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Episodes{" "}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<text fg={muted()}>tab to toggle</text>
|
||||||
|
</box>
|
||||||
<Show when={searchStore.isSearching()}>
|
<Show when={searchStore.isSearching()}>
|
||||||
<text fg={theme.warning}>Searching...</text>
|
<LoadingIndicator label="Searching…" />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={searchStore.error()}>
|
<Show when={searchStore.error()}>
|
||||||
<text fg={theme.error}>{searchStore.error()}</text>
|
<text fg={theme.error}>{searchStore.error()}</text>
|
||||||
@@ -258,23 +487,47 @@ function SearchPage() {
|
|||||||
{(query, index) => {
|
{(query, index) => {
|
||||||
const lf = () => focus(0);
|
const lf = () => focus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
// While the input is focused (typing), the list is not
|
||||||
|
// in focus: no bg, no accent fg, no `❯` on any entry.
|
||||||
|
const typing = () => inputActive();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={
|
||||||
|
typing()
|
||||||
|
? undefined
|
||||||
|
: focusBg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(index(), 0);
|
||||||
|
// A recent is an action, not an item: clicking
|
||||||
|
// it re-runs that search (focus-only would be
|
||||||
|
// invisible — the input still owns the keys).
|
||||||
|
selectRecent(query);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text
|
||||||
{index() === lf() ? "❯" : " "}
|
fg={
|
||||||
|
typing()
|
||||||
|
? theme.text
|
||||||
|
: focusFg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{index() === lf() && !typing() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
typing()
|
||||||
|
? theme.text
|
||||||
|
: focusFg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{query}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -284,7 +537,7 @@ function SearchPage() {
|
|||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{inputActive()
|
{inputActive()
|
||||||
? "Enter to search · Esc to defocus"
|
? "Enter to search · Esc to defocus"
|
||||||
: "j/k recents · s to type · h back"}
|
: "j/k recents · s to type · tab scope · h back"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -294,11 +547,20 @@ function SearchPage() {
|
|||||||
when={results().length > 0}
|
when={results().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>
|
<Show
|
||||||
{searchStore.query()
|
when={searchStore.isSearching()}
|
||||||
? "No results found"
|
fallback={
|
||||||
: "Enter a search term to find podcasts"}
|
<text fg={muted()}>
|
||||||
</text>
|
{searchStore.query()
|
||||||
|
? "No results found"
|
||||||
|
: searchStore.scope() === "episode"
|
||||||
|
? "Enter a search term to find episodes"
|
||||||
|
: "Enter a search term to find podcasts"}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Searching…" />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -306,12 +568,18 @@ function SearchPage() {
|
|||||||
{(result, index) => {
|
{(result, index) => {
|
||||||
const fi = () => focusedResultIdx();
|
const fi = () => focusedResultIdx();
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
|
// Episode download status badge ("" when absent).
|
||||||
|
const dlLabel = () =>
|
||||||
|
result.kind === "episode"
|
||||||
|
? downloadLabel(result.episode.id)
|
||||||
|
: "";
|
||||||
|
const dlEpId = () =>
|
||||||
|
result.kind === "episode" ? result.episode.id : "";
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -321,11 +589,18 @@ function SearchPage() {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
{index() === fi() ? "❯" : " "}
|
{index() === fi() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
{result.podcast.title}
|
{result.kind === "episode"
|
||||||
|
? result.episode.title
|
||||||
|
: result.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={dlLabel()}>
|
||||||
|
<text fg={downloadColor(dlEpId())}>
|
||||||
|
{dlLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<Show when={result.podcast.isSubscribed}>
|
<Show when={result.podcast.isSubscribed}>
|
||||||
<text
|
<text
|
||||||
fg={index() === fi() ? theme.surface : theme.success}
|
fg={index() === fi() ? theme.surface : theme.success}
|
||||||
@@ -334,14 +609,24 @@ function SearchPage() {
|
|||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={result.podcast.author}>
|
{result.kind === "episode" ? (
|
||||||
<text
|
<text
|
||||||
fg={index() === fi() ? theme.surface : muted()}
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
>
|
>
|
||||||
by {result.podcast.author}
|
{result.podcast.title} ·{" "}
|
||||||
|
{formatDate(result.episode.pubDate)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
) : (
|
||||||
|
<Show when={result.podcast.author}>
|
||||||
|
<text
|
||||||
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
|
paddingLeft={2}
|
||||||
|
>
|
||||||
|
by {result.podcast.author}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -359,6 +644,10 @@ function SearchPage() {
|
|||||||
<strong>Search</strong>
|
<strong>Search</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>Type a query, press Enter to search.</text>
|
<text fg={muted()}>Type a query, press Enter to search.</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Tab toggles Shows ↔ Episodes (episode search finds guests
|
||||||
|
and topics).
|
||||||
|
</text>
|
||||||
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||||
@@ -375,62 +664,138 @@ function SearchPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(result) => (
|
{(result) => {
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
const r = result();
|
||||||
<text fg={theme.text}>
|
if (r.kind === "episode") {
|
||||||
<strong>{result().podcast.title}</strong>
|
return (
|
||||||
</text>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
<Show when={result().podcast.author}>
|
<text fg={theme.text}>
|
||||||
<text fg={muted()}>by {result().podcast.author}</text>
|
<strong>{r.episode.title}</strong>
|
||||||
</Show>
|
</text>
|
||||||
<Show when={result().podcast.description}>
|
<text fg={theme.textSecondary}>{r.podcast.title}</text>
|
||||||
<text fg={theme.textSecondary}>
|
<Show when={r.podcast.author}>
|
||||||
{result().podcast.description!.slice(0, 400) ??
|
<text fg={muted()}>by {r.podcast.author}</text>
|
||||||
"No description available."}
|
</Show>
|
||||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
<Show when={r.episode.description}>
|
||||||
</text>
|
<text fg={theme.textSecondary}>
|
||||||
</Show>
|
{r.episode.description!.slice(0, 400)}
|
||||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
<box flexDirection="row" gap={1}>
|
</text>
|
||||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
</Show>
|
||||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
<box flexDirection="row" gap={2}>
|
||||||
</For>
|
<text fg={muted()}>
|
||||||
|
Published: {formatDate(r.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<Show when={downloadLabel(r.episode.id)}>
|
||||||
|
<text fg={downloadColor(r.episode.id)}>
|
||||||
|
{downloadLabel(r.episode.id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||||
|
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={r.sourceName}>
|
||||||
|
<text fg={muted()}>Source: {r.sourceName}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<Show when={!r.podcast.isSubscribed}>
|
||||||
|
<text fg={theme.primary}>[+] Subscribe (a)</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={r.podcast.isSubscribed}>
|
||||||
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<Show
|
||||||
|
when={r.podcast.isSubscribed}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · a: subscribe · d: download · h: back to query
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · d: download · x: unsubscribe
|
||||||
|
{downloadStore.getDownloadStatus(r.episode.id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""}{" "}
|
||||||
|
· h: back to query
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
);
|
||||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
}
|
||||||
<text fg={muted()}>
|
return (
|
||||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
</text>
|
<text fg={theme.text}>
|
||||||
<Show when={result().sourceName}>
|
<strong>{r.podcast.title}</strong>
|
||||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
</text>
|
||||||
</Show>
|
<Show when={r.podcast.author}>
|
||||||
<box height={1} />
|
<text fg={muted()}>by {r.podcast.author}</text>
|
||||||
<Show when={!result().podcast.isSubscribed}>
|
</Show>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<Show when={r.podcast.description}>
|
||||||
</Show>
|
<text fg={theme.textSecondary}>
|
||||||
<Show when={result().podcast.isSubscribed}>
|
{r.podcast.description!.slice(0, 400)}
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
{(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</Show>
|
</text>
|
||||||
<box height={1} />
|
</Show>
|
||||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||||
</box>
|
<box flexDirection="row" gap={1}>
|
||||||
)}
|
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||||
|
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Feed:{" "}
|
||||||
|
{r.podcast.feedUrl ||
|
||||||
|
"not listed by source — resolves on subscribe"}
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Updated: {formatDate(r.podcast.lastUpdated)}
|
||||||
|
</text>
|
||||||
|
<Show when={r.sourceName}>
|
||||||
|
<text fg={muted()}>Source: {r.sourceName}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<Show when={!r.podcast.isSubscribed}>
|
||||||
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={r.podcast.isSubscribed}>
|
||||||
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: subscribe
|
||||||
|
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
|
||||||
|
· h: back to query
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Search · ${recents().length} recent`
|
? `Search · ${recents().length} recent`
|
||||||
: `Results · ${results().length}`;
|
: `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
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,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||||
*
|
*
|
||||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||||
* • one item per show — action item; Enter deletes all that show's
|
* • one item per subscribed show — action item; Enter deletes all that
|
||||||
* downloads (file + metadata, aborts in-flight).
|
* show's downloads (file + metadata, aborts
|
||||||
* • one item per episode — action item; Enter deletes a single download.
|
* in-flight).
|
||||||
|
* • "Unsubscribed Show Downloads" — downloads made from episode search for
|
||||||
|
* shows that aren't subscribed, grouped
|
||||||
|
* under their own header.
|
||||||
|
* • one item per episode — action item; Enter deletes a single download.
|
||||||
*
|
*
|
||||||
* Titles resolve from the feed store at render time (reactive), falling back
|
* Titles resolve from the feed store at render time (reactive), falling back
|
||||||
* to the episode id when the feed is no longer loaded. Movement flows through
|
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||||
* nav.action — no own useKeyboard (matches the other panels).
|
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||||
|
* panels).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
@@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Episode title for a download, resolved from the feed store (reactive). */
|
/** Episode title for a download, resolved from the feed store (reactive);
|
||||||
|
* falls back to the persisted title (kept for unsubscribed-show downloads). */
|
||||||
function episodeTitle(
|
function episodeTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
d: DownloadedEpisode,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(d.feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||||
return ep?.title ?? d.episodeId;
|
return ep?.title ?? d.episodeTitle ?? d.episodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show title for a download's feed id. */
|
/** Show title for a download's feed id; falls back to the persisted show
|
||||||
|
* title (unsubscribed-show downloads have no feed to resolve from). */
|
||||||
function feedTitle(
|
function feedTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
feedId: string,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
if (feed) return feed.customName || feed.podcast.title;
|
||||||
|
return d.podcastTitle ?? d.feedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDownloadItems(): SettingItem[] {
|
export function useDownloadItems(): SettingItem[] {
|
||||||
@@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Group downloads by feed so each show gets a delete-by-show item.
|
// Group downloads by feed so each subscribed show gets a delete-by-show
|
||||||
|
// item. Unsubscribed-show downloads (search downloads, synthetic feed
|
||||||
|
// ids) are kept out of these groups and listed under their own section
|
||||||
|
// below.
|
||||||
|
const unsubscribed = downloadStore.getUnsubscribedDownloads();
|
||||||
|
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
|
||||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
const arr = byFeed.get(d.feedId) ?? [];
|
const arr = byFeed.get(d.feedId) ?? [];
|
||||||
arr.push(d);
|
arr.push(d);
|
||||||
byFeed.set(d.feedId, arr);
|
byFeed.set(d.feedId, arr);
|
||||||
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||||
items.push({
|
items.push({
|
||||||
id: `feed:${feedId}`,
|
id: `feed:${feedId}`,
|
||||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
label: `Show: ${feedTitle(feedStore, eps[0])}`,
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
|
downloadStore
|
||||||
|
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
|
||||||
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// One item per individual episode download.
|
// Unsubscribed-show downloads: a section header + one item per episode.
|
||||||
|
if (unsubscribed.length > 0) {
|
||||||
|
items.push({
|
||||||
|
id: "unsubscribed-header",
|
||||||
|
label: "Unsubscribed Show Downloads",
|
||||||
|
kind: "info",
|
||||||
|
display: () => `${unsubscribed.length} files`,
|
||||||
|
help: () =>
|
||||||
|
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const d of unsubscribed) {
|
||||||
|
items.push({
|
||||||
|
id: `unsub:${d.episodeId}`,
|
||||||
|
label: episodeTitle(feedStore, d),
|
||||||
|
kind: "action",
|
||||||
|
display: () =>
|
||||||
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
|
help: () =>
|
||||||
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
|
run: () => {
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One item per individual (subscribed-show) episode download.
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: `ep:${d.episodeId}`,
|
id: `ep:${d.episodeId}`,
|
||||||
label: episodeTitle(feedStore, d),
|
label: episodeTitle(feedStore, d),
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () =>
|
display: () =>
|
||||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete this single download (file + metadata). Enter to run.`,
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
|||||||
@@ -1,38 +1,58 @@
|
|||||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
let current = value
|
let current = value;
|
||||||
return [() => current, (next) => {
|
return [
|
||||||
current = next
|
() => current,
|
||||||
}]
|
(next) => {
|
||||||
}
|
current = next;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
import { SyncStatus } from "./SyncStatus"
|
import { SyncStatus } from "./SyncStatus";
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
export function ExportDialog() {
|
export function ExportDialog() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const filename = createSignal("podcast-sync.json")
|
const filename = createSignal("podcast-sync.json");
|
||||||
const format = createSignal<"json" | "xml">("json")
|
const format = createSignal<"json" | "xml">("json");
|
||||||
|
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||||
|
const filenameRef = useInputFocusNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
<box
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
border
|
||||||
<text fg={theme.text}>File:</text>
|
title="Export"
|
||||||
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
style={{ padding: 1, flexDirection: "column", gap: 1 }}
|
||||||
</box>
|
>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<text fg={theme.text}>Format:</text>
|
<text fg={theme.text}>File:</text>
|
||||||
<tab_select
|
<input
|
||||||
options={[
|
ref={filenameRef}
|
||||||
{ name: "JSON", description: "Portable" },
|
value={filename[0]()}
|
||||||
{ name: "XML", description: "Structured" },
|
onInput={filename[1]}
|
||||||
]}
|
style={{ width: 30 }}
|
||||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
textColor={theme.text}
|
||||||
/>
|
focusedTextColor={theme.accent}
|
||||||
</box>
|
cursorColor={theme.accent}
|
||||||
<box border borderColor={theme.border}>
|
/>
|
||||||
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
|
</box>
|
||||||
</box>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<SyncStatus />
|
<text fg={theme.text}>Format:</text>
|
||||||
</box>
|
<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,31 @@
|
|||||||
import { detectFormat } from "@/utils/file-detector";
|
import { detectFormat } from "@/utils/file-detector";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
type FilePickerProps = {
|
type FilePickerProps = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilePicker(props: FilePickerProps) {
|
export function FilePicker(props: FilePickerProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const format = detectFormat(props.value);
|
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||||
|
const inputRef = useInputFocusNav();
|
||||||
|
const format = detectFormat(props.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||||
<input
|
<input
|
||||||
value={props.value}
|
ref={inputRef}
|
||||||
onInput={props.onChange}
|
value={props.value}
|
||||||
placeholder="/path/to/sync-file.json"
|
onInput={props.onChange}
|
||||||
style={{ width: 40 }}
|
placeholder="/path/to/sync-file.json"
|
||||||
/>
|
style={{ width: 40 }}
|
||||||
<text fg={theme.text}>Format: {format}</text>
|
textColor={theme.text}
|
||||||
</box>
|
focusedTextColor={theme.accent}
|
||||||
);
|
cursorColor={theme.accent}
|
||||||
|
/>
|
||||||
|
<text fg={theme.text}>Format: {format}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,38 @@
|
|||||||
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
||||||
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
||||||
* driven by the Shell router via nav.action.
|
* driven by the Shell router via nav.action.
|
||||||
|
*
|
||||||
|
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
||||||
|
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
||||||
|
* field over subscribed shows; suggestions toggle
|
||||||
|
* in/out with Space (j/k to move, Esc to browse).
|
||||||
|
* • Episode Cache Mode — date or count bound for the episode list
|
||||||
|
* (default: date)
|
||||||
|
* • Episode Cache Count — N most recent episodes when mode is count
|
||||||
|
* (default: 25)
|
||||||
|
* • Episode Cache Days — rolling N-day window when mode is date
|
||||||
|
* (default: 60)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||||
|
import { RenderableEvents, type InputRenderable } from "@opentui/core";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import type { ThemeName } from "@/types/settings";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
import {
|
||||||
|
NavMode,
|
||||||
|
useNavigation,
|
||||||
|
DEPTH_CENTER_PANE,
|
||||||
|
type PaneId,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { on } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import { TABS } from "@/utils/navigation";
|
||||||
|
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
|
||||||
|
import type { Feed } from "@/types/feed";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||||
@@ -17,13 +45,32 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
|||||||
{ value: "custom", label: "Custom" },
|
{ value: "custom", label: "Custom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "whitelist", label: "Whitelist" },
|
||||||
|
];
|
||||||
|
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "count", label: "Count" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function cacheModeLabel(mode: EpisodeCacheMode): string {
|
||||||
|
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(scope: AutoDownloadScope): string {
|
||||||
|
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
||||||
|
}
|
||||||
|
|
||||||
export function usePreferencesItems(): SettingItem[] {
|
export function usePreferencesItems(): SettingItem[] {
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
const settings = () => app.state().settings;
|
const settings = () => app.state().settings;
|
||||||
const prefs = () => app.state().preferences;
|
const prefs = () => app.state().preferences;
|
||||||
|
|
||||||
return [
|
const items: SettingItem[] = [
|
||||||
{
|
{
|
||||||
id: "theme",
|
id: "theme",
|
||||||
label: "Theme",
|
label: "Theme",
|
||||||
@@ -39,6 +86,31 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
app.setTheme(THEME_LABELS[next].value);
|
app.setTheme(THEME_LABELS[next].value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "transparentBackground",
|
||||||
|
label: "Transparent Background",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () =>
|
||||||
|
settings().transparentBackground ? "On" : "Off",
|
||||||
|
help: () =>
|
||||||
|
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updateSettings({
|
||||||
|
transparentBackground: !settings().transparentBackground,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "showSelectionMarker",
|
||||||
|
label: "Selection Marker",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (settings().showSelectionMarker ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Show the ❯ marker on the focused row of every list (tabs, shows, episodes, results).\nType: toggle\nDefault: off\nCurrent: ${settings().showSelectionMarker ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updateSettings({
|
||||||
|
showSelectionMarker: !settings().showSelectionMarker,
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "fontSize",
|
id: "fontSize",
|
||||||
label: "Font Size",
|
label: "Font Size",
|
||||||
@@ -84,11 +156,430 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
display: () => (prefs().autoDownload ? "On" : "Off"),
|
display: () => (prefs().autoDownload ? "On" : "Off"),
|
||||||
help: () =>
|
help: () =>
|
||||||
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
|
`Download the ${prefs().autoDownloadCount} most recent episodes of your shows automatically (see Count/Scope below).\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () => {
|
||||||
|
app.updatePreferences({ autoDownload: !prefs().autoDownload });
|
||||||
|
feedStore.runAutoDownload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autoDownloadCount",
|
||||||
|
label: "Auto Download Count",
|
||||||
|
kind: "number",
|
||||||
|
display: () => `${prefs().autoDownloadCount} per show`,
|
||||||
|
help: () =>
|
||||||
|
`How many of the most recent episodes to auto-download per in-scope show.\nType: number (any positive integer)\nDefault: 2\nCurrent: ${prefs().autoDownloadCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.max(1, prefs().autoDownloadCount + dir);
|
||||||
|
app.updatePreferences({ autoDownloadCount: next });
|
||||||
|
feedStore.runAutoDownload();
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Auto Download Count"
|
||||||
|
value={() => prefs().autoDownloadCount}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({ autoDownloadCount: n });
|
||||||
|
feedStore.runAutoDownload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autoDownloadScope",
|
||||||
|
label: "Auto Download Scope",
|
||||||
|
kind: "select",
|
||||||
|
display: () => scopeLabel(prefs().autoDownloadScope),
|
||||||
|
help: () =>
|
||||||
|
`Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on the focused show; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const idx = SCOPE_LABELS.findIndex(
|
||||||
|
(s) => s.value === prefs().autoDownloadScope,
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
SCOPE_LABELS[(idx + dir + SCOPE_LABELS.length) % SCOPE_LABELS.length]
|
||||||
|
.value;
|
||||||
|
app.updatePreferences({ autoDownloadScope: next });
|
||||||
|
feedStore.runAutoDownload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autoJumpToPlayer",
|
||||||
|
label: "Auto Jump to Player",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
toggle: () =>
|
toggle: () =>
|
||||||
app.updatePreferences({
|
app.updatePreferences({
|
||||||
autoDownload: !prefs().autoDownload,
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheMode",
|
||||||
|
label: "Episode Cache Mode",
|
||||||
|
kind: "select",
|
||||||
|
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const idx = CACHE_MODE_LABELS.findIndex(
|
||||||
|
(s) => s.value === prefs().episodeCacheMode,
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
CACHE_MODE_LABELS[
|
||||||
|
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
|
||||||
|
].value;
|
||||||
|
app.updatePreferences({ episodeCacheMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheCount",
|
||||||
|
label: "Episode Cache Count",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "count"
|
||||||
|
? `${prefs().episodeCacheCount} eps`
|
||||||
|
: "(date mode)",
|
||||||
|
help: () =>
|
||||||
|
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.max(1, prefs().episodeCacheCount + dir);
|
||||||
|
app.updatePreferences({ episodeCacheCount: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Count"
|
||||||
|
value={() => prefs().episodeCacheCount}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheCount: Math.max(1, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheDays",
|
||||||
|
label: "Episode Cache Days",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "date"
|
||||||
|
? `${prefs().episodeCacheDays} days`
|
||||||
|
: "(count mode)",
|
||||||
|
help: () =>
|
||||||
|
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
365,
|
||||||
|
Math.max(1, prefs().episodeCacheDays + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ episodeCacheDays: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Days"
|
||||||
|
value={() => prefs().episodeCacheDays}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheDays: Math.min(365, Math.max(1, n)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fetchMore",
|
||||||
|
label: "Fetch More",
|
||||||
|
kind: "select",
|
||||||
|
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\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 ?? "auto");
|
||||||
|
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||||
|
app.updatePreferences({ fetchMoreMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "refreshInterval",
|
||||||
|
label: "Feed Refresh Interval",
|
||||||
|
kind: "number",
|
||||||
|
display: () => `${prefs().refreshIntervalMinutes} min`,
|
||||||
|
help: () =>
|
||||||
|
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
120,
|
||||||
|
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ refreshIntervalMinutes: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Feed Refresh Interval (minutes)"
|
||||||
|
value={() => prefs().refreshIntervalMinutes}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
refreshIntervalMinutes: Math.min(120, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Whitelist management only appears while scope is set to "whitelist".
|
||||||
|
if (prefs().autoDownloadScope === "whitelist") {
|
||||||
|
items.push({
|
||||||
|
id: "autoDownloadWhitelist",
|
||||||
|
label: "Auto Download Whitelist",
|
||||||
|
kind: "editor",
|
||||||
|
display: () => `${prefs().autoDownloadWhitelist.length} shows`,
|
||||||
|
help: () =>
|
||||||
|
`Shows included in auto-download (scope: whitelist).\nSearch your subscribed shows; suggestions toggle in/out with Space.\nType: editor\nCurrent: ${prefs().autoDownloadWhitelist.length} shows`,
|
||||||
|
renderEditor: () => <WhitelistEditor />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Number editor ────────────────────────────────────────────────────────────
|
||||||
|
// Lets the user type any positive integer (Enter commits; Esc defocuses and
|
||||||
|
// j/k ±1 cycling takes over — SettingsPage's depth-2 step handler).
|
||||||
|
|
||||||
|
function NumberInputEditor(props: {
|
||||||
|
label: string;
|
||||||
|
value: () => number;
|
||||||
|
commit: (n: number) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useInputFocusNav();
|
||||||
|
const [draft, setDraft] = createSignal(String(props.value()));
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const n = Number(draft().trim());
|
||||||
|
if (!Number.isInteger(n) || n < 1) {
|
||||||
|
setError("Enter a whole number ≥ 1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.commit(n);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" padding={1} gap={1}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>{props.label}</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={theme.textMuted}>Episodes per show:</text>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
value={draft()}
|
||||||
|
onInput={(v) => {
|
||||||
|
setDraft(v);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onSubmit={submit}
|
||||||
|
focused
|
||||||
|
width={8}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<Show when={error()}>
|
||||||
|
<text fg={theme.error}>{error()}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.muted ?? theme.textMuted}>
|
||||||
|
Type a number, Enter to apply · Esc to browse (j/k ±1) · h back
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Whitelist editor ─────────────────────────────────────────────────────────
|
||||||
|
// Search field over subscribed shows + a navigable suggestion list. Space
|
||||||
|
// (toggle-select) toggles the focused show in/out of the whitelist; Enter
|
||||||
|
// does the same. While the input is focused, keys type; Esc (handled in the
|
||||||
|
// Shell) defocuses so j/k move the list.
|
||||||
|
//
|
||||||
|
// Transient UI state lives at module level so preference updates (which
|
||||||
|
// rebuild the item list) never reset the search or yank focus back into the
|
||||||
|
// input mid-browse.
|
||||||
|
//
|
||||||
|
// The nav.action listener is registered ONCE at module level, not per
|
||||||
|
// component instance: toggling a show updates preferences, which remounts
|
||||||
|
// the editor (SettingsPage re-resolves the item's renderEditor), and
|
||||||
|
// re-registering the listener via onMount/onCleanup during a bus emit
|
||||||
|
// mutates the handler set mid-iteration — the event bus then re-delivers to
|
||||||
|
// the fresh listener forever. A single stable listener guarded by an active
|
||||||
|
// flag sidesteps that entirely.
|
||||||
|
|
||||||
|
const [wlQuery, setWlQuery] = createSignal("");
|
||||||
|
const [wlCursor, setWlCursor] = createSignal(0);
|
||||||
|
const [wlTyping, setWlTyping] = createSignal(true);
|
||||||
|
let wlEditorActive = false;
|
||||||
|
// Indirection for refocusing the search input from the module-level nav.action
|
||||||
|
// listener (which cannot call useNavigation — that needs the provider).
|
||||||
|
let wlFocusInput: (() => void) | null = null;
|
||||||
|
|
||||||
|
function wlSuggestions(): Feed[] {
|
||||||
|
const q = wlQuery().trim().toLowerCase();
|
||||||
|
const all = useFeedStore().getFilteredFeeds();
|
||||||
|
if (!q) return all;
|
||||||
|
return all.filter((f) =>
|
||||||
|
(f.customName || f.podcast.title).toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the cursor inside the (possibly shrinking) suggestion list. */
|
||||||
|
function wlCursorClamped(): number {
|
||||||
|
return Math.min(wlCursor(), Math.max(wlSuggestions().length - 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wlToggle(feedId: string): void {
|
||||||
|
const app = useAppStore();
|
||||||
|
const cur = app.state().preferences.autoDownloadWhitelist ?? [];
|
||||||
|
const next = cur.includes(feedId)
|
||||||
|
? cur.filter((id) => id !== feedId)
|
||||||
|
: [...cur, feedId];
|
||||||
|
app.updatePreferences({ autoDownloadWhitelist: next });
|
||||||
|
useFeedStore().runAutoDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wlOnAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
tab: TABS;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
// Fire at most once per dispatch: the editor is only ever open inside the
|
||||||
|
// Settings tab's depth-2 pane, so scope on tab + pane and gate on the
|
||||||
|
// mount flag (which flips during remounts without re-registering).
|
||||||
|
if (!wlEditorActive) return;
|
||||||
|
if (data.tab !== TABS.SETTINGS) return;
|
||||||
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
|
const list = wlSuggestions();
|
||||||
|
if (list.length === 0) return;
|
||||||
|
switch (data.action) {
|
||||||
|
case "move-down":
|
||||||
|
setWlCursor((c) => Math.min(c + 1, list.length - 1));
|
||||||
|
break;
|
||||||
|
case "move-up":
|
||||||
|
setWlCursor((c) => Math.max(c - 1, 0));
|
||||||
|
break;
|
||||||
|
case "toggle-select":
|
||||||
|
case "open":
|
||||||
|
wlToggle(list[wlCursorClamped()].id);
|
||||||
|
break;
|
||||||
|
case "search":
|
||||||
|
// `s` while browsing re-enters typing mode (mirrors SearchPage).
|
||||||
|
wlFocusInput?.();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
on("nav.action", wlOnAction);
|
||||||
|
|
||||||
|
function WhitelistEditor() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const app = useAppStore();
|
||||||
|
|
||||||
|
const whitelist = () => app.state().preferences.autoDownloadWhitelist ?? [];
|
||||||
|
const inList = (feedId: string) => whitelist().includes(feedId);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
wlEditorActive = true;
|
||||||
|
// Restore the last typing/browsing mode across the remounts that
|
||||||
|
// preference updates trigger. nav.inputFocused() drives the input's
|
||||||
|
// focused prop (deterministic Esc-to-blur, same as SearchPage), so
|
||||||
|
// keep the store in sync with the persisted module mode.
|
||||||
|
nav.setInputFocused(wlTyping());
|
||||||
|
wlFocusInput = () => nav.setInputFocused(true);
|
||||||
|
onCleanup(() => {
|
||||||
|
wlEditorActive = false;
|
||||||
|
nav.setInputFocused(false);
|
||||||
|
wlFocusInput = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const focusNavRef = useInputFocusNav();
|
||||||
|
const inputRef = (el: InputRenderable | null | undefined) => {
|
||||||
|
focusNavRef(el);
|
||||||
|
if (el) {
|
||||||
|
// Sync the persisted mode with real focus changes so remounts
|
||||||
|
// (e.g. after a toggle) restore the right state.
|
||||||
|
el.on(RenderableEvents.FOCUSED, () => setWlTyping(true));
|
||||||
|
el.on(RenderableEvents.BLURRED, () => setWlTyping(false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" padding={1} gap={1}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>Auto Download Whitelist</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg={theme.textMuted}>Search:</text>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={wlQuery()}
|
||||||
|
onInput={setWlQuery}
|
||||||
|
focused={nav.inputFocused()}
|
||||||
|
placeholder="Type to filter shows…"
|
||||||
|
width={30}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<Show when={wlSuggestions().length === 0}>
|
||||||
|
<text fg={theme.muted ?? theme.textMuted}>
|
||||||
|
No subscribed shows match.
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<For each={wlSuggestions()}>
|
||||||
|
{(feed, index) => {
|
||||||
|
// While the input is focused (typing), no row shows the
|
||||||
|
// accent highlight or `❯` — only the input is "in focus".
|
||||||
|
const focused = () =>
|
||||||
|
!nav.inputFocused() && index() === wlCursorClamped();
|
||||||
|
const ref = useScrollIntoView(focused);
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
const bg = () => (focused() ? theme.primary : undefined);
|
||||||
|
const fg = () => (focused() ? theme.surface : theme.text);
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
setWlCursor(index());
|
||||||
|
// Click toggles membership directly (works even
|
||||||
|
// while typing, where Space is input text).
|
||||||
|
wlToggle(feed.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>{focused() ? marker() : " "}</text>
|
||||||
|
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
|
||||||
|
<text fg={fg()}>
|
||||||
|
{feed.customName || feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<text fg={theme.muted ?? theme.textMuted}>
|
||||||
|
Type to search · Esc to browse · j/k move · Space toggles · s to
|
||||||
|
type · h back
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* Renders entirely through `<PaneRow>` (parent | current | preview):
|
* Renders entirely through `<PaneRow>` (parent | current | preview):
|
||||||
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
* 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
|
* current = the current-depth list (or editor at depth 2); the only
|
||||||
* focusable column.
|
* focusable column.
|
||||||
* preview = help/preview text for the hovered item in current.
|
* preview = help/preview text for the hovered item in current.
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type PaneId,
|
type PaneId,
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||||
import { usePreferencesItems } from "./PreferencesPanel";
|
import { usePreferencesItems } from "./PreferencesPanel";
|
||||||
@@ -37,6 +38,7 @@ import { useDownloadItems } from "./DownloadManager";
|
|||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const SettingsPaneCount = 1;
|
export const SettingsPaneCount = 1;
|
||||||
|
|
||||||
@@ -45,29 +47,38 @@ const SECTIONS: SettingsSectionDef[] = [
|
|||||||
id: 0,
|
id: 0,
|
||||||
label: "Sync",
|
label: "Sync",
|
||||||
description: "Import/export subscriptions and sync status.",
|
description: "Import/export subscriptions and sync status.",
|
||||||
|
icon: NF_ICONS.sync,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
label: "Sources",
|
label: "Sources",
|
||||||
description: "Podcast search/RSS sources — add, enable, remove.",
|
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||||
|
icon: NF_ICONS.sources,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
label: "Preferences",
|
label: "Preferences",
|
||||||
description: "Theme, font, playback speed, explicit/auto-download.",
|
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||||
|
icon: NF_ICONS.preferences,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
label: "Visualizer",
|
label: "Visualizer",
|
||||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
|
||||||
|
icon: NF_ICONS.visualizer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
label: "Downloads",
|
label: "Downloads",
|
||||||
description: "Manage downloaded episodes — delete by show or individually.",
|
description: "Manage downloaded episodes — delete by show or individually.",
|
||||||
|
icon: NF_ICONS.downloads,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Static: detection never changes mid-session. Module-level because the Row
|
||||||
|
// component below (a sibling module function) needs it too.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
|
|
||||||
/** Resolve the items for a section id at render time. */
|
/** Resolve the items for a section id at render time. */
|
||||||
function sectionItems(sectionId: number): SettingItem[] {
|
function sectionItems(sectionId: number): SettingItem[] {
|
||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
@@ -129,7 +140,6 @@ export function SettingsPage() {
|
|||||||
function open() {
|
function open() {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 0) {
|
if (d === 0) {
|
||||||
// drill into the focused section's items
|
|
||||||
const id = focusedSection().id;
|
const id = focusedSection().id;
|
||||||
nav.pushDepth({
|
nav.pushDepth({
|
||||||
kind: `settings:${id}`,
|
kind: `settings:${id}`,
|
||||||
@@ -195,7 +205,6 @@ export function SettingsPage() {
|
|||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 2) {
|
if (d === 2) {
|
||||||
// editor: j/k nudges the value
|
|
||||||
const it = editorItem();
|
const it = editorItem();
|
||||||
if (it?.kind === "number" || it?.kind === "select")
|
if (it?.kind === "number" || it?.kind === "select")
|
||||||
it.cycle?.(delta as -1 | 1);
|
it.cycle?.(delta as -1 | 1);
|
||||||
@@ -209,7 +218,6 @@ export function SettingsPage() {
|
|||||||
pane: PaneId;
|
pane: PaneId;
|
||||||
mode: NavMode;
|
mode: NavMode;
|
||||||
}) => {
|
}) => {
|
||||||
// ignore actions meant for non-center panes
|
|
||||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
const handler = PAGE_ACTIONS[data.action];
|
const handler = PAGE_ACTIONS[data.action];
|
||||||
@@ -267,12 +275,6 @@ export function SettingsPage() {
|
|||||||
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
||||||
return editorItem()?.label ?? "Editor";
|
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) ────────────────
|
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
||||||
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
||||||
@@ -292,6 +294,7 @@ export function SettingsPage() {
|
|||||||
{(section, index) => (
|
{(section, index) => (
|
||||||
<Row
|
<Row
|
||||||
label={section.label}
|
label={section.label}
|
||||||
|
icon={section.icon}
|
||||||
focused={index() === focusedSectionIdx()}
|
focused={index() === focusedSectionIdx()}
|
||||||
active={false}
|
active={false}
|
||||||
/>
|
/>
|
||||||
@@ -321,6 +324,7 @@ export function SettingsPage() {
|
|||||||
{(section, index) => (
|
{(section, index) => (
|
||||||
<Row
|
<Row
|
||||||
label={section.label}
|
label={section.label}
|
||||||
|
icon={section.icon}
|
||||||
focused={index() === focusedSectionIdx()}
|
focused={index() === focusedSectionIdx()}
|
||||||
active={isActive()}
|
active={isActive()}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -384,9 +388,7 @@ export function SettingsPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={parentLabel}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -415,6 +417,7 @@ function Row(props: {
|
|||||||
focused: boolean;
|
focused: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
icon?: string;
|
||||||
onMouseDown?: () => void;
|
onMouseDown?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -424,19 +427,25 @@ function Row(props: {
|
|||||||
: props.focused
|
: props.focused
|
||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: 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);
|
const ref = useScrollIntoView(() => props.focused);
|
||||||
|
const marker = useSelectionMarker();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={bg()}
|
backgroundColor={bg()}
|
||||||
onMouseDown={props.onMouseDown}
|
onMouseDown={props.onMouseDown}
|
||||||
>
|
>
|
||||||
<text fg={fg()}>{props.focused ? "❯" : " "}</text>
|
<text fg={fg()}>{props.focused ? marker() : " "}</text>
|
||||||
|
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
|
||||||
<text fg={fg()}>{props.label}</text>
|
<text fg={fg()}>{props.label}</text>
|
||||||
<Show when={props.value}>
|
<Show when={props.value}>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
|
|||||||
@@ -11,15 +11,24 @@
|
|||||||
* right-pane key conflicts).
|
* right-pane key conflicts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show, onMount } from "solid-js";
|
||||||
|
import { Renderable } from "@opentui/core";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
import { useDialog } from "@/ui/dialog";
|
||||||
|
import { useToast } from "@/ui/toast";
|
||||||
|
import {
|
||||||
|
resolveSourceCredentials,
|
||||||
|
savePodcastIndexCredentials,
|
||||||
|
} from "@/utils/source-credentials";
|
||||||
import { SourceType } from "@/types/source";
|
import { SourceType } from "@/types/source";
|
||||||
import type { PodcastSource } from "@/types/source";
|
import type { PodcastSource } from "@/types/source";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
export function useSourceItems(): SettingItem[] {
|
export function useSourceItems(): SettingItem[] {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
const typeBadge = (s: PodcastSource) =>
|
const typeBadge = (s: PodcastSource) =>
|
||||||
s.type === SourceType.API
|
s.type === SourceType.API
|
||||||
@@ -47,8 +56,20 @@ export function useSourceItems(): SettingItem[] {
|
|||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
s.id === "podcastindex"
|
||||||
toggle: () => feedStore.toggleSource(s.id),
|
? `Source: ${s.name} (open podcast directory)\nEnabled: ${s.enabled}\nSpace to ${s.enabled ? "disable" : "enable"}: enabling asks for API keys.\nKeys are masked in the UI and stored in the macOS keychain\n(encrypted at rest), falling back to config.json when the\nkeychain is unavailable; they are kept when disabled.`
|
||||||
|
: `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () => {
|
||||||
|
// Enabling Podcast Index requires credentials: ask first
|
||||||
|
// (prefilled with the stored key, masked) instead of flipping
|
||||||
|
// the source into a key-less "on" state. Disabling never
|
||||||
|
// clears the stored credentials.
|
||||||
|
if (s.id === "podcastindex" && !s.enabled) {
|
||||||
|
dialog.push(() => <PodcastIndexCredentialsDialog />);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedStore.toggleSource(s.id);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +82,9 @@ function AddSourceForm() {
|
|||||||
const [name, setName] = createSignal("");
|
const [name, setName] = createSignal("");
|
||||||
const [url, setUrl] = createSignal("");
|
const [url, setUrl] = createSignal("");
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
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 submit = () => {
|
||||||
const u = url().trim();
|
const u = url().trim();
|
||||||
@@ -94,15 +118,20 @@ function AddSourceForm() {
|
|||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>Name:</text>
|
<text fg={theme.textMuted}>Name:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={nameRef}
|
||||||
value={name()}
|
value={name()}
|
||||||
onInput={setName}
|
onInput={setName}
|
||||||
placeholder="My Custom Feed"
|
placeholder="My Custom Feed"
|
||||||
width={25}
|
width={25}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>URL:</text>
|
<text fg={theme.textMuted}>URL:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={urlRef}
|
||||||
value={url()}
|
value={url()}
|
||||||
onInput={(v) => {
|
onInput={(v) => {
|
||||||
setUrl(v);
|
setUrl(v);
|
||||||
@@ -110,6 +139,9 @@ function AddSourceForm() {
|
|||||||
}}
|
}}
|
||||||
placeholder="https://example.com/feed.rss"
|
placeholder="https://example.com/feed.rss"
|
||||||
width={35}
|
width={35}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
@@ -139,3 +171,152 @@ function AddSourceForm() {
|
|||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mask a stored credential for prefill: first 3 chars then "...". */
|
||||||
|
const maskCredential = (value: string): string => `${value.slice(0, 3)}...`;
|
||||||
|
|
||||||
|
/** Credentials popup shown when enabling the Podcast Index source. Prefilled
|
||||||
|
* (masked) with stored credentials so re-enabling just needs Enter; leaving
|
||||||
|
* a masked field untouched keeps the stored value. Credentials are saved to
|
||||||
|
* the macOS keychain (encrypted at rest) with a plaintext config.json
|
||||||
|
* fallback when the keychain is unavailable. */
|
||||||
|
function PodcastIndexCredentialsDialog() {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const dialog = useDialog();
|
||||||
|
const toast = useToast();
|
||||||
|
const source = feedStore.sources().find((s) => s.id === "podcastindex");
|
||||||
|
const [key, setKey] = createSignal("");
|
||||||
|
const [secret, setSecret] = createSignal("");
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
// Yield navigation keybinds to the Shell router while an input is focused.
|
||||||
|
const keyRef = useInputFocusNav();
|
||||||
|
const secretRef = useInputFocusNav();
|
||||||
|
let keyEl: Renderable | null | undefined;
|
||||||
|
let secretEl: Renderable | null | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Prefill stored credentials (masked) when re-enabling after a
|
||||||
|
// disable — toggling off never clears them. Masked either way, so a
|
||||||
|
// plaintext-stored key never appears in full in the UI.
|
||||||
|
if (source) {
|
||||||
|
resolveSourceCredentials(source)
|
||||||
|
.then((stored) => {
|
||||||
|
if (stored?.apiKey) setKey(maskCredential(stored.apiKey));
|
||||||
|
if (stored?.apiSecret) setSecret(maskCredential(stored.apiSecret));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
setTimeout(() => keyEl?.focus(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving()) return;
|
||||||
|
const stored = source
|
||||||
|
? await resolveSourceCredentials(source).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const keyValue = key().trim();
|
||||||
|
const secretValue = secret().trim();
|
||||||
|
// A field still showing its masked prefill means "keep what's stored".
|
||||||
|
const apiKey =
|
||||||
|
stored?.apiKey && keyValue === maskCredential(stored.apiKey)
|
||||||
|
? stored.apiKey
|
||||||
|
: keyValue;
|
||||||
|
const apiSecret =
|
||||||
|
stored?.apiSecret && secretValue === maskCredential(stored.apiSecret)
|
||||||
|
? stored.apiSecret
|
||||||
|
: secretValue;
|
||||||
|
if (!apiKey || !apiSecret) {
|
||||||
|
setError(
|
||||||
|
"Both API key and secret are required (free at podcastindex.org)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const ok = await savePodcastIndexCredentials(apiKey, apiSecret).catch(
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
setSaving(false);
|
||||||
|
if (!ok) {
|
||||||
|
// Keychain unavailable (non-macOS, locked, sandboxed): plaintext
|
||||||
|
// fallback on the source so the fallback search still works.
|
||||||
|
feedStore.updateSource("podcastindex", {
|
||||||
|
hasCredentials: true,
|
||||||
|
credentialStorage: "plaintext",
|
||||||
|
apiKey,
|
||||||
|
apiSecret,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
toast.show({
|
||||||
|
title: "Credentials stored in config.json",
|
||||||
|
message: "macOS keychain unavailable — API keys saved unencrypted.",
|
||||||
|
variant: "warning",
|
||||||
|
});
|
||||||
|
dialog.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedStore.updateSource("podcastindex", {
|
||||||
|
hasCredentials: true,
|
||||||
|
credentialStorage: "keychain",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
dialog.pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
title="Podcast Index API Keys"
|
||||||
|
padding={1}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
Free key + secret from https://podcastindex.org/. Used as a
|
||||||
|
fallback when other sources return fewer than 3 results.
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={theme.text}>API Key:</text>
|
||||||
|
<input
|
||||||
|
ref={(el: Renderable | null | undefined) => {
|
||||||
|
keyRef(el);
|
||||||
|
keyEl = el;
|
||||||
|
}}
|
||||||
|
value={key()}
|
||||||
|
onInput={setKey}
|
||||||
|
onSubmit={() => secretEl?.focus()}
|
||||||
|
placeholder="e.g. UXKCGDSYGUUEVQJSYDZH"
|
||||||
|
width={30}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={theme.text}>API Secret:</text>
|
||||||
|
<input
|
||||||
|
ref={(el: Renderable | null | undefined) => {
|
||||||
|
secretRef(el);
|
||||||
|
secretEl = el;
|
||||||
|
}}
|
||||||
|
value={secret()}
|
||||||
|
onInput={setSecret}
|
||||||
|
onSubmit={() => save()}
|
||||||
|
placeholder="e.g. yzJe2eE7XV-3eY576dyRZ6wXyAbndh6LUrCZ8KN|"
|
||||||
|
width={40}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
|
||||||
|
<Show when={saving()}>
|
||||||
|
<text fg={theme.textMuted}>Storing credentials...</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
[Enter] save · [Esc] cancel — keys stay stored when disabled.
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,21 +3,13 @@
|
|||||||
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { ImportDialog } from "./ImportDialog";
|
import { ImportDialog } from "./ImportDialog";
|
||||||
import { ExportDialog } from "./ExportDialog";
|
import { ExportDialog } from "./ExportDialog";
|
||||||
import { SyncStatus } from "./SyncStatus";
|
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
// Module-level state so the action items can open their dialogs as depth-2
|
// closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
|
||||||
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
|
// (the syncEditor signal) was removed as dead — nothing ever read it.
|
||||||
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
|
export function closeSyncEditor() {}
|
||||||
null,
|
|
||||||
);
|
|
||||||
export { syncEditor };
|
|
||||||
export function closeSyncEditor() {
|
|
||||||
setSyncEditor(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSyncItems(): SettingItem[] {
|
export function useSyncItems(): SettingItem[] {
|
||||||
return [
|
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 />;
|
|
||||||
}
|
|
||||||
|
|||||||