Compare commits
33 Commits
v0.2.1
...
6134dea044
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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: |
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -34,3 +34,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.harness/
|
.harness/
|
||||||
.ralpi
|
.ralpi
|
||||||
|
notes.md
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
242
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,12 +51,16 @@ 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
|
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
|
||||||
> side by side (see [Packaging model](#packaging-model)). It does **not**
|
through a copy of mpv that lives **inside the bundle**, so macOS attributes
|
||||||
> depend on Bun.
|
the Now Playing session to PodTui — the Control Center / lock-screen entry
|
||||||
|
shows the PodTui name and icon, and podcast cover art as its artwork —
|
||||||
|
rather than a blank placeholder for an unbundled binary. Installers can drop
|
||||||
|
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
|
||||||
|
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
|
||||||
|
|
||||||
### 2. Standalone tarball (all platforms)
|
### 2. Standalone tarball (all platforms)
|
||||||
|
|
||||||
@@ -71,69 +79,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 +113,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 +188,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 "Fetch More" row). 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 |
BIN
assets/App Icon/AppIcon.icns
Normal file
132
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,132 @@ 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
|
||||||
|
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
|
||||||
|
// Playing session to PodTui — the source-app icon + name in Control
|
||||||
|
// Center / lock screen — instead of a blank placeholder for an
|
||||||
|
// unbundled binary. AudioPlayer's resolver prefers this sibling.
|
||||||
|
if (platform === "darwin") {
|
||||||
|
const appRoot = join(tarRoot, "PodTui.app");
|
||||||
|
const macosDir = join(appRoot, "Contents", "MacOS");
|
||||||
|
const resDir = join(appRoot, "Contents", "Resources");
|
||||||
|
mkdirSync(macosDir, { recursive: true });
|
||||||
|
mkdirSync(resDir, { recursive: true });
|
||||||
|
|
||||||
|
copyFileSync(outfile, join(macosDir, "podtui"));
|
||||||
|
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
|
||||||
|
const s = join("dist", lib);
|
||||||
|
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
|
||||||
|
const mpvPath =
|
||||||
|
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
|
||||||
|
if (mpvPath) {
|
||||||
|
copyFileSync(mpvPath, join(macosDir, "mpv"));
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
|
||||||
|
if (existsSync(icnsSrc)) {
|
||||||
|
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
|
||||||
|
Bun.write(
|
||||||
|
join(appRoot, "Contents", "Info.plist"),
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>PodTui</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>PodTui</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.mikefreno.podtui</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>podtui</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.3.1</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.3.1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>12.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
|
||||||
|
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
|
||||||
|
// ID cert for release builds); default ad-hoc.
|
||||||
|
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
|
||||||
|
const sign = Bun.spawnSync([
|
||||||
|
"codesign",
|
||||||
|
"--force",
|
||||||
|
"--deep",
|
||||||
|
"-s",
|
||||||
|
signIdentity,
|
||||||
|
appRoot,
|
||||||
|
]);
|
||||||
|
if (sign.exitCode !== 0) {
|
||||||
|
console.warn(
|
||||||
|
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
|
||||||
|
// resolves the Now Playing client from the registering process's
|
||||||
|
// code-signing identifier — without an explicit --identifier codesign
|
||||||
|
// stamps "mpv" (its basename) and the audio center shows a blank
|
||||||
|
// placeholder. Must run after the bundle sign above (a later bundle
|
||||||
|
// re-seal would re-derive the basename identifier).
|
||||||
|
const signMpv = Bun.spawnSync([
|
||||||
|
"codesign",
|
||||||
|
"--force",
|
||||||
|
"-s",
|
||||||
|
signIdentity,
|
||||||
|
"--identifier",
|
||||||
|
"com.mikefreno.podtui",
|
||||||
|
join(macosDir, "mpv"),
|
||||||
|
]);
|
||||||
|
if (signMpv.exitCode !== 0) {
|
||||||
|
console.warn(
|
||||||
|
`Warning: nested mpv signing failed (${signMpv.stderr
|
||||||
|
.toString()
|
||||||
|
.trim()}) — Now Playing attribution won't work`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`App bundle: ${appRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -18,21 +18,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/uuid": "^11.0.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"eslint": "^9.39.2",
|
"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",
|
"solid-js": "^1.9.9"
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { PodcastSource } from "../types/source"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import { parseRSSFeed } from "./rss-parser"
|
|
||||||
|
|
||||||
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
|
|
||||||
return {
|
|
||||||
id: `${sourceId}-${podcast.id}`,
|
|
||||||
podcast,
|
|
||||||
episodes: [],
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isPinned: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
|
|
||||||
if (!source.baseUrl) return []
|
|
||||||
const response = await fetch(source.baseUrl)
|
|
||||||
if (!response.ok) return []
|
|
||||||
const xml = await response.text()
|
|
||||||
const parsed = parseRSSFeed(xml, source.baseUrl)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: `${source.id}-${parsed.feedUrl}`,
|
|
||||||
podcast: {
|
|
||||||
id: parsed.id,
|
|
||||||
title: parsed.title,
|
|
||||||
description: parsed.description,
|
|
||||||
feedUrl: parsed.feedUrl,
|
|
||||||
author: parsed.author,
|
|
||||||
categories: parsed.categories,
|
|
||||||
lastUpdated: parsed.lastUpdated,
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: parsed.episodes,
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId: source.id,
|
|
||||||
lastUpdated: parsed.lastUpdated,
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleAPISource = async (
|
|
||||||
source: PodcastSource,
|
|
||||||
query: string
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
|
|
||||||
url.searchParams.set("term", query || "podcast")
|
|
||||||
url.searchParams.set("media", "podcast")
|
|
||||||
url.searchParams.set("entity", "podcast")
|
|
||||||
url.searchParams.set("country", source.country || "US")
|
|
||||||
url.searchParams.set("lang", source.language || "en_us")
|
|
||||||
|
|
||||||
const response = await fetch(url.toString())
|
|
||||||
if (!response.ok) return []
|
|
||||||
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
|
|
||||||
const results = data.results ?? []
|
|
||||||
|
|
||||||
return results
|
|
||||||
.filter((item) => item.collectionName && item.feedUrl)
|
|
||||||
.map((item) => {
|
|
||||||
const podcast: Podcast = {
|
|
||||||
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
|
|
||||||
title: item.collectionName || "Untitled Podcast",
|
|
||||||
description: item.collectionName || "",
|
|
||||||
feedUrl: item.feedUrl || "",
|
|
||||||
author: item.artistName,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
}
|
|
||||||
return buildFeedFromPodcast(podcast, source.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleCustomSource = async (
|
|
||||||
source: PodcastSource,
|
|
||||||
query: string
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
if (!query) return []
|
|
||||||
const podcast: Podcast = {
|
|
||||||
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
|
|
||||||
title: `${query} Highlights`,
|
|
||||||
description: `Curated results for ${query}`,
|
|
||||||
feedUrl: source.baseUrl || "",
|
|
||||||
author: source.name,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
}
|
|
||||||
return [buildFeedFromPodcast(podcast, source.id)]
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
import { createSignal, createMemo, onCleanup } from "solid-js";
|
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
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 = [1, 2, 2]` contract: three columns grow at
|
||||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
||||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
||||||
* so the ratio is exact regardless of content width — a column's content can
|
* the ratio is exact regardless of content width — a column's content can
|
||||||
* never stretch its slot.
|
* 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 1/5 slot when blank (never collapses to width 0).
|
||||||
|
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||||
|
* header row: the CURRENT column's title renders top-left in the
|
||||||
|
* parent's slot (the panes above current/preview were removed).
|
||||||
* current — the current-depth list. The only focusable content column; it
|
* 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,19 +46,19 @@ 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). */
|
||||||
@@ -69,16 +72,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 +99,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 +117,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 +157,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 +169,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).
|
||||||
@@ -179,29 +184,29 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
|
|
||||||
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 (1/5) — 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={["left", "right"]}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (2/5) — 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,43 +11,48 @@
|
|||||||
* 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";
|
||||||
|
|
||||||
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();
|
||||||
const t = theme.theme;
|
const t = theme.theme;
|
||||||
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 +88,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 ───────────────────────────────────────────────
|
||||||
@@ -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,54 @@ 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 on a 300ms timer when its text overflows; when it fits
|
||||||
|
// (or the bar is too narrow to show anything) it renders statically.
|
||||||
|
const dims = useTerminalDimensions();
|
||||||
|
const GAP = 3;
|
||||||
|
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;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setScrollOffset((o) => (o + 1) % cycle);
|
||||||
|
}, 150);
|
||||||
|
onCleanup(() => clearInterval(id));
|
||||||
|
});
|
||||||
|
|
||||||
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 +308,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 +318,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 +331,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>
|
||||||
@@ -458,18 +497,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,7 +19,9 @@ 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",
|
||||||
@@ -30,14 +32,27 @@ const TAB_LABEL: Record<TABS, string> = {
|
|||||||
[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 }) {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const cursor = () => nav.tabCursor();
|
const cursor = () => nav.tabCursor();
|
||||||
const activeTab = () => nav.activeTab();
|
const activeTab = () => nav.activeTab();
|
||||||
@@ -53,7 +68,11 @@ export function TabListPane(props: { muted?: boolean }) {
|
|||||||
? 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}>
|
||||||
@@ -77,9 +96,22 @@ export function TabListPane(props: { muted?: boolean }) {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(tab)}
|
backgroundColor={focusBg(tab)}
|
||||||
|
onMouseDown={() => {
|
||||||
|
// Click = hover + open, the yazi "open" of the row
|
||||||
|
// (switches to the tab and enters its content), the same
|
||||||
|
// as l/Enter. Restores mouse support the tab-strip
|
||||||
|
// refactor dropped.
|
||||||
|
nav.setTabCursor(tab);
|
||||||
|
nav.activateTabCursor();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||||
|
{nerd && (
|
||||||
|
<text fg={focusFg(tab)} paddingRight={1}>
|
||||||
|
{TAB_ICON[tab]}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
||||||
<text fg={labelFg()} paddingLeft={1}>
|
<text fg={labelFg()} paddingLeft={1}>
|
||||||
{TAB_LABEL[tab]}
|
{TAB_LABEL[tab]}
|
||||||
|
|||||||
@@ -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,9 +51,11 @@
|
|||||||
"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)
|
||||||
@@ -63,12 +66,17 @@
|
|||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
"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]
|
|
||||||
@@ -68,24 +68,14 @@ export type KeybindActionName =
|
|||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
| "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 +136,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 +144,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 +161,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 +170,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 +328,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 +345,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";
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
generateSubtleSyntax,
|
generateSubtleSyntax,
|
||||||
} from "../utils/syntax-highlighter";
|
} from "../utils/syntax-highlighter";
|
||||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
||||||
|
import { detectModeFromBackground } from "../utils/system-theme";
|
||||||
import { createSimpleContext } from "./helper";
|
import { createSimpleContext } from "./helper";
|
||||||
import {
|
import {
|
||||||
setupThemeSignalHandler,
|
setupThemeSignalHandler,
|
||||||
@@ -84,6 +86,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 +98,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.
|
||||||
*
|
*
|
||||||
@@ -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 (1/5 : 2/5 : 2/5 in the final remake) 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,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
|
import { unlinkSync } from "fs";
|
||||||
|
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
@@ -109,6 +111,11 @@ function registerExitTeardown(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* best-effort at exit */
|
/* best-effort at exit */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
unlinkSync(coverTempPath());
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
process.on("exit", teardown);
|
process.on("exit", teardown);
|
||||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||||
@@ -122,23 +129,26 @@ function registerExitTeardown(): void {
|
|||||||
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 || !isPlaying() || pollInFlight) return;
|
||||||
|
pollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const pos = await backend.getPosition();
|
const pos = await backend.getPosition();
|
||||||
const dur = await backend.getDuration();
|
const dur = await backend.getDuration();
|
||||||
setPosition(pos);
|
setPosition(pos);
|
||||||
if (dur > 0) setDuration(dur);
|
if (dur > 0) setDuration(dur);
|
||||||
|
|
||||||
// Save progress every ~5 seconds (10 ticks * 500ms)
|
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (pollCount % 10 === 0) {
|
if (pollCount % 33 === 0) {
|
||||||
const ep = currentEpisode();
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore();
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
// Update platform media position
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.setPosition(pos);
|
media.setPosition(pos);
|
||||||
}
|
}
|
||||||
@@ -157,8 +167,10 @@ function startPolling(): void {
|
|||||||
}
|
}
|
||||||
} 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 +180,11 @@ 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.
|
||||||
|
|
||||||
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 +201,13 @@ 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 || "";
|
||||||
|
const coverArtPath = feed?.podcast.coverUrl
|
||||||
|
? await fetchCoverArt(feed.podcast.coverUrl)
|
||||||
|
: null;
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -195,6 +219,8 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
|
mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
@@ -207,7 +233,7 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
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,6 +241,9 @@ 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);
|
||||||
@@ -285,7 +314,6 @@ async function stop(): Promise<void> {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
// Clear platform media controls
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -332,12 +360,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 +371,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,10 +384,22 @@ 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 {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore
|
||||||
|
.feeds()
|
||||||
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const coverArtPath = feed?.podcast.coverUrl
|
||||||
|
? await fetchCoverArt(feed.podcast.coverUrl)
|
||||||
|
: null;
|
||||||
await backend.play(ep.audioUrl, {
|
await backend.play(ep.audioUrl, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
|
mediaTitle: podcastTitle
|
||||||
|
? `${podcastTitle} — ${ep.title}`
|
||||||
|
: ep.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
startPolling();
|
startPolling();
|
||||||
@@ -388,14 +422,10 @@ export function useAudio(): AudioControls {
|
|||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed from app store
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,14 +460,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 +546,6 @@ export function useAudio(): AudioControls {
|
|||||||
unsubMediaToggle();
|
unsubMediaToggle();
|
||||||
unsubMediaVolUp();
|
unsubMediaVolUp();
|
||||||
unsubMediaVolDown();
|
unsubMediaVolDown();
|
||||||
unsubMediaSeekFwd();
|
|
||||||
unsubMediaSeekBack();
|
|
||||||
unsubMediaSpeed();
|
unsubMediaSpeed();
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
|
|||||||
@@ -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,16 +56,6 @@ 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":
|
||||||
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 ? "❯" : " ";
|
||||||
|
}
|
||||||
317
src/index.tsx
@@ -1,4 +1,7 @@
|
|||||||
const VERSION = "0.2.1";
|
import type { Feed } from "./types/feed"
|
||||||
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
|
const VERSION = "0.3.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
@@ -37,160 +40,182 @@ if (cliArgs.version) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Find the most recent episode across all feeds */
|
||||||
|
function findLatestEpisode(
|
||||||
|
feeds: Feed[],
|
||||||
|
): { feed: Feed; episode: Episode } | null {
|
||||||
|
let latest: { feed: Feed; episode: Episode } | null = null
|
||||||
|
let latestDate = 0
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (feed.episodes.length === 0) continue
|
||||||
|
const ep = feed.episodes[0]
|
||||||
|
const epDate =
|
||||||
|
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
|
||||||
|
if (epDate > latestDate) {
|
||||||
|
latestDate = epDate
|
||||||
|
latest = { feed, episode: ep }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search feeds by title and print matching shows */
|
||||||
|
function handleQuery(feeds: Feed[], query: string): void {
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
const matches = feeds.filter((feed) => {
|
||||||
|
const title = feed.podcast.title.toLowerCase()
|
||||||
|
return title.includes(normalizedQuery)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
console.log(`No shows found matching: ${query}`)
|
||||||
|
if (feeds.length > 0) {
|
||||||
|
console.log("\nAvailable shows:")
|
||||||
|
feeds.slice(0, 5).forEach((feed) => {
|
||||||
|
console.log(` - ${feed.podcast.title}`)
|
||||||
|
})
|
||||||
|
if (feeds.length > 5) {
|
||||||
|
console.log(` ... and ${feeds.length - 5} more`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const feed = matches[0]
|
||||||
|
console.log(`\n${feed.podcast.title}`)
|
||||||
|
if (feed.podcast.description) {
|
||||||
|
console.log(
|
||||||
|
feed.podcast.description.substring(0, 200) +
|
||||||
|
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
const date =
|
||||||
|
ep.pubDate instanceof Date
|
||||||
|
? ep.pubDate.toLocaleDateString()
|
||||||
|
: String(ep.pubDate)
|
||||||
|
console.log(` ${idx + 1}. ${ep.title} (${date})`)
|
||||||
|
})
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nClosest matches for "${query}":`)
|
||||||
|
matches.slice(0, 5).forEach((feed, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${feed.podcast.title}`)
|
||||||
|
})
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve and play an episode from `arg` (title path or "latest") */
|
||||||
|
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||||
|
const normalizedArg = arg.toLowerCase()
|
||||||
|
|
||||||
|
let feedResult: Feed | null = null
|
||||||
|
let episodeResult: Episode | null = null
|
||||||
|
|
||||||
|
if (normalizedArg === "latest") {
|
||||||
|
const latest = findLatestEpisode(feeds)
|
||||||
|
if (latest) {
|
||||||
|
feedResult = latest.feed
|
||||||
|
episodeResult = latest.episode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parts = normalizedArg.split("/")
|
||||||
|
const showQuery = parts[0]
|
||||||
|
const episodeQuery = parts[1]
|
||||||
|
|
||||||
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingFeeds.length === 0) {
|
||||||
|
console.log(`No show found matching: ${showQuery}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = matchingFeeds[0]
|
||||||
|
|
||||||
|
if (!episodeQuery) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = feed.episodes[0]
|
||||||
|
} else {
|
||||||
|
console.log(`No episodes available for: ${feed.podcast.title}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else if (episodeQuery === "latest") {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = feed.episodes[0]
|
||||||
|
} else {
|
||||||
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
|
ep.title.toLowerCase().includes(episodeQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingEpisode) {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = matchingEpisode
|
||||||
|
} else {
|
||||||
|
console.log(`Episode not found: ${episodeQuery}`)
|
||||||
|
console.log(`Available episodes for ${feed.podcast.title}:`)
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ep.title}`)
|
||||||
|
})
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedResult || !episodeResult) {
|
||||||
|
console.log("Could not find episode to play")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nPlaying: ${episodeResult.title}`)
|
||||||
|
console.log(`Show: ${feedResult.podcast.title}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAudioBackend } = await import("./utils/audio-player")
|
||||||
|
const { 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.
|
||||||
|
const coverArtPath = feedResult.podcast.coverUrl
|
||||||
|
? await fetchCoverArt(feedResult.podcast.coverUrl)
|
||||||
|
: null
|
||||||
|
await backend.play(episodeResult.audioUrl, {
|
||||||
|
mediaTitle: `${feedResult.podcast.title} — ${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,7 +2,7 @@
|
|||||||
* 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
|
* preview — detail of the hovered item (category summary, or
|
||||||
@@ -27,18 +27,24 @@ 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 { 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 { 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 depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
@@ -146,7 +152,11 @@ 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
|
||||||
@@ -154,35 +164,46 @@ function DiscoverPage() {
|
|||||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// ── 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane ───────────────────────────────────────────────────────────
|
// ── current pane ───────────────────────────────────────────────────────────
|
||||||
@@ -199,7 +220,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 +229,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>
|
||||||
);
|
);
|
||||||
@@ -223,7 +248,14 @@ function DiscoverPage() {
|
|||||||
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 +268,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 +277,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 +302,11 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={discoverStore.isLoading()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator label="Refreshing…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
@@ -373,9 +409,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,9 +16,10 @@
|
|||||||
* 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 { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
@@ -31,6 +32,7 @@ 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 { NF_ICONS, 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";
|
||||||
@@ -38,12 +40,15 @@ 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 { 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 +56,54 @@ 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[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
|
// reaching the bottom row fetches automatically (see the effect below).
|
||||||
|
const app = useAppStore();
|
||||||
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
||||||
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
|
// Total navigable rows: episodes + the optional Fetch More row.
|
||||||
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focus = () => nav.depthFocus(0);
|
const 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();
|
||||||
|
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||||
|
|
||||||
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}`,
|
||||||
@@ -118,6 +154,10 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── open ───────────────────────────────────────────────────────────────────
|
// ── open ───────────────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
|
if (focusedOnMore()) {
|
||||||
|
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
playEpisode(focusedItem());
|
playEpisode(focusedItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +176,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(() => {});
|
||||||
},
|
},
|
||||||
@@ -168,7 +220,11 @@ function FeedPage() {
|
|||||||
? 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;
|
||||||
|
|
||||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
@@ -181,7 +237,16 @@ function FeedPage() {
|
|||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
|
<Show
|
||||||
|
when={feedStore.isLoadingFeeds()}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
No feeds. Subscribe from Discover/Search.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Refreshing…" />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -194,7 +259,6 @@ function FeedPage() {
|
|||||||
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={() => {
|
||||||
@@ -204,7 +268,7 @@ function FeedPage() {
|
|||||||
>
|
>
|
||||||
<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())}>
|
||||||
{item.episode.episodeNumber
|
{item.episode.episodeNumber
|
||||||
@@ -236,60 +300,117 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={showFetchMore()}>
|
||||||
|
<box
|
||||||
|
ref={moreRef}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(episodes().length, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||||
|
{focusedOnMore() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
{nerd && (
|
||||||
|
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||||
|
{NF_ICONS.more}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<Show
|
||||||
|
when={!feedStore.isLoadingMore()}
|
||||||
|
fallback={<LoadingIndicator label="Fetching…" />}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||||
|
[Fetch More]
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator label="Refreshing…" />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||||
const previewContent = () => (
|
const previewContent = () => (
|
||||||
<Show
|
<>
|
||||||
when={focusedItem()}
|
<Show when={focusedOnMore()}>
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episode focused</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(item) => (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
<strong>
|
<strong>[Fetch More]</strong>
|
||||||
{item().episode.episodeNumber
|
|
||||||
? `#${item().episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{item().episode.title}
|
|
||||||
</strong>
|
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
|
||||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
|
||||||
<Show when={downloadLabel(item().episode.id)}>
|
|
||||||
<text fg={downloadColor(item().episode.id)}>
|
|
||||||
{downloadLabel(item().episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{item().feed.customName || item().feed.podcast.title}
|
{feedStore.isLoadingMore()
|
||||||
</text>
|
? "Loading the next batch of episodes…"
|
||||||
<Show when={item().feed.podcast.author}>
|
: fetchMoreMode() === "auto"
|
||||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
</Show>
|
: "Load the next batch of older episodes across all feeds (Enter)."}
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{item().episode.description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
</Show>
|
||||||
</Show>
|
<Show when={!focusedOnMore()}>
|
||||||
|
<Show
|
||||||
|
when={focusedItem()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{item().episode.episodeNumber
|
||||||
|
? `#${item().episode.episodeNumber} `
|
||||||
|
: ""}
|
||||||
|
{item().episode.title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||||
|
<Show when={downloadLabel(item().episode.id)}>
|
||||||
|
<text fg={downloadColor(item().episode.id)}>
|
||||||
|
{downloadLabel(item().episode.id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{item().feed.customName || item().feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={item().feed.podcast.author}>
|
||||||
|
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{item().episode.description?.slice(0, 400) ??
|
||||||
|
"No description available."}
|
||||||
|
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · d: download
|
||||||
|
{downloadStore.getDownloadStatus(item().episode.id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""}{" "}
|
||||||
|
· space: select · h back
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -297,9 +418,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,7 +2,7 @@
|
|||||||
* 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.
|
||||||
*
|
*
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { 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 { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
@@ -34,17 +35,20 @@ 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 { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const MyShowsPaneCount = 1;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
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;
|
||||||
@@ -161,6 +165,33 @@ 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() < 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(() => {});
|
||||||
@@ -199,7 +230,11 @@ export function MyShowsPage() {
|
|||||||
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 showTitle = (f: Feed) => f.customName || f.podcast.title;
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
@@ -208,7 +243,6 @@ export function MyShowsPage() {
|
|||||||
: `${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 = () => (
|
||||||
@@ -222,12 +256,11 @@ export function MyShowsPage() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), false)}>
|
<text fg={focusFg(index(), lf(), false)}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
<text fg={muted()}>({feed.episodes.length})</text>
|
||||||
@@ -257,12 +290,16 @@ export function MyShowsPage() {
|
|||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => focusedShowIdx();
|
const lf = () => focusedShowIdx();
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
const wlScope =
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist";
|
||||||
|
const wlInList = (
|
||||||
|
app.state().preferences.autoDownloadWhitelist ?? []
|
||||||
|
).includes(feed.id);
|
||||||
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={focusBg(index(), lf(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -271,7 +308,7 @@ export function MyShowsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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())}>
|
||||||
{showTitle(feed)}
|
{showTitle(feed)}
|
||||||
@@ -279,6 +316,19 @@ export function MyShowsPage() {
|
|||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||||
({feed.episodes.length})
|
({feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={wlScope}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
index() === lf()
|
||||||
|
? theme.surface
|
||||||
|
: wlInList
|
||||||
|
? theme.warning
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{wlInList ? "●" : "○"}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -304,7 +354,6 @@ export function MyShowsPage() {
|
|||||||
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={() => {
|
||||||
@@ -314,7 +363,7 @@ export function MyShowsPage() {
|
|||||||
>
|
>
|
||||||
<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())}>
|
||||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||||
@@ -343,7 +392,7 @@ export function MyShowsPage() {
|
|||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={feedStore.isLoadingMore()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator label="Loading more…" />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -378,7 +427,16 @@ export function MyShowsPage() {
|
|||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
|
<text fg={muted()}>
|
||||||
|
enter/l: open · h: back · x: unsubscribe
|
||||||
|
{app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ??
|
||||||
|
[]
|
||||||
|
).includes(show().id)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -418,7 +476,21 @@ export function MyShowsPage() {
|
|||||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
<text fg={muted()}>
|
||||||
|
enter: play · d: download
|
||||||
|
{downloadStore.getDownloadStatus(ep().id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""}
|
||||||
|
{app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
|
drilledShowId(),
|
||||||
|
)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""}{" "}
|
||||||
|
· space: select · h: back
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -429,9 +501,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show } 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 { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
@@ -79,6 +80,8 @@ export function PlayerPage() {
|
|||||||
{ep().description?.slice(0, 500) ?? "No description available."}
|
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<ProgressBar />
|
||||||
|
|
||||||
<RealtimeWaveform
|
<RealtimeWaveform
|
||||||
visualizerConfig={(() => {
|
visualizerConfig={(() => {
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
@@ -110,7 +113,7 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{"P play/pause N next B prev ◀▶ seek h back"}
|
{"P play/pause N next B prev < > seek h back"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -119,7 +122,6 @@ 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}
|
||||||
|
|||||||
68
src/pages/Player/ProgressBar.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
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}>{"\u2588".repeat(playedChars())}</text>
|
||||||
|
)}
|
||||||
|
<text fg={remainingColor}>
|
||||||
|
{"\u2591".repeat(width() - playedChars())}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type CavaCoreConfig,
|
type CavaCoreConfig,
|
||||||
} from "@/utils/cavacore";
|
} from "@/utils/cavacore";
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
|
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
@@ -25,19 +26,6 @@ export type RealtimeWaveformProps = {
|
|||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
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) */
|
/** Target frame interval in ms (~30 fps) */
|
||||||
const FRAME_INTERVAL = 33;
|
const FRAME_INTERVAL = 33;
|
||||||
|
|
||||||
@@ -53,6 +41,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
|
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
|
||||||
|
// frame against the running peak so a loud start can't pin every bar
|
||||||
|
// at full height and quiet content still gets normalized up.
|
||||||
|
const scaler = createBarScaler();
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
let cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
let reader: AudioStreamReader | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -88,6 +81,29 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Smooth position clock ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
||||||
|
// polls, interpolate the position from wall time so the FFT window
|
||||||
|
// tracks the audio continuously instead of stepping. The 0.5s cap
|
||||||
|
// prevents extrapolating far beyond reality when the player stalls
|
||||||
|
// (e.g. network re-buffering).
|
||||||
|
|
||||||
|
let lastPolledPosition = 0;
|
||||||
|
let lastPolledAt = 0;
|
||||||
|
const smoothPosition = () => {
|
||||||
|
const pos = audio.position();
|
||||||
|
const now = performance.now();
|
||||||
|
if (pos !== lastPolledPosition) {
|
||||||
|
lastPolledPosition = pos;
|
||||||
|
lastPolledAt = now;
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
if (lastPolledAt === 0) return pos;
|
||||||
|
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
|
||||||
|
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
const startVisualization = (url: string, position: number, speed: number) => {
|
||||||
@@ -98,14 +114,26 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Initialize cavacore with current resolution + any overrides.
|
// Initialize cavacore with current resolution + any overrides.
|
||||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||||
|
// autosens is disabled (after the spread so it always wins): cava's
|
||||||
|
// autosens gain-ramps during silence then clips everything to 1.0
|
||||||
|
// when audio arrives — the JS peak scaler handles dynamics instead.
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: numBars(),
|
bars: numBars(),
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
...props.visualizerConfig,
|
...props.visualizerConfig,
|
||||||
|
autosens: 0,
|
||||||
};
|
};
|
||||||
cava.init(config);
|
cava.init(config);
|
||||||
|
|
||||||
|
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
||||||
|
// uninitialized, so the first real frame would FFT garbage and
|
||||||
|
// render full-scale bars. One zero frame the size of the whole
|
||||||
|
// input buffer clears it (at 44.1kHz mono the window is 8192
|
||||||
|
// samples — FFTbassbufferSize × channels; a 512-sample frame would
|
||||||
|
// leave the tail garbage).
|
||||||
|
cava.execute(new Float64Array(8192));
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
// Pre-allocate sample read buffer
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||||
|
|
||||||
@@ -116,7 +144,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
}
|
}
|
||||||
reader.start(position, speed);
|
reader.start(position, speed);
|
||||||
|
|
||||||
// Start render loop
|
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,19 +167,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const renderFrame = () => {
|
const renderFrame = () => {
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||||
|
|
||||||
// Read available PCM samples from the stream
|
// Sample the FFT window at the player's position, not the decode
|
||||||
const count = reader.read(sampleBuffer);
|
// head — the reader decodes independently (paced at the player's
|
||||||
if (count === 0) return;
|
// clock rate with a LEAD_SECONDS burst head start) and only the
|
||||||
|
// position clock ties the bars to what's actually playing.
|
||||||
|
const target = smoothPosition();
|
||||||
|
const count = reader.read(sampleBuffer, target);
|
||||||
|
// Never feed a partial FFT window to cava.
|
||||||
|
if (count < sampleBuffer.length) return;
|
||||||
|
|
||||||
// Feed samples to cavacore → get frequency bars
|
const output = cava.execute(sampleBuffer);
|
||||||
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
|
// Normalize against the running peak and copy to a new array
|
||||||
setBarData(Array.from(output as Float64Array));
|
setBarData(scaler(output));
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -198,7 +225,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stopVisualization();
|
stopVisualization();
|
||||||
if (reader) {
|
if (reader) {
|
||||||
@@ -213,64 +239,34 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const playedRatio = () =>
|
|
||||||
audio.duration() <= 0
|
|
||||||
? 0
|
|
||||||
: Math.min(1, audio.position() / audio.duration());
|
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
const count = numBars();
|
const count = numBars();
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,9 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
|||||||
import type { SearchResult } from "@/types/source";
|
import type { SearchResult } 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";
|
||||||
|
|
||||||
export const SearchPaneCount = 1;
|
export const SearchPaneCount = 1;
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ function SearchPage() {
|
|||||||
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;
|
||||||
@@ -73,12 +76,16 @@ function SearchPage() {
|
|||||||
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
||||||
// scroll responds.
|
// scroll responds.
|
||||||
//
|
//
|
||||||
// The effect only re-runs on a depth transition, so Escape (defocus) and
|
// The depth STACK signal is also written by focus moves (setDepthFocus),
|
||||||
// `s` (refocus) at the same depth are not clobbered.
|
// so gate the sync on the depth VALUE via a memo: the effect must re-run
|
||||||
|
// only on an actual depth transition. Without the memo every j/k at the
|
||||||
|
// query depth re-focuses the input (undoing Escape), which keeps the
|
||||||
|
// recents list unreachable by keyboard.
|
||||||
onMount(() => nav.setInputFocused(depth() === 0));
|
onMount(() => nav.setInputFocused(depth() === 0));
|
||||||
onCleanup(() => nav.setInputFocused(false));
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
|
const isQueryDepth = createMemo(() => depth() === 0);
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
nav.setInputFocused(depth() === 0);
|
nav.setInputFocused(isQueryDepth());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||||
@@ -205,18 +212,32 @@ 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={muted()}>h: back to query</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane ────────────────────────────────────────────────────────────
|
// ── current pane ────────────────────────────────────────────────────────────
|
||||||
@@ -234,10 +255,13 @@ function SearchPage() {
|
|||||||
placeholder="Enter podcast name..."
|
placeholder="Enter podcast name..."
|
||||||
focused={inputActive()}
|
focused={inputActive()}
|
||||||
width={28}
|
width={28}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</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 +282,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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -294,11 +342,18 @@ 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"
|
||||||
|
: "Enter a search term to find podcasts"}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Searching…" />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -311,7 +366,6 @@ function SearchPage() {
|
|||||||
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,7 +375,7 @@ 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.podcast.title}
|
||||||
@@ -385,8 +439,7 @@ function SearchPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={result().podcast.description}>
|
<Show when={result().podcast.description}>
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
{result().podcast.description!.slice(0, 400) ??
|
{result().podcast.description!.slice(0, 400)}
|
||||||
"No description available."}
|
|
||||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -428,9 +481,7 @@ function SearchPage() {
|
|||||||
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,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,36 @@
|
|||||||
* 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 — master toggle (default: off)
|
||||||
|
* • Auto Download Count — X most recent episodes per show (default: 2,
|
||||||
|
* any positive integer — type it in the editor)
|
||||||
|
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
|
||||||
|
* • 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).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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, 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 +43,24 @@ 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 +76,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 +146,331 @@ 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: "fetchMore",
|
||||||
|
label: "Fetch More",
|
||||||
|
kind: "select",
|
||||||
|
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||||
|
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
||||||
|
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||||
|
app.updatePreferences({ fetchMoreMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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: 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) {
|
||||||
@@ -267,12 +278,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 +297,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 +327,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 +391,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 +420,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 +430,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} />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
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";
|
||||||
@@ -61,6 +62,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 +98,19 @@ 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}
|
||||||
/>
|
/>
|
||||||
</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 +118,8 @@ function AddSourceForm() {
|
|||||||
}}
|
}}
|
||||||
placeholder="https://example.com/feed.rss"
|
placeholder="https://example.com/feed.rss"
|
||||||
width={35}
|
width={35}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<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 />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,5 +41,7 @@ export interface SettingsSectionDef {
|
|||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Nerd Font glyph for the section row (rendered only when supported). */
|
||||||
|
icon: string;
|
||||||
items?: () => SettingItem[];
|
items?: () => SettingItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,19 @@ const defaultSettings: AppSettings = {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
|
transparentBackground: false,
|
||||||
|
showSelectionMarker: false,
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoDownloadCount: 2,
|
||||||
|
autoDownloadScope: "all",
|
||||||
|
autoDownloadWhitelist: [],
|
||||||
|
autoJumpToPlayer: true,
|
||||||
|
fetchMoreMode: "manual",
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -43,7 +50,7 @@ const defaultState: AppState = {
|
|||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAppStore() {
|
function createAppStore() {
|
||||||
// Start with defaults; async load will update once ready
|
// Start with defaults; async load will update once ready
|
||||||
const [state, setState] = createSignal<AppState>(defaultState);
|
const [state, setState] = createSignal<AppState>(defaultState);
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const defaultNavState: AudioNavState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Create audio navigation store */
|
/** Create audio navigation store */
|
||||||
export function createAudioNavStore() {
|
function createAudioNavStore() {
|
||||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
/** Persist current navigation state to file (fire-and-forget) */
|
/** Persist current navigation state to file (fire-and-forget) */
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ export interface DiscoverCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||||
{ id: "all", name: "All", icon: "*" },
|
{ id: "all", name: "All", icon: "\uF0CA" },
|
||||||
{ id: "technology", name: "Technology", icon: ">" },
|
{ id: "technology", name: "Technology", icon: "\uF2DB" },
|
||||||
{ id: "science", name: "Science", icon: "~" },
|
{ id: "science", name: "Science", icon: "\uF0C3" },
|
||||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
{ id: "comedy", name: "Comedy", icon: "\uF118" },
|
||||||
{ id: "news", name: "News", icon: "!" },
|
{ id: "news", name: "News", icon: "\uF1EA" },
|
||||||
{ id: "business", name: "Business", icon: "$" },
|
{ id: "business", name: "Business", icon: "\uF0B1" },
|
||||||
{ id: "health", name: "Health", icon: "+" },
|
{ id: "health", name: "Health", icon: "\uF21E" },
|
||||||
{ id: "education", name: "Education", icon: "?" },
|
{ id: "education", name: "Education", icon: "\uF19D" },
|
||||||
{ id: "sports", name: "Sports", icon: "#" },
|
{ id: "sports", name: "Sports", icon: "\uF1E3" },
|
||||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
{ id: "true-crime", name: "True Crime", icon: "\uF00E" },
|
||||||
{ id: "arts", name: "Arts", icon: "@" },
|
{ id: "arts", name: "Arts", icon: "\uF1FC" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
||||||
@@ -127,7 +127,6 @@ export function createDiscoverStore() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the podcast list from the manifest entries
|
|
||||||
const fetched = manifest.podcasts.map(entryToPodcast);
|
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||||
cachedAt = now;
|
cachedAt = now;
|
||||||
setPodcasts(fetched);
|
setPodcasts(fetched);
|
||||||
@@ -173,7 +172,6 @@ export function createDiscoverStore() {
|
|||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
// Remove the feed from the feed store
|
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
feedStore.removeFeedByUrl(podcast.feedUrl);
|
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { DownloadedEpisode } from "../types/episode";
|
|||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import { downloadEpisode } from "../utils/episode-downloader";
|
import { downloadEpisode } from "../utils/episode-downloader";
|
||||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||||
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
const DOWNLOADS_FILE = "downloads.json";
|
const DOWNLOADS_FILE = "downloads.json";
|
||||||
const MAX_CONCURRENT = 2;
|
const MAX_CONCURRENT = 2;
|
||||||
@@ -38,7 +39,7 @@ interface QueueItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create download store */
|
/** Create download store */
|
||||||
export function createDownloadStore() {
|
function createDownloadStore() {
|
||||||
const [downloads, setDownloads] = createSignal<
|
const [downloads, setDownloads] = createSignal<
|
||||||
Map<string, DownloadedEpisode>
|
Map<string, DownloadedEpisode>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
@@ -48,7 +49,6 @@ export function createDownloadStore() {
|
|||||||
/** Active AbortControllers keyed by episodeId */
|
/** Active AbortControllers keyed by episodeId */
|
||||||
const abortControllers = new Map<string, AbortController>();
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
// Load persisted downloads on init
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const loaded = await loadDownloads();
|
const loaded = await loadDownloads();
|
||||||
if (loaded.size > 0) setDownloads(loaded);
|
if (loaded.size > 0) setDownloads(loaded);
|
||||||
@@ -153,7 +153,6 @@ export function createDownloadStore() {
|
|||||||
const slotsAvailable = MAX_CONCURRENT - current;
|
const slotsAvailable = MAX_CONCURRENT - current;
|
||||||
const toStart = q.slice(0, slotsAvailable);
|
const toStart = q.slice(0, slotsAvailable);
|
||||||
|
|
||||||
// Remove started items from queue
|
|
||||||
if (toStart.length > 0) {
|
if (toStart.length > 0) {
|
||||||
setQueue((prev) => prev.slice(toStart.length));
|
setQueue((prev) => prev.slice(toStart.length));
|
||||||
}
|
}
|
||||||
@@ -203,6 +202,27 @@ export function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Write the podcast cover beside the audio so mpv's
|
||||||
|
// --cover-art-auto=exact picks it up for Now Playing art.
|
||||||
|
const coverUrl = useFeedStore()
|
||||||
|
.feeds()
|
||||||
|
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
|
||||||
|
if (coverUrl && result.filePath) {
|
||||||
|
const dot = result.filePath.lastIndexOf(".");
|
||||||
|
if (dot > 0) {
|
||||||
|
const coverPath = result.filePath.slice(0, dot) + ".jpg";
|
||||||
|
fetch(coverUrl)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) return;
|
||||||
|
await Bun.write(
|
||||||
|
coverPath,
|
||||||
|
new Uint8Array(await r.arrayBuffer()),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateDownload(item.episodeId, {
|
updateDownload(item.episodeId, {
|
||||||
status: DownloadStatus.FAILED,
|
status: DownloadStatus.FAILED,
|
||||||
@@ -250,7 +270,6 @@ export function createDownloadStore() {
|
|||||||
return; // Already downloading or queued
|
return; // Already downloading or queued
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create download entry
|
|
||||||
const entry: DownloadedEpisode = {
|
const entry: DownloadedEpisode = {
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
feedId,
|
feedId,
|
||||||
@@ -269,7 +288,6 @@ export function createDownloadStore() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
const queueItem: QueueItem = {
|
const queueItem: QueueItem = {
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
feedId,
|
feedId,
|
||||||
@@ -291,10 +309,8 @@ export function createDownloadStore() {
|
|||||||
abortControllers.delete(episodeId);
|
abortControllers.delete(episodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from queue
|
|
||||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||||
|
|
||||||
// Update status
|
|
||||||
updateDownload(episodeId, {
|
updateDownload(episodeId, {
|
||||||
status: DownloadStatus.NONE,
|
status: DownloadStatus.NONE,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -312,6 +328,11 @@ export function createDownloadStore() {
|
|||||||
try {
|
try {
|
||||||
const { unlink } = await import("fs/promises");
|
const { unlink } = await import("fs/promises");
|
||||||
await unlink(dl.filePath);
|
await unlink(dl.filePath);
|
||||||
|
const dot = dl.filePath.lastIndexOf(".");
|
||||||
|
if (dot > 0) {
|
||||||
|
const coverPath = dl.filePath.slice(0, dot) + ".jpg";
|
||||||
|
await unlink(coverPath);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// File may already be gone
|
// File may already be gone
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
|
import { useAppStore } from "./app";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk */
|
||||||
@@ -43,7 +44,7 @@ function saveSources(sources: PodcastSource[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
export function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
...DEFAULT_SOURCES,
|
...DEFAULT_SOURCES,
|
||||||
@@ -62,22 +63,18 @@ export function createFeedStore() {
|
|||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
|
|
||||||
// Filter by visibility
|
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
|
||||||
if (f.sourceId) {
|
if (f.sourceId) {
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by pinned
|
|
||||||
if (f.pinnedOnly) {
|
if (f.pinnedOnly) {
|
||||||
result = result.filter((feed) => feed.isPinned);
|
result = result.filter((feed) => feed.isPinned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
|
||||||
if (f.searchQuery) {
|
if (f.searchQuery) {
|
||||||
const query = f.searchQuery.toLowerCase();
|
const query = f.searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
@@ -88,7 +85,6 @@ export function createFeedStore() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by selected field
|
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
switch (f.sortBy) {
|
switch (f.sortBy) {
|
||||||
@@ -111,7 +107,6 @@ export function createFeedStore() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pinned feeds always first
|
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
@@ -215,34 +210,42 @@ export function createFeedStore() {
|
|||||||
saveFeeds(updated);
|
saveFeeds(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
// Global auto-download: newly subscribed shows join the next pass.
|
||||||
|
runAutoDownload();
|
||||||
return newFeed;
|
return newFeed;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Auto-download newest episodes for a feed */
|
/** Download the N most recent episodes of every in-scope show, per the
|
||||||
const autoDownloadEpisodes = (
|
* global auto-download preferences (master toggle + scope + whitelist +
|
||||||
feedId: string,
|
* count). Skips episodes already downloaded, queued, or in flight;
|
||||||
newEpisodes: Episode[],
|
* retries failed ones. Idempotent — safe to run after any settings
|
||||||
count: number,
|
* change, feed refresh, or subscribe. */
|
||||||
) => {
|
const runAutoDownload = (): void => {
|
||||||
try {
|
const app = useAppStore();
|
||||||
const dlStore = useDownloadStore();
|
const prefs = app.state().preferences;
|
||||||
// Sort by pubDate descending (newest first)
|
if (!prefs.autoDownload || prefs.autoDownloadScope === "none") return;
|
||||||
const sorted = [...newEpisodes].sort(
|
const whitelist = prefs.autoDownloadWhitelist ?? [];
|
||||||
|
const count = Math.max(1, prefs.autoDownloadCount ?? 2);
|
||||||
|
const dlStore = useDownloadStore();
|
||||||
|
for (const feed of feeds()) {
|
||||||
|
if (
|
||||||
|
prefs.autoDownloadScope === "whitelist" &&
|
||||||
|
!whitelist.includes(feed.id)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sorted = [...feed.episodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
// count = 0 means download all new episodes
|
for (const ep of sorted.slice(0, count)) {
|
||||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
|
||||||
for (const ep of toDownload) {
|
|
||||||
const status = dlStore.getDownloadStatus(ep.id);
|
const status = dlStore.getDownloadStatus(ep.id);
|
||||||
if (
|
if (
|
||||||
status === DownloadStatus.NONE ||
|
status === DownloadStatus.NONE ||
|
||||||
status === DownloadStatus.FAILED
|
status === DownloadStatus.FAILED
|
||||||
) {
|
) {
|
||||||
dlStore.startDownload(ep, feedId);
|
dlStore.startDownload(ep, feed.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Download store may not be available yet
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,7 +253,6 @@ export function createFeedStore() {
|
|||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId);
|
const feed = getFeed(feedId);
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
|
||||||
const episodes = await fetchEpisodes(
|
const episodes = await fetchEpisodes(
|
||||||
feed.podcast.feedUrl,
|
feed.podcast.feedUrl,
|
||||||
MAX_EPISODES_REFRESH,
|
MAX_EPISODES_REFRESH,
|
||||||
@@ -264,13 +266,9 @@ export function createFeedStore() {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-download new episodes if enabled for this feed
|
// Global auto-download: ensure the N most recent episodes of in-scope
|
||||||
if (feed.autoDownload) {
|
// shows are available offline after every refresh (idempotent).
|
||||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
runAutoDownload();
|
||||||
if (newEpisodes.length > 0) {
|
|
||||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh all feeds */
|
/** Refresh all feeds */
|
||||||
@@ -290,7 +288,15 @@ export function createFeedStore() {
|
|||||||
const loadedFeeds = await loadFeedsFromFile();
|
const loadedFeeds = await loadFeedsFromFile();
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
// The default "rss" placeholder source fabricated fake search results
|
||||||
|
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
|
||||||
|
// too. User-added custom feeds keep their own ids and are untouched.
|
||||||
|
const migratedSources =
|
||||||
|
loadedSources?.filter((source) => source.id !== "rss") ?? [];
|
||||||
|
if (migratedSources.length > 0) {
|
||||||
|
setSources(migratedSources);
|
||||||
|
saveSources(migratedSources);
|
||||||
|
}
|
||||||
await refreshAllFeeds();
|
await refreshAllFeeds();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -369,7 +375,7 @@ export function createFeedStore() {
|
|||||||
/** Remove a source */
|
/** Remove a source */
|
||||||
const removeSource = (sourceId: string) => {
|
const removeSource = (sourceId: string) => {
|
||||||
// Don't remove default sources
|
// Don't remove default sources
|
||||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
if (sourceId === "itunes") return false;
|
||||||
|
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.filter((s) => s.id !== sourceId);
|
const updated = prev.filter((s) => s.id !== sourceId);
|
||||||
@@ -409,64 +415,87 @@ export function createFeedStore() {
|
|||||||
return loaded < cached.length;
|
return loaded < cached.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Load the next chunk of episodes for one feed from the cache.
|
||||||
|
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||||
|
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||||
|
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||||
|
const feed = getFeed(feedId);
|
||||||
|
if (!feed) return;
|
||||||
|
|
||||||
|
let cached = fullEpisodeCache.get(feedId);
|
||||||
|
|
||||||
|
// If no cache, re-fetch and parse the full feed
|
||||||
|
if (!cached) {
|
||||||
|
const response = await fetch(feed.podcast.feedUrl, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const xml = await response.text();
|
||||||
|
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||||
|
cached = parsed.episodes;
|
||||||
|
fullEpisodeCache.set(feedId, cached);
|
||||||
|
// Set current load count to match what's already displayed
|
||||||
|
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||||
|
const newCount = Math.min(
|
||||||
|
currentCount + MAX_EPISODES_REFRESH,
|
||||||
|
cached.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
|
|
||||||
|
episodeLoadCount.set(feedId, newCount);
|
||||||
|
const episodes = cached.slice(0, newCount);
|
||||||
|
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, episodes } : f,
|
||||||
|
);
|
||||||
|
saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for a feed from the cache.
|
/** Load the next chunk of episodes for a feed from the cache.
|
||||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||||
const loadMoreEpisodes = async (feedId: string) => {
|
const loadMoreEpisodes = async (feedId: string) => {
|
||||||
if (isLoadingMore()) return;
|
if (isLoadingMore()) return;
|
||||||
const feed = getFeed(feedId);
|
|
||||||
if (!feed) return;
|
|
||||||
|
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
let cached = fullEpisodeCache.get(feedId);
|
await loadMoreEpisodesForFeed(feedId);
|
||||||
|
|
||||||
// If no cache, re-fetch and parse the full feed
|
|
||||||
if (!cached) {
|
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
|
||||||
headers: {
|
|
||||||
"Accept-Encoding": "identity",
|
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) return;
|
|
||||||
const xml = await response.text();
|
|
||||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
|
||||||
cached = parsed.episodes;
|
|
||||||
fullEpisodeCache.set(feedId, cached);
|
|
||||||
// Set current load count to match what's already displayed
|
|
||||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
|
||||||
const newCount = Math.min(
|
|
||||||
currentCount + MAX_EPISODES_REFRESH,
|
|
||||||
cached.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newCount <= currentCount) return; // nothing more to load
|
|
||||||
|
|
||||||
episodeLoadCount.set(feedId, newCount);
|
|
||||||
const episodes = cached.slice(0, newCount);
|
|
||||||
|
|
||||||
setFeeds((prev) => {
|
|
||||||
const updated = prev.map((f) =>
|
|
||||||
f.id === feedId ? { ...f, episodes } : f,
|
|
||||||
);
|
|
||||||
saveFeeds(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Set auto-download settings for a feed */
|
/** True if any feed still has cached episodes beyond its loaded window. */
|
||||||
const setAutoDownload = (
|
const hasMoreAcrossAll = (): boolean => {
|
||||||
feedId: string,
|
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||||
enabled: boolean,
|
};
|
||||||
count: number = 0,
|
|
||||||
) => {
|
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
||||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
* still has cached episodes — powers the Feed page's "[Fetch More]". */
|
||||||
|
const loadMoreAllFeeds = async () => {
|
||||||
|
if (isLoadingMore()) return;
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
||||||
|
for (const feed of pending) {
|
||||||
|
await loadMoreEpisodesForFeed(feed.id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run the global auto-download pass (see runAutoDownload above). */
|
||||||
|
const runAutoDownloadNow = (): void => {
|
||||||
|
runAutoDownload();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -497,11 +526,13 @@ export function createFeedStore() {
|
|||||||
refreshFeed,
|
refreshFeed,
|
||||||
refreshAllFeeds,
|
refreshAllFeeds,
|
||||||
loadMoreEpisodes,
|
loadMoreEpisodes,
|
||||||
|
loadMoreAllFeeds,
|
||||||
|
hasMoreAcrossAll,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
updateSource,
|
updateSource,
|
||||||
setAutoDownload,
|
runAutoDownload: runAutoDownloadNow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts } from "../utils/search";
|
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult } from "../types/source";
|
import type { SearchResult } from "../types/source";
|
||||||
|
|
||||||
@@ -80,10 +80,18 @@ export function createSearchStore() {
|
|||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Add to history
|
|
||||||
addToHistory(q);
|
addToHistory(q);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// A query that is a direct RSS feed URL (e.g. a private feed that
|
||||||
|
// isn't in any public directory) resolves to that feed directly,
|
||||||
|
// independent of enabled search sources.
|
||||||
|
const urlResults = await searchByFeedUrl(q);
|
||||||
|
if (urlResults.length > 0) {
|
||||||
|
setResults(applySubscribedStatus(urlResults));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sources = feedStore.sources();
|
const sources = feedStore.sources();
|
||||||
const enabledSourceIds = sources
|
const enabledSourceIds = sources
|
||||||
.filter((s) => s.enabled)
|
.filter((s) => s.enabled)
|
||||||
@@ -122,7 +130,6 @@ export function createSearchStore() {
|
|||||||
/** Add query to history */
|
/** Add query to history */
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
// Remove duplicates and add to front
|
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||||
saveHistory(updated);
|
saveHistory(updated);
|
||||||
|
|||||||