Compare commits
83 Commits
c8d29ed59d
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d6d4918bc | |||
| 35ad858d0d | |||
| af827a9a96 | |||
| 2cf9559b0b | |||
| 20336ea716 | |||
| 8b7b38276e | |||
| 8496922aaf | |||
| 5e3ad48a2d | |||
| 005ac8fde3 | |||
| 1f0b9de456 | |||
| 3388757185 | |||
| 8049d02457 | |||
| 1b55b7117c | |||
| 15f8a098b5 | |||
| 2d7d49b91c | |||
| df9c519439 | |||
| 2bf1c229c7 | |||
| b2e9e5c16c | |||
| 41c0002090 | |||
| c0252fc9b8 | |||
| 0c3506beb5 | |||
| ef9fc13aaa | |||
| 0b0637b9dc | |||
| e73e608b9f | |||
| ebed49237c | |||
| dc2b22eaa5 | |||
| 2bb612ee07 | |||
| dc855ab8a0 | |||
| f976bdc2b7 | |||
| 1cf3361e59 | |||
| ada441300a | |||
| 6134dea044 | |||
| 93d5925dfd | |||
| de6d0ccbf6 | |||
| cda29bcb95 | |||
| 3775a9801d | |||
| a9589e7686 | |||
| c52fa14e42 | |||
| 116d095ad5 | |||
| b280af484c | |||
| 5dce21c038 | |||
| d2c46631ef | |||
| 67032460ff | |||
| 0f3ffcf934 | |||
| c813949d48 | |||
| eb220386ce | |||
| 19eae4fd5a | |||
| e70469b1ec | |||
| 30b7ff57d3 | |||
| fd689aba04 | |||
| 8eaca82ce9 | |||
| 8ac1ec1162 | |||
| 4a94ff5910 | |||
| 2e69868ffc | |||
| 491a736c32 | |||
| 12bd6be4bc | |||
| d2f6c5c525 | |||
| f758b53336 | |||
| b0bfa41028 | |||
| 25307f83e9 | |||
| db285530b6 | |||
| e1cdd6b2a5 | |||
| 2abdbaa4e9 | |||
| 1d06156b8b | |||
| c63e9e1b9c | |||
| 0facfff51b | |||
| 3ef19f80b8 | |||
| 13a31aabdc | |||
| 8dbdebfd30 | |||
| 529817323d | |||
| ace883b505 | |||
| de01cedee0 | |||
| 2730fa3cae | |||
| 91a831c5f9 | |||
| 52e9ae0ab7 | |||
| 64d8b40e61 | |||
| 0cc15c8d90 | |||
| 1d3abd53d4 | |||
| 592cfd4093 | |||
| 69e12cf5b9 | |||
| c9e3aa92ec | |||
| 25fe7f6ac9 | |||
| 85cb9fba26 |
115
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Builds standalone PodTui binaries for each supported OS/arch and attaches
|
||||||
|
# them to a GitHub Release. One runner per platform because Bun cannot
|
||||||
|
# cross-compile — each runner runs `make dist`, which emits a
|
||||||
|
# podtui-<platform>-<arch>.tar.gz (binary + native libs side by side).
|
||||||
|
#
|
||||||
|
# Trigger: push a tag like v0.1.0. Bump VERSION in src/index.tsx in the same
|
||||||
|
# commit as the tag so the released binary reports the tagged version.
|
||||||
|
|
||||||
|
on: # intentional: YAML `on` key
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build (${{ matrix.os }} / ${{ matrix.arch }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: x64
|
||||||
|
plat: linux
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
plat: linux
|
||||||
|
- os: macos-15-intel
|
||||||
|
arch: x64
|
||||||
|
plat: darwin
|
||||||
|
- os: macos-14
|
||||||
|
arch: arm64
|
||||||
|
plat: darwin
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Install fftw (cavacore build dependency)
|
||||||
|
run: |
|
||||||
|
if uname -s | grep -qi darwin; then
|
||||||
|
# mpv is required for the release bundle: build.ts copies it into
|
||||||
|
# PodTui.app (signed with the podtui bundle identifier) so macOS
|
||||||
|
# Now Playing shows the PodTui icon instead of a blank placeholder.
|
||||||
|
brew install fftw mpv
|
||||||
|
else
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libfftw3-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build native cavacore library
|
||||||
|
run: scripts/build-cavacore.sh
|
||||||
|
|
||||||
|
- name: Build standalone binary + tarball
|
||||||
|
run: make dist
|
||||||
|
|
||||||
|
- name: Smoke-test binary boot
|
||||||
|
env:
|
||||||
|
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||||
|
run: |
|
||||||
|
# The binary is compiled with bunfig autoload disabled
|
||||||
|
# (autoloadBunfig: false in build.ts), so it must boot even from a
|
||||||
|
# directory holding a bunfig.toml with a top-level preload the
|
||||||
|
# standalone can't resolve. Plant one to make this a real regression
|
||||||
|
# test for "preload not found".
|
||||||
|
SMOKE_DIR=$(mktemp -d)
|
||||||
|
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
||||||
|
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
||||||
|
cd "$SMOKE_DIR"
|
||||||
|
./podtui-*/podtui --version
|
||||||
|
# macOS tarballs must ship PodTui.app with a working bundled mpv
|
||||||
|
# carrying the podtui bundle identifier — otherwise Now Playing
|
||||||
|
# attribution silently regresses to a blank icon.
|
||||||
|
if [ "${{ matrix.plat }}" = "darwin" ]; then
|
||||||
|
MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv
|
||||||
|
test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; }
|
||||||
|
$MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; }
|
||||||
|
codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \
|
||||||
|
|| { echo "bundled mpv lacks podtui signing identifier"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
||||||
|
path: dist/podtui-*.tar.gz
|
||||||
|
|
||||||
|
upload:
|
||||||
|
name: Attach to GitHub Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all binaries
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
artifacts/**/*.tar.gz
|
||||||
|
LICENSE
|
||||||
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
|
||||||
|
|||||||
230
CONTRIBUTING.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Contributing to PodTui
|
||||||
|
|
||||||
|
This file is written **for humans**. If you're an AI agent or LLM working in
|
||||||
|
this repo, read [AGENTS.md](AGENTS.md) instead — it has the machine-oriented
|
||||||
|
build/test/lint contract and code-style rules. Both describe the same project;
|
||||||
|
CONTRIBUTING.md focuses on *understanding* and *navigating* the codebase.
|
||||||
|
|
||||||
|
PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript +
|
||||||
|
[OpenTUI](https://github.com/opentui/opentui) on top, [Bun](https://bun.sh)
|
||||||
|
as the runtime and toolchain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install bun # or: curl -fsSL https://bun.sh/install | bash
|
||||||
|
git clone git@github.com:mikefreno/podtui.git
|
||||||
|
cd podtui
|
||||||
|
|
||||||
|
bun install # install JS dependencies
|
||||||
|
make native # build libcavacore.dylib from the vendored C source
|
||||||
|
bun run dev # launch with hot reload (alias: make dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
|
||||||
|
WezTerm, tmux, …). It will not render in a plain captured `bash` session.
|
||||||
|
|
||||||
|
## What each command does
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|--------------------|--------------------------------------------------------------------------|
|
||||||
|
| `bun install` | Install JS dependencies |
|
||||||
|
| `make native` | Compile `cava/cavacore.c` → `src/native/libcavacore.<dylib\|so>` |
|
||||||
|
| `bun run dev` | Run with hot reload |
|
||||||
|
| `bun run start` | Run once (no watch) |
|
||||||
|
| `bun test` | Run the test suite (see [Testing](#testing)) |
|
||||||
|
| `bun run lint` | Type-check |
|
||||||
|
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
||||||
|
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
||||||
|
| `make dist-mac` / `make dist-linux` | Aliases for `dist` on their platform (CI runs these) |
|
||||||
|
| `make clean` | Remove `dist/` |
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/ Network + XML/RSS — client.ts, rss-parser.ts
|
||||||
|
components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel…
|
||||||
|
config/ App config: keybinds.jsonc, shortcuts, auth
|
||||||
|
constants/ Static tables (sync formats, themes)
|
||||||
|
context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext
|
||||||
|
hooks/ useAudio, useMultimediaKeys, useCachedData
|
||||||
|
native/ FFI glue + the built libcavacore.{dylib,so}
|
||||||
|
pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings
|
||||||
|
stores/ Zustand stores — app, feed, audio-nav, search, auth, progress…
|
||||||
|
styles/ theme.css
|
||||||
|
themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json
|
||||||
|
types/ All shared interfaces (podcast, episode, feed, settings…)
|
||||||
|
ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx
|
||||||
|
utils/ Parser/persistence/audio helpers (audio-player, config-dir…)
|
||||||
|
scripts/
|
||||||
|
build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian
|
||||||
|
tui-harness.tsx Headless harness for scripted interaction (see below)
|
||||||
|
cava/ Vendored cavacore C source (MIT, from karlstav/cava)
|
||||||
|
tests/ bun test suite + cavacore smoke test
|
||||||
|
dist/ Build output (JS bundle + libs + tarballs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native libraries: how the FFI layer works
|
||||||
|
|
||||||
|
PodTui loads **two** native libraries at runtime:
|
||||||
|
|
||||||
|
1. **libopentui** — the OpenTUI renderer (shipped inside the
|
||||||
|
`@opentui/core-<platform>-<arch>` npm packages, copied to `dist/` by
|
||||||
|
`build.ts`).
|
||||||
|
2. **libcavacore** — the audio spectrum renderer, built from C. The source is
|
||||||
|
vendored under `cava/` (it must stay committed — every CI runner builds it).
|
||||||
|
`libfftw3` is needed to build it:
|
||||||
|
- macOS: `brew install fftw`
|
||||||
|
- Debian/Ubuntu: `apt-get install libfftw3-dev`
|
||||||
|
(CI installs it for you; locally run `make native`.)
|
||||||
|
|
||||||
|
**Critical sibling rule**: both libraries are loaded *relative to the binary*,
|
||||||
|
so `podtui`, `libopentui.*` and `libcavacore.*` must sit in the **same
|
||||||
|
directory**. Never move a single binary out of the tarball. The Homebrew
|
||||||
|
formula keeps all three in `libexec/` and exposes only a `podtui` symlink.
|
||||||
|
|
||||||
|
Cavacore smoke test: `bun tests/cavacore-smoke.ts`
|
||||||
|
(FFI-calls `cava_init` / `cava_execute` / `cava_destroy` and prints results).
|
||||||
|
|
||||||
|
## Gotchas (read before touching anything)
|
||||||
|
|
||||||
|
1. **The compiled binary must keep bunfig autoload disabled.**
|
||||||
|
`build.ts` compiles the standalone with `autoloadBunfig: false`, so its
|
||||||
|
embedded runtime *never* reads the launching CWD's `bunfig.toml`. Without
|
||||||
|
that flag, a top-level `preload` in the CWD bunfig (common in Bun project
|
||||||
|
dirs) resolves against the CWD rather than the binary and kills startup
|
||||||
|
with `preload not found`. Don't remove the flag. Preloads for dev/test
|
||||||
|
belong in the explicit `--preload` flags in `package.json` and the
|
||||||
|
`[test]` section of `bunfig.toml` — not as a top-level entry.
|
||||||
|
|
||||||
|
2. **Smoke-test the binary from a dir with a poisoned bunfig.**
|
||||||
|
The CI smoke test unpacks the tarball into a `mktemp` dir, drops a
|
||||||
|
`bunfig.toml` containing an unresolvable top-level `preload` next to it,
|
||||||
|
and boots the binary — proving bunfig autoload stayed disabled. `./dist/
|
||||||
|
podtui --version` must work from any directory, including the repo root;
|
||||||
|
do the same check when testing a release build locally.
|
||||||
|
|
||||||
|
3. **Homebrew's dylib-repair warning is benign.**
|
||||||
|
`brew install` may print “load commands do not fit in the header … needs
|
||||||
|
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
|
||||||
|
the warning is cosmetic; installs complete and the app boots.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test # full suite (54 tests across 6 files today)
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane
|
||||||
|
logic; plus `tests/cavacore-smoke.ts` asserting the native lib exports.
|
||||||
|
|
||||||
|
For scripted end-to-end interaction there's a **headless harness**,
|
||||||
|
`scripts/tui-harness.tsx`: each invocation snapshot-rebuilds the app state
|
||||||
|
into a sandboxed `.harness/` config dir, replays the saved action log
|
||||||
|
(`.harness/actions.json`), executes one more key/action passed on the CLI, and
|
||||||
|
prints the resulting frame + a style summary — all without a real terminal.
|
||||||
|
Audio is a no-op during those snapshots. The last frame lands in
|
||||||
|
`.harness/last-frame.{json,txt}` for inspection.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Releases are built and published from **tags**
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Run `scripts/release-tag.sh` (interactive: pick major/minor/patch/custom,
|
||||||
|
confirms the plan, bumps `VERSION` in `src/index.tsx`, commits, tags
|
||||||
|
`vX.Y.Z`, and pushes branch + tag to every remote). If the version bump is
|
||||||
|
already committed but the tag is missing, it offers a tag-only path.
|
||||||
|
`--dry-run` prints the plan without doing anything.
|
||||||
|
2. Equivalent manual commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
3. CI (`.github/workflows/release.yml`) runs four builds in parallel,
|
||||||
|
each producing `podtui-<platform>-<arch>.tar.gz`:
|
||||||
|
|
||||||
|
| Runner | Platform/Arch |
|
||||||
|
|---------------------|---------------|
|
||||||
|
| `ubuntu-latest` | linux-x64 |
|
||||||
|
| `ubuntu-24.04-arm` | linux-arm64 |
|
||||||
|
| `macos-15-intel` | darwin-x64 |
|
||||||
|
| `macos-14` | darwin-arm64 |
|
||||||
|
|
||||||
|
Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh`
|
||||||
|
→ `make dist` → smoke-boots the binary from a temp dir → uploads the
|
||||||
|
tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.)
|
||||||
|
|
||||||
|
4. A release is auto-created with all 4 tarballs attached. `brew` never
|
||||||
|
sees the new version: the **tap self-updates**: the
|
||||||
|
`mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
|
||||||
|
polls GitHub releases, and when a new tag appears, rewrites
|
||||||
|
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||||
|
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
||||||
|
test: `brew install mikefreno/tap/podtui`.
|
||||||
|
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
|
||||||
|
staged, not yet published (AUR account registrations are closed; see the
|
||||||
|
README's Installation section). On each release, keep the AUR sources in sync
|
||||||
|
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
|
||||||
|
entries, keep the `LICENSE` asset source (the workflow above uploads
|
||||||
|
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
|
||||||
|
`bash packaging/aur/gen-srcinfo.sh`.
|
||||||
|
|
||||||
|
### Manual fallback
|
||||||
|
|
||||||
|
If you ever need to sync the tap by hand (or before the hourly job runs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <clone of mikefreno/homebrew-tap>
|
||||||
|
./scripts/sync-formula.sh 0.2.0
|
||||||
|
git commit -am 'podtui 0.2.0' && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local release build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dist # builds the binary + tarball for THIS machine only
|
||||||
|
```
|
||||||
|
|
||||||
|
Bun cannot cross-compile — the other platforms come from CI.
|
||||||
|
|
||||||
|
## Distribution & packaging
|
||||||
|
|
||||||
|
A release tarball is three files sitting side by side: the `podtui` binary
|
||||||
|
plus its two FFI libraries (`libopentui.<dylib|so>`,
|
||||||
|
`libcavacore.<dylib|so>`). The sibling rule above is why they ship together.
|
||||||
|
|
||||||
|
PodTui deliberately ships **no** `.deb`, `.rpm`, Flatpak, or Snap packages:
|
||||||
|
for a terminal app that's overwhelmingly installed through repositories or
|
||||||
|
archives, those formats add desktop-sandboxing overhead and a packaging tax
|
||||||
|
with little benefit. Instead:
|
||||||
|
|
||||||
|
- **GitHub Release tarballs** are the universal path — one upload per
|
||||||
|
OS/arch, works on any distro with `curl` + `tar`.
|
||||||
|
- **AUR (`podtui-bin`)** covers Arch/Manjaro with the same binary through the
|
||||||
|
native package manager.
|
||||||
|
- **Nix / cross-distro** users build from source (or a Nix flake can be added
|
||||||
|
later).
|
||||||
|
|
||||||
|
This keeps maintenance to a single build per OS/arch while still reaching the
|
||||||
|
vast majority of desktop Linux users. The AUR PKGBUILD lives in
|
||||||
|
`packaging/aur/` and can be built locally to test before publication:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packaging/aur && makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open items / things to sort out
|
||||||
|
|
||||||
|
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||||
|
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||||
|
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||||
|
ships from it directly until a full rebuild replaces it. On other hosts the
|
||||||
|
`make native` build is required — see `scripts/build-cavacore.sh`.
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Michael Freno
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
65
Makefile
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# PodTui — Makefile
|
||||||
|
#
|
||||||
|
# Development targets:
|
||||||
|
# make install install dependencies (bun install) + build native lib
|
||||||
|
# make dev run with hot reload
|
||||||
|
# make test run the test suite
|
||||||
|
# make build produce the JS bundle + native libs in dist/
|
||||||
|
# make native build the cavacore FFI library from C source
|
||||||
|
# make lint run typecheck-style checks (lsp), not eslint
|
||||||
|
#
|
||||||
|
# Packaging / release targets:
|
||||||
|
# make dist build a standalone compiled binary + tarball for the
|
||||||
|
# CURRENT platform (see dist/ for podtui + libs + tarball)
|
||||||
|
# make dist-mac alias for `dist` targeting macOS (run on macOS)
|
||||||
|
# make dist-linux alias for `dist` targeting Linux (run on Linux)
|
||||||
|
# make clean remove dist/ output
|
||||||
|
#
|
||||||
|
# Cross-platform binaries are produced by CI (GitHub Actions) with one runner
|
||||||
|
# per OS/arch — Bun cannot cross-compile, so dist:mac / dist:linux only produce
|
||||||
|
# the binary for the OS they run on. Each runner runs `make dist` and uploads
|
||||||
|
# its podtui-<platform>-<arch>.tar.gz artifact.
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
.PHONY: install dev build native dist dist-mac dist-linux test clean
|
||||||
|
|
||||||
|
## Install dependencies and build the native runtime library.
|
||||||
|
install:
|
||||||
|
bun install
|
||||||
|
make native
|
||||||
|
|
||||||
|
## Run the dev server with hot reload.
|
||||||
|
dev:
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
## Type-check the whole project. (See AGENTS.md: `bun run lint` points at a
|
||||||
|
## nonexistent lint.ts; LSP diagnostics are the maintained clean bar.)
|
||||||
|
lint:
|
||||||
|
bun tsc --noEmit
|
||||||
|
|
||||||
|
## Build the JS bundle + native libs into dist/ (the `podtui` npm bin target).
|
||||||
|
build:
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
## Build the cavacore FFI library from src/native/cavacore.c.
|
||||||
|
native:
|
||||||
|
scripts/build-cavacore.sh
|
||||||
|
|
||||||
|
## Standalone binary + native-libs tarball for the current platform.
|
||||||
|
## Built with bunfig autoload disabled (build.ts sets autoloadBunfig: false),
|
||||||
|
## so the embedded runtime ignores any bunfig.toml in the launching directory.
|
||||||
|
dist:
|
||||||
|
bun run build.ts --compile
|
||||||
|
|
||||||
|
## macOS build (run on a macOS runner / host).
|
||||||
|
dist-mac:
|
||||||
|
bun run build.ts --compile
|
||||||
|
|
||||||
|
## Linux build (run on a Linux runner / host).
|
||||||
|
dist-linux:
|
||||||
|
bun run build.ts --compile
|
||||||
|
|
||||||
|
## Remove build artifacts.
|
||||||
|
clean:
|
||||||
|
rm -rf dist
|
||||||
240
README.md
@@ -1,15 +1,241 @@
|
|||||||
# solid
|
# PodTui
|
||||||
|
|
||||||
To install dependencies:
|
A keyboard-first, terminal podcast client built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
||||||
|
browse episodes in a three-pane file-manager layout, and play audio through an
|
||||||
|
external player with full transport control — all from your terminal.
|
||||||
|
|
||||||
```bash
|
## Features
|
||||||
bun install
|
|
||||||
|
- **Vim/yazi-style navigation** — `j/k` to move, `h/l` to swipe between panes,
|
||||||
|
`Enter` to open, `1–6` / `[` `]` to switch tabs. The tab list is the app root:
|
||||||
|
at launch it fills the current pane, and drilling into a tab's contents slides
|
||||||
|
it into the parent pane.
|
||||||
|
- **Three-pane view** — parent / current / preview (Up | Current | Preview).
|
||||||
|
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
||||||
|
(My Shows, Discover, Feed tabs).
|
||||||
|
- **Search** across your subscribed shows.
|
||||||
|
- **Audio playback** through an external player with full transport control:
|
||||||
|
play/pause, next/previous, seek, speed, and per-episode resume progress.
|
||||||
|
- **Themeable** and **remappable keybindings**.
|
||||||
|
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||||
|
a system audio player.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Install PodTui ([Installation](#installation)) and make sure **mpv** is in
|
||||||
|
your `PATH`.
|
||||||
|
2. Run `podtui` in your terminal.
|
||||||
|
3. Press `3` to open **Discover** (or `4` to open **Search**, then `s`), drill
|
||||||
|
in with `Enter`, and press `Enter` on a show to subscribe.
|
||||||
|
4. Press `1` (**Feed**) or `2` (**My Shows**), open an episode with `Enter`,
|
||||||
|
and use `P` to play/pause, `N`/`B` for next/previous, and `shift-.` /
|
||||||
|
`shift-,` to seek.
|
||||||
|
|
||||||
|
Press `~` any time for in-app help. All keys are remappable — see
|
||||||
|
[Keybindings](#keybindings).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
|
||||||
|
Ghostty, tmux etc.).
|
||||||
|
- **mpv** on `PATH` for audio playback. PodTui drives mpv over JSON IPC, so
|
||||||
|
seek, speed, and position tracking all work. Without `mpv` on `PATH`,
|
||||||
|
playback is a silent no-op (the `none` backend) — see
|
||||||
|
[Troubleshooting](#troubleshooting).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
PodTui distributes as a **self-contained binary** for macOS (arm64/x64) and
|
||||||
|
Linux (arm64/x64). Pick whichever fits your platform.
|
||||||
|
|
||||||
|
### 1. Homebrew (macOS)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew install mikefreno/tap/podtui
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
|
||||||
|
through a copy of mpv that lives **inside the bundle**, so macOS attributes
|
||||||
|
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)
|
||||||
|
|
||||||
|
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
||||||
|
[GitHub Release](https://github.com/mikefreno/podtui/releases), unpack it, and
|
||||||
|
put `podtui` on your `PATH`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun dev
|
curl -sS -o /tmp/podtui.tar.gz \
|
||||||
|
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
||||||
|
sudo mkdir -p /opt/podtui
|
||||||
|
sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1
|
||||||
|
sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.
|
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||||
|
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||||
|
> binary alone), or the native FFI libraries won't load.
|
||||||
|
|
||||||
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S podtui-bin # once published
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires an AUR helper ([paru](https://github.com/morgan/paru)); the package
|
||||||
|
pulls in `mpv` as a dependency.
|
||||||
|
|
||||||
|
> **Not yet on the AUR.** The `podtui-bin` package is staged and awaiting
|
||||||
|
> publication (AUR account registrations are currently suspended). Until it
|
||||||
|
> lands, use the standalone tarball above.
|
||||||
|
|
||||||
|
### 4. From source
|
||||||
|
|
||||||
|
PodTui is written in TypeScript and runs on [Bun](https://bun.sh). To build
|
||||||
|
from source (development, distro packaging, unreleased versions), see
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Launch `podtui`. Press `~` for the in-app help.
|
||||||
|
|
||||||
|
### Command-line flags
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-v`, `--version` | Print the version and exit |
|
||||||
|
| `-q`, `--query <term>` | Query feeds for a show title and print matching shows, without launching the TUI |
|
||||||
|
| `-p`, `--play <term>` | Play the matching show, without launching the TUI |
|
||||||
|
|
||||||
|
### Keybindings
|
||||||
|
|
||||||
|
All keys are remappable — edit `keybinds.jsonc` in your config directory
|
||||||
|
(see [Configuration](#configuration)).
|
||||||
|
|
||||||
|
**Movement**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `j` / `k` (or `down` / `up`) | Move down / up |
|
||||||
|
| `J` / `K` | Jump 5 lines down / up |
|
||||||
|
| `ctrl-d` / `ctrl-u` | Page down / up |
|
||||||
|
| `ctrl-f` / `ctrl-b` | Full page down / up |
|
||||||
|
| `gg` / `G` | Go to top / bottom |
|
||||||
|
|
||||||
|
**Panes**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `h` / `l` (or `left` / `right`) | Focus parent pane / preview pane |
|
||||||
|
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
|
||||||
|
| `shift-enter` | Open with the interactive variant |
|
||||||
|
|
||||||
|
**Selection**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `Space` | Toggle selection |
|
||||||
|
| `v` | Visual mode (multi-select) |
|
||||||
|
| `ctrl-a` | Select / deselect all |
|
||||||
|
| `ctrl-r` | Invert selection |
|
||||||
|
| `Esc` | Cancel / escape |
|
||||||
|
|
||||||
|
**Tabs**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
||||||
|
| `[` / `]` | Previous / next tab |
|
||||||
|
|
||||||
|
**Commands, help, quit**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `:` or `q` | Open the command palette (type `q` + `Enter` there to quit) |
|
||||||
|
| `Q` or `ctrl-c` | Quit |
|
||||||
|
| `~` or `f1` | In-app help |
|
||||||
|
|
||||||
|
**Lists**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `s` | Search |
|
||||||
|
| `f` | Filter |
|
||||||
|
| `,` | Sort |
|
||||||
|
| `.` | Toggle hidden |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `x` | Unsubscribe the focused show (My Shows) |
|
||||||
|
| `d` | Download the focused episode (Feed / My Shows detail pane) |
|
||||||
|
| `D` | Delete the focused episode's download (if one exists) |
|
||||||
|
| `w` | Toggle the focused show in/out of the auto-download whitelist (My Shows, whitelist scope) |
|
||||||
|
|
||||||
|
**Audio**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `P` | Play / pause |
|
||||||
|
| `N` / `B` | Next / previous episode |
|
||||||
|
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration lives under the XDG config directory — `~/.config/podtui` by
|
||||||
|
default (`$XDG_CONFIG_HOME/podtui` if set).
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `config.json` | Unified settings (theme, playback speed, download path), feeds, and custom feed sources |
|
||||||
|
| `downloads.json` | Downloaded episode metadata |
|
||||||
|
| `keybinds.jsonc` | Keybinding remaps (see above) |
|
||||||
|
| `themes/` | Optional custom theme files |
|
||||||
|
|
||||||
|
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
|
||||||
|
into `config.json` on first run.
|
||||||
|
|
||||||
|
**Auto-download** — in Settings → Preferences: `Auto Download` (master
|
||||||
|
toggle) downloads the `Auto Download Count` most recent episodes (default 2,
|
||||||
|
any positive integer — type it in the editor) of every show in the `Auto
|
||||||
|
Download Scope` (all / none / whitelist, default all). With the whitelist
|
||||||
|
scope, a search field appears under the setting to pick shows (Space toggles
|
||||||
|
a suggestion in/out), and `w` in My Shows adds/removes the focused show.
|
||||||
|
|
||||||
|
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
|
||||||
|
|
||||||
|
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed and per-show "Fetch More" rows). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`preload not found` at startup** — this used to happen when the binary was
|
||||||
|
launched from a Bun project directory whose `bunfig.toml` had a `preload`
|
||||||
|
entry. Releases are compiled with bunfig autoload disabled
|
||||||
|
(`autoloadBunfig: false`), so current binaries ignore the CWD's `bunfig.toml`
|
||||||
|
entirely. If you still hit it, you're on an old release — upgrade.
|
||||||
|
|
||||||
|
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
|
||||||
|
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
|
||||||
|
|
||||||
|
**Homebrew prints a dylib warning** — “load commands do not fit in the header
|
||||||
|
… needs `-headerpad`” is benign: the app loads its libraries by path, the
|
||||||
|
install completes, and the app boots normally.
|
||||||
|
|
||||||
|
**The app won't start / no spectrum after moving files** — `podtui` loads its
|
||||||
|
two native libraries relative to the binary, so keep `podtui`,
|
||||||
|
`libopentui.*`, and `libcavacore.*` together in the same directory (the
|
||||||
|
tarball unpacks them side by side).
|
||||||
|
|
||||||
|
## Building from source / contributing
|
||||||
|
|
||||||
|
Development setup, the test suite, packaging, and the release process are
|
||||||
|
documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [OpenTUI](https://github.com/opentui/opentui) — the TUI framework driving the interface
|
||||||
|
|||||||
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="400" xmlns="http://www.w3.org/2000/svg" height="125.714" id="screenshot-993fe4cb-279d-80e0-8008-769a7b1374b8" viewBox="0 0 400 125.714" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-769a7b1374b8" rx="0" ry="0"><g id="shape-993fe4cb-279d-80e0-8008-769a7a62c77d"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a62c77d"><rect rx="6.857142857142833" ry="6.857142857142833" x="0" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a89c956"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a89c956"><rect rx="15" ry="15" x="45.71428571428572" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><rect rx="15" ry="15" x="91.42857142857144" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ab46172"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ab46172"><rect rx="15" ry="15" x="137.1428571428571" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ac82b71"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ac82b71"><rect rx="15" ry="15" x="182.85714285714283" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="125.71428571428578" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><rect rx="15" ry="15" x="228.57142857142856" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7aea70cd"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7aea70cd"><rect rx="15" ry="15" x="274.28571428571433" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7af77904"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7af77904"><rect rx="15" ry="15" x="320" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7b04705e"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7b04705e"><rect rx="6.857142857142833" ry="6.857142857142833" x="365.7142857142858" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
1
assets/App Icon/App Icon.icon/Assets/Terminal Cursor.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="80" xmlns="http://www.w3.org/2000/svg" height="256" id="screenshot-993fe4cb-279d-80e0-8008-76a1e45d35f1" viewBox="0 0 80 256" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-76a1e45d35f1"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-76a1e45d35f1"><rect rx="10" ry="10" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="80" height="256" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 526 B |
51
assets/App Icon/App Icon.icon/icon.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"solid" : "display-p3:0.18481,0.20325,0.24683,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "Podcast Waveform.svg",
|
||||||
|
"name" : "Podcast Waveform",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 2,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode" : "normal",
|
||||||
|
"fill" : "automatic",
|
||||||
|
"glass" : false,
|
||||||
|
"image-name" : "Terminal Cursor.svg",
|
||||||
|
"name" : "Terminal Cursor",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 2,
|
||||||
|
"translation-in-points" : [
|
||||||
|
320.00000000000006,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
assets/App Icon/App Icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
assets/App Icon/App Icon.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/App Icon/App Icon_2x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/App Icon/App Icon_4x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
assets/App Icon/App Icon_6x.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
assets/App Icon/AppIcon.icns
Normal file
308
build.ts
@@ -1,62 +1,274 @@
|
|||||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
import solidPlugin from "@opentui/solid/bun-plugin";
|
||||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { join, dirname } from "node:path"
|
import { join } from "node:path";
|
||||||
|
import { plugin } from "bun";
|
||||||
|
|
||||||
|
// Register the solid transform globally (dedup'd by name). This is what makes
|
||||||
|
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins
|
||||||
|
// that are registered via `plugin()`, not the `plugins:` array. The transform
|
||||||
|
// is fully embedded in the compiled binary.
|
||||||
|
plugin(solidPlugin);
|
||||||
|
|
||||||
|
const COMPILE =
|
||||||
|
process.argv.includes("--compile") || process.env.PODTUI_COMPILE === "1";
|
||||||
|
|
||||||
|
const platform = process.platform;
|
||||||
|
const arch = process.arch;
|
||||||
|
|
||||||
|
// Platform/arch → OpenTUI package name
|
||||||
|
const platformMap: Record<string, string> = {
|
||||||
|
"darwin-arm64": "darwin-arm64",
|
||||||
|
"darwin-x64": "darwin-x64",
|
||||||
|
"linux-x64": "linux-x64",
|
||||||
|
"linux-arm64": "linux-arm64",
|
||||||
|
"win32-x64": "win32-x64",
|
||||||
|
"win32-arm64": "win32-arm64",
|
||||||
|
};
|
||||||
|
|
||||||
|
const libExt =
|
||||||
|
platform === "win32" ? "dll" : platform === "darwin" ? "dylib" : "so";
|
||||||
|
|
||||||
// Build the JavaScript bundle
|
// Build the JavaScript bundle
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
entrypoints: ["./src/index.tsx"],
|
entrypoints: ["./src/index.tsx"],
|
||||||
outdir: "./dist",
|
outdir: "./dist",
|
||||||
target: "bun",
|
target: "bun",
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: "external",
|
sourcemap: "external",
|
||||||
plugins: [solidPlugin],
|
plugins: [solidPlugin],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Copy the native library to dist for distribution
|
// Copy the opentui native library to dist for distribution.
|
||||||
const platform = process.platform
|
const platformKey = `${platform}-${arch}`;
|
||||||
const arch = process.arch
|
const platformPkg = platformMap[platformKey];
|
||||||
|
|
||||||
// Map platform/arch to OpenTUI package names
|
|
||||||
const platformMap: Record<string, string> = {
|
|
||||||
"darwin-arm64": "darwin-arm64",
|
|
||||||
"darwin-x64": "darwin-x64",
|
|
||||||
"linux-x64": "linux-x64",
|
|
||||||
"linux-arm64": "linux-arm64",
|
|
||||||
"win32-x64": "win32-x64",
|
|
||||||
"win32-arm64": "win32-arm64",
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformKey = `${platform}-${arch}`
|
|
||||||
const platformPkg = platformMap[platformKey]
|
|
||||||
|
|
||||||
if (platformPkg) {
|
if (platformPkg) {
|
||||||
const libName = platform === "win32"
|
const libName = `libopentui.${libExt}`;
|
||||||
? "opentui.dll"
|
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName);
|
||||||
: platform === "darwin"
|
|
||||||
? "libopentui.dylib"
|
if (existsSync(srcPath)) {
|
||||||
: "libopentui.so"
|
const destPath = join("dist", libName);
|
||||||
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName)
|
copyFileSync(srcPath, destPath);
|
||||||
|
console.log(`Copied native library: ${libName}`);
|
||||||
if (existsSync(srcPath)) {
|
}
|
||||||
const destPath = join("dist", libName)
|
|
||||||
copyFileSync(srcPath, destPath)
|
|
||||||
console.log(`Copied native library: ${libName}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy cavacore native library to dist
|
// Copy cavacore native library to dist
|
||||||
const cavacoreLib = platform === "darwin"
|
const cavacoreLib = `libcavacore.${libExt}`;
|
||||||
? "libcavacore.dylib"
|
const cavacoreSrc = join("src", "native", cavacoreLib);
|
||||||
: platform === "win32"
|
|
||||||
? "cavacore.dll"
|
|
||||||
: "libcavacore.so"
|
|
||||||
const cavacoreSrc = join("src", "native", cavacoreLib)
|
|
||||||
|
|
||||||
if (existsSync(cavacoreSrc)) {
|
if (existsSync(cavacoreSrc)) {
|
||||||
copyFileSync(cavacoreSrc, join("dist", cavacoreLib))
|
copyFileSync(cavacoreSrc, join("dist", cavacoreLib));
|
||||||
console.log(`Copied cavacore library: ${cavacoreLib}`)
|
console.log(`Copied cavacore library: ${cavacoreLib}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`)
|
console.warn(
|
||||||
|
`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Build complete")
|
// ── Standalone compiled binary (dist/podtui + libs beside it) ──────────────
|
||||||
|
// `bun run build.ts --compile` (or PODTUI_COMPILE=1). Embeds the Bun runtime
|
||||||
|
// so end users need nothing installed; the two FFI libs are shipped as
|
||||||
|
// SIBLING FILES next to the binary (both loaders already resolve them that
|
||||||
|
// way: cavacore checks dirname(process.execPath); opentui embeds via its
|
||||||
|
// bun-plugin and handles the embedded-file path itself).
|
||||||
|
if (COMPILE) {
|
||||||
|
const outfile = join("dist", "podtui");
|
||||||
|
await Bun.build({
|
||||||
|
entrypoints: ["./src/index.tsx"],
|
||||||
|
target: "bun",
|
||||||
|
minify: true,
|
||||||
|
sourcemap: "external",
|
||||||
|
plugins: [solidPlugin],
|
||||||
|
compile: {
|
||||||
|
outfile,
|
||||||
|
// Don't let the embedded runtime autoload the launching CWD's
|
||||||
|
// bunfig.toml. A top-level `preload` there (common in Bun project
|
||||||
|
// dirs) resolves against the CWD, not the binary, so startup dies
|
||||||
|
// with "preload not found". With autoload disabled, the binary is
|
||||||
|
// config-independent and boots from any directory.
|
||||||
|
autoloadBunfig: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Compiled standalone binary: ${outfile}`);
|
||||||
|
|
||||||
|
// Ensure both native libs sit beside the binary.
|
||||||
|
const opentuiSrc = join(
|
||||||
|
"node_modules",
|
||||||
|
`@opentui/core-${platformPkg}`,
|
||||||
|
`libopentui.${libExt}`,
|
||||||
|
);
|
||||||
|
if (existsSync(opentuiSrc)) {
|
||||||
|
copyFileSync(opentuiSrc, join("dist", `libopentui.${libExt}`));
|
||||||
|
}
|
||||||
|
if (!existsSync(join("dist", cavacoreLib))) {
|
||||||
|
console.warn(
|
||||||
|
`Warning: ${cavacoreLib} missing beside the binary — run scripts/build-cavacore.sh`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tarball: podtui + the two native libs (drop the JS bundle dir)
|
||||||
|
const tarRoot = join("dist", `podtui-${platform}-${arch}`);
|
||||||
|
rmSync(tarRoot, { recursive: true, force: true });
|
||||||
|
mkdirSync(tarRoot, { recursive: true });
|
||||||
|
copyFileSync(outfile, join(tarRoot, "podtui"));
|
||||||
|
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
|
||||||
|
const s = join("dist", lib);
|
||||||
|
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
|
||||||
|
}
|
||||||
|
|
||||||
|
// App icon: bundled into every platform tarball; Linux also gets the
|
||||||
|
// desktop entry so the AUR package can install both system-wide
|
||||||
|
// (icon to hicolor, entry to applications/).
|
||||||
|
const iconSrc = join("assets", "App Icon", "App Icon.png");
|
||||||
|
if (existsSync(iconSrc)) {
|
||||||
|
copyFileSync(iconSrc, join(tarRoot, "podtui.png"));
|
||||||
|
}
|
||||||
|
if (platform === "linux") {
|
||||||
|
const desktopSrc = join("packaging", "podtui.desktop");
|
||||||
|
if (existsSync(desktopSrc)) {
|
||||||
|
copyFileSync(desktopSrc, join(tarRoot, "podtui.desktop"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// A darwin release tarball without a bundled mpv silently ships
|
||||||
|
// without Now Playing attribution (blank icon). Fail loudly so CI
|
||||||
|
// can't produce it — the runner must have mpv installed.
|
||||||
|
console.error(
|
||||||
|
"Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version for the bundle comes from src/index.tsx (single source of
|
||||||
|
// truth — release.yml requires bumping it in the tag commit).
|
||||||
|
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
|
||||||
|
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
|
||||||
|
const bundleVersion = versionMatch?.[1];
|
||||||
|
if (!bundleVersion) {
|
||||||
|
console.error("Error: could not read VERSION from src/index.tsx");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>${bundleVersion}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>${bundleVersion}</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([
|
||||||
|
"tar",
|
||||||
|
"-czf",
|
||||||
|
`${tarRoot}.tar.gz`,
|
||||||
|
"-C",
|
||||||
|
"dist",
|
||||||
|
`podtui-${platform}-${arch}`,
|
||||||
|
]);
|
||||||
|
if (tar.exitCode !== 0) {
|
||||||
|
console.error(tar.stderr.toString());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Tarball: ${tarRoot}.tar.gz`);
|
||||||
|
rmSync(tarRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Build complete");
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
preload = ["@opentui/solid/preload"]
|
# No top-level `preload` here — dev/test get the solid JSX transform via the
|
||||||
|
# explicit `--preload` flags in package.json and the [test] section below.
|
||||||
|
# Releases don't read this file at all: build.ts compiles the standalone with
|
||||||
|
# `autoloadBunfig: false`, so its embedded runtime ignores any bunfig.toml in
|
||||||
|
# the launching directory — no more "preload not found" from CWD bunfigs.
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
preload = "@opentui/solid/preload"
|
preload = "@opentui/solid/preload"
|
||||||
|
|||||||
19
cava/LICENSE-cava.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2015 Karl Stavestrand <karl@stavestrand.no>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
588
cava/cavacore.c
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
#include "cavacore.h"
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.1415926535897932385
|
||||||
|
#endif
|
||||||
|
#include <fftw3.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
#include <jni.h>
|
||||||
|
struct cava_plan *plan;
|
||||||
|
double *cava_in;
|
||||||
|
double *cava_out;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static double amplitude_to_decibels(double value) {
|
||||||
|
// Magic number 20 comes from converting amplitude ratios to decibels.
|
||||||
|
return 20 * log10(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels, int autosens,
|
||||||
|
double noise_reduction, int low_cut_off, int high_cut_off,
|
||||||
|
int scaling_mode) {
|
||||||
|
struct cava_plan *p = malloc(sizeof(struct cava_plan));
|
||||||
|
p->status = 0;
|
||||||
|
|
||||||
|
// sanity checks:
|
||||||
|
if (channels < 1 || channels > 2) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"cava_init called with illegal number of channels: %d, number of channels "
|
||||||
|
"supported are "
|
||||||
|
"1 and 2",
|
||||||
|
channels);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (rate < 1 || rate > 384000) {
|
||||||
|
snprintf(p->error_message, 1024, "cava_init called with illegal sample rate: %d\n", rate);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fft_buffer_size = 512;
|
||||||
|
|
||||||
|
if (rate > 8125 && rate <= 16250)
|
||||||
|
fft_buffer_size *= 2;
|
||||||
|
else if (rate > 16250 && rate <= 32500)
|
||||||
|
fft_buffer_size *= 4;
|
||||||
|
else if (rate > 32500 && rate <= 75000)
|
||||||
|
fft_buffer_size *= 8;
|
||||||
|
else if (rate > 75000 && rate <= 150000)
|
||||||
|
fft_buffer_size *= 16;
|
||||||
|
else if (rate > 150000 && rate <= 300000)
|
||||||
|
fft_buffer_size *= 32;
|
||||||
|
else if (rate > 300000)
|
||||||
|
fft_buffer_size *= 64;
|
||||||
|
|
||||||
|
if (number_of_bars < 1) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"cava_init called with illegal number of bars: %d, number of channels must be "
|
||||||
|
"positive integer\n",
|
||||||
|
number_of_bars);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (number_of_bars > fft_buffer_size / 2 + 1) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"cava_init called with illegal number of bars: %d, for %d sample rate number of "
|
||||||
|
"bars can't be more than %d\n",
|
||||||
|
number_of_bars, rate, fft_buffer_size / 2 + 1);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (low_cut_off < 1 || high_cut_off < 1) {
|
||||||
|
snprintf(p->error_message, 1024, "low_cut_off must be a positive value\n");
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (low_cut_off >= high_cut_off) {
|
||||||
|
snprintf(p->error_message, 1024, "high_cut_off must be a higher than low_cut_off\n");
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if ((unsigned int)high_cut_off > rate / 2) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"high_cut_off can't be higher than sample rate / 2. (Nyquist Sampling Theorem)\n");
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (scaling_mode != CAVA_SCALING_LINEAR && scaling_mode != CAVA_SCALING_DECIBEL) {
|
||||||
|
snprintf(p->error_message, 1024, "unknown scaling mode: %d\n", scaling_mode);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
p->number_of_bars = number_of_bars;
|
||||||
|
p->audio_channels = channels;
|
||||||
|
p->rate = rate;
|
||||||
|
p->autosens = 1;
|
||||||
|
p->sens_init = 1;
|
||||||
|
p->sens = 1.0;
|
||||||
|
p->autosens = autosens;
|
||||||
|
p->framerate = 75;
|
||||||
|
p->frame_skip = 1;
|
||||||
|
p->noise_reduction = noise_reduction;
|
||||||
|
p->scaling_mode = scaling_mode;
|
||||||
|
|
||||||
|
int fftw_flag = FFTW_MEASURE;
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
fftw_flag = FFTW_ESTIMATE;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
p->FFTbassbufferSize = fft_buffer_size * 2;
|
||||||
|
p->FFTbufferSize = fft_buffer_size;
|
||||||
|
|
||||||
|
p->input_buffer_size = p->FFTbassbufferSize * channels;
|
||||||
|
|
||||||
|
p->input_buffer = (double *)malloc(p->input_buffer_size * sizeof(double));
|
||||||
|
|
||||||
|
p->FFTbuffer_lower_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
|
||||||
|
p->FFTbuffer_upper_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
|
||||||
|
p->eq = (double *)malloc((number_of_bars + 1) * sizeof(double));
|
||||||
|
p->cut_off_frequency = (float *)malloc((number_of_bars + 1) * sizeof(float));
|
||||||
|
|
||||||
|
p->cava_fall = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
p->cava_mem = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
p->cava_peak = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
p->prev_cava_out = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
|
||||||
|
// Hann Window calculate multipliers
|
||||||
|
p->bass_multiplier = (double *)malloc(p->FFTbassbufferSize * sizeof(double));
|
||||||
|
p->multiplier = (double *)malloc(p->FFTbufferSize * sizeof(double));
|
||||||
|
for (int i = 0; i < p->FFTbassbufferSize; i++) {
|
||||||
|
p->bass_multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbassbufferSize - 1)));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < p->FFTbufferSize; i++) {
|
||||||
|
p->multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbufferSize - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASS
|
||||||
|
p->in_bass_l = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->in_bass_l_raw = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->out_bass_l = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
|
||||||
|
p->p_bass_l =
|
||||||
|
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_l, p->out_bass_l, fftw_flag);
|
||||||
|
|
||||||
|
// MID + TREBLE
|
||||||
|
p->in_l = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->in_l_raw = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->out_l = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
|
||||||
|
p->p_l = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_l, p->out_l, fftw_flag);
|
||||||
|
|
||||||
|
memset(p->in_bass_l, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_l, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->in_bass_l_raw, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_l_raw, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->out_bass_l, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
memset(p->out_l, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
// BASS
|
||||||
|
p->in_bass_r = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->in_bass_r_raw = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->out_bass_r = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
|
||||||
|
p->p_bass_r =
|
||||||
|
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_r, p->out_bass_r, fftw_flag);
|
||||||
|
|
||||||
|
// MID + TREBLE
|
||||||
|
p->in_r = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->in_r_raw = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->out_r = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
|
||||||
|
|
||||||
|
p->p_r = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_r, p->out_r, fftw_flag);
|
||||||
|
|
||||||
|
memset(p->in_bass_r, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_r, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->in_bass_r_raw, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_r_raw, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->out_bass_r, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
memset(p->out_r, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(p->input_buffer, 0, sizeof(double) * p->input_buffer_size);
|
||||||
|
|
||||||
|
memset(p->cava_fall, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
memset(p->cava_mem, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
memset(p->cava_peak, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
memset(p->prev_cava_out, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
|
||||||
|
// process: calculate cutoff frequencies and eq
|
||||||
|
int lower_cut_off = low_cut_off;
|
||||||
|
int upper_cut_off = high_cut_off;
|
||||||
|
int bass_cut_off = 100;
|
||||||
|
|
||||||
|
// calculate frequency constant (used to distribute bars across the frequency band)
|
||||||
|
double frequency_constant = log10((float)lower_cut_off / (float)upper_cut_off) /
|
||||||
|
(1 / ((float)p->number_of_bars + 1) - 1);
|
||||||
|
|
||||||
|
float *relative_cut_off = (float *)malloc((p->number_of_bars + 1) * sizeof(float));
|
||||||
|
|
||||||
|
p->bass_cut_off_bar = 0;
|
||||||
|
int first_bar = 1;
|
||||||
|
|
||||||
|
float min_bandwidth = p->rate / p->FFTbassbufferSize;
|
||||||
|
|
||||||
|
for (int n = 0; n < p->number_of_bars + 1; n++) {
|
||||||
|
double bar_distribution_coefficient = frequency_constant * (-1);
|
||||||
|
bar_distribution_coefficient +=
|
||||||
|
((float)n + 1) / ((float)p->number_of_bars + 1) * frequency_constant;
|
||||||
|
p->cut_off_frequency[n] = upper_cut_off * pow(10, bar_distribution_coefficient);
|
||||||
|
|
||||||
|
if (n > 0) {
|
||||||
|
if (p->cut_off_frequency[n - 1] >= p->cut_off_frequency[n])
|
||||||
|
p->cut_off_frequency[n] = p->cut_off_frequency[n - 1] + min_bandwidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember nyquist!
|
||||||
|
relative_cut_off[n] = p->cut_off_frequency[n] / (p->rate / 2);
|
||||||
|
|
||||||
|
if (p->cut_off_frequency[n] < bass_cut_off) {
|
||||||
|
// BASS
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = relative_cut_off[n] * (p->FFTbassbufferSize / 2);
|
||||||
|
p->bass_cut_off_bar++;
|
||||||
|
if (p->bass_cut_off_bar > 1)
|
||||||
|
first_bar = 0;
|
||||||
|
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbassbufferSize / 2) {
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = p->FFTbassbufferSize / 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MID + TREBLE
|
||||||
|
p->FFTbuffer_lower_cut_off[n] =
|
||||||
|
ceil(relative_cut_off[n] * (float)(p->FFTbufferSize / 2));
|
||||||
|
if (n == p->bass_cut_off_bar) {
|
||||||
|
first_bar = 1;
|
||||||
|
if (n > 0) {
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] =
|
||||||
|
relative_cut_off[n] * (p->FFTbassbufferSize / 2) - 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
first_bar = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbufferSize / 2) {
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = p->FFTbufferSize / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n > 0) {
|
||||||
|
if (!first_bar) {
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
|
||||||
|
|
||||||
|
// pushing the spectrum up if the exponential function gets "clumped" in the
|
||||||
|
// bass and calculating new cut off frequencies
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n] <= p->FFTbuffer_lower_cut_off[n - 1]) {
|
||||||
|
|
||||||
|
// check if there is room for more first
|
||||||
|
int room_for_more = 0;
|
||||||
|
|
||||||
|
if (n < p->bass_cut_off_bar) {
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbassbufferSize / 2 + 1)
|
||||||
|
room_for_more = 1;
|
||||||
|
} else {
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbufferSize / 2 + 1)
|
||||||
|
room_for_more = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room_for_more) {
|
||||||
|
// push the spectrum up
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (p->FFTbuffer_upper_cut_off[n - 1] < p->FFTbuffer_lower_cut_off[n - 1])
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// calculate actual cut off frequency
|
||||||
|
if (n < p->bass_cut_off_bar)
|
||||||
|
relative_cut_off[n] =
|
||||||
|
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbassbufferSize / 2);
|
||||||
|
else
|
||||||
|
relative_cut_off[n] =
|
||||||
|
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbufferSize / 2);
|
||||||
|
|
||||||
|
p->cut_off_frequency[n] = relative_cut_off[n] * ((float)p->rate / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// hard coded eq
|
||||||
|
for (int n = 0; n < p->number_of_bars; n++) {
|
||||||
|
|
||||||
|
// the numbers that come out of the FFT are very high
|
||||||
|
// the EQ is used to "normalize" them by dividing with this very huge number
|
||||||
|
p->eq[n] = 1 / pow(2, 28);
|
||||||
|
|
||||||
|
// need to boost the EQ for higher frequencies
|
||||||
|
p->eq[n] *= pow(p->cut_off_frequency[n + 1], 0.85);
|
||||||
|
|
||||||
|
if (n < p->bass_cut_off_bar) {
|
||||||
|
p->eq[n] /= log2(p->FFTbassbufferSize);
|
||||||
|
} else {
|
||||||
|
p->eq[n] /= log2(p->FFTbufferSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
p->eq[n] /= p->FFTbuffer_upper_cut_off[n] - p->FFTbuffer_lower_cut_off[n] + 1;
|
||||||
|
}
|
||||||
|
free(relative_cut_off);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cava_execute(double *cava_in, int new_samples, double *cava_out, struct cava_plan *p) {
|
||||||
|
|
||||||
|
// do not overflow
|
||||||
|
if (new_samples > p->input_buffer_size) {
|
||||||
|
new_samples = p->input_buffer_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
int silence = 1;
|
||||||
|
if (new_samples > 0) {
|
||||||
|
// process: approximate actual framerate. This will be off by +10% at 60 fps, but should be
|
||||||
|
// good enough for the autosens and smoothing algorithms to be adjusted accordingly if
|
||||||
|
// framerate is a lot more or less.
|
||||||
|
p->framerate -= p->framerate / 64.0;
|
||||||
|
p->framerate +=
|
||||||
|
(double)(p->rate * p->frame_skip) / (new_samples / p->audio_channels) / 64.0;
|
||||||
|
p->frame_skip = 1;
|
||||||
|
|
||||||
|
// shifting input buffer
|
||||||
|
for (int n = p->input_buffer_size - 1; n >= new_samples; n--) {
|
||||||
|
p->input_buffer[n] = p->input_buffer[n - new_samples];
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill the input buffer
|
||||||
|
for (int n = 0; n < new_samples; n++) {
|
||||||
|
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||||
|
// Audio signals come in the range [-32768, 32768], normalize to [-1, 1].
|
||||||
|
p->input_buffer[new_samples - n - 1] = cava_in[n] / 32768.0;
|
||||||
|
} else {
|
||||||
|
p->input_buffer[new_samples - n - 1] = cava_in[n];
|
||||||
|
}
|
||||||
|
if (cava_in[n]) {
|
||||||
|
silence = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p->frame_skip++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill the bass, mid and treble buffers
|
||||||
|
for (int n = 0; n < p->FFTbassbufferSize; n++) {
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
p->in_bass_r_raw[n] = p->input_buffer[n * 2];
|
||||||
|
p->in_bass_l_raw[n] = p->input_buffer[n * 2 + 1];
|
||||||
|
} else {
|
||||||
|
p->in_bass_l_raw[n] = p->input_buffer[n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int n = 0; n < p->FFTbufferSize; n++) {
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
p->in_r_raw[n] = p->input_buffer[n * 2];
|
||||||
|
p->in_l_raw[n] = p->input_buffer[n * 2 + 1];
|
||||||
|
} else {
|
||||||
|
p->in_l_raw[n] = p->input_buffer[n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hann Window
|
||||||
|
for (int i = 0; i < p->FFTbassbufferSize; i++) {
|
||||||
|
p->in_bass_l[i] = p->bass_multiplier[i] * p->in_bass_l_raw[i];
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
p->in_bass_r[i] = p->bass_multiplier[i] * p->in_bass_r_raw[i];
|
||||||
|
}
|
||||||
|
for (int i = 0; i < p->FFTbufferSize; i++) {
|
||||||
|
p->in_l[i] = p->multiplier[i] * p->in_l_raw[i];
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
p->in_r[i] = p->multiplier[i] * p->in_r_raw[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// process: execute FFT and sort frequency bands
|
||||||
|
|
||||||
|
fftw_execute(p->p_bass_l);
|
||||||
|
fftw_execute(p->p_l);
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
fftw_execute(p->p_bass_r);
|
||||||
|
fftw_execute(p->p_r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// process: separate frequency bands
|
||||||
|
for (int n = 0; n < p->number_of_bars; n++) {
|
||||||
|
|
||||||
|
double temp_l = 0;
|
||||||
|
double temp_r = 0;
|
||||||
|
|
||||||
|
// process: add upp FFT values within bands
|
||||||
|
for (int i = p->FFTbuffer_lower_cut_off[n]; i <= p->FFTbuffer_upper_cut_off[n]; i++) {
|
||||||
|
|
||||||
|
if (n < p->bass_cut_off_bar) {
|
||||||
|
temp_l += hypot(p->out_bass_l[i][0], p->out_bass_l[i][1]);
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
temp_r += hypot(p->out_bass_r[i][0], p->out_bass_r[i][1]);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
temp_l += hypot(p->out_l[i][0], p->out_l[i][1]);
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
temp_r += hypot(p->out_r[i][0], p->out_r[i][1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getting average and applying configured scaling
|
||||||
|
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||||
|
const double max_db = 70;
|
||||||
|
temp_l = amplitude_to_decibels(temp_l) / max_db;
|
||||||
|
if (!isfinite(temp_l)) {
|
||||||
|
temp_l = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
temp_l *= p->eq[n];
|
||||||
|
}
|
||||||
|
cava_out[n] = temp_l;
|
||||||
|
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||||
|
const double max_db = 70;
|
||||||
|
temp_r = amplitude_to_decibels(temp_r) / max_db;
|
||||||
|
if (!isfinite(temp_r)) {
|
||||||
|
temp_r = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
temp_r *= p->eq[n];
|
||||||
|
}
|
||||||
|
cava_out[n + p->number_of_bars] = temp_r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applying sens or getting max value
|
||||||
|
if (p->autosens) {
|
||||||
|
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
|
||||||
|
cava_out[n] *= p->sens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// process [smoothing]
|
||||||
|
int overshoot = 0;
|
||||||
|
|
||||||
|
double framerate_mod = 66 / p->framerate;
|
||||||
|
double gravity_mod = pow((framerate_mod), 2.5) * 2 / p->noise_reduction;
|
||||||
|
double integral_mod = pow((framerate_mod), 0.1);
|
||||||
|
|
||||||
|
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
|
||||||
|
|
||||||
|
// process [smoothing]: falloff
|
||||||
|
|
||||||
|
if (cava_out[n] < p->prev_cava_out[n] && p->noise_reduction > 0.1) {
|
||||||
|
cava_out[n] =
|
||||||
|
p->cava_peak[n] * (1.0 - (p->cava_fall[n] * p->cava_fall[n] * gravity_mod));
|
||||||
|
|
||||||
|
if (cava_out[n] < 0.0)
|
||||||
|
cava_out[n] = 0.0;
|
||||||
|
p->cava_fall[n] += 0.028;
|
||||||
|
} else {
|
||||||
|
p->cava_peak[n] = cava_out[n];
|
||||||
|
p->cava_fall[n] = 0.0;
|
||||||
|
}
|
||||||
|
p->prev_cava_out[n] = cava_out[n];
|
||||||
|
|
||||||
|
// process [smoothing]: integral
|
||||||
|
cava_out[n] = p->cava_mem[n] * p->noise_reduction / integral_mod + cava_out[n];
|
||||||
|
|
||||||
|
p->cava_mem[n] = cava_out[n];
|
||||||
|
if (p->autosens) {
|
||||||
|
// check if we overshoot target height
|
||||||
|
if (cava_out[n] > 1.0) {
|
||||||
|
overshoot = 1;
|
||||||
|
cava_out[n] = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculating automatic sense adjustment
|
||||||
|
if (p->autosens) {
|
||||||
|
if (overshoot) {
|
||||||
|
p->sens = p->sens * (1 - (0.02 * framerate_mod));
|
||||||
|
p->sens_init = 0;
|
||||||
|
} else {
|
||||||
|
if (!silence) {
|
||||||
|
p->sens = p->sens * (1 + (0.001 * framerate_mod * p->autosens));
|
||||||
|
if (p->sens_init)
|
||||||
|
p->sens = p->sens * (1 + (0.1 * framerate_mod));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cava_destroy(struct cava_plan *p) {
|
||||||
|
|
||||||
|
free(p->input_buffer);
|
||||||
|
free(p->bass_multiplier);
|
||||||
|
free(p->multiplier);
|
||||||
|
free(p->eq);
|
||||||
|
free(p->cut_off_frequency);
|
||||||
|
free(p->FFTbuffer_lower_cut_off);
|
||||||
|
free(p->FFTbuffer_upper_cut_off);
|
||||||
|
free(p->cava_fall);
|
||||||
|
free(p->cava_mem);
|
||||||
|
free(p->cava_peak);
|
||||||
|
free(p->prev_cava_out);
|
||||||
|
|
||||||
|
fftw_free(p->in_bass_l);
|
||||||
|
fftw_free(p->in_bass_l_raw);
|
||||||
|
fftw_free(p->out_bass_l);
|
||||||
|
fftw_destroy_plan(p->p_bass_l);
|
||||||
|
|
||||||
|
fftw_free(p->in_l);
|
||||||
|
fftw_free(p->in_l_raw);
|
||||||
|
fftw_free(p->out_l);
|
||||||
|
fftw_destroy_plan(p->p_l);
|
||||||
|
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
fftw_free(p->in_bass_r);
|
||||||
|
fftw_free(p->in_bass_r_raw);
|
||||||
|
fftw_free(p->out_bass_r);
|
||||||
|
fftw_destroy_plan(p->p_bass_r);
|
||||||
|
|
||||||
|
fftw_free(p->in_r);
|
||||||
|
fftw_free(p->out_r);
|
||||||
|
fftw_free(p->in_r_raw);
|
||||||
|
fftw_destroy_plan(p->p_r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
JNIEXPORT jfloatArray JNICALL Java_com_karlstav_cava_MyGLRenderer_InitCava(
|
||||||
|
JNIEnv *env, jobject thiz, jint number_of_bars_set, jint refresh_rate, jint lower_cut_off,
|
||||||
|
jint higher_cut_off) {
|
||||||
|
jfloatArray cuttOffFreq = (*env)->NewFloatArray(env, number_of_bars_set + 1);
|
||||||
|
float noise_reduction = pow((float)refresh_rate / 130, 0.75);
|
||||||
|
|
||||||
|
plan = cava_init(number_of_bars_set, 44100, 1, 1, noise_reduction, lower_cut_off,
|
||||||
|
higher_cut_off, CAVA_SCALING_LINEAR);
|
||||||
|
cava_in = (double *)malloc(plan->FFTbassbufferSize * sizeof(double));
|
||||||
|
cava_out = (double *)malloc(plan->number_of_bars * sizeof(double));
|
||||||
|
(*env)->SetFloatArrayRegion(env, cuttOffFreq, 0, plan->number_of_bars + 1,
|
||||||
|
plan->cut_off_frequency);
|
||||||
|
return cuttOffFreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_MyGLRenderer_ExecCava(JNIEnv *env,
|
||||||
|
jobject thiz,
|
||||||
|
jdoubleArray cava_input,
|
||||||
|
jint new_samples) {
|
||||||
|
|
||||||
|
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
|
||||||
|
|
||||||
|
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
|
||||||
|
|
||||||
|
cava_execute(cava_in, new_samples, cava_out, plan);
|
||||||
|
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
|
||||||
|
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
|
||||||
|
|
||||||
|
return cavaReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT int JNICALL Java_com_karlstav_cava_CavaCoreTest_InitCava(JNIEnv *env, jobject thiz,
|
||||||
|
jint number_of_bars_set) {
|
||||||
|
|
||||||
|
plan = cava_init(number_of_bars_set, 44100, 1, 1, 0.7, 50, 10000, CAVA_SCALING_LINEAR);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_CavaCoreTest_ExecCava(JNIEnv *env,
|
||||||
|
jobject thiz,
|
||||||
|
jdoubleArray cava_input,
|
||||||
|
jint new_samples) {
|
||||||
|
|
||||||
|
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
|
||||||
|
|
||||||
|
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
|
||||||
|
|
||||||
|
cava_execute(cava_in, new_samples, cava_out, plan);
|
||||||
|
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
|
||||||
|
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
|
||||||
|
|
||||||
|
return cavaReturn;
|
||||||
|
}
|
||||||
|
JNIEXPORT void JNICALL Java_com_karlstav_cava_MyGLRenderer_DestroyCava(JNIEnv *env, jobject thiz) {
|
||||||
|
cava_destroy(plan);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
139
cava/cavacore.h
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2022 Karl Stavestrand <karl@stavestrand.no>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <fftw3.h>
|
||||||
|
|
||||||
|
#define CAVA_SCALING_LINEAR 0
|
||||||
|
#define CAVA_SCALING_DECIBEL 1
|
||||||
|
|
||||||
|
// cava_plan, parameters used internally by cavacore, do not modify these directly
|
||||||
|
// only the cut off frequencies is of any potential interest to read out,
|
||||||
|
// the rest should most likely be hidden somehow
|
||||||
|
struct cava_plan {
|
||||||
|
int FFTbassbufferSize;
|
||||||
|
int FFTbufferSize;
|
||||||
|
int number_of_bars;
|
||||||
|
int audio_channels;
|
||||||
|
int input_buffer_size;
|
||||||
|
int rate;
|
||||||
|
int bass_cut_off_bar;
|
||||||
|
int sens_init;
|
||||||
|
int autosens;
|
||||||
|
int frame_skip;
|
||||||
|
int status;
|
||||||
|
int scaling_mode;
|
||||||
|
char error_message[1024];
|
||||||
|
|
||||||
|
double sens;
|
||||||
|
double framerate;
|
||||||
|
double noise_reduction;
|
||||||
|
|
||||||
|
fftw_plan p_bass_l, p_bass_r;
|
||||||
|
fftw_plan p_l, p_r;
|
||||||
|
|
||||||
|
fftw_complex *out_bass_l, *out_bass_r;
|
||||||
|
fftw_complex *out_l, *out_r;
|
||||||
|
|
||||||
|
double *bass_multiplier;
|
||||||
|
double *multiplier;
|
||||||
|
|
||||||
|
double *in_bass_r_raw, *in_bass_l_raw;
|
||||||
|
double *in_r_raw, *in_l_raw;
|
||||||
|
double *in_bass_r, *in_bass_l;
|
||||||
|
double *in_r, *in_l;
|
||||||
|
double *prev_cava_out, *cava_mem;
|
||||||
|
double *input_buffer, *cava_peak;
|
||||||
|
|
||||||
|
double *eq;
|
||||||
|
|
||||||
|
float *cut_off_frequency;
|
||||||
|
int *FFTbuffer_lower_cut_off;
|
||||||
|
int *FFTbuffer_upper_cut_off;
|
||||||
|
double *cava_fall;
|
||||||
|
};
|
||||||
|
|
||||||
|
// cava_init, initialize visualization, takes the following parameters:
|
||||||
|
|
||||||
|
// number_of_bars, number of wanted bars per channel
|
||||||
|
|
||||||
|
// rate, sample rate of input signal
|
||||||
|
|
||||||
|
// channels, number of interleaved channels in input
|
||||||
|
|
||||||
|
// autosens, toggle automatic sensitivity adjustment 1 = on, 0 = off
|
||||||
|
// on, gives a dynamically adjusted output signal from 0 to 1
|
||||||
|
// the output is continuously adjusted to use the entire range
|
||||||
|
// off, will pass the raw values from cava directly to the output
|
||||||
|
// the max values will then be dependent on the input
|
||||||
|
|
||||||
|
// noise_reduction, adjust noise reduction filters. 0 - 1, recommended 0.77
|
||||||
|
// the raw visualization is very noisy, this factor adjusts the integral
|
||||||
|
// and gravity filters inside cavacore to keep the signal smooth
|
||||||
|
// 1 will be very slow and smooth, 0 will be fast but noisy.
|
||||||
|
|
||||||
|
// low_cut_off, high_cut_off cut off frequencies for visualization in Hz
|
||||||
|
// recommended: 50, 10000
|
||||||
|
|
||||||
|
// scaling_mode, output scaling mode:
|
||||||
|
// CAVA_SCALING_LINEAR = legacy linear scaling
|
||||||
|
// CAVA_SCALING_DECIBEL = dB-based logarithmic scaling
|
||||||
|
|
||||||
|
// returns a cava_plan to be used by cava_execute. If cava_plan.status is 0 all is OK.
|
||||||
|
// If cava_plan.status is -1, cava_init was called with an illegal parameter, see error string in
|
||||||
|
// cava_plan.error_message
|
||||||
|
extern struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels,
|
||||||
|
int autosens, double noise_reduction, int low_cut_off,
|
||||||
|
int high_cut_off, int scaling_mode);
|
||||||
|
|
||||||
|
// cava_execute, executes visualization
|
||||||
|
|
||||||
|
// cava_in, input buffer can be any size. internal buffers in cavacore is
|
||||||
|
// 4096 * number of channels at 44100 samples rate, if new_samples is greater
|
||||||
|
// then samples will be discarded. However it is recommended to use less
|
||||||
|
// new samples per execution as this determines your framerate.
|
||||||
|
// 512 samples at 44100 sample rate mono, gives about 86 frames per second.
|
||||||
|
|
||||||
|
// new_samples, the number of samples in cava_in to be processed per execution
|
||||||
|
// in case of async reading of data this number is allowed to vary from execution to execution
|
||||||
|
|
||||||
|
// cava_out, output buffer. Size must be number of bars * number of channels. Bars will
|
||||||
|
// be sorted from lowest to highest frequency. If stereo input channels are configured
|
||||||
|
// then all left channel bars will be first then the right.
|
||||||
|
|
||||||
|
// plan, the cava_plan struct returned from cava_init
|
||||||
|
|
||||||
|
// cava_execute assumes cava_in samples to be interleaved if more than one channel
|
||||||
|
// only up to two channels are supported.
|
||||||
|
extern void cava_execute(double *cava_in, int new_samples, double *cava_out,
|
||||||
|
struct cava_plan *plan);
|
||||||
|
|
||||||
|
// cava_destroy, destroys the plan, frees up memory
|
||||||
|
extern void cava_destroy(struct cava_plan *plan);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
547
discover/featured.json
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"podcasts": [
|
||||||
|
{
|
||||||
|
"id": "discover-daily",
|
||||||
|
"title": "The Daily",
|
||||||
|
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
|
||||||
|
"feedUrl": "http://rss.art19.com/the-daily",
|
||||||
|
"author": "The New York Times",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-up-first",
|
||||||
|
"title": "Up First",
|
||||||
|
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-npr-politics",
|
||||||
|
"title": "The NPR Politics Podcast",
|
||||||
|
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-ben-shapiro",
|
||||||
|
"title": "The Ben Shapiro Show",
|
||||||
|
"description": "Ben Shapiro delivers unapologetically conservative commentary on the biggest news stories of the day, blending sharp analysis with his trademark fact-based approach.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/benshow",
|
||||||
|
"author": "The Daily Wire",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-advisory-opinions",
|
||||||
|
"title": "Advisory Opinions",
|
||||||
|
"description": "Host Sarah Isgur and permanent guest David French have twice-weekly conversations about the law, the courts, their collision with politics, and why it all matters — from The Dispatch.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/DISPME4573820108",
|
||||||
|
"author": "The Dispatch",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-crime-junkie",
|
||||||
|
"title": "Crime Junkie",
|
||||||
|
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
|
||||||
|
"author": "audiochuck",
|
||||||
|
"categories": [
|
||||||
|
"True Crime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-serial",
|
||||||
|
"title": "Serial",
|
||||||
|
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
|
||||||
|
"author": "Serial Productions & The New York Times",
|
||||||
|
"categories": [
|
||||||
|
"True Crime",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-intelligence-matters",
|
||||||
|
"title": "Intelligence Matters",
|
||||||
|
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
|
||||||
|
"feedUrl": "https://rss.art19.com/intelligence-matters",
|
||||||
|
"author": "CBS News",
|
||||||
|
"categories": [
|
||||||
|
"True Crime",
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-smartless",
|
||||||
|
"title": "SmartLess",
|
||||||
|
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
|
||||||
|
"feedUrl": "https://rss.art19.com/smartless",
|
||||||
|
"author": "Jason Bateman, Sean Hayes, Will Arnett",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-this-past-weekend",
|
||||||
|
"title": "This Past Weekend w/ Theo Von",
|
||||||
|
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
|
||||||
|
"author": "Theo Von",
|
||||||
|
"categories": [
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-joe-rogan",
|
||||||
|
"title": "The Joe Rogan Experience",
|
||||||
|
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
|
||||||
|
"author": "Joe Rogan",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-comedy-bang-bang",
|
||||||
|
"title": "Comedy Bang Bang: The Podcast",
|
||||||
|
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
|
||||||
|
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": [
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-office-ladies",
|
||||||
|
"title": "Office Ladies",
|
||||||
|
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
|
||||||
|
"feedUrl": "https://rss.art19.com/office-ladies",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-how-did-this-get-made",
|
||||||
|
"title": "How Did This Get Made?",
|
||||||
|
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
|
||||||
|
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Film"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-wait-wait",
|
||||||
|
"title": "Wait Wait... Don't Tell Me!",
|
||||||
|
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-new-heights",
|
||||||
|
"title": "New Heights with Jason & Travis Kelce",
|
||||||
|
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
|
||||||
|
"feedUrl": "https://rss.art19.com/new-heights",
|
||||||
|
"author": "Jason & Travis Kelce",
|
||||||
|
"categories": [
|
||||||
|
"Sports",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-bill-simmons",
|
||||||
|
"title": "The Bill Simmons Podcast",
|
||||||
|
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
|
||||||
|
"author": "The Ringer",
|
||||||
|
"categories": [
|
||||||
|
"Sports",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-acquired",
|
||||||
|
"title": "Acquired",
|
||||||
|
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
|
||||||
|
"feedUrl": "https://feeds.transistor.fm/acquired",
|
||||||
|
"author": "Ben Gilbert & David Rosenthal",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-all-in",
|
||||||
|
"title": "All-In Podcast",
|
||||||
|
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
||||||
|
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
|
||||||
|
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology",
|
||||||
|
"Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-planet-money",
|
||||||
|
"title": "Planet Money",
|
||||||
|
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Economics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-how-i-built-this",
|
||||||
|
"title": "How I Built This with Guy Raz",
|
||||||
|
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
|
||||||
|
"author": "NPR / Wondery",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-freakonomics",
|
||||||
|
"title": "Freakonomics Radio",
|
||||||
|
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
|
||||||
|
"author": "Stephen J. Dubner",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Economics",
|
||||||
|
"Society"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-darknet-diaries",
|
||||||
|
"title": "Darknet Diaries",
|
||||||
|
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
|
||||||
|
"feedUrl": "https://podcast.darknetdiaries.com/",
|
||||||
|
"author": "Jack Rhysider",
|
||||||
|
"categories": [
|
||||||
|
"Technology",
|
||||||
|
"True Crime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-changelog",
|
||||||
|
"title": "The Changelog",
|
||||||
|
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
|
||||||
|
"feedUrl": "https://changelog.fm/rss",
|
||||||
|
"author": "Changelog Media",
|
||||||
|
"categories": [
|
||||||
|
"Technology",
|
||||||
|
"Software Engineering"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-twit",
|
||||||
|
"title": "This Week in Tech (TWiT)",
|
||||||
|
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
|
||||||
|
"feedUrl": "https://feeds.twit.tv/twit.xml",
|
||||||
|
"author": "TWiT",
|
||||||
|
"categories": [
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-radiolab",
|
||||||
|
"title": "Radiolab",
|
||||||
|
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
|
||||||
|
"feedUrl": "http://feeds.wnyc.org/radiolab",
|
||||||
|
"author": "WNYC Studios",
|
||||||
|
"categories": [
|
||||||
|
"Science",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-huberman-lab",
|
||||||
|
"title": "Huberman Lab",
|
||||||
|
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
|
||||||
|
"author": "Scicomm Media",
|
||||||
|
"categories": [
|
||||||
|
"Health",
|
||||||
|
"Science"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-skeptics-guide",
|
||||||
|
"title": "The Skeptics' Guide to the Universe",
|
||||||
|
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
|
||||||
|
"author": "Steven Novella",
|
||||||
|
"categories": [
|
||||||
|
"Science",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-throughline",
|
||||||
|
"title": "Throughline",
|
||||||
|
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"History",
|
||||||
|
"Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-hardcore-history",
|
||||||
|
"title": "Dan Carlin's Hardcore History",
|
||||||
|
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
|
||||||
|
"author": "Dan Carlin",
|
||||||
|
"categories": [
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-history-of-rome",
|
||||||
|
"title": "The History of Rome",
|
||||||
|
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
|
||||||
|
"author": "Mike Duncan",
|
||||||
|
"categories": [
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-philosophize-this",
|
||||||
|
"title": "Philosophize This!",
|
||||||
|
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
||||||
|
"feedUrl": "https://philosophizethis.libsyn.com/rss",
|
||||||
|
"author": "Stephen West",
|
||||||
|
"categories": [
|
||||||
|
"Philosophy",
|
||||||
|
"Education"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-very-bad-wizards",
|
||||||
|
"title": "Very Bad Wizards",
|
||||||
|
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
||||||
|
"feedUrl": "https://feeds.libsyn.com/474285/rss",
|
||||||
|
"author": "Tamler Sommers & David Pizarro",
|
||||||
|
"categories": [
|
||||||
|
"Philosophy",
|
||||||
|
"Science"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-big-picture",
|
||||||
|
"title": "The Big Picture",
|
||||||
|
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-big-picture",
|
||||||
|
"author": "The Ringer",
|
||||||
|
"categories": [
|
||||||
|
"Film",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-all-songs-considered",
|
||||||
|
"title": "All Songs Considered",
|
||||||
|
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
|
||||||
|
"author": "NPR Music",
|
||||||
|
"categories": [
|
||||||
|
"Music"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-switched-on-pop",
|
||||||
|
"title": "Switched on Pop",
|
||||||
|
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
|
||||||
|
"author": "Vox Media / Panoply",
|
||||||
|
"categories": [
|
||||||
|
"Music"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-hit-parade",
|
||||||
|
"title": "Hit Parade",
|
||||||
|
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/hitparade",
|
||||||
|
"author": "Slate",
|
||||||
|
"categories": [
|
||||||
|
"Music",
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-song-exploder",
|
||||||
|
"title": "Song Exploder",
|
||||||
|
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
|
||||||
|
"feedUrl": "https://songexploder.net/rss",
|
||||||
|
"author": "Hrishikesh Hirway",
|
||||||
|
"categories": [
|
||||||
|
"Music",
|
||||||
|
"Arts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-blank-check",
|
||||||
|
"title": "Blank Check with Griffin & David",
|
||||||
|
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
|
||||||
|
"feedUrl": "https://audioboom.com/channels/4278829.rss",
|
||||||
|
"author": "Griffin Newman & David Sims",
|
||||||
|
"categories": [
|
||||||
|
"Film",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-99-invisible",
|
||||||
|
"title": "99% Invisible",
|
||||||
|
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
|
||||||
|
"author": "Roman Mars",
|
||||||
|
"categories": [
|
||||||
|
"Design",
|
||||||
|
"Arts",
|
||||||
|
"Culture"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-gastropod",
|
||||||
|
"title": "Gastropod",
|
||||||
|
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
|
||||||
|
"feedUrl": "https://gastropod.com/feed",
|
||||||
|
"author": "Cynthia Graber & Nicola Twilley",
|
||||||
|
"categories": [
|
||||||
|
"Food",
|
||||||
|
"Science",
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-this-american-life",
|
||||||
|
"title": "This American Life",
|
||||||
|
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
|
||||||
|
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
|
||||||
|
"author": "This American Life",
|
||||||
|
"categories": [
|
||||||
|
"Storytelling",
|
||||||
|
"Culture"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-ted-talks-daily",
|
||||||
|
"title": "TED Talks Daily",
|
||||||
|
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
|
||||||
|
"author": "TED",
|
||||||
|
"categories": [
|
||||||
|
"Education",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-tim-ferriss",
|
||||||
|
"title": "The Tim Ferriss Show",
|
||||||
|
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
|
||||||
|
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
|
||||||
|
"author": "Tim Ferriss",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Business"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-jordan-harbinger",
|
||||||
|
"title": "The Jordan Harbinger Show",
|
||||||
|
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
|
||||||
|
"author": "Jordan Harbinger",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-on-purpose",
|
||||||
|
"title": "On Purpose with Jay Shetty",
|
||||||
|
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
|
||||||
|
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
|
||||||
|
"author": "Jay Shetty",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Health"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-10-percent-happier",
|
||||||
|
"title": "10% Happier with Dan Harris",
|
||||||
|
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
|
||||||
|
"feedUrl": "https://rss.art19.com/ten-percent-happier",
|
||||||
|
"author": "Dan Harris",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Health",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-school-of-greatness",
|
||||||
|
"title": "The School of Greatness",
|
||||||
|
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
|
||||||
|
"author": "Lewis Howes",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Business"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-sysk",
|
||||||
|
"title": "Stuff You Should Know",
|
||||||
|
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
||||||
|
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
|
||||||
|
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
|
||||||
|
"categories": [
|
||||||
|
"Education",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-in-our-time",
|
||||||
|
"title": "In Our Time",
|
||||||
|
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
|
||||||
|
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
|
||||||
|
"author": "BBC Radio 4",
|
||||||
|
"categories": [
|
||||||
|
"History",
|
||||||
|
"Education",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
package.json
@@ -8,31 +8,25 @@
|
|||||||
"podtui": "./dist/index.js"
|
"podtui": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun src/index.tsx",
|
"start": "bun --preload @opentui/solid/preload src/index.tsx",
|
||||||
"dev": "bun --watch src/index.tsx",
|
"dev": "bun --preload @opentui/solid/preload --watch src/index.tsx",
|
||||||
"build:native": "bash scripts/build-cavacore.sh",
|
"build:native": "bash scripts/build-cavacore.sh",
|
||||||
"build": "bun run build.ts",
|
"build": "bun run build.ts",
|
||||||
"dist": "bun dist/index.js",
|
"dist": "bun dist/index.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"lint": "bun run lint.ts"
|
"lint": "bun tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
packaging/aur/.SRCINFO
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
pkgbase = podtui-bin
|
||||||
|
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||||
|
pkgver = 0.2.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/mikefreno/podtui
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = MIT
|
||||||
|
depends = mpv
|
||||||
|
provides = podtui
|
||||||
|
conflicts = podtui
|
||||||
|
options = !strip
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||||
|
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||||
|
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
|
||||||
|
pkgname = podtui-bin
|
||||||
|
pkgver = 0.2.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/mikefreno/podtui
|
||||||
|
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = MIT
|
||||||
|
depends = mpv
|
||||||
|
provides = podtui
|
||||||
|
conflicts = podtui
|
||||||
|
options = !strip
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||||
|
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||||
|
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
63
packaging/aur/PKGBUILD
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Maintainer: Michael Freno <michael.freno@gmail.com>
|
||||||
|
# Contributor: Michael Freno <michael.freno@gmail.com>
|
||||||
|
# podtui-bin — TUI podcast/audiobook player with synchronized audio-waveform
|
||||||
|
# visualization. Serves the official standalone release binary and its two FFI
|
||||||
|
# sibling libraries (libcavacore.so + libopentui.so) from GitHub Releases.
|
||||||
|
#
|
||||||
|
# The embedded Bun runtime is statically linked into the binary — no Bun, no
|
||||||
|
# fftw needed at runtime (fftw3 is linked statically into libcavacore.so).
|
||||||
|
|
||||||
|
pkgname=podtui-bin
|
||||||
|
_pkgname=podtui
|
||||||
|
pkgver=0.2.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Terminal podcast and audio player with synchronized audio-waveform visualization"
|
||||||
|
url="https://github.com/mikefreno/podtui"
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
|
license=('MIT')
|
||||||
|
depends=('mpv') # sole audio backend; no-op without it
|
||||||
|
provides=("${_pkgname}")
|
||||||
|
conflicts=("${_pkgname}")
|
||||||
|
options=('!strip') # standalone binary, pre-minified
|
||||||
|
source_x86_64=(
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-x64.tar.gz"
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
|
||||||
|
)
|
||||||
|
source_aarch64=(
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-arm64.tar.gz"
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
|
||||||
|
)
|
||||||
|
sha256sums_x86_64=(
|
||||||
|
'5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc'
|
||||||
|
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
|
||||||
|
)
|
||||||
|
sha256sums_aarch64=(
|
||||||
|
'c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5'
|
||||||
|
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
|
||||||
|
)
|
||||||
|
|
||||||
|
package() {
|
||||||
|
local libdir
|
||||||
|
|
||||||
|
case "$CARCH" in
|
||||||
|
x86_64) libdir="podtui-linux-x64" ;;
|
||||||
|
aarch64) libdir="podtui-linux-arm64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Binary + native FFI libs must stay side by side in /usr/lib/podtui/;
|
||||||
|
# a /usr/bin symlink works because the embedded Bun runtime resolves
|
||||||
|
# process.execPath through symlinks (verified against the compiled binary).
|
||||||
|
install -Dm755 "${srcdir}/${libdir}/podtui" "${pkgdir}/usr/lib/podtui/podtui"
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
||||||
|
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
|
||||||
|
|
||||||
|
# App icon + desktop entry ship inside the release tarball; Terminal=true
|
||||||
|
# makes launchers drop the TUI into a terminal window.
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/podtui.png" \
|
||||||
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/podtui.png"
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/podtui.desktop" \
|
||||||
|
"${pkgdir}/usr/share/applications/podtui.desktop"
|
||||||
|
|
||||||
|
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
|
}
|
||||||
60
packaging/aur/gen-srcinfo.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# gen-srcinfo.sh — emit .SRCINFO for the podtui-bin PKGBUILD without makepkg.
|
||||||
|
# Emits the same field set/ordering makepkg --printsrcinfo produces for this
|
||||||
|
# PKGBUILD shape (single package, per-arch source + sha256sums arrays).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./PKGBUILD
|
||||||
|
|
||||||
|
emit() { printf '\t%s = %s\n' "$1" "$2"; }
|
||||||
|
emit_multi() { # $1 field, rest values
|
||||||
|
local f="$1"
|
||||||
|
shift
|
||||||
|
for v in "$@"; do emit "$f" "$v"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgbase_section() {
|
||||||
|
echo "pkgbase = ${pkgname}"
|
||||||
|
for f in pkgdesc pkgver pkgrel url; do
|
||||||
|
v="${!f}"
|
||||||
|
[ -n "${v:-}" ] && emit "$f" "$v"
|
||||||
|
done
|
||||||
|
[ -n "${install:-}" ] && emit install "$install"
|
||||||
|
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||||
|
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||||
|
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||||
|
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||||
|
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||||
|
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||||
|
emit_arch_arrays
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_arch_arrays() {
|
||||||
|
for a in "${arch[@]}"; do
|
||||||
|
src_name="source_${a}"
|
||||||
|
sha_name="sha256sums_${a}"
|
||||||
|
src_val="${src_name}[@]"
|
||||||
|
sha_val="${sha_name}[@]"
|
||||||
|
[ "${#src_name}" -gt 0 ] && emit_multi "source_${a}" "${!src_val}"
|
||||||
|
emit_multi "sha256sums_${a}" "${!sha_val}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgbase_section
|
||||||
|
echo ""
|
||||||
|
echo "pkgname = ${pkgname}"
|
||||||
|
for v in pkgver pkgrel url; do
|
||||||
|
val="${!v}"
|
||||||
|
[ -n "${val:-}" ] && emit "$v" "$val"
|
||||||
|
done
|
||||||
|
emit pkgdesc "$pkgdesc"
|
||||||
|
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||||
|
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||||
|
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||||
|
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||||
|
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||||
|
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||||
|
emit_arch_arrays
|
||||||
11
packaging/podtui.desktop
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=PodTui
|
||||||
|
GenericName=Podcast Player
|
||||||
|
Comment=Terminal podcast and audio player with waveform visualization
|
||||||
|
Exec=podtui
|
||||||
|
Icon=podtui
|
||||||
|
Terminal=true
|
||||||
|
Categories=Audio;AudioVideo;Player;
|
||||||
|
Keywords=podcast;audio;player;terminal;
|
||||||
|
StartupNotify=false
|
||||||
@@ -19,36 +19,58 @@ mkdir -p "$OUT_DIR"
|
|||||||
OS="$(uname -s)"
|
OS="$(uname -s)"
|
||||||
ARCH="$(uname -m)"
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
# Resolve fftw3 paths
|
# Resolve fftw3 paths. The static archive lives in different places per
|
||||||
|
# platform: Homebrew (/opt/homebrew on arm64, /usr/local on Intel) and, on
|
||||||
|
# Debian/Ubuntu, the multiarch dir /usr/lib/<triplet> (e.g.
|
||||||
|
# x86_64-linux-gnu, aarch64-linux-gnu).
|
||||||
if [ "$OS" = "Darwin" ]; then
|
if [ "$OS" = "Darwin" ]; then
|
||||||
if [ "$ARCH" = "arm64" ]; then
|
LIB_EXT="dylib"
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
|
SHARED_FLAG="-dynamiclib"
|
||||||
else
|
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
|
if [ "$ARCH" = "arm64" ]; then
|
||||||
fi
|
FFTW_HINTS="/opt/homebrew /usr/local"
|
||||||
LIB_EXT="dylib"
|
else
|
||||||
SHARED_FLAG="-dynamiclib"
|
FFTW_HINTS="/usr/local /opt/homebrew"
|
||||||
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
fi
|
||||||
else
|
else
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr}"
|
LIB_EXT="so"
|
||||||
LIB_EXT="so"
|
SHARED_FLAG="-shared"
|
||||||
SHARED_FLAG="-shared"
|
INSTALL_NAME=""
|
||||||
INSTALL_NAME=""
|
FFTW_HINTS="/usr /usr/local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FFTW_PREFIX="${FFTW_PREFIX:-}"
|
||||||
|
FFTW_STATIC=""
|
||||||
|
if [ -n "$FFTW_PREFIX" ]; then
|
||||||
|
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
|
||||||
|
else
|
||||||
|
for hint in $FFTW_HINTS; do
|
||||||
|
for cand in "$hint/lib/libfftw3.a" "$hint/lib/${ARCH}-linux-gnu/libfftw3.a"; do
|
||||||
|
if [ -f "$cand" ]; then
|
||||||
|
FFTW_STATIC="$cand"
|
||||||
|
FFTW_PREFIX="$hint"
|
||||||
|
break 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$FFTW_STATIC" ] || [ ! -f "$FFTW_STATIC" ]; then
|
||||||
|
echo "Error: libfftw3.a not found (searched: ${FFTW_HINTS})"
|
||||||
|
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
|
||||||
|
echo "or point FFTW_PREFIX at a prefix containing lib/libfftw3.a."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FFTW_INCLUDE="$FFTW_PREFIX/include"
|
FFTW_INCLUDE="$FFTW_PREFIX/include"
|
||||||
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
|
if [ ! -d "$FFTW_INCLUDE" ]; then
|
||||||
|
FFTW_INCLUDE="$FFTW_PREFIX/include/$(basename "$(dirname "$FFTW_STATIC")")"
|
||||||
if [ ! -f "$FFTW_STATIC" ]; then
|
|
||||||
echo "Error: libfftw3.a not found at $FFTW_STATIC"
|
|
||||||
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "$SRC" ]; then
|
if [ ! -f "$SRC" ]; then
|
||||||
echo "Error: cavacore.c not found at $SRC"
|
echo "Error: cavacore.c not found at $SRC"
|
||||||
echo "Ensure the cava submodule is initialized: git submodule update --init"
|
echo "The cava source is vendored under cava/ (from github.com/karlstav/cava, MIT)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
OUT="$OUT_DIR/libcavacore.$LIB_EXT"
|
OUT="$OUT_DIR/libcavacore.$LIB_EXT"
|
||||||
@@ -59,21 +81,21 @@ echo " FFTW3: $FFTW_STATIC"
|
|||||||
echo " Output: $OUT"
|
echo " Output: $OUT"
|
||||||
|
|
||||||
cc -O2 \
|
cc -O2 \
|
||||||
$SHARED_FLAG \
|
$SHARED_FLAG \
|
||||||
$INSTALL_NAME \
|
$INSTALL_NAME \
|
||||||
-fPIC \
|
-fPIC \
|
||||||
-I"$FFTW_INCLUDE" \
|
-I"$FFTW_INCLUDE" \
|
||||||
-I"$ROOT/cava" \
|
-I"$ROOT/cava" \
|
||||||
-o "$OUT" \
|
-o "$OUT" \
|
||||||
"$SRC" \
|
"$SRC" \
|
||||||
"$FFTW_STATIC" \
|
"$FFTW_STATIC" \
|
||||||
-lm
|
-lm
|
||||||
|
|
||||||
echo "Built: $OUT"
|
echo "Built: $OUT"
|
||||||
|
|
||||||
# Verify exported symbols
|
# Verify exported symbols
|
||||||
if [ "$OS" = "Darwin" ]; then
|
if [ "$OS" = "Darwin" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "Exported symbols:"
|
echo "Exported symbols:"
|
||||||
nm -gU "$OUT" | grep "cava_"
|
nm -gU "$OUT" | grep "cava_"
|
||||||
fi
|
fi
|
||||||
|
|||||||
240
scripts/release-tag.sh
Executable file
@@ -0,0 +1,240 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# release-tag.sh — PodTui version bump, commit, tag, and push.
|
||||||
|
#
|
||||||
|
# Mirrors the release flow from FlexLove's scripts/make-tag.sh, adapted for
|
||||||
|
# PodTui's single version source (src/index.tsx) and dual remotes (gh, gt).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/release-tag.sh interactive release
|
||||||
|
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
|
||||||
|
#
|
||||||
|
# Pushing a v* tag to the `gh` remote triggers .github/workflows/release.yml
|
||||||
|
# (4-platform tarball builds) — the release and the Homebrew tap update then
|
||||||
|
# happen automatically and need no further local action.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
DRY_RUN=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run | -n) DRY_RUN=1 ;;
|
||||||
|
--help | -h)
|
||||||
|
echo "Usage: scripts/release-tag.sh [--dry-run]"
|
||||||
|
echo " --dry-run, -n show the plan without committing, tagging, or pushing"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unknown option: ${arg}${NC}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -d .git ] && [ ! -f .git ]; then
|
||||||
|
echo -e "${RED}Error: Not in a git repository${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff-index --quiet HEAD --; then
|
||||||
|
echo -e "${YELLOW}You have uncommitted changes:${NC}"
|
||||||
|
git status --short
|
||||||
|
echo ""
|
||||||
|
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${RED}Aborted${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Current version from the latest tag; fall back to src/index.tsx.
|
||||||
|
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
|
||||||
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
|
CURRENT_VERSION=$(grep -m 1 "^const VERSION" src/index.tsx | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
|
||||||
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
|
echo -e "${RED}Error: could not extract version from git tags or src/index.tsx${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}No tags found; using VERSION from src/index.tsx (${CURRENT_VERSION})${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
|
||||||
|
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
|
||||||
|
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
|
||||||
|
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
|
||||||
|
|
||||||
|
echo -e "${CYAN}Select version bump type:${NC}"
|
||||||
|
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH} → $((MAJOR + 1)).0.0"
|
||||||
|
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.$((MINOR + 1)).0"
|
||||||
|
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
echo " 4) Custom version"
|
||||||
|
echo " 5) Cancel"
|
||||||
|
echo ""
|
||||||
|
read -p "Enter choice (1-5): " -n 1 -r CHOICE
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case $CHOICE in
|
||||||
|
1)
|
||||||
|
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
read -p "Enter custom version (e.g., 1.0.0-beta): " -r NEW_VERSION
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
echo -e "${RED}Cancelled${NC}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Version sanity check (tags are vMAJOR.MINOR.PATCH).
|
||||||
|
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo -e "${RED}Error: ${NEW_VERSION} is not a valid X.Y.Z version (v tags only)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}This will:${NC}"
|
||||||
|
echo " 1. Set src/index.tsx → VERSION = \"${NEW_VERSION}\""
|
||||||
|
echo " 2. Commit the bump"
|
||||||
|
echo " 3. Create annotated tag v${NEW_VERSION}"
|
||||||
|
echo " 4. Push master and the tag to every remote"
|
||||||
|
REMOTES=$(git remote)
|
||||||
|
for r in $REMOTES; do
|
||||||
|
echo " → $r"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
||||||
|
echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Proceed? (y/n) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[dry-run]${NC} would have:"
|
||||||
|
echo " sed src/index.tsx: VERSION \"${CURRENT_VERSION}\" → \"${NEW_VERSION}\""
|
||||||
|
echo " git commit -m \"bump VERSION to ${NEW_VERSION}\""
|
||||||
|
echo " git tag -a v${NEW_VERSION} -m \"PodTUI v${NEW_VERSION}\""
|
||||||
|
for r in $REMOTES; do echo " push $r master"; done
|
||||||
|
for r in $REMOTES; do echo " push $r v${NEW_VERSION}"; done
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Plan only — nothing written${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Apply the bump ───────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[1/4]${NC} Updating src/index.tsx..."
|
||||||
|
sed -i.bak "s/const VERSION = \"[^\"]*\"/const VERSION = \"${NEW_VERSION}\"/" src/index.tsx
|
||||||
|
rm -f src/index.tsx.bak
|
||||||
|
echo -e "${GREEN}✓ src/index.tsx updated${NC}"
|
||||||
|
|
||||||
|
if git diff --quiet -- src/index.tsx; then
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
|
||||||
|
echo -e "${YELLOW}Already at ${NEW_VERSION} and tag v${NEW_VERSION} exists — nothing to release.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}VERSION is already ${NEW_VERSION} (bump already committed).${NC}"
|
||||||
|
echo -e "${YELLOW}Will skip the commit and just create the missing tag + push.${NC}"
|
||||||
|
read -p "Tag v${NEW_VERSION} on current HEAD and push? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git add src/index.tsx
|
||||||
|
echo -e "${GREEN}✓ staged${NC}"
|
||||||
|
|
||||||
|
echo -e "${CYAN}[2/4]${NC} Committing..."
|
||||||
|
DEFAULT_COMMIT_MSG="bump VERSION to ${NEW_VERSION}"
|
||||||
|
echo -e "Default commit message: ${CYAN}${DEFAULT_COMMIT_MSG}${NC}"
|
||||||
|
read -p "Use default? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||||
|
read -p "Enter commit message: " -r COMMIT_MSG
|
||||||
|
else
|
||||||
|
COMMIT_MSG="$DEFAULT_COMMIT_MSG"
|
||||||
|
fi
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
echo -e "${GREEN}✓ committed: ${COMMIT_MSG}${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}[3/4]${NC} Tagging..."
|
||||||
|
git tag -a "v${NEW_VERSION}" -m "PodTUI v${NEW_VERSION}"
|
||||||
|
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${CYAN}[4/4]${NC} Pushing..."
|
||||||
|
FAILED=""
|
||||||
|
for r in $REMOTES; do
|
||||||
|
if ! git push "$r" master; then
|
||||||
|
FAILED="${FAILED}${r} (branch) "
|
||||||
|
fi
|
||||||
|
if ! git push "$r" tag "v${NEW_VERSION}"; then
|
||||||
|
FAILED="${FAILED}${r} (tag) "
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ -n "$FAILED" ]; then
|
||||||
|
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||||
|
echo -e "${RED}✗ Push failed for: ${FAILED}${NC}"
|
||||||
|
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}The commit and tag exist locally. To retry:${NC}"
|
||||||
|
for r in $REMOTES; do
|
||||||
|
echo " git push ${r} master"
|
||||||
|
echo " git push ${r} v${NEW_VERSION}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}To undo:${NC}"
|
||||||
|
echo " git tag -d v${NEW_VERSION}"
|
||||||
|
echo " git reset --soft HEAD~1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN}✓ PodTui v${NEW_VERSION} released${NC}"
|
||||||
|
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION} → ${GREEN}${NEW_VERSION}${NC}"
|
||||||
|
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||||
|
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
||||||
|
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
||||||
|
echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
|
||||||
|
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||||
@@ -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;
|
||||||
@@ -436,16 +499,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (newAction) {
|
if (newAction) {
|
||||||
if (flags.audio && audioControls?.switchBackend) {
|
if (flags.audio && audioControls?.switchBackend) {
|
||||||
// Re-detect: clear env so detection picks the best real backend.
|
|
||||||
delete process.env.PODTUI_AUDIO_BACKEND;
|
|
||||||
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
|
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
|
||||||
|
delete process.env.PODTUI_AUDIO_BACKEND;
|
||||||
await audioControls.switchBackend("mpv").catch(() => {});
|
await audioControls.switchBackend("mpv").catch(() => {});
|
||||||
if (
|
|
||||||
!audioControls.backendName() ||
|
|
||||||
audioControls.backendName() === "none"
|
|
||||||
) {
|
|
||||||
await audioControls.switchBackend("afplay").catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
actions.push(newAction);
|
actions.push(newAction);
|
||||||
saveActions(actions);
|
saveActions(actions);
|
||||||
@@ -455,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.
|
||||||
@@ -469,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 {
|
||||||
@@ -521,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}”`);
|
||||||
@@ -579,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 ──
|
||||||
@@ -593,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(() => {});
|
||||||
@@ -613,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);
|
||||||
|
|||||||
10
src/App.tsx
@@ -1,6 +1,5 @@
|
|||||||
import { ErrorBoundary } from "solid-js";
|
import { ErrorBoundary } from "solid-js";
|
||||||
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
||||||
import { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
@@ -19,23 +18,21 @@ const DEBUG = import.meta.env.DEBUG;
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const auth = useAuthStore();
|
|
||||||
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).
|
||||||
@@ -52,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,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Code validation component for PodTUI
|
|
||||||
* 8-character alphanumeric code input for sync authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { AUTH_CONFIG } from "@/config/auth";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface CodeValidationProps {
|
|
||||||
focused?: boolean;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusField = "code" | "submit" | "back";
|
|
||||||
|
|
||||||
export function CodeValidation(props: CodeValidationProps) {
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [code, setCode] = createSignal("");
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
|
||||||
const [codeError, setCodeError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const fields: FocusField[] = ["code", "submit", "back"];
|
|
||||||
|
|
||||||
/** Format code as user types (uppercase, alphanumeric only) */
|
|
||||||
const handleCodeInput = (value: string) => {
|
|
||||||
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
||||||
// Limit to max length
|
|
||||||
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
|
|
||||||
setCode(limited);
|
|
||||||
|
|
||||||
// Clear error when typing
|
|
||||||
if (codeError()) {
|
|
||||||
setCodeError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateCode = (value: string): boolean => {
|
|
||||||
if (!value) {
|
|
||||||
setCodeError("Code is required");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
|
||||||
setCodeError(
|
|
||||||
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
|
|
||||||
setCodeError("Code must contain only letters and numbers");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setCodeError(null);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!validateCode(code())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await auth.validateCode(code());
|
|
||||||
if (!success && auth.error) {
|
|
||||||
setCodeError(auth.error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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" || key.name === "tab") {
|
|
||||||
if (focusField() === "submit") {
|
|
||||||
handleSubmit();
|
|
||||||
} else if (focusField() === "back" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
} else if (key.name === "escape" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeProgress = () => {
|
|
||||||
const len = code().length;
|
|
||||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
|
||||||
return `${len}/${max}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeDisplay = () => {
|
|
||||||
const current = code();
|
|
||||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
|
||||||
const filled = current.split("");
|
|
||||||
const empty = Array(max - filled.length).fill("_");
|
|
||||||
return [...filled, ...empty].join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Enter Sync Code</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter your 8-character sync code to link your account.
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Code display */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
|
||||||
Code ({codeProgress()}):
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box border padding={1} borderColor={theme.border}>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
code().length === AUTH_CONFIG.codeValidation.codeLength
|
|
||||||
? theme.success
|
|
||||||
: theme.warning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{codeDisplay()}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Hidden input for actual typing */}
|
|
||||||
<input
|
|
||||||
value={code()}
|
|
||||||
onInput={handleCodeInput}
|
|
||||||
placeholder=""
|
|
||||||
focused={props.focused && focusField() === "code"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{codeError() && <text fg={theme.error}>{codeError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "submit" ? theme.primary : undefined}>
|
|
||||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
|
||||||
[Esc] Back to Login
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Auth error message */}
|
|
||||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,28 +0,0 @@
|
|||||||
import type { TabId } from "./Tab"
|
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
|
||||||
|
|
||||||
type NavigationProps = {
|
|
||||||
activeTab: TabId
|
|
||||||
onTabSelect: (tab: TabId) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "}
|
|
||||||
<span> </span>
|
|
||||||
{props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
215
src/components/PaneRow.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||||
|
*
|
||||||
|
* Implements yazi's `mgr.ratio` contract: three columns grow at
|
||||||
|
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
|
||||||
|
* so every list tab renders an identical, layout-stable shell. Columns use
|
||||||
|
* `flexBasis={0}` so the ratio is exact regardless of content width — a
|
||||||
|
* column's content can never stretch its slot.
|
||||||
|
*
|
||||||
|
* Column semantics (per the yazi depth model):
|
||||||
|
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||||
|
* KEEPS its 20% slot when blank (never collapses to width 0).
|
||||||
|
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||||
|
* header row: the CURRENT column's title renders top-left in the
|
||||||
|
* parent's slot (the panes above current/preview were removed).
|
||||||
|
* current — the current-depth list. The only focusable content column; it
|
||||||
|
* is the ONLY bordered column — left/right edges only, always
|
||||||
|
* muted (no active-border highlight, focused or not).
|
||||||
|
* preview — detail of the hovered item in `current`. Borderless, no header.
|
||||||
|
*
|
||||||
|
* The primitive is purely structural: callers pass their own JSX per column
|
||||||
|
* (static elements or accessors) plus the current-column title. Theme colors
|
||||||
|
* are resolved internally via `useTheme()`. Only the current column's
|
||||||
|
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
|
||||||
|
* stay in the current pane).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <PaneRow
|
||||||
|
* parent={parentList}
|
||||||
|
* current={currentList}
|
||||||
|
* preview={detail}
|
||||||
|
* currentLabel="List · 42"
|
||||||
|
* focused={isActive}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createMemo, Show } from "solid-js";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
import type { RGBA, BorderSides } from "@opentui/core";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||||
|
type PaneLabel = string | (() => string);
|
||||||
|
|
||||||
|
export type PaneRowProps = {
|
||||||
|
/** Parent column content (previous-depth list, or null for a muted
|
||||||
|
* placeholder — the 1/5 slot is always preserved). */
|
||||||
|
parent?: PaneContent;
|
||||||
|
/** Current column content (the focused list). */
|
||||||
|
current?: PaneContent;
|
||||||
|
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||||
|
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||||
|
preview?: PaneContent;
|
||||||
|
/** Title of the current column — rendered once, top-left in the parent
|
||||||
|
* pane's header slot (the per-pane Up/Detail headers are gone). */
|
||||||
|
currentLabel?: PaneLabel;
|
||||||
|
/** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
|
||||||
|
* true; pass `false` (or a signal) when the row is inactive. Does NOT change
|
||||||
|
* border colors — the current column's border is always muted. */
|
||||||
|
focused?: boolean | (() => boolean);
|
||||||
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
|
panes?: 2 | 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function resolveLabel(v: PaneLabel | undefined): string {
|
||||||
|
if (v == null) return "";
|
||||||
|
return typeof v === "function" ? v() : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
||||||
|
* We deliberately avoid Solid's `children()` helper: it flattens accessor
|
||||||
|
* children into a stable resolved-nodes array and won't re-resolve on a
|
||||||
|
* truthy→truthy root swap (e.g. the current pane switching between a
|
||||||
|
* depth-1 list fragment and a depth-2 editor), freezing the previous
|
||||||
|
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
|
||||||
|
* expression — a tracked `insert` effect that disposes the old subtree and
|
||||||
|
* mounts the new whenever the accessor returns a different element identity. */
|
||||||
|
function normalizeContent(
|
||||||
|
v: PaneContent | undefined,
|
||||||
|
): () => JSX.Element | undefined {
|
||||||
|
if (v == null) return () => undefined;
|
||||||
|
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Placeholder(props: { color: () => RGBA }) {
|
||||||
|
return (
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={props.color()}>—</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||||
|
function Pane(props: {
|
||||||
|
grow: number;
|
||||||
|
label: () => string;
|
||||||
|
content: () => JSX.Element | undefined;
|
||||||
|
border: boolean | BorderSides[];
|
||||||
|
scrollFocused: () => boolean;
|
||||||
|
}) {
|
||||||
|
const themeContext = useTheme();
|
||||||
|
const theme = themeContext.theme;
|
||||||
|
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||||
|
|
||||||
|
// Memoize the scroll-focus accessor result so the prop expression below
|
||||||
|
// stays reactive when the underlying signal (e.g. `focused`) changes.
|
||||||
|
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={props.grow}
|
||||||
|
flexBasis={0}
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
{/* ── title row: rendered only when the pane carries a label ────────── */}
|
||||||
|
<Show when={props.label() !== ""}>
|
||||||
|
<box
|
||||||
|
height={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
backgroundColor={
|
||||||
|
themeContext.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: theme.background
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
{/* ── scrollbox; border always muted (focused or not) ──────────────── */}
|
||||||
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
focused={scrollFocused()}
|
||||||
|
border={props.border}
|
||||||
|
// Only supply colors when a border is requested — opentui flips a
|
||||||
|
// borderless box to bordered when borderColor/focusedBorderColor
|
||||||
|
// are passed, which would frame the parent/preview panes too.
|
||||||
|
borderColor={props.border === false ? undefined : theme.border}
|
||||||
|
focusedBorderColor={
|
||||||
|
props.border === false ? undefined : theme.border
|
||||||
|
}
|
||||||
|
backgroundColor={
|
||||||
|
themeContext.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: theme.background
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.content() ?? <Placeholder color={muted} />}
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||||
|
export function PaneRow(props: PaneRowProps) {
|
||||||
|
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||||
|
const focused = createMemo(() => {
|
||||||
|
const f = props.focused;
|
||||||
|
return typeof f === "function" ? f() : (f ?? true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize static JSX and accessor children into reactive accessors
|
||||||
|
// (see normalizeContent for why we avoid Solid's `children()` helper).
|
||||||
|
const parentContent = normalizeContent(props.parent);
|
||||||
|
const currentContent = normalizeContent(props.current);
|
||||||
|
const previewContent = normalizeContent(props.preview);
|
||||||
|
|
||||||
|
// The single title: the CURRENT column's label, rendered in the parent
|
||||||
|
// pane's header slot (top-left). Current/preview panes have no headers.
|
||||||
|
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||||
|
|
||||||
|
// 2-pane mode (parent|current) grows the current column to fill the
|
||||||
|
// preview slot. Defaults to 3 (parent|current|preview).
|
||||||
|
const panes = createMemo(() => props.panes ?? 3);
|
||||||
|
const currentGrow = createMemo(() =>
|
||||||
|
panes() === 2
|
||||||
|
? PANE_RATIO.current + PANE_RATIO.preview
|
||||||
|
: PANE_RATIO.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
|
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
|
||||||
|
<Pane
|
||||||
|
grow={PANE_RATIO.parent}
|
||||||
|
label={currentLabel}
|
||||||
|
content={parentContent}
|
||||||
|
border={false}
|
||||||
|
scrollFocused={() => false}
|
||||||
|
/>
|
||||||
|
{/* ── current — the focused list; left/right borders only ─────────── */}
|
||||||
|
<Pane
|
||||||
|
grow={currentGrow()}
|
||||||
|
label={() => ""}
|
||||||
|
content={currentContent}
|
||||||
|
border={["left", "right"]}
|
||||||
|
scrollFocused={() => focused()}
|
||||||
|
/>
|
||||||
|
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||||
|
<Show when={panes() === 3}>
|
||||||
|
<Pane
|
||||||
|
grow={PANE_RATIO.preview}
|
||||||
|
label={() => ""}
|
||||||
|
content={previewContent}
|
||||||
|
border={false}
|
||||||
|
scrollFocused={() => false}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,31 +11,22 @@
|
|||||||
* 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 type { Episode } from "@/types/episode";
|
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 { YaziPaneRow } from "@/components/YaziPaneRow";
|
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();
|
||||||
@@ -43,12 +34,25 @@ export function Shell() {
|
|||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const k = useKeybinds();
|
const k = useKeybinds();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
const renderer = useRenderer();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
const [showHelp, setShowHelp] = createSignal(false);
|
const [showHelp, setShowHelp] = createSignal(false);
|
||||||
|
|
||||||
|
// ── Auto jump to Player on podcast start ───────────────────────────────────
|
||||||
|
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
|
||||||
|
// "player.started" — distinct from "player.play", which also fires on
|
||||||
|
// resume), switch to the Player tab and drop into its content pane.
|
||||||
|
on("player.started", () => {
|
||||||
|
const app = useAppStore();
|
||||||
|
if (app.state().preferences.autoJumpToPlayer) {
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
||||||
* within its feed's episode list. Updates audio-nav context accordingly. */
|
* within its feed's episode list. Updates audio-nav context accordingly. */
|
||||||
function advanceEpisode(offset: number) {
|
function advanceEpisode(offset: number) {
|
||||||
@@ -84,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 ───────────────────────────────────────────────
|
||||||
@@ -200,8 +190,21 @@ export function Shell() {
|
|||||||
|
|
||||||
useKeyboard(
|
useKeyboard(
|
||||||
(evt: any) => {
|
(evt: any) => {
|
||||||
// Input fields (search boxes, dialogs) own their keys.
|
// Input fields (search boxes, dialogs) own their keys — except Escape,
|
||||||
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return;
|
// which defocuses the input so j/k/h navigation resumes (search: h back
|
||||||
|
// to the tab root, j/k to move the recent-searches list).
|
||||||
|
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
|
||||||
|
if (evt.name === "escape") {
|
||||||
|
evt.preventDefault();
|
||||||
|
nav.setInputFocused(false);
|
||||||
|
// Actually blur the focused renderable too — setting the flag alone
|
||||||
|
// leaves the opentui input owning keys, so nav keys would still be
|
||||||
|
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
|
||||||
|
// (and re-blurs the SearchPage input via its `focused` prop).
|
||||||
|
renderer.currentFocusedRenderable?.blur();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (nav.mode() === NavMode.COMMAND) {
|
if (nav.mode() === NavMode.COMMAND) {
|
||||||
handleCommandKey(evt);
|
handleCommandKey(evt);
|
||||||
return;
|
return;
|
||||||
@@ -213,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()} --`;
|
||||||
@@ -227,13 +237,77 @@ export function Shell() {
|
|||||||
.map((s) => s.key)
|
.map((s) => s.key)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
// ── Now-playing marquee ────────────────────────────────────────────────────
|
||||||
|
// The now-playing segment takes the full remaining status-bar width and
|
||||||
|
// marquee-scrolls when its text overflows; when it fits (or the bar is too
|
||||||
|
// narrow to show anything) it renders statically. Each pass scrolls at
|
||||||
|
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
||||||
|
// before scrolling again.
|
||||||
|
const dims = useTerminalDimensions();
|
||||||
|
const GAP = 3;
|
||||||
|
const SCROLL_STEP_MS = 150;
|
||||||
|
const SCROLL_HOLD_MS = 10_000;
|
||||||
|
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||||
|
const leftFixed = () =>
|
||||||
|
modeLabel().length +
|
||||||
|
(nav.selectedIds().length > 0
|
||||||
|
? 4 + String(nav.selectedIds().length).length
|
||||||
|
: 0);
|
||||||
|
const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3;
|
||||||
|
const availableWidth = () =>
|
||||||
|
Math.max(0, dims().width - leftFixed() - rightFixed() - 2);
|
||||||
|
const visible = () => {
|
||||||
|
const text = nowPlayingText();
|
||||||
|
const avail = availableWidth();
|
||||||
|
if (!text || avail <= 0) return "";
|
||||||
|
if (text.length <= avail) return text;
|
||||||
|
// Double the text with a gap so the wrap is seamless: the window
|
||||||
|
// slides over text + gap + text without ever hitting the tail.
|
||||||
|
return (text + " ".repeat(GAP) + text).slice(
|
||||||
|
scrollOffset(),
|
||||||
|
scrollOffset() + avail,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
createEffect(() => {
|
||||||
|
const text = nowPlayingText();
|
||||||
|
const avail = availableWidth();
|
||||||
|
setScrollOffset(0);
|
||||||
|
if (!text || avail <= 0 || text.length <= avail) return;
|
||||||
|
const cycle = text.length + GAP - avail;
|
||||||
|
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
||||||
|
// then hold again before the next pass.
|
||||||
|
let holdId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let scrollId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const startHold = () => {
|
||||||
|
setScrollOffset(0);
|
||||||
|
holdId = setTimeout(() => {
|
||||||
|
scrollId = setInterval(() => {
|
||||||
|
const next = scrollOffset() + 1;
|
||||||
|
if (next >= cycle) {
|
||||||
|
clearInterval(scrollId!);
|
||||||
|
startHold();
|
||||||
|
} else {
|
||||||
|
setScrollOffset(next);
|
||||||
|
}
|
||||||
|
}, SCROLL_STEP_MS);
|
||||||
|
}, SCROLL_HOLD_MS);
|
||||||
|
};
|
||||||
|
startHold();
|
||||||
|
onCleanup(() => {
|
||||||
|
if (holdId) clearTimeout(holdId);
|
||||||
|
if (scrollId) clearInterval(scrollId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={t.surface}
|
backgroundColor={
|
||||||
>
|
theme.transparentBackground() ? "transparent" : t.surface
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||||
<box flexDirection="row" flexGrow={1} width="100%">
|
<box flexDirection="row" flexGrow={1} width="100%">
|
||||||
<Show
|
<Show
|
||||||
@@ -245,7 +319,7 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={
|
parent={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={t.textMuted}>—</text>
|
<text fg={t.textMuted}>—</text>
|
||||||
@@ -257,9 +331,7 @@ export function Shell() {
|
|||||||
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel="Tabs"
|
currentLabel="Tabs"
|
||||||
previewLabel=""
|
|
||||||
focused
|
focused
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -269,7 +341,11 @@ export function Shell() {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
height={1}
|
height={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
backgroundColor={t.backgroundPanel ?? t.background}
|
backgroundColor={
|
||||||
|
theme.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: (t.backgroundPanel ?? t.background)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={nav.mode() === NavMode.COMMAND}
|
when={nav.mode() === NavMode.COMMAND}
|
||||||
@@ -278,26 +354,19 @@ export function Shell() {
|
|||||||
<text fg={t.accent} paddingLeft={1}>
|
<text fg={t.accent} paddingLeft={1}>
|
||||||
{modeLabel()}
|
{modeLabel()}
|
||||||
</text>
|
</text>
|
||||||
<text fg={t.textMuted} paddingLeft={1}>
|
|
||||||
{nav.atRootTab()
|
|
||||||
? "Tabs · root"
|
|
||||||
: `${TAB_LABEL[nav.activeTab()]} · ${
|
|
||||||
nav.isDepthTab()
|
|
||||||
? `depth ${nav.currentDepth()}`
|
|
||||||
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
|
||||||
}`}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.selectedIds().length > 0}>
|
<Show when={nav.selectedIds().length > 0}>
|
||||||
<text fg={t.warning} paddingLeft={1}>
|
<text fg={t.warning} paddingLeft={1}>
|
||||||
● {nav.selectedIds().length}
|
● {nav.selectedIds().length}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={nowPlaying()}>
|
<Show when={nowPlayingText()}>
|
||||||
<text fg={t.primary} paddingLeft={1}>
|
<box flexGrow={1} paddingLeft={1}>
|
||||||
{nowPlaying()}
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
</text>
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
|
<text fg={t.primary} content={visible()} />
|
||||||
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<box flexGrow={1} />
|
|
||||||
<text fg={t.textMuted} paddingRight={1}>
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
{pendingLabel()}
|
{pendingLabel()}
|
||||||
</text>
|
</text>
|
||||||
@@ -378,6 +447,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
|||||||
["enter", "open"],
|
["enter", "open"],
|
||||||
["r", "refresh"],
|
["r", "refresh"],
|
||||||
["s", "search"],
|
["s", "search"],
|
||||||
|
[p("search-scope-toggle"), "shows/episodes"],
|
||||||
["f", "filter"],
|
["f", "filter"],
|
||||||
[",", "sort"],
|
[",", "sort"],
|
||||||
[".", "hidden"],
|
[".", "hidden"],
|
||||||
@@ -451,17 +521,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);
|
|
||||||
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 };
|
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;
|
|
||||||
};
|
|
||||||
@@ -2,77 +2,122 @@
|
|||||||
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
|
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
|
||||||
* PREVIEW flow (replaces the old fixed chrome tab column).
|
* PREVIEW flow (replaces the old fixed chrome tab column).
|
||||||
*
|
*
|
||||||
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
|
* Renders one row per tab (digit + label) using the same selection UI every
|
||||||
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
|
* other yazi pane uses: the CURSOR row (the one j/k hovers) gets a `❯` marker
|
||||||
* `focused` only matters to the surrounding frame (the CURRENT column draws
|
* and the focus background (`theme.primary` when this pane is the CURRENT
|
||||||
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
|
* column, `theme.border` when it is the muted UP/parent column). The ACTIVE
|
||||||
* column (`muted`), the cursor highlight is suppressed and only the active ●
|
* tab (the one whose content is open) always carries a `●` marker in accent so
|
||||||
* shows, so it reads as the read-only parent listing.
|
* it stays readable in both positions.
|
||||||
|
*
|
||||||
|
* `muted` marks the parent-column rendering: the highlight is dimmed (border
|
||||||
|
* bg, text fg) rather than suppressed, so the Up pane still shows the cursor
|
||||||
|
* and active tab — matching how every other pane's parent column renders its
|
||||||
|
* focused row.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { For } from "solid-js";
|
import { 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 { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
|
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[TABS.FEED]: "Feed",
|
||||||
[TABS.MYSHOWS]: "My Shows",
|
[TABS.MYSHOWS]: "My Shows",
|
||||||
[TABS.DISCOVER]: "Discover",
|
[TABS.DISCOVER]: "Discover",
|
||||||
[TABS.SEARCH]: "Search",
|
[TABS.SEARCH]: "Search",
|
||||||
[TABS.PLAYER]: "Player",
|
[TABS.PLAYER]: "Player",
|
||||||
[TABS.SETTINGS]: "Settings",
|
[TABS.SETTINGS]: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
|
||||||
|
const TAB_ICON: Record<TABS, string> = {
|
||||||
|
[TABS.FEED]: NF_ICONS.feed,
|
||||||
|
[TABS.MYSHOWS]: NF_ICONS.shows,
|
||||||
|
[TABS.DISCOVER]: NF_ICONS.discover,
|
||||||
|
[TABS.SEARCH]: NF_ICONS.search,
|
||||||
|
[TABS.PLAYER]: NF_ICONS.player,
|
||||||
|
[TABS.SETTINGS]: NF_ICONS.settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||||
const TAB_ORDER = Object.values(TABS).filter(
|
const TAB_ORDER = Object.values(TABS).filter(
|
||||||
(v): v is TABS => typeof v === "number",
|
(v): v is TABS => typeof v === "number",
|
||||||
) as TABS[];
|
) as TABS[];
|
||||||
|
|
||||||
export function TabListPane(props: { muted?: boolean }) {
|
export function TabListPane(props: { muted?: boolean }) {
|
||||||
const { theme } = useTheme();
|
// Static: detection never changes mid-session.
|
||||||
const nav = useNavigation();
|
const nerd = supportsNerdFonts();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const cursor = () => nav.tabCursor();
|
const cursor = () => nav.tabCursor();
|
||||||
const active = () => nav.activeTab();
|
const activeTab = () => nav.activeTab();
|
||||||
const muted = () => props.muted ?? false;
|
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||||
|
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||||
|
const active = () => !props.muted;
|
||||||
|
|
||||||
return (
|
// Same focus-bg / focus-fg contract every other pane uses.
|
||||||
<For each={TAB_ORDER}>
|
const focusBg = (t: TABS) =>
|
||||||
{(tab) => {
|
t === cursor() && active()
|
||||||
const isCursor = () => cursor() === tab && !muted();
|
? theme.primary
|
||||||
const isActive = () => active() === tab;
|
: t === cursor()
|
||||||
const fg = () =>
|
? theme.border
|
||||||
isCursor()
|
: undefined;
|
||||||
? theme.textSelectedPrimary
|
const focusFg = (t: TABS) =>
|
||||||
: isActive()
|
t === cursor() && active()
|
||||||
? theme.accent
|
? theme.surface
|
||||||
: theme.text;
|
: t === cursor()
|
||||||
return (
|
? theme.selectedListItemText ?? theme.text
|
||||||
<box
|
: theme.text;
|
||||||
width="100%"
|
|
||||||
height={1}
|
return (
|
||||||
flexDirection="row"
|
<For each={TAB_ORDER}>
|
||||||
backgroundColor={isCursor() ? theme.primary : "transparent"}
|
{(tab) => {
|
||||||
>
|
const isCursor = () => cursor() === tab;
|
||||||
<text
|
const isActive = () => activeTab() === tab;
|
||||||
width={2}
|
// The active tab is only accented in the Up/parent position — when this
|
||||||
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
|
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||||
>
|
const labelFg = () =>
|
||||||
{isActive() ? "●" : " "}
|
isCursor()
|
||||||
</text>
|
? focusFg(tab)
|
||||||
<text
|
: isActive() && !active()
|
||||||
width={2}
|
? theme.accent
|
||||||
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
|
: theme.text;
|
||||||
>
|
const ref = useScrollIntoView(isCursor);
|
||||||
{tab}
|
return (
|
||||||
</text>
|
<box
|
||||||
<text fg={fg()} paddingLeft={1}>
|
ref={ref}
|
||||||
{TAB_LABEL[tab]}
|
width="100%"
|
||||||
</text>
|
height={1}
|
||||||
</box>
|
flexDirection="row"
|
||||||
);
|
paddingRight={1}
|
||||||
}}
|
backgroundColor={focusBg(tab)}
|
||||||
</For>
|
onMouseDown={() => {
|
||||||
);
|
// Click = hover + open, the yazi "open" of the row
|
||||||
|
// (switches to the tab and enters its content), the same
|
||||||
|
// as l/Enter. Restores mouse support the tab-strip
|
||||||
|
// refactor dropped.
|
||||||
|
nav.setTabCursor(tab);
|
||||||
|
nav.activateTabCursor();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||||
|
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||||
|
{nerd && (
|
||||||
|
<text fg={focusFg(tab)} paddingRight={1}>
|
||||||
|
{TAB_ICON[tab]}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={labelFg()} paddingLeft={1}>
|
||||||
|
{TAB_LABEL[tab]}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
|
|
||||||
*
|
|
||||||
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
|
|
||||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
|
||||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
|
||||||
* so the ratio is exact regardless of content width — a column's content can
|
|
||||||
* never stretch its slot.
|
|
||||||
*
|
|
||||||
* Column semantics (per the yazi depth model):
|
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
|
||||||
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
|
||||||
* current — the current-depth list. The only focusable content column; it
|
|
||||||
* carries the accent focus ring when `focused` is truthy.
|
|
||||||
* preview — detail of the hovered item in `current`; always muted border.
|
|
||||||
*
|
|
||||||
* The primitive is purely structural: callers pass their own JSX per column
|
|
||||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
|
||||||
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
|
||||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* <YaziPaneRow
|
|
||||||
* parent={parentList}
|
|
||||||
* current={currentList}
|
|
||||||
* preview={detail}
|
|
||||||
* parentLabel="Up"
|
|
||||||
* currentLabel="List · 42"
|
|
||||||
* previewLabel="Detail"
|
|
||||||
* focused={isActive}
|
|
||||||
* />
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createMemo } from "solid-js";
|
|
||||||
import type { JSX } from "solid-js";
|
|
||||||
import type { RGBA } from "@opentui/core";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
type PaneContent = JSX.Element | (() => JSX.Element);
|
|
||||||
type PaneLabel = string | (() => string);
|
|
||||||
|
|
||||||
export type YaziPaneRowProps = {
|
|
||||||
/** Parent column content (previous-depth list, or null for a muted
|
|
||||||
* placeholder — the 1/7 slot is always preserved). */
|
|
||||||
parent?: PaneContent;
|
|
||||||
/** Current column content (the focused list). */
|
|
||||||
current?: PaneContent;
|
|
||||||
/** Preview column content (detail of the hovered item). */
|
|
||||||
preview?: PaneContent;
|
|
||||||
parentLabel?: PaneLabel;
|
|
||||||
currentLabel?: PaneLabel;
|
|
||||||
previewLabel?: PaneLabel;
|
|
||||||
/** Whether the current column carries the accent focus ring. Defaults to
|
|
||||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
|
||||||
* preview columns always render muted borders. */
|
|
||||||
focused?: boolean | (() => boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
function resolveLabel(v: PaneLabel | undefined): string {
|
|
||||||
if (v == null) return "";
|
|
||||||
return typeof v === "function" ? v() : v;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
|
||||||
* We deliberately do NOT use Solid's `children()` helper here: that helper
|
|
||||||
* flattens accessor children into a stable resolved-nodes array and is the
|
|
||||||
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
|
|
||||||
* switching between a depth-1 list fragment and a depth-2 editor — both
|
|
||||||
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
|
|
||||||
* root swap, freezing the previous subtree in place. Instead we hand the
|
|
||||||
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
|
|
||||||
* which Solid compiles into a tracked `insert` effect that disposes the old
|
|
||||||
* subtree and mounts the new whenever the accessor returns a different
|
|
||||||
* element identity. */
|
|
||||||
function normalizeContent(
|
|
||||||
v: PaneContent | undefined,
|
|
||||||
): () => JSX.Element | undefined {
|
|
||||||
if (v == null) return () => undefined;
|
|
||||||
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Placeholder(props: { color: () => RGBA }) {
|
|
||||||
return (
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={props.color()}>—</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pane column ─────────────────────────────────────────────────────────────
|
|
||||||
function YaziPane(props: {
|
|
||||||
grow: number;
|
|
||||||
label: () => string;
|
|
||||||
content: () => JSX.Element | undefined;
|
|
||||||
borderColor: () => RGBA;
|
|
||||||
scrollFocused: () => boolean;
|
|
||||||
}) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
|
||||||
|
|
||||||
// Memoize accessor results so the prop expressions below stay reactive
|
|
||||||
// when the underlying signals (e.g. `focused`) change.
|
|
||||||
const borderColor = createMemo(() => props.borderColor());
|
|
||||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
|
|
||||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
|
||||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
|
||||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
|
||||||
</box>
|
|
||||||
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
|
||||||
<scrollbox
|
|
||||||
height="100%"
|
|
||||||
focused={scrollFocused()}
|
|
||||||
border
|
|
||||||
borderColor={borderColor()}
|
|
||||||
backgroundColor={theme.background}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* Render the content accessor directly via a reactive expression.
|
|
||||||
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
|
|
||||||
* effect that re-runs whenever the accessor's tracked signals
|
|
||||||
* change (e.g. `depth()` swapping the root from a list fragment to
|
|
||||||
* an editor). Solid disposes the previously-rendered subtree and
|
|
||||||
* mounts the new element identity. `null`/`undefined` falls back
|
|
||||||
* to the muted placeholder so the parent pane keeps its 1/7 slot
|
|
||||||
* visibly blank at depth 0. This is the correct tool for root
|
|
||||||
* swapping — unlike Solid's `children()` / `<Show>`-children,
|
|
||||||
* which only react to truthiness flips, not truthy<@->truthy root
|
|
||||||
* identity changes.
|
|
||||||
*/}
|
|
||||||
{props.content() ?? <Placeholder color={muted} />}
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
|
||||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
/** true → the current column gets the accent focus ring. */
|
|
||||||
const focused = createMemo(() => {
|
|
||||||
const f = props.focused;
|
|
||||||
return typeof f === "function" ? f() : f ?? true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Normalize static JSX and accessor children into reactive accessors
|
|
||||||
// (see normalizeContent for why we avoid Solid's `children()` helper).
|
|
||||||
const parentContent = normalizeContent(props.parent);
|
|
||||||
const currentContent = normalizeContent(props.current);
|
|
||||||
const previewContent = normalizeContent(props.preview);
|
|
||||||
|
|
||||||
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
|
|
||||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
|
||||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
|
||||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
|
||||||
<YaziPane
|
|
||||||
grow={PANE_RATIO.parent}
|
|
||||||
label={parentLabel}
|
|
||||||
content={parentContent}
|
|
||||||
borderColor={() => theme.border}
|
|
||||||
scrollFocused={() => false}
|
|
||||||
/>
|
|
||||||
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
|
|
||||||
<YaziPane
|
|
||||||
grow={PANE_RATIO.current}
|
|
||||||
label={currentLabel}
|
|
||||||
content={currentContent}
|
|
||||||
borderColor={() => (focused() ? theme.accent : theme.border)}
|
|
||||||
scrollFocused={() => focused()}
|
|
||||||
/>
|
|
||||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
|
||||||
<YaziPane
|
|
||||||
grow={PANE_RATIO.preview}
|
|
||||||
label={previewLabel}
|
|
||||||
content={previewContent}
|
|
||||||
borderColor={() => theme.border}
|
|
||||||
scrollFocused={() => false}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication configuration for PodTUI
|
|
||||||
* Authentication is DISABLED by default - users can opt-in
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { OAuthProvider, type OAuthProviderConfig } from "../types/auth"
|
|
||||||
|
|
||||||
/** Default auth enabled state - DISABLED by default */
|
|
||||||
export const DEFAULT_AUTH_ENABLED = false
|
|
||||||
|
|
||||||
/** Authentication configuration */
|
|
||||||
export const AUTH_CONFIG = {
|
|
||||||
/** Whether auth is enabled by default */
|
|
||||||
defaultEnabled: DEFAULT_AUTH_ENABLED,
|
|
||||||
|
|
||||||
/** Code validation settings */
|
|
||||||
codeValidation: {
|
|
||||||
/** Code length (8 characters) */
|
|
||||||
codeLength: 8,
|
|
||||||
/** Allowed characters (alphanumeric) */
|
|
||||||
allowedChars: /^[A-Z0-9]+$/,
|
|
||||||
/** Code expiration time in minutes */
|
|
||||||
expirationMinutes: 15,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Password requirements */
|
|
||||||
password: {
|
|
||||||
minLength: 8,
|
|
||||||
requireUppercase: false,
|
|
||||||
requireLowercase: false,
|
|
||||||
requireNumber: false,
|
|
||||||
requireSpecial: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Email validation */
|
|
||||||
email: {
|
|
||||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Local storage keys */
|
|
||||||
storage: {
|
|
||||||
authState: "podtui_auth_state",
|
|
||||||
user: "podtui_user",
|
|
||||||
lastLogin: "podtui_last_login",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** OAuth provider configurations */
|
|
||||||
export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [
|
|
||||||
{
|
|
||||||
id: OAuthProvider.GOOGLE,
|
|
||||||
name: "Google",
|
|
||||||
enabled: false, // Not feasible in terminal
|
|
||||||
description: "Sign in with Google (requires browser redirect)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OAuthProvider.APPLE,
|
|
||||||
name: "Apple",
|
|
||||||
enabled: false, // Not feasible in terminal
|
|
||||||
description: "Sign in with Apple (requires browser redirect)",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Terminal OAuth limitation message */
|
|
||||||
export const OAUTH_LIMITATION_MESSAGE = `
|
|
||||||
OAuth authentication (Google, Apple) is not directly available in terminal applications.
|
|
||||||
|
|
||||||
To use OAuth:
|
|
||||||
1. Visit the web portal in your browser
|
|
||||||
2. Sign in with your preferred provider
|
|
||||||
3. Generate a sync code
|
|
||||||
4. Enter the code here to link your account
|
|
||||||
|
|
||||||
Alternatively, use email/password authentication or file-based sync.
|
|
||||||
`.trim()
|
|
||||||
@@ -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,17 +51,29 @@
|
|||||||
"tab-goto-5": ["5"],
|
"tab-goto-5": ["5"],
|
||||||
"tab-goto-6": ["6"],
|
"tab-goto-6": ["6"],
|
||||||
|
|
||||||
// ── Command bar & help & quit ────────────────────────────────────────────
|
// ── Command palette & help & quit ────────────────────────────────────────
|
||||||
"command": [":"],
|
// q opens the command palette (neovim-style: type q + Enter to quit there).
|
||||||
"quit": ["q", "ctrl-c"],
|
// Q (shift+q) is the instant quick quit. ctrl-c also quits.
|
||||||
|
"command": [":", "q"],
|
||||||
|
"quit": ["Q", "ctrl-c"],
|
||||||
"help": ["~", "f1"],
|
"help": ["~", "f1"],
|
||||||
|
|
||||||
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
||||||
"search": ["s"],
|
"search": ["s"],
|
||||||
"filter": ["f"],
|
// tab toggles the Search page between show and episode scope (re-runs the
|
||||||
|
// current query when viewing results)
|
||||||
|
"search-scope-toggle": ["tab"],
|
||||||
|
"filter": ["f"],
|
||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
|
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
|
||||||
|
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||||
|
|
||||||
|
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||||
|
"download": ["d"], // download the focused episode (detail pane)
|
||||||
|
"delete-download": ["D"], // delete the focused episode's download (if any)
|
||||||
|
"whitelist-toggle": ["w"], // add/remove the focused show from the auto-download whitelist (My Shows)
|
||||||
|
|
||||||
// ── Audio transport (preserved) ──────────────────────────────────────────
|
// ── 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
|
||||||
@@ -68,6 +81,6 @@
|
|||||||
"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,26 +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: ", / .", action: "Sort / toggle hidden" },
|
|
||||||
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
|
||||||
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
|
||||||
{ keys: "~ / F1", action: "Help" },
|
|
||||||
{ keys: "q", action: "Quit" },
|
|
||||||
] as const;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export const syncFormats = {
|
|
||||||
json: {
|
|
||||||
version: "1.0",
|
|
||||||
extension: ".json",
|
|
||||||
},
|
|
||||||
xml: {
|
|
||||||
version: "1.0",
|
|
||||||
extension: ".xml",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]
|
|
||||||
@@ -63,28 +63,21 @@ export type KeybindActionName =
|
|||||||
| "quit"
|
| "quit"
|
||||||
| "help"
|
| "help"
|
||||||
| "search"
|
| "search"
|
||||||
|
| "search-scope-toggle"
|
||||||
| "filter"
|
| "filter"
|
||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "subscribe"
|
||||||
|
| "unsubscribe"
|
||||||
|
| "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>>;
|
||||||
@@ -145,7 +138,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
||||||
export function strokeFromEvent(evt: {
|
function strokeFromEvent(evt: {
|
||||||
name: string;
|
name: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
@@ -153,7 +146,7 @@ export function strokeFromEvent(evt: {
|
|||||||
}): Stroke {
|
}): Stroke {
|
||||||
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
||||||
return {
|
return {
|
||||||
key: (evt.name ?? "").toLowerCase(),
|
key: evt.name.toLowerCase(),
|
||||||
ctrl: !!evt.ctrl,
|
ctrl: !!evt.ctrl,
|
||||||
shift: !!evt.shift,
|
shift: !!evt.shift,
|
||||||
meta: !!evt.meta,
|
meta: !!evt.meta,
|
||||||
@@ -170,7 +163,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A human label for a stroke, for the status bar / help. */
|
/** A human label for a stroke, for the status bar / help. */
|
||||||
export function strokeLabel(s: Stroke): string {
|
function strokeLabel(s: Stroke): string {
|
||||||
let out = "";
|
let out = "";
|
||||||
if (s.ctrl) out += "C-";
|
if (s.ctrl) out += "C-";
|
||||||
if (s.meta) out += "M-";
|
if (s.meta) out += "M-";
|
||||||
@@ -179,7 +172,7 @@ export function strokeLabel(s: Stroke): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sequenceLabel(seq: Stroke[]): string {
|
function sequenceLabel(seq: Stroke[]): string {
|
||||||
return seq.map(strokeLabel).join(" ");
|
return seq.map(strokeLabel).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,17 +330,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
|
|
||||||
// so it always reports false. Migrated callers should use tryMatch().
|
|
||||||
function isInverting(_evt: {
|
|
||||||
name: string;
|
|
||||||
ctrl?: boolean;
|
|
||||||
meta?: boolean;
|
|
||||||
shift?: boolean;
|
|
||||||
}): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load().catch(() => {});
|
load().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -365,7 +347,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
pending,
|
pending,
|
||||||
match,
|
match,
|
||||||
tryMatch,
|
tryMatch,
|
||||||
isInverting,
|
|
||||||
print,
|
print,
|
||||||
save,
|
save,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
||||||
import { createStore, produce } from "solid-js/store";
|
import { createStore, produce } from "solid-js/store";
|
||||||
import { useRenderer } from "@opentui/solid";
|
import { useRenderer } from "@opentui/solid";
|
||||||
@@ -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,36 +13,33 @@
|
|||||||
*
|
*
|
||||||
* parent | current | preview
|
* parent | current | preview
|
||||||
*
|
*
|
||||||
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
|
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
|
||||||
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||||
* nav model — which column is focused and where its list cursor lives. The
|
* nav model — which column is focused and where its list cursor lives. The
|
||||||
* parent/preview columns are always derived, never focused.
|
* parent/preview columns are always derived, never focused.
|
||||||
*
|
*
|
||||||
* Two pane models coexist under a single TAB list:
|
* The tab list is the app's ROOT and participates in the same pane flow as
|
||||||
|
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:
|
||||||
*
|
*
|
||||||
* • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal,
|
* • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
|
||||||
* focusable pane at the left of every tab's content, just like in yazi.
|
* • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
|
||||||
* Starting focus lives here; tab switches made from here keep focus here.
|
* that tab's content becomes CURRENT and its hovered item PREVIEW
|
||||||
* When it is focused, j/k moves the tab cursor (`tabCursor`) and
|
* (`enterTabContent`).
|
||||||
* `l`/Enter opens the hovered tab into its content. Swiping left past
|
* • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
|
||||||
* it goes out of the panes (inert — there is no pane beyond it).
|
* own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
|
||||||
|
* OUT of the 3-pane view.
|
||||||
|
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
|
||||||
|
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
|
||||||
|
* stays (out of the panes — no-op).
|
||||||
*
|
*
|
||||||
* • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
|
* Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
|
||||||
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The
|
* ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
|
||||||
* parent column renders the previous depth's list (blank at depth 0); the
|
* the parent/preview are derived. Search drills query→results; Player is a
|
||||||
* preview column renders the hovered item. `l`/Enter drills in (push a
|
* single now-playing pane under the tab list (2-pane, no preview). Every
|
||||||
* frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus
|
* tab returns to the root via `h` at depth 0 (`backToTabRoot`).
|
||||||
* to the tab list (TAB_PANE).
|
|
||||||
*
|
*
|
||||||
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
|
* Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
|
||||||
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the
|
* `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
|
||||||
* parent/current/preview columns with `h`/`l`, clamped to
|
|
||||||
* [1, paneCount]; `h` on the first content pane (1) moves focus to the tab
|
|
||||||
* list; `h` on the tab list stays out-of-panear (inert).
|
|
||||||
*
|
|
||||||
* Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
|
|
||||||
* Focus on the tab list persists across a tab switch; from there `l`/Enter
|
|
||||||
* drops into the active tab's content (panes 1..N).
|
|
||||||
*/
|
*/
|
||||||
import { createSignal, batch } from "solid-js";
|
import { createSignal, batch } from "solid-js";
|
||||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||||
@@ -55,10 +52,9 @@ export enum NavMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The current content pane of the active tab, i.e. the focusable column
|
/** The current content pane of the active tab, i.e. the focusable column
|
||||||
* (index 1) for depth-tabs, and the default landing pane for fixed-pane
|
* (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
|
||||||
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
|
* pane 0. A tab switch made while focused on content resets `activePane` to
|
||||||
* while focused on content resets `activePane` to this pane (unless already
|
* this pane (unless already on the tab list). */
|
||||||
* on the tab list). */
|
|
||||||
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||||
|
|
||||||
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
||||||
@@ -67,13 +63,6 @@ export const DEPTH_CENTER_PANE = 1 as PaneId;
|
|||||||
* swiping left past the first content pane returns to it. Swiping left again
|
* swiping left past the first content pane returns to it. Swiping left again
|
||||||
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
||||||
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
||||||
/** Content pane slots for fixed-pane tabs (Search). Values are the global
|
|
||||||
* pane indices (content starts at 1). */
|
|
||||||
export enum PaneSlot {
|
|
||||||
PARENT = 1, // Search: input
|
|
||||||
CURRENT = 2, // Search: results
|
|
||||||
PREVIEW = 3, // Search: detail
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
||||||
|
|
||||||
@@ -110,11 +99,11 @@ export function createNavigation() {
|
|||||||
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
||||||
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
||||||
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
||||||
// App focus starts on the tab list (the app root). `activePane` drives the
|
// App focus starts on the tab list (the app root). `activePane` is always
|
||||||
// fixed-pane pages (Search/Player) and each page's content focus ring;
|
// DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
|
||||||
// depth-tab focus is instead described by the per-tab depth stack plus the
|
// the `atRootTab` flag describe where focus sits (the tab is the CURRENT
|
||||||
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and
|
// pane when at the root, and slides into the UP/parent pane once content is
|
||||||
// slides into the UP/parent pane once content is opened).
|
// opened).
|
||||||
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
||||||
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
||||||
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
||||||
@@ -128,9 +117,9 @@ export function createNavigation() {
|
|||||||
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
||||||
);
|
);
|
||||||
|
|
||||||
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
|
// per-pane focused index map (unused by depth-tabs, which read/write the
|
||||||
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
|
// top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
|
||||||
// for pane 0 (DEPTH_CENTER_PANE) instead.
|
// pages). Keyed by `${tab}:${pane}`.
|
||||||
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -143,7 +132,7 @@ export function createNavigation() {
|
|||||||
const [commandBuffer, setCommandBuffer] = createSignal("");
|
const [commandBuffer, setCommandBuffer] = createSignal("");
|
||||||
const [commandError, setCommandError] = createSignal<string | null>(null);
|
const [commandError, setCommandError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
/** Depth stack for a tab (empty for fixed-pane tabs). */
|
/** Depth stack for a tab (always non-empty — every tab is a depth-tab). */
|
||||||
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
|
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
|
||||||
|
|
||||||
const ensureStack = (tab: TABS) => {
|
const ensureStack = (tab: TABS) => {
|
||||||
@@ -159,21 +148,17 @@ export function createNavigation() {
|
|||||||
* no-op (server build). Routing every tab change through this helper
|
* no-op (server build). Routing every tab change through this helper
|
||||||
* keeps the behavior identical under both runtimes.
|
* keeps the behavior identical under both runtimes.
|
||||||
*
|
*
|
||||||
* - when switching to a special (fixed-pane) tab from the tab root, leave the
|
* - a depth-tab switch from the root keeps the root (the tab list stays
|
||||||
* root — those tabs render only their content, never the tab-list view.
|
* CURRENT); switches made from inside content drop into the new tab's
|
||||||
* - keep focus on the tab root if it is focused (depth-tab switch),
|
* current/center pane.
|
||||||
* otherwise recenter on the active tab's current/center pane
|
|
||||||
* - clear mode/command/visual/count state */
|
* - clear mode/command/visual/count state */
|
||||||
const applyTabSwitch = (tab: TABS) => {
|
const applyTabSwitch = (tab: TABS) => {
|
||||||
ensureStack(tab);
|
ensureStack(tab);
|
||||||
batch(() => {
|
batch(() => {
|
||||||
// A depth-tab switch from the root keeps the root; switching to a
|
if (atRootTabSignal()) {
|
||||||
// special (fixed-pane) tab always leaves it. Switches made from
|
// a depth-tab switch from the root keeps the root (focus stays on
|
||||||
// inside content drop into the new tab's content pane.
|
// the tab list); only entering content (enterTabContent) leaves it.
|
||||||
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) {
|
} else {
|
||||||
setAtTabRoot(false);
|
|
||||||
}
|
|
||||||
if (!atRootTabSignal()) {
|
|
||||||
setActivePane(DEPTH_CENTER_PANE);
|
setActivePane(DEPTH_CENTER_PANE);
|
||||||
}
|
}
|
||||||
setMode(NavMode.NORMAL);
|
setMode(NavMode.NORMAL);
|
||||||
@@ -256,23 +241,14 @@ export function createNavigation() {
|
|||||||
// ── pane focus ──────────────────────────────────────────────────────────
|
// ── pane focus ──────────────────────────────────────────────────────────
|
||||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||||
|
|
||||||
/** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` =
|
// (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
|
||||||
* -1 (left, toward parent) or +1 (right, toward preview). Clamped to
|
|
||||||
* [0, paneCount-1]. The root panel transition (from content pane 0 to
|
|
||||||
* (1..TabPaneCount) is handled by the dispatcher, not here. */
|
|
||||||
const swipe = (dir: -1 | 1, paneCount: number) => {
|
|
||||||
setActivePane((p) => {
|
|
||||||
const n = Math.max(1, Math.min(paneCount, p + dir));
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
||||||
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
||||||
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings)
|
* with nothing above it. Applies to every tab: a depth-tab switch from
|
||||||
* participate; Search & Player are special and always show their content. */
|
* the root keeps it; entering content (`enterTabContent`) clears it; `h`
|
||||||
const atRootTab = (): boolean =>
|
* at content depth 0 regains it via `backToTabRoot`. */
|
||||||
atRootTabSignal() && DEPTH_TABS.has(activeTab());
|
const atRootTab = (): boolean => atRootTabSignal();
|
||||||
|
|
||||||
/** Open the active tab's content: the tab slides from CURRENT into the
|
/** Open the active tab's content: the tab slides from CURRENT into the
|
||||||
* UP/parent pane and focus lands on the content's current pane. */
|
* UP/parent pane and focus lands on the content's current pane. */
|
||||||
@@ -291,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);
|
||||||
@@ -306,9 +285,9 @@ export function createNavigation() {
|
|||||||
// ── per-pane focus index ────────────────────────────────────────────────
|
// ── per-pane focus index ────────────────────────────────────────────────
|
||||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
||||||
|
|
||||||
/** For depth-tabs, pane 0 (the center/current pane) reads/writes
|
/** For depth-tabs (every tab), pane 1 (DEPTH_CENTER_PANE) reads/writes
|
||||||
* the top frame's focus. Other panes and fixed-pane tabs use the
|
* the top frame's focus. Other panes fall back to the per-pane index
|
||||||
* per-pane index map. */
|
* map (unused by current pages). */
|
||||||
const focusedIndex = (pane: PaneId = activePane()): number => {
|
const focusedIndex = (pane: PaneId = activePane()): number => {
|
||||||
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
||||||
return topFrame()?.focus ?? 0;
|
return topFrame()?.focus ?? 0;
|
||||||
@@ -493,11 +472,11 @@ export function createNavigation() {
|
|||||||
enterTabContent,
|
enterTabContent,
|
||||||
backToTabRoot,
|
backToTabRoot,
|
||||||
tabCursor,
|
tabCursor,
|
||||||
|
setTabCursor: setTabCursorTo,
|
||||||
moveTabCursor,
|
moveTabCursor,
|
||||||
activateTabCursor,
|
activateTabCursor,
|
||||||
// pane focus
|
// pane focus
|
||||||
setActivePane: setPane,
|
setActivePane: setPane,
|
||||||
swipe,
|
|
||||||
// focus index
|
// focus index
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
setFocusedIndex,
|
setFocusedIndex,
|
||||||
@@ -513,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,
|
||||||
|
|||||||
@@ -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,47 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*
|
*
|
||||||
* Keys are only handled when an episode is loaded (or for play/pause,
|
* Volume and speed are app-level settings — adjustable with or without
|
||||||
* always). This prevents accidental volume/seek changes when there's
|
* an episode loaded (they apply to the next playback and persist). Seek
|
||||||
* nothing playing.
|
* is 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";
|
||||||
import { emit } from "../utils/event-bus"
|
import { emit } from "../utils/event-bus";
|
||||||
|
|
||||||
export type MediaKeyAction =
|
export type MediaKeyAction =
|
||||||
| "media.toggle"
|
| "media.toggle"
|
||||||
| "media.volumeUp"
|
| "media.volumeUp"
|
||||||
| "media.volumeDown"
|
| "media.volumeDown"
|
||||||
| "media.seekForward"
|
| "media.speedCycle";
|
||||||
| "media.seekBackward"
|
|
||||||
| "media.speedCycle"
|
|
||||||
|
|
||||||
/** Key-to-action mappings for multimedia controls */
|
|
||||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
|
||||||
// Common terminal media keys — these overlap with Player.tsx local
|
|
||||||
// bindings, but Player guards on `props.focused` so the global
|
|
||||||
// handler fires independently when the player tab is *not* active.
|
|
||||||
//
|
|
||||||
// When Player IS focused both handlers fire, but since the audio
|
|
||||||
// actions are idempotent (toggle = toggle, seek = additive) having
|
|
||||||
// them called twice for the same keypress is avoided by the event
|
|
||||||
// bus approach — the audio hook only processes event-bus events, and
|
|
||||||
// Player.tsx calls audio methods directly. We therefore guard with
|
|
||||||
// a "playerFocused" flag passed via options.
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MultimediaKeysOptions {
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,50 +32,39 @@ export interface MultimediaKeysOptions {
|
|||||||
* event bus. Call once at the app level (e.g. in App.tsx).
|
* event bus. Call once at the app level (e.g. in App.tsx).
|
||||||
*/
|
*/
|
||||||
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||||
useKeyboard((key) => {
|
useKeyboard((key) => {
|
||||||
// Don't intercept when a text input owns the keyboard
|
// Don't intercept when a text input owns the keyboard
|
||||||
if (options.inputFocused?.()) return
|
if (options.inputFocused?.()) return;
|
||||||
|
|
||||||
// Don't intercept when Player component handles its own keys
|
// Don't intercept when Player component handles its own keys
|
||||||
if (options.playerFocused?.()) return
|
if (options.playerFocused?.()) return;
|
||||||
|
|
||||||
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||||
if (key.ctrl || key.meta) return
|
if (key.ctrl || key.meta) return;
|
||||||
|
|
||||||
switch (key.name) {
|
switch (key.name) {
|
||||||
case "space":
|
case "space":
|
||||||
// Toggle play/pause — always valid (may start a loaded episode)
|
// Toggle play/pause — always valid (may start a loaded episode)
|
||||||
emit("media.toggle", {})
|
emit("media.toggle", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "up":
|
case "up":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.volumeUp", {});
|
||||||
emit("media.volumeUp", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
case "down":
|
case "down":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.volumeDown", {});
|
||||||
emit("media.volumeDown", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
case "left":
|
case "s":
|
||||||
if (!options.hasEpisode?.()) return
|
// Speed is shift+s (S) so plain `s` stays free for search.
|
||||||
emit("media.seekBackward", {})
|
if (!key.shift) return;
|
||||||
break
|
emit("media.speedCycle", {});
|
||||||
|
break;
|
||||||
|
|
||||||
case "right":
|
default:
|
||||||
if (!options.hasEpisode?.()) return
|
// Not a media key — do nothing
|
||||||
emit("media.seekForward", {})
|
break;
|
||||||
break
|
}
|
||||||
|
});
|
||||||
case "s":
|
|
||||||
if (!options.hasEpisode?.()) return
|
|
||||||
emit("media.speedCycle", {})
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Not a media key — do nothing
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/hooks/useScrollIntoView.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* useScrollIntoView — keeps the ref'd row visible inside its enclosing
|
||||||
|
* `<scrollbox>` whenever the focus accessor is true.
|
||||||
|
*
|
||||||
|
* OpenTUI's `ScrollBoxRenderable` has built-in *keyboard* scrolling but does
|
||||||
|
* NOT auto-scroll to follow a programmatically-focused child (the app moves
|
||||||
|
* its own cursor via the yazi nav store, so the scrollbox never sees a key
|
||||||
|
* for row movement). Every scrollable panel therefore drifts out of view the
|
||||||
|
* moment the cursor crosses the viewport edge.
|
||||||
|
*
|
||||||
|
* Attach the returned `ref` callback to the element that represents the
|
||||||
|
* focused row of a scrollable list and call the hook with a `when()` that is
|
||||||
|
* true for exactly that row (e.g. `() => index() === focus()`). Whenever the
|
||||||
|
* accessor flips true, the nearest ScrollBoxRenderable is scrolled just enough
|
||||||
|
* to bring the element back into the viewport — a "nearest-edge" scroll:
|
||||||
|
* • scroll up only if the row's top is clipped above the viewport,
|
||||||
|
* • scroll down only if the row's bottom is clipped below the viewport,
|
||||||
|
* never snapping more than necessary (matches yazi list behaviour).
|
||||||
|
*
|
||||||
|
* Timing: for ordinary cursor movement (j/k) the list layout does not change
|
||||||
|
* — only background colour and the cursor glyph flip — so the focused row's
|
||||||
|
* Yoga-computed position is already valid when this effect fires, and the
|
||||||
|
* scroll is applied synchronously. On first mount / content population the
|
||||||
|
* layout for the new rows has not yet been computed, so the hook polls on a
|
||||||
|
* short timer until layout resolves (bounded so it can never loop forever).
|
||||||
|
*/
|
||||||
|
import { createEffect, onCleanup } from "solid-js";
|
||||||
|
|
||||||
|
/** Walk up the renderable parent chain to the nearest ScrollBoxRenderable,
|
||||||
|
* identified by its `viewport` + `content` + numeric `scrollTop`. */
|
||||||
|
function findScrollBox(node: any): any | null {
|
||||||
|
let p: any = node?.parent;
|
||||||
|
while (p) {
|
||||||
|
if (p.viewport && p.content && typeof p.scrollTop === "number") return p;
|
||||||
|
p = p.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of retries while waiting for Yoga layout to populate the
|
||||||
|
* row/viewport dimensions (handles the first-mount frame). */
|
||||||
|
const MAX_RETRIES = 12;
|
||||||
|
const RETRY_MS = 16;
|
||||||
|
|
||||||
|
export function useScrollIntoView(when: () => boolean) {
|
||||||
|
let el: any = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const ref = (node: any) => {
|
||||||
|
el = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compute the target scrollTop that brings `el` into the viewport of its
|
||||||
|
* enclosing scrollbox, or `null` if no scroll is possible / needed yet.
|
||||||
|
* Returns the decision so the caller knows whether to poll again. */
|
||||||
|
const compute = (): { scroll: number | null; ready: boolean } => {
|
||||||
|
const node = el;
|
||||||
|
if (!node) return { scroll: null, ready: false };
|
||||||
|
const sb = findScrollBox(node);
|
||||||
|
if (!sb) return { scroll: null, ready: false };
|
||||||
|
const vp = sb.viewport;
|
||||||
|
const top: number = sb.scrollTop ?? 0;
|
||||||
|
const vpH: number = vp?.height ?? 0;
|
||||||
|
// The scrollbar's onChange sets `content.translateY = -scrollTop`, so
|
||||||
|
// the child's cumulative `.y` already includes `-scrollTop`; subtracting
|
||||||
|
// the viewport's stable `.y` and re-adding `scrollTop` recovers the
|
||||||
|
// row's layout-space offset within the content (scroll-independent).
|
||||||
|
const childTop: number = node.y ?? 0;
|
||||||
|
const childH: number = node.height ?? 0;
|
||||||
|
if (!vpH || !childH) return { scroll: null, ready: false };
|
||||||
|
|
||||||
|
const offset = childTop - (vp.y ?? 0) + top;
|
||||||
|
let target = top;
|
||||||
|
if (offset < top) target = offset;
|
||||||
|
else if (offset + childH > top + vpH) target = offset + childH - vpH;
|
||||||
|
const max = Math.max(0, (sb.scrollHeight ?? 0) - vpH);
|
||||||
|
if (target > max) target = max;
|
||||||
|
if (target < 0) target = 0;
|
||||||
|
target = Math.round(target);
|
||||||
|
if (target === Math.round(top)) return { scroll: null, ready: true };
|
||||||
|
return { scroll: target, ready: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryScroll = (retriesLeft: number) => {
|
||||||
|
const { scroll, ready } = compute();
|
||||||
|
if (!ready) {
|
||||||
|
if (retriesLeft > 0)
|
||||||
|
timer = setTimeout(() => tryScroll(retriesLeft - 1), RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scroll != null) {
|
||||||
|
const sb = findScrollBox(el);
|
||||||
|
if (sb) sb.scrollTo(scroll);
|
||||||
|
}
|
||||||
|
clearTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!when()) return;
|
||||||
|
clearTimer();
|
||||||
|
tryScroll(MAX_RETRIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
clearTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
16
src/hooks/useSelectionMarker.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* useSelectionMarker — reactive accessor for the row-selection marker glyph.
|
||||||
|
*
|
||||||
|
* When the `showSelectionMarker` setting is on, the focused row of every list
|
||||||
|
* renders `❯`; when off (the default), it renders a space so column alignment
|
||||||
|
* is preserved. Every list pane in the app reads the marker through this hook
|
||||||
|
* so the setting applies consistently everywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
|
export function useSelectionMarker(): () => string {
|
||||||
|
const app = useAppStore();
|
||||||
|
return () =>
|
||||||
|
app.state().settings.showSelectionMarker ? "❯" : " ";
|
||||||
|
}
|
||||||
446
src/index.tsx
@@ -1,225 +1,263 @@
|
|||||||
const VERSION = "0.1.0";
|
import type { Feed } from "./types/feed"
|
||||||
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
|
const VERSION = "0.5.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
query: string | null;
|
query: string | null;
|
||||||
play: string | null;
|
play: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(): CliArgs {
|
function parseArgs(): CliArgs {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const result: CliArgs = {
|
const result: CliArgs = {
|
||||||
version: false,
|
version: false,
|
||||||
query: null,
|
query: null,
|
||||||
play: null,
|
play: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
if (arg === "--version" || arg === "-v") {
|
if (arg === "--version" || arg === "-v") {
|
||||||
result.version = true;
|
result.version = true;
|
||||||
} else if (arg === "--query" || arg === "-q") {
|
} else if (arg === "--query" || arg === "-q") {
|
||||||
result.query = args[i + 1] || "";
|
result.query = args[i + 1] || "";
|
||||||
i++;
|
i++;
|
||||||
} else if (arg === "--play" || arg === "-p") {
|
} else if (arg === "--play" || arg === "-p") {
|
||||||
result.play = args[i + 1] || "";
|
result.play = args[i + 1] || "";
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliArgs = parseArgs();
|
const cliArgs = parseArgs();
|
||||||
|
|
||||||
if (cliArgs.version) {
|
if (cliArgs.version) {
|
||||||
console.log(`PodTUI version ${VERSION}`);
|
console.log(`PodTUI version ${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").then(async ({ loadFeedsFromFile }) => {
|
import("./utils/feeds-persistence")
|
||||||
const feeds = await loadFeedsFromFile();
|
.then(async ({ 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) => {
|
if (cliArgs.play !== null) {
|
||||||
const title = feed.podcast.title.toLowerCase();
|
await handlePlay(feeds, cliArgs.play)
|
||||||
return title.includes(normalizedQuery);
|
}
|
||||||
});
|
})
|
||||||
|
.catch((err) => {
|
||||||
if (matches.length === 0) {
|
console.error("Error:", err);
|
||||||
console.log(`No shows found matching: ${query}`);
|
process.exit(1);
|
||||||
if (feeds.length > 0) {
|
});
|
||||||
console.log("\nAvailable shows:");
|
|
||||||
feeds.slice(0, 5).forEach((feed) => {
|
|
||||||
console.log(` - ${feed.podcast.title}`);
|
|
||||||
});
|
|
||||||
if (feeds.length > 5) {
|
|
||||||
console.log(` ... and ${feeds.length - 5} more`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length === 1) {
|
|
||||||
const feed = matches[0];
|
|
||||||
console.log(`\n${feed.podcast.title}`);
|
|
||||||
if (feed.podcast.description) {
|
|
||||||
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
|
|
||||||
}
|
|
||||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
|
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
|
||||||
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
|
|
||||||
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nClosest matches for "${query}":`);
|
|
||||||
matches.slice(0, 5).forEach((feed, idx) => {
|
|
||||||
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliArgs.play !== null) {
|
|
||||||
const playArg = cliArgs.play;
|
|
||||||
const normalizedArg = playArg.toLowerCase();
|
|
||||||
|
|
||||||
let feedResult: typeof feeds[0] | null = null;
|
|
||||||
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
|
||||||
|
|
||||||
if (normalizedArg === "latest") {
|
|
||||||
let latestFeed: typeof feeds[0] | null = null;
|
|
||||||
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
|
||||||
let latestDate = 0;
|
|
||||||
|
|
||||||
for (const feed of feeds) {
|
|
||||||
if (feed.episodes.length > 0) {
|
|
||||||
const ep = feed.episodes[0];
|
|
||||||
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
|
||||||
if (epDate > latestDate) {
|
|
||||||
latestDate = epDate;
|
|
||||||
latestFeed = feed;
|
|
||||||
latestEpisode = ep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
feedResult = latestFeed;
|
|
||||||
episodeResult = latestEpisode;
|
|
||||||
} else {
|
|
||||||
const parts = normalizedArg.split("/");
|
|
||||||
const showQuery = parts[0];
|
|
||||||
const episodeQuery = parts[1];
|
|
||||||
|
|
||||||
const matchingFeeds = feeds.filter((feed) =>
|
|
||||||
feed.podcast.title.toLowerCase().includes(showQuery)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingFeeds.length === 0) {
|
|
||||||
console.log(`No show found matching: ${showQuery}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const feed = matchingFeeds[0];
|
|
||||||
|
|
||||||
if (!episodeQuery) {
|
|
||||||
if (feed.episodes.length > 0) {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = feed.episodes[0];
|
|
||||||
} else {
|
|
||||||
console.log(`No episodes available for: ${feed.podcast.title}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else if (episodeQuery === "latest") {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = feed.episodes[0];
|
|
||||||
} else {
|
|
||||||
const matchingEpisode = feed.episodes.find((ep) =>
|
|
||||||
ep.title.toLowerCase().includes(episodeQuery)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingEpisode) {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = matchingEpisode;
|
|
||||||
} else {
|
|
||||||
console.log(`Episode not found: ${episodeQuery}`);
|
|
||||||
console.log(`Available episodes for ${feed.podcast.title}:`);
|
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
|
||||||
console.log(` ${idx + 1}. ${ep.title}`);
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!feedResult || !episodeResult) {
|
|
||||||
console.log("Could not find episode to play");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nPlaying: ${episodeResult.title}`);
|
|
||||||
console.log(`Show: ${feedResult.podcast.title}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { createAudioBackend } = await import("./utils/audio-player");
|
|
||||||
const backend = createAudioBackend();
|
|
||||||
if (episodeResult.audioUrl) {
|
|
||||||
await backend.play(episodeResult.audioUrl);
|
|
||||||
console.log("Playback started (use the UI to control)");
|
|
||||||
} else {
|
|
||||||
console.log("No audio URL available for this episode");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Playback error:", err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("Error:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
||||||
const { App } = await import("./App");
|
const { App } = await import("./App");
|
||||||
const { ThemeProvider } = await import("./context/ThemeContext");
|
const { ThemeProvider } = await import("./context/ThemeContext");
|
||||||
const toast = await import("./ui/toast");
|
const toast = await import("./ui/toast");
|
||||||
const { KeybindProvider } = await import("./context/KeybindContext");
|
const { KeybindProvider } = await import("./context/KeybindContext");
|
||||||
const { NavigationProvider } = await import("./context/NavigationContext");
|
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||||
const { DialogProvider } = await import("./ui/dialog");
|
const { DialogProvider } = await import("./ui/dialog");
|
||||||
const { CommandProvider } = await import("./ui/command");
|
const { CommandProvider } = await import("./ui/command");
|
||||||
|
|
||||||
function RendererSetup(props: { children: unknown }) {
|
function RendererSetup(props: { children: unknown }) {
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
renderer.disableStdoutInterception();
|
renderer.disableStdoutInterception();
|
||||||
return props.children;
|
return props.children;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<RendererSetup>
|
<RendererSetup>
|
||||||
<toast.ToastProvider>
|
<toast.ToastProvider>
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
<KeybindProvider>
|
<KeybindProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<App />
|
<App />
|
||||||
<toast.Toast />
|
<toast.Toast />
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</KeybindProvider>
|
</KeybindProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</toast.ToastProvider>
|
</toast.ToastProvider>
|
||||||
</RendererSetup>
|
</RendererSetup>
|
||||||
),
|
),
|
||||||
{ useThread: false },
|
{ useThread: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
* 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
|
||||||
* podcast detail + subscribe action).
|
* podcast detail + subscribe action).
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||||
* column. Moving through categories at depth 0 updates the store's selected
|
* column. Moving through categories at depth 0 updates the store's selected
|
||||||
@@ -27,19 +27,25 @@ 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 { YaziPaneRow } from "@/components/YaziPaneRow";
|
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 { 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 stack = nav.depthStack;
|
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
|
|
||||||
@@ -65,6 +71,12 @@ function DiscoverPage() {
|
|||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
|
// Auto-fetch the featured-shows manifest on first mount (network failure is
|
||||||
|
// non-fatal — the list stays empty until the user hits refresh).
|
||||||
|
onMount(() => {
|
||||||
|
discoverStore.refresh().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return categories()[i]?.id;
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
@@ -140,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
|
||||||
@@ -148,30 +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 />
|
||||||
<box
|
</Show>
|
||||||
flexDirection="row"
|
<Show when={depth() >= 1}>
|
||||||
gap={1}
|
<For each={categories()}>
|
||||||
paddingLeft={1}
|
{(cat, index) => {
|
||||||
paddingRight={1}
|
const lf = () => nav.depthFocus(0);
|
||||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
>
|
return (
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<box
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
ref={ref}
|
||||||
</text>
|
flexDirection="row"
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
gap={1}
|
||||||
{cat.name}
|
paddingRight={1}
|
||||||
</text>
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
</box>
|
>
|
||||||
)}
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
</For>
|
{index() === nav.depthFocus(0) ? marker() : " "}
|
||||||
</Show>
|
</text>
|
||||||
|
{nerd && (
|
||||||
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
|
{cat.icon}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
|
{cat.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane ───────────────────────────────────────────────────────────
|
// ── current pane ───────────────────────────────────────────────────────────
|
||||||
@@ -181,30 +213,30 @@ function DiscoverPage() {
|
|||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<For each={categories()}>
|
<For each={categories()}>
|
||||||
{(cat, index) => {
|
{(cat, index) => {
|
||||||
const lf = focusedCatIdx();
|
const lf = () => focusedCatIdx();
|
||||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
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={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(index(), 0);
|
||||||
discoverStore.setSelectedCategory(cat.id);
|
discoverStore.setSelectedCategory(cat.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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())}>{cat.name}</text>
|
{nerd && (
|
||||||
<Show when={selected()}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
{cat.icon}
|
||||||
*
|
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
)}
|
||||||
|
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -216,41 +248,51 @@ 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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={podcasts()}>
|
<For each={podcasts()}>
|
||||||
{(podcast, index) => {
|
{(podcast, index) => {
|
||||||
const lf = focusedPodIdx();
|
const lf = () => focusedPodIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
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={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 1);
|
nav.setDepthFocus(index(), 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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}
|
||||||
</text>
|
</text>
|
||||||
<Show when={podcast.isSubscribed}>
|
<Show when={podcast.isSubscribed}>
|
||||||
<text fg={index() === lf ? theme.surface : theme.success}>
|
<text
|
||||||
|
fg={index() === lf() ? theme.surface : theme.success}
|
||||||
|
>
|
||||||
[+]
|
[+]
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={podcast.author}>
|
<Show when={podcast.author}>
|
||||||
<text
|
<text
|
||||||
fg={index() === lf ? theme.surface : muted()}
|
fg={index() === lf() ? theme.surface : muted()}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
>
|
>
|
||||||
by {podcast.author}
|
by {podcast.author}
|
||||||
@@ -260,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>
|
||||||
</>
|
</>
|
||||||
@@ -268,7 +315,7 @@ function DiscoverPage() {
|
|||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered category
|
// depth 0 preview: shows for the hovered category
|
||||||
<Show
|
<Show
|
||||||
when={focusedCategory()}
|
when={focusedCategory()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -278,16 +325,35 @@ function DiscoverPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(cat) => (
|
{(cat) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<box flexDirection="column" gap={0} padding={1}>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
<strong>{cat().name}</strong>
|
<strong>{cat().name}</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textSecondary}>
|
<Show when={(cat() as any).description}>
|
||||||
{(cat() as any).description ??
|
<text fg={theme.textSecondary}>{(cat() as any).description}</text>
|
||||||
`Browse top podcasts in ${cat().name}.`}
|
</Show>
|
||||||
</text>
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
<Show
|
||||||
|
when={podcasts().length > 0}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
No shows in this category yet. :refresh
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(pod) => (
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={theme.text}>{pod.title}</text>
|
||||||
|
<Show when={pod.author}>
|
||||||
|
<text fg={muted()} paddingLeft={2}>
|
||||||
|
by {pod.author}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -339,13 +405,11 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
* FeedPage — flat chronological list of episodes across all subscribed feeds.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
* depth 0 (current) — every episode from every feed, newest-first (the
|
||||||
* virtual "All Feeds". Parent pane shows the muted
|
* combined view the old "All Feeds" virtual row used to
|
||||||
* placeholder (1/7 slot kept).
|
* drill into). Parent pane shows the muted tab list.
|
||||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
* preview — detail of the hovered episode.
|
||||||
* chronological). Parent pane = the feeds list (prev).
|
|
||||||
* preview — detail of the hovered item in the current column.
|
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
* This page does NOT drill: the previous depth-1 "episodes of one feed" panel
|
||||||
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
|
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
||||||
* (push); `h` pops a depth (noop at 0). j/k move only within the current
|
* shows the full flat episodes list immediately.
|
||||||
* column. The Shell router drives everything over `nav.action`; this page
|
*
|
||||||
* only handles list/preview data.
|
* Renders entirely through `<PaneRow>` (the shared parent|current|preview
|
||||||
|
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
|
||||||
|
* root. j/k move only within the current column. The Shell router drives
|
||||||
|
* everything over `nav.action`; this page only handles list/preview data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, 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";
|
||||||
@@ -27,23 +29,26 @@ import {
|
|||||||
NavMode,
|
NavMode,
|
||||||
DEPTH_CENTER_PANE,
|
DEPTH_CENTER_PANE,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
type DepthFrame,
|
|
||||||
} 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";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
|
||||||
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,58 +56,59 @@ 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();
|
||||||
|
|
||||||
const stack = nav.depthStack;
|
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||||
const depth = nav.currentDepth;
|
const episodes = createMemo<EpItem[]>(
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
|
);
|
||||||
|
|
||||||
// ── feeds list (depth 0) ─────────────────────────────────────────────────
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
const feedList = createMemo<FeedListItem[]>(() => {
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
const all: FeedListItem[] = [{ kind: "all" }];
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
for (const f of feedStore.getFilteredFeeds())
|
// reaching the bottom row fetches automatically (see the effect below).
|
||||||
all.push({ kind: "feed", feed: f });
|
const app = useAppStore();
|
||||||
return all;
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
});
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
const focusedFeedIdx = () =>
|
// Total navigable rows: episodes + the optional Fetch More row.
|
||||||
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focusedFeedItem = (): FeedListItem | undefined =>
|
const focus = () => nav.depthFocus(0);
|
||||||
feedList()[focusedFeedIdx()];
|
const focusedRow = () =>
|
||||||
|
rowCount() === 0 ? 0 : Math.min(focus(), rowCount() - 1);
|
||||||
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
|
const focusedOnMore = () =>
|
||||||
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
|
showFetchMore() && focusedRow() === episodes().length;
|
||||||
const episodes = createMemo<EpItem[]>(() => {
|
// -1 while the Fetch More row is focused so no episode row renders the
|
||||||
if (depth() < 1) return [];
|
// cursor/highlight (the button is the focused row, not the last episode).
|
||||||
const id = drilledFeedId();
|
|
||||||
if (id === "all")
|
|
||||||
return feedStore.getAllEpisodesChronological() as EpItem[];
|
|
||||||
const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id);
|
|
||||||
if (!f) return [];
|
|
||||||
return [...f.episodes]
|
|
||||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
|
|
||||||
.map((episode) => ({ episode, feed: f }));
|
|
||||||
});
|
|
||||||
const focusedEpIdx = () =>
|
const focusedEpIdx = () =>
|
||||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
focusedOnMore()
|
||||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
? -1
|
||||||
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
|
const focusedItem = (): EpItem | undefined =>
|
||||||
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
const curLen = () => rowCount();
|
||||||
|
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
nav.setDepthFocus(feedList().length - 1, 0);
|
nav.setDepthFocus(rowCount() - 1, 0);
|
||||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
|
||||||
nav.setDepthFocus(episodes().length - 1, 1);
|
|
||||||
};
|
};
|
||||||
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.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(
|
||||||
if (depth() === 0) {
|
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||||
const it = feedList()[i];
|
(i) => episodes()[i]?.episode.id,
|
||||||
return it?.kind === "feed" ? it.feed.podcast.id : "all";
|
);
|
||||||
}
|
|
||||||
return episodes()[i]?.episode.id;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -146,19 +152,13 @@ function FeedPage() {
|
|||||||
audioNav.setSource(AudioSource.FEED);
|
audioNav.setSource(AudioSource.FEED);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── open ───────────────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (focusedOnMore()) {
|
||||||
const item = focusedFeedItem();
|
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||||
if (!item) return;
|
|
||||||
const ctx = item.kind === "all" ? "all" : item.feed.podcast.id;
|
|
||||||
nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame);
|
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
playEpisode(focusedItem());
|
||||||
playEpisode(focusedItem());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── nav.action handler ────────────────────────────────────────────────────
|
// ── nav.action handler ────────────────────────────────────────────────────
|
||||||
@@ -173,16 +173,23 @@ function FeedPage() {
|
|||||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
open: () => open(),
|
open: () => open(),
|
||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() >= 1) {
|
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: () => {
|
||||||
const item = focusedFeedItem();
|
feedStore.refreshAllFeeds().catch(() => {});
|
||||||
if (item?.kind === "feed")
|
|
||||||
feedStore.refreshFeed(item.feed.id).catch(() => {});
|
|
||||||
else feedStore.refreshAllFeeds().catch(() => {});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
@@ -205,7 +212,7 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
// Row highlight within a list. `active=true` only for the current pane.
|
// Row highlight within the list. `active=true` only for the current pane.
|
||||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||||
i === listFocus && active
|
i === listFocus && active
|
||||||
? theme.primary
|
? theme.primary
|
||||||
@@ -213,269 +220,229 @@ 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 feedLabel = (item: FeedListItem) =>
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
item.kind === "all"
|
|
||||||
? "All Feeds"
|
|
||||||
: item.feed.customName || item.feed.podcast.title;
|
|
||||||
const feedCount = (item: FeedListItem) =>
|
|
||||||
item.kind === "all"
|
|
||||||
? feedStore.getAllEpisodesChronological().length
|
|
||||||
: item.feed.episodes.length;
|
|
||||||
|
|
||||||
const currentLabel = () =>
|
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
|
||||||
depth() === 0
|
const parentContent = () => <TabListPane muted />;
|
||||||
? `Feeds · ${feedList().length - 1}`
|
|
||||||
: `${(() => {
|
|
||||||
const fi = focusedFeedItem();
|
|
||||||
return fi?.kind === "feed"
|
|
||||||
? fi.feed.customName || fi.feed.podcast.title
|
|
||||||
: "All Episodes";
|
|
||||||
})()} · ${episodes().length}`;
|
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
// ── current pane: the flat episodes list (the only focusable column) ──────
|
||||||
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
|
const currentContent = () => (
|
||||||
// mounts/unmounts cleanly on depth change instead of swapping roots.
|
<Show
|
||||||
const parentContent = () => (
|
when={episodes().length > 0}
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
fallback={
|
||||||
<For each={feedList()}>
|
<box padding={1}>
|
||||||
|
<Show
|
||||||
|
when={feedStore.isLoadingFeeds()}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
No feeds. Subscribe from Discover/Search.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Refreshing…" />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={episodes()}>
|
||||||
{(item, index) => {
|
{(item, index) => {
|
||||||
const lf = nav.depthFocus(0);
|
const fi = () => focusedEpIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
ref={ref}
|
||||||
gap={1}
|
flexDirection="column"
|
||||||
paddingLeft={1}
|
gap={0}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf, false)}
|
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf, false)}>
|
<box flexDirection="row" gap={1}>
|
||||||
{index() === lf ? "❯" : " "}
|
<text
|
||||||
</text>
|
flexShrink={0}
|
||||||
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
|
fg={focusFg(index(), fi(), isActive())}
|
||||||
<text fg={muted()}>({feedCount(item)})</text>
|
>
|
||||||
|
{index() === fi() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={focusFg(index(), fi(), isActive())}
|
||||||
|
>
|
||||||
|
{item.episode.episodeNumber
|
||||||
|
? `#${item.episode.episodeNumber} `
|
||||||
|
: ""}
|
||||||
|
{item.episode.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
{/* podcast name on its own row — readable at a glance; the
|
||||||
|
50% current pane fits it in full for typical names, and
|
||||||
|
truncate keeps the row one line tall either way */}
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={index() === fi() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{item.feed.customName || item.feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === fi() ? theme.surface : theme.info}
|
||||||
|
>
|
||||||
|
{formatDate(item.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
|
>
|
||||||
|
{formatDuration(item.episode.duration)}
|
||||||
|
</text>
|
||||||
|
<Show when={nav.isSelected(item.episode.id)}>
|
||||||
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={downloadLabel(item.episode.id)}>
|
||||||
|
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
|
||||||
|
{downloadLabel(item.episode.id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</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()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator label="Refreshing…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane: the current-depth list (the only focusable column) ──────
|
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||||
const currentContent = () => (
|
const previewContent = () => (
|
||||||
<>
|
<>
|
||||||
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
|
<Show when={focusedOnMore()}>
|
||||||
<Show when={depth() === 0}>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>[Fetch More]</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{feedStore.isLoadingMore()
|
||||||
|
? "Loading the next batch of episodes…"
|
||||||
|
: fetchMoreMode() === "auto"
|
||||||
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
|
: "Load the next batch of older episodes across all feeds (Enter)."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
when={feedList().length > 1}
|
when={focusedItem()}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<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()}>
|
<text fg={muted()}>
|
||||||
No feeds. Subscribe from Discover/Search.
|
{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>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
)}
|
||||||
>
|
|
||||||
<For each={feedList()}>
|
|
||||||
{(item, index) => {
|
|
||||||
const fi = focusedFeedIdx();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={focusBg(index(), fi, isActive())}
|
|
||||||
onMouseDown={() => {
|
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
||||||
nav.setDepthFocus(index(), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={focusFg(index(), fi, isActive())}>
|
|
||||||
{index() === fi ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), fi, isActive())}>
|
|
||||||
{feedLabel(item)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi ? theme.surface : muted()}>
|
|
||||||
({feedCount(item)})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
<Show when={depth() >= 1}>
|
|
||||||
{/* depth ≥1: episodes */}
|
|
||||||
<Show
|
|
||||||
when={episodes().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episodes. :refresh</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={episodes()}>
|
|
||||||
{(item, index) => {
|
|
||||||
const fi = focusedEpIdx();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={focusBg(index(), fi, isActive())}
|
|
||||||
onMouseDown={() => {
|
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
||||||
nav.setDepthFocus(index(), 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusFg(index(), fi, isActive())}>
|
|
||||||
{index() === fi ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), fi, isActive())}>
|
|
||||||
{item.episode.episodeNumber
|
|
||||||
? `#${item.episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{item.episode.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
|
||||||
{formatDate(item.episode.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi ? theme.surface : muted()}>
|
|
||||||
{formatDuration(item.episode.duration)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi ? theme.surface : muted()}>
|
|
||||||
{item.feed.customName || item.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(item.episode.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(item.episode.id)}>
|
|
||||||
<text fg={downloadColor(item.episode.id)}>
|
|
||||||
{downloadLabel(item.episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-item detail ──────────────────────────────────────
|
|
||||||
const previewContent = () =>
|
|
||||||
depth() === 0 ? (
|
|
||||||
// depth 0 preview: hovered feed
|
|
||||||
<Show
|
|
||||||
when={focusedFeedItem()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No feed focused</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(item) => {
|
|
||||||
const it = item();
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
|
||||||
<strong>{feedLabel(it)}</strong>
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{it.kind === "feed"
|
|
||||||
? `by ${it.feed.podcast.author ?? "unknown"}`
|
|
||||||
: ""}
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{it.kind === "all"
|
|
||||||
? `${feedCount(it)} episodes across all feeds`
|
|
||||||
: `${feedCount(it)} episodes`}
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{it.kind === "feed"
|
|
||||||
? (it.feed.podcast.description?.slice(0, 400) ??
|
|
||||||
"No description.")
|
|
||||||
: "Drill in to see episodes across every feed."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
) : (
|
|
||||||
// depth ≥1 preview: hovered episode
|
|
||||||
<Show
|
|
||||||
when={focusedItem()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episode focused</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(item) => {
|
|
||||||
const it = item();
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
|
||||||
<strong>
|
|
||||||
{it.episode.episodeNumber
|
|
||||||
? `#${it.episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{it.episode.title}
|
|
||||||
</strong>
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
|
|
||||||
<text fg={muted()}>{formatDuration(it.episode.duration)}</text>
|
|
||||||
<Show when={downloadLabel(it.episode.id)}>
|
|
||||||
<text fg={downloadColor(it.episode.id)}>
|
|
||||||
{downloadLabel(it.episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{it.feed.customName || it.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={it.feed.podcast.author}>
|
|
||||||
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{it.episode.description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
* 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.
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
|
||||||
|
* as the Feed tab) that loads the next batch of episodes for that show.
|
||||||
|
*
|
||||||
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||||
* 0). j/k move only within the current column.
|
* 0). j/k move only within the current column.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { 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";
|
||||||
@@ -27,23 +31,30 @@ 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, DownloadedEpisode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const MyShowsPaneCount = 1;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
|
const app = useAppStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const stack = nav.depthStack;
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
@@ -51,9 +62,28 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
|
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||||
|
// listed as their own section under the shows list. Reads feeds() so an
|
||||||
|
// entry drops out the moment the user subscribes to its show.
|
||||||
|
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||||
|
|
||||||
|
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
|
||||||
|
const depth0Count = () => shows().length + unsubs().length;
|
||||||
|
|
||||||
const focusedShowIdx = () =>
|
const focusedShowIdx = () =>
|
||||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||||
|
* row (past the shows list). */
|
||||||
|
const focusedOnUnsub = () =>
|
||||||
|
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
|
||||||
|
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||||
|
if (!focusedOnUnsub()) return undefined;
|
||||||
|
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
|
||||||
|
};
|
||||||
|
const selectedShow = (): Feed | undefined => {
|
||||||
|
if (focusedOnUnsub()) return undefined;
|
||||||
|
return shows()[focusedShowIdx()];
|
||||||
|
};
|
||||||
|
|
||||||
// depth-1 frame ctx = the drilled feed id
|
// depth-1 frame ctx = the drilled feed id
|
||||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||||
@@ -66,27 +96,64 @@ export function MyShowsPage() {
|
|||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
|
// A "[Fetch More]" row at the bottom of a drilled show's episode list
|
||||||
|
// advances that show's loaded window by 50 episodes — the per-show
|
||||||
|
// counterpart to the Feed page's row (which loads every feed). manual
|
||||||
|
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
||||||
|
// automatically (see the effect below).
|
||||||
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
|
const showFetchMore = () =>
|
||||||
|
depth() >= 1 &&
|
||||||
|
!!drilledShowId() &&
|
||||||
|
feedStore.hasMoreEpisodes(drilledShowId());
|
||||||
|
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
|
||||||
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
|
const focusedRow = () =>
|
||||||
|
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
||||||
|
const focusedOnMore = () =>
|
||||||
|
showFetchMore() && focusedRow() === episodes().length;
|
||||||
|
// -1 while the Fetch More row is focused so no episode row renders the
|
||||||
|
// cursor/highlight (the button is the focused row, not the last episode).
|
||||||
const focusedEpIdx = () =>
|
const focusedEpIdx = () =>
|
||||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
focusedOnMore()
|
||||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
? -1
|
||||||
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
|
const focusedEpisode = () =>
|
||||||
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||||
|
|
||||||
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (shows().length > 0 && focus(0) >= shows().length)
|
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||||
nav.setDepthFocus(shows().length - 1, 0);
|
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||||
nav.setDepthFocus(episodes().length - 1, 1);
|
nav.setDepthFocus(rowCount() - 1, 1);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return shows()[i]?.id;
|
if (depth() === 0) {
|
||||||
|
if (i < shows().length) return shows()[i]?.id;
|
||||||
|
return unsubs()[i - shows().length]?.episodeId;
|
||||||
|
}
|
||||||
return episodes()[i]?.id;
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto mode: reaching the bottom of a drilled show's list loads its next
|
||||||
|
// batch. Guarded by isLoadingMore so concurrent loads never stack.
|
||||||
|
createEffect(() => {
|
||||||
|
if (depth() < 1) return;
|
||||||
|
if (fetchMoreMode() !== "auto") return;
|
||||||
|
if (!showFetchMore()) return;
|
||||||
|
if (feedStore.isLoadingMore()) return;
|
||||||
|
if (focusedRow() < rowCount() - 1) return;
|
||||||
|
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
const formatDuration = (s: number) => {
|
const formatDuration = (s: number) => {
|
||||||
@@ -127,9 +194,31 @@ export function MyShowsPage() {
|
|||||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stream an unsubscribed-show download. The record carries only what was
|
||||||
|
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||||
|
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||||
|
audio
|
||||||
|
.play({
|
||||||
|
id: d.episodeId,
|
||||||
|
podcastId: d.feedId,
|
||||||
|
title: d.episodeTitle ?? d.episodeId,
|
||||||
|
description: "",
|
||||||
|
audioUrl: d.audioUrl ?? "",
|
||||||
|
duration: 0,
|
||||||
|
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
playUnsubscribedDownload(d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||||
@@ -138,6 +227,10 @@ export function MyShowsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() >= 1) {
|
||||||
|
if (focusedOnMore()) {
|
||||||
|
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ep = focusedEpisode();
|
const ep = focusedEpisode();
|
||||||
if (ep) playEpisode(ep);
|
if (ep) playEpisode(ep);
|
||||||
}
|
}
|
||||||
@@ -160,10 +253,55 @@ export function MyShowsPage() {
|
|||||||
if (ep) nav.toggleSelected(ep.id);
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
download: () => {
|
||||||
|
if (depth() < 1) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
downloadStore.cancelDownload(d.episodeId);
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() < 1) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!ep) return;
|
||||||
|
const id = ep.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
"whitelist-toggle": () => {
|
||||||
|
const prefs = app.state().preferences;
|
||||||
|
if (prefs.autoDownloadScope !== "whitelist") return;
|
||||||
|
// depth 0: the focused show; depth ≥1: the drilled show.
|
||||||
|
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
|
||||||
|
if (!id) return;
|
||||||
|
const cur = prefs.autoDownloadWhitelist ?? [];
|
||||||
|
const next = cur.includes(id)
|
||||||
|
? cur.filter((x) => x !== id)
|
||||||
|
: [...cur, id];
|
||||||
|
app.updatePreferences({ autoDownloadWhitelist: next });
|
||||||
|
feedStore.runAutoDownload();
|
||||||
|
},
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||||
},
|
},
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (depth() !== 0) return;
|
||||||
|
const show = selectedShow();
|
||||||
|
if (show) {
|
||||||
|
// unsubscribe = remove feed + purge its downloaded files
|
||||||
|
feedStore.removeFeed(show.id);
|
||||||
|
downloadStore.removeDownloadsForFeed(show.id).catch(() => {});
|
||||||
|
ensureFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
nav.move(delta, curLen());
|
nav.move(delta, curLen());
|
||||||
@@ -188,35 +326,41 @@ 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 = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Shows (${shows().length})`
|
? `Shows (${shows().length})${
|
||||||
|
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||||
|
}`
|
||||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
|
||||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
// mounts/unmounts cleanly on depth change.
|
// mounts/unmounts cleanly on depth change.
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
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>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -231,7 +375,7 @@ export function MyShowsPage() {
|
|||||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={depth0Count() > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
@@ -242,32 +386,116 @@ export function MyShowsPage() {
|
|||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = focusedShowIdx();
|
const lf = () => focusedShowIdx();
|
||||||
|
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}
|
||||||
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={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(index(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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)}
|
||||||
</text>
|
</text>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={unsubs().length > 0}>
|
||||||
|
<box paddingLeft={1} paddingTop={1}>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
Unsubscribed Show Downloads
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<For each={unsubs()}>
|
||||||
|
{(d, index) => {
|
||||||
|
// Rows continue after the shows list.
|
||||||
|
const rowIdx = () => shows().length + index();
|
||||||
|
const lf = () => nav.depthFocus(0);
|
||||||
|
const ref = useScrollIntoView(() => rowIdx() === lf());
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(rowIdx(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||||
|
>
|
||||||
|
{rowIdx() === lf() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||||
|
>
|
||||||
|
{d.episodeTitle ?? d.episodeId}
|
||||||
|
</text>
|
||||||
|
<Show when={downloadLabel(d.episodeId)}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={downloadColor(d.episodeId)}
|
||||||
|
>
|
||||||
|
{downloadLabel(d.episodeId)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={
|
||||||
|
rowIdx() === lf()
|
||||||
|
? theme.surface
|
||||||
|
: theme.textSecondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.podcastTitle ?? d.feedId}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: episodes */}
|
{/* depth ≥1: episodes */}
|
||||||
@@ -282,40 +510,56 @@ export function MyShowsPage() {
|
|||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(ep, index) => {
|
{(ep, index) => {
|
||||||
const lf = focusedEpIdx();
|
const lf = () => focusedEpIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
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={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 1);
|
nav.setDepthFocus(index(), 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), lf, isActive())}>
|
<text
|
||||||
{index() === lf ? "❯" : " "}
|
flexShrink={0}
|
||||||
|
fg={focusFg(index(), lf(), isActive())}
|
||||||
|
>
|
||||||
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf, isActive())}>
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={focusFg(index(), lf(), isActive())}
|
||||||
|
>
|
||||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||||
{ep.title}
|
{ep.title}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === lf() ? theme.surface : theme.info}
|
||||||
|
>
|
||||||
{formatDate(ep.pubDate)}
|
{formatDate(ep.pubDate)}
|
||||||
</text>
|
</text>
|
||||||
<text fg={index() === lf ? theme.surface : muted()}>
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === lf() ? theme.surface : muted()}
|
||||||
|
>
|
||||||
{formatDuration(ep.duration)}
|
{formatDuration(ep.duration)}
|
||||||
</text>
|
</text>
|
||||||
<Show when={nav.isSelected(ep.id)}>
|
<Show when={nav.isSelected(ep.id)}>
|
||||||
<text fg={theme.warning}>●</text>
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={downloadLabel(ep.id)}>
|
<Show when={downloadLabel(ep.id)}>
|
||||||
<text fg={downloadColor(ep.id)}>
|
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
||||||
{downloadLabel(ep.id)}
|
{downloadLabel(ep.id)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -324,9 +568,38 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box
|
||||||
<LoadingIndicator />
|
ref={moreRef}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(
|
||||||
|
episodes().length,
|
||||||
|
focusedRow(),
|
||||||
|
isActive(),
|
||||||
|
)}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(episodes().length, 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -337,44 +610,110 @@ export function MyShowsPage() {
|
|||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered show
|
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||||
|
// hovered show.
|
||||||
<Show
|
<Show
|
||||||
when={selectedShow()}
|
when={focusedUnsub()}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<Show
|
||||||
<text fg={muted()}>No show focused</text>
|
when={selectedShow()}
|
||||||
</box>
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No show focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(show) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{showTitle(show())}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={show().podcast.author}>
|
||||||
|
<text fg={muted()}>by {show().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{show().episodes.length} episodes
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{show().podcast.description?.slice(0, 400) ??
|
||||||
|
"No description."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter/l: open · h: back · x: unsubscribe
|
||||||
|
{app.state().preferences.autoDownloadScope ===
|
||||||
|
"whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ??
|
||||||
|
[]
|
||||||
|
).includes(show().id)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(show) => (
|
{(d) => (
|
||||||
<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>{showTitle(show())}</strong>
|
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||||
</text>
|
</text>
|
||||||
<Show when={show().podcast.author}>
|
|
||||||
<text fg={muted()}>by {show().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
{show().episodes.length} episodes
|
{d().podcastTitle ?? d().feedId}
|
||||||
</text>
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<Show when={d().pubDate}>
|
||||||
|
<text fg={theme.info}>
|
||||||
|
{formatDate(new Date(d().pubDate!))}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={downloadLabel(d().episodeId)}>
|
||||||
|
<text fg={downloadColor(d().episodeId)}>
|
||||||
|
{downloadLabel(d().episodeId)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
Downloaded from episode search — the show is not
|
||||||
|
subscribed.
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
<text fg={muted()}>
|
||||||
|
enter: play · D: delete download · h: back
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : (
|
||||||
// depth ≥1 preview: hovered episode
|
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||||
<Show
|
<>
|
||||||
when={focusedEpisode()}
|
<Show when={focusedOnMore()}>
|
||||||
fallback={
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
<box padding={1}>
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
<text fg={muted()}>No episode focused</text>
|
<strong>[Fetch More]</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{feedStore.isLoadingMore()
|
||||||
|
? "Loading the next batch of episodes…"
|
||||||
|
: fetchMoreMode() === "auto"
|
||||||
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
|
: "Load the next batch of older episodes for this show (Enter)."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
</Show>
|
||||||
>
|
<Show when={!focusedOnMore()}>
|
||||||
|
<Show
|
||||||
|
when={focusedEpisode()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
{(ep) => (
|
{(ep) => (
|
||||||
<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}>
|
||||||
@@ -401,20 +740,34 @@ 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>
|
</Show>
|
||||||
);
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,64 +1,96 @@
|
|||||||
import type { BackendName } from "../utils/audio-player"
|
import type { BackendName } from "@/utils/audio-player";
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
type PlaybackControlsProps = {
|
type PlaybackControlsProps = {
|
||||||
isPlaying: boolean
|
isPlaying: boolean;
|
||||||
volume: number
|
volume: number;
|
||||||
speed: number
|
speed: number;
|
||||||
backendName?: BackendName
|
backendName?: BackendName;
|
||||||
hasAudioUrl?: boolean
|
hasAudioUrl?: boolean;
|
||||||
onToggle: () => void
|
onToggle: () => void;
|
||||||
onPrev: () => void
|
onPrev: () => void;
|
||||||
onNext: () => void
|
onNext: () => void;
|
||||||
onVolumeChange: (value: number) => void
|
onVolumeChange: (value: number) => void;
|
||||||
onSpeedChange: (value: number) => void
|
onSpeedChange: (value: number) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
|
||||||
mpv: "mpv",
|
|
||||||
ffplay: "ffplay",
|
|
||||||
afplay: "afplay",
|
|
||||||
system: "system",
|
|
||||||
none: "none",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}>
|
<box
|
||||||
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}>
|
flexDirection="row"
|
||||||
<text fg={theme.primary}>[Prev]</text>
|
flexWrap="wrap"
|
||||||
</box>
|
gap={1}
|
||||||
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
|
alignItems="center"
|
||||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
justifyContent="center"
|
||||||
</box>
|
border
|
||||||
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
|
padding={1}
|
||||||
<text fg={theme.primary}>[Next]</text>
|
borderColor={theme.border}
|
||||||
</box>
|
>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
{/* transport buttons — wrap as a unit, centered on their own line */}
|
||||||
<text fg={theme.textMuted}>Vol</text>
|
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
<box
|
||||||
</box>
|
border
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
padding={0}
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
onMouseDown={props.onPrev}
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
borderColor={theme.border}
|
||||||
</box>
|
>
|
||||||
{props.backendName && props.backendName !== "none" && (
|
<text fg={theme.primary} wrapMode="none">[Prev]</text>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
</box>
|
||||||
<text fg={theme.textMuted}>via</text>
|
<box
|
||||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
border
|
||||||
</box>
|
padding={0}
|
||||||
)}
|
onMouseDown={props.onToggle}
|
||||||
{props.backendName === "none" && (
|
borderColor={theme.border}
|
||||||
<box marginLeft={2}>
|
>
|
||||||
<text fg={theme.warning}>No audio player found</text>
|
<text fg={theme.primary} wrapMode="none">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
<box
|
||||||
{props.hasAudioUrl === false && (
|
border
|
||||||
<box marginLeft={2}>
|
padding={0}
|
||||||
<text fg={theme.warning}>No audio URL</text>
|
onMouseDown={props.onNext}
|
||||||
</box>
|
borderColor={theme.border}
|
||||||
)}
|
>
|
||||||
</box>
|
<text fg={theme.primary} wrapMode="none">[Next]</text>
|
||||||
)
|
</box>
|
||||||
|
</box>
|
||||||
|
{/* status group — always follows the buttons; wrap point is here */}
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
alignItems="center"
|
||||||
|
marginLeft={2}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
|
<text fg={theme.textMuted}>↑↓</text>
|
||||||
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
<text fg={theme.textMuted}>S</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||||
|
{(props.backendName === "none" || props.hasAudioUrl === false) && (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
alignItems="center"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{props.backendName === "none" && (
|
||||||
|
<box marginLeft={2}>
|
||||||
|
<text fg={theme.warning}>No audio player found</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
{props.hasAudioUrl === false && (
|
||||||
|
<box marginLeft={2}>
|
||||||
|
<text fg={theme.warning}>No audio URL</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* PlayerPage — single-pane audio now-playing view.
|
* PlayerPage — 2-pane yazi depth view of the now-playing episode.
|
||||||
*
|
*
|
||||||
* Audio transport (play/pause, next/prev, seek) is handled globally by the
|
* depth 0 (parent) — tab list (muted, read-only).
|
||||||
* Shell router (P/N/B/</>). This page renders a single rich pane showing the
|
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
||||||
* current episode, waveform, and playback controls. Panes/swipe do nothing
|
*
|
||||||
* (PaneCount=1).
|
* No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
|
||||||
|
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
||||||
|
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
||||||
|
* tab root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show, onMount, onCleanup } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
export const PlayerPaneCount = 1;
|
export const PlayerPaneCount = 1;
|
||||||
|
|
||||||
@@ -21,11 +28,21 @@ export function PlayerPage() {
|
|||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const viz = useVisualizer();
|
||||||
|
const app = useAppStore();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
|
// Settings master switch: off hides the waveform entirely (the store
|
||||||
|
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
||||||
|
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
||||||
|
|
||||||
// Single pane — always active.
|
// The page is mounted exactly while the Player tab is in focus (Shell
|
||||||
const isActive = () => true;
|
// renders only the active tab), so mount ⇔ focused. Report it to the
|
||||||
const border = () => theme.accent;
|
// visualizer store: losing focus starts the unload grace timer instead
|
||||||
|
// of killing the pipeline with the page; regaining focus restarts it.
|
||||||
|
onMount(() => viz.setFocused(true));
|
||||||
|
onCleanup(() => viz.setFocused(false));
|
||||||
|
|
||||||
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
const progressPercent = () => {
|
const progressPercent = () => {
|
||||||
const d = audio.duration();
|
const d = audio.duration();
|
||||||
@@ -39,84 +56,83 @@ export function PlayerPage() {
|
|||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ── parent pane: the tab list (muted) ──────────────────────────────────────
|
||||||
<box flexDirection="column" width="100%" height="100%">
|
const parentContent = () => <TabListPane muted />;
|
||||||
{/* ── pane 0: now playing ─────────────────────────────────────────── */}
|
|
||||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
// ── current pane: now playing ───────────────────────────────────────────────
|
||||||
<text fg={theme.textSecondary}>Player</text>
|
const currentContent = () => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<strong>Now Playing</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||||
|
{progressPercent()}%)
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<scrollbox
|
|
||||||
height="100%"
|
<Show when={audio.error()}>
|
||||||
focused={isActive()}
|
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||||
border
|
</Show>
|
||||||
borderColor={border()}
|
|
||||||
backgroundColor={theme.background}
|
<Show
|
||||||
|
when={audio.currentEpisode()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode loaded.</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
{(ep) => (
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="column" gap={1}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<strong>Now Playing</strong>
|
<strong>{ep().title}</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||||
{progressPercent()}%)
|
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<ProgressBar />
|
||||||
|
|
||||||
|
<Show when={vizEnabled()}>
|
||||||
|
<RealtimeWaveform />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={audio.error()}>
|
<PlaybackControls
|
||||||
{(err) => <text fg={theme.error}>{err()}</text>}
|
isPlaying={audio.isPlaying()}
|
||||||
</Show>
|
volume={audio.volume()}
|
||||||
|
speed={audio.speed()}
|
||||||
|
backendName={audio.backendName()}
|
||||||
|
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
||||||
|
onToggle={audio.togglePlayback}
|
||||||
|
onPrev={() => audio.seek(0)}
|
||||||
|
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
|
||||||
|
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||||
|
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Show
|
<box height={1} />
|
||||||
when={audio.currentEpisode()}
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
fallback={
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
<box padding={1}>
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
<text fg={muted()}>No episode loaded.</text>
|
<text
|
||||||
</box>
|
fg={muted()}
|
||||||
}
|
content={"P play/pause N next B prev < > seek h back"}
|
||||||
>
|
/>
|
||||||
{(ep) => (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>{ep().title}</strong>
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{ep().description?.slice(0, 500) ??
|
|
||||||
"No description available."}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<RealtimeWaveform
|
|
||||||
visualizerConfig={(() => {
|
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
|
||||||
return {
|
|
||||||
bars: viz.bars,
|
|
||||||
noiseReduction: viz.noiseReduction,
|
|
||||||
lowCutOff: viz.lowCutOff,
|
|
||||||
highCutOff: viz.highCutOff,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<PlaybackControls
|
|
||||||
isPlaying={audio.isPlaying()}
|
|
||||||
volume={audio.volume()}
|
|
||||||
speed={audio.speed()}
|
|
||||||
backendName={audio.backendName()}
|
|
||||||
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
|
||||||
onToggle={audio.togglePlayback}
|
|
||||||
onPrev={() => audio.seek(0)}
|
|
||||||
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
|
|
||||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
|
||||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaneRow
|
||||||
|
parent={parentContent}
|
||||||
|
current={currentContent}
|
||||||
|
currentLabel="Player"
|
||||||
|
panes={2}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,256 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||||
*
|
*
|
||||||
* Spawns an independent ffmpeg
|
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||||
* for FFT analysis, and renders frequency bars as colored terminal
|
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||||
* characters at ~30fps.
|
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
|
||||||
|
*
|
||||||
|
* This component only subscribes to store state, reports the width-derived
|
||||||
|
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||||
|
* a braille spinner while the pipeline is loading its first frames, the
|
||||||
|
* frequency bars once frames arrive, and a dotted placeholder when idle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createEffect, on } from "solid-js";
|
||||||
import {
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
loadCavaCore,
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
type CavaCore,
|
|
||||||
type CavaCoreConfig,
|
|
||||||
} from "@/utils/cavacore";
|
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||||
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
export type RealtimeWaveformProps = {
|
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
|
||||||
const BARS = [
|
|
||||||
" ",
|
|
||||||
"\u2581",
|
|
||||||
"\u2582",
|
|
||||||
"\u2583",
|
|
||||||
"\u2584",
|
|
||||||
"\u2585",
|
|
||||||
"\u2586",
|
|
||||||
"\u2587",
|
|
||||||
"\u2588",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Target frame interval in ms (~30 fps) */
|
|
||||||
const FRAME_INTERVAL = 33;
|
|
||||||
|
|
||||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
|
||||||
const SAMPLES_PER_FRAME = 512;
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const audio = useAudio();
|
const viz = useVisualizer();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
|
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
|
||||||
|
// chrome (scrollbox border + box padding + waveform border + padding).
|
||||||
|
// Falls back to 64 before the renderer reports a real size.
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
const numBars = () => {
|
||||||
|
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
|
||||||
|
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
|
||||||
|
const width = dimensions().width;
|
||||||
|
if (!width) return 64;
|
||||||
|
return Math.max(
|
||||||
|
8,
|
||||||
|
Math.min(256, Math.floor((width * current) / total) - 8),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Track whether cavacore is available
|
// Keep the store's bar count in sync with the terminal width; the store
|
||||||
const [available, setAvailable] = createSignal(false);
|
// re-inits the running pipeline when it changes (terminal resize).
|
||||||
|
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
let reader: AudioStreamReader | null = null;
|
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let sampleBuffer: Float64Array | null = null;
|
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
const renderLine = () => {
|
||||||
|
const bars = viz.barData();
|
||||||
|
const count = numBars();
|
||||||
|
|
||||||
const initCava = () => {
|
// Loading state: the braille spinner shows while the pipeline warms
|
||||||
if (cava) return true;
|
// up — but only when there are no bars to render yet (first play /
|
||||||
|
// after an unload). On resume/seek the last bars stay on screen
|
||||||
|
// until fresh frames arrive, so the waveform never blanks out for
|
||||||
|
// the (multi-second, network-bound) cold start.
|
||||||
|
if (bars.length === 0 && viz.isLoading()) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
cava = loadCavaCore();
|
if (bars.length === 0) {
|
||||||
if (!cava) {
|
const placeholder = ".".repeat(count);
|
||||||
setAvailable(false);
|
return (
|
||||||
return false;
|
<box flexDirection="column" gap={0}>
|
||||||
}
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setAvailable(true);
|
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
|
||||||
return true;
|
const top = pairs.map((pair) => pair.top).join("");
|
||||||
};
|
const bottom = pairs.map((pair) => pair.bottom).join("");
|
||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
return (
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={theme.primary}>{top}</text>
|
||||||
|
<text fg={theme.primary}>{bottom}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
return (
|
||||||
stopVisualization();
|
<box border borderColor={theme.border} padding={1}>
|
||||||
|
{renderLine()}
|
||||||
if (!url || !initCava() || !cava) return;
|
</box>
|
||||||
|
);
|
||||||
// Initialize cavacore with current resolution + any overrides
|
}
|
||||||
const config: CavaCoreConfig = {
|
|
||||||
bars: 32,
|
|
||||||
sampleRate: 44100,
|
|
||||||
channels: 1,
|
|
||||||
...props.visualizerConfig,
|
|
||||||
};
|
|
||||||
cava.init(config);
|
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
|
||||||
|
|
||||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
|
||||||
if (!reader || reader.url !== url) {
|
|
||||||
if (reader) reader.stop();
|
|
||||||
reader = new AudioStreamReader({ url });
|
|
||||||
}
|
|
||||||
reader.start(position, speed);
|
|
||||||
|
|
||||||
// Start render loop
|
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopVisualization = () => {
|
|
||||||
if (frameTimer) {
|
|
||||||
clearInterval(frameTimer);
|
|
||||||
frameTimer = null;
|
|
||||||
}
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
// Don't null reader — we reuse it across start/stop cycles
|
|
||||||
}
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
sampleBuffer = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
|
||||||
|
|
||||||
const renderFrame = () => {
|
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
|
||||||
|
|
||||||
// Read available PCM samples from the stream
|
|
||||||
const count = reader.read(sampleBuffer);
|
|
||||||
if (count === 0) return;
|
|
||||||
|
|
||||||
// Feed samples to cavacore → get frequency bars
|
|
||||||
const input =
|
|
||||||
count < sampleBuffer.length
|
|
||||||
? sampleBuffer.subarray(0, count)
|
|
||||||
: sampleBuffer;
|
|
||||||
const output = cava.execute(input);
|
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
|
||||||
setBarData(Array.from(output));
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
[
|
|
||||||
audio.isPlaying,
|
|
||||||
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
|
|
||||||
audio.speed,
|
|
||||||
() => 32,
|
|
||||||
],
|
|
||||||
([playing, url, speed]) => {
|
|
||||||
if (playing && url) {
|
|
||||||
const pos = untrack(audio.position);
|
|
||||||
startVisualization(url, pos, speed);
|
|
||||||
} else {
|
|
||||||
stopVisualization();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
|
||||||
//
|
|
||||||
// Watches position and restarts the reader (not the whole pipeline)
|
|
||||||
// only on significant jumps (>2s), which indicate a user seek.
|
|
||||||
// This is intentionally a separate effect — it should NOT trigger a
|
|
||||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
|
||||||
createEffect(
|
|
||||||
on(audio.position, (pos) => {
|
|
||||||
if (!audio.isPlaying || !reader?.running) {
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
|
|
||||||
if (delta > 2) {
|
|
||||||
reader.restart(pos, audio.speed() ?? 1);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onCleanup(() => {
|
|
||||||
stopVisualization();
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const playedRatio = () =>
|
|
||||||
audio.duration() <= 0
|
|
||||||
? 0
|
|
||||||
: Math.min(1, audio.position() / audio.duration());
|
|
||||||
|
|
||||||
const renderLine = () => {
|
|
||||||
const bars = barData();
|
|
||||||
const numBars = 32;
|
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
|
||||||
if (bars.length === 0) {
|
|
||||||
const placeholder = ".".repeat(numBars);
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={0}>
|
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const played = Math.floor(numBars * playedRatio());
|
|
||||||
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
|
||||||
const futureColor = "#3b4252";
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<box flexDirection="row" gap={0}>
|
|
||||||
<text fg={playedColor}>{playedChars || " "}</text>
|
|
||||||
<text fg={futureColor}>{futureChars || " "}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (event: { x: number }) => {
|
|
||||||
const numBars = 32;
|
|
||||||
const ratio = event.x / numBars;
|
|
||||||
const next = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
|
||||||
);
|
|
||||||
audio.seek(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
|
|
||||||
{renderLine()}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||