Compare commits
12 Commits
c8d29ed59d
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| de01cedee0 | |||
| 2730fa3cae | |||
| 91a831c5f9 | |||
| 52e9ae0ab7 | |||
| 64d8b40e61 | |||
| 0cc15c8d90 | |||
| 1d3abd53d4 | |||
| 592cfd4093 | |||
| 69e12cf5b9 | |||
| c9e3aa92ec | |||
| 25fe7f6ac9 | |||
| 85cb9fba26 |
98
.github/workflows/release.yml
vendored
Normal file
98
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: release
|
||||
|
||||
# Builds standalone PodTui binaries for each supported OS/arch and attaches
|
||||
# them to a GitHub Release. One runner per platform because Bun cannot
|
||||
# cross-compile — each runner runs `make dist`, which emits a
|
||||
# podtui-<platform>-<arch>.tar.gz (binary + native libs side by side).
|
||||
#
|
||||
# Trigger: push a tag like v0.1.0. Bump VERSION in src/index.tsx in the same
|
||||
# commit as the tag so the released binary reports the tagged version.
|
||||
|
||||
on: # intentional: YAML `on` key
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ matrix.os }} / ${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x64
|
||||
plat: linux
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
plat: linux
|
||||
- os: macos-15-intel
|
||||
arch: x64
|
||||
plat: darwin
|
||||
- os: macos-14
|
||||
arch: arm64
|
||||
plat: darwin
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install fftw (cavacore build dependency)
|
||||
run: |
|
||||
if uname -s | grep -qi darwin; then
|
||||
brew install fftw
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfftw3-dev
|
||||
fi
|
||||
|
||||
- name: Build native cavacore library
|
||||
run: scripts/build-cavacore.sh
|
||||
|
||||
- name: Build standalone binary + tarball
|
||||
run: make dist
|
||||
|
||||
- name: Smoke-test binary boot
|
||||
env:
|
||||
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||
run: |
|
||||
# The embedded runtime reads the launching process's CWD bunfig.toml.
|
||||
# This repo's bunfig lists a preload the standalone can't resolve
|
||||
# ("preload not found"), so kicking the binary from the workspace root
|
||||
# would falsely fail every build. cd into a clean dir first.
|
||||
SMOKE_DIR=$(mktemp -d)
|
||||
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
||||
cd "$SMOKE_DIR"
|
||||
./podtui-*/podtui --version
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
||||
path: dist/podtui-*.tar.gz
|
||||
|
||||
upload:
|
||||
name: Attach to GitHub Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: artifacts/**/*.tar.gz
|
||||
193
CONTRIBUTING.md
Normal file
193
CONTRIBUTING.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Contributing to PodTui
|
||||
|
||||
This file is written **for humans**. If you're an AI agent or LLM working in
|
||||
this repo, read [AGENTS.md](AGENTS.md) instead — it has the machine-oriented
|
||||
build/test/lint contract and code-style rules. Both describe the same project;
|
||||
CONTRIBUTING.md focuses on *understanding* and *navigating* the codebase.
|
||||
|
||||
PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript +
|
||||
[OpenTUI](https://github.com/opentui/opentui) on top, [Bun](https://bun.sh)
|
||||
as the runtime and toolchain.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
brew install bun # or: curl -fsSL https://bun.sh/install | bash
|
||||
git clone git@github.com:mikefreno/podtui.git
|
||||
cd podtui
|
||||
|
||||
bun install # install JS dependencies
|
||||
make native # build libcavacore.dylib from the vendored C source
|
||||
bun run dev # launch with hot reload (alias: make dev)
|
||||
```
|
||||
|
||||
The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
|
||||
WezTerm, tmux, …). It will not render in a plain captured `bash` session.
|
||||
|
||||
## What each command does
|
||||
|
||||
| Command | Purpose |
|
||||
|--------------------|--------------------------------------------------------------------------|
|
||||
| `bun install` | Install JS dependencies |
|
||||
| `make native` | Compile `cava/cavacore.c` → `src/native/libcavacore.<dylib\|so>` |
|
||||
| `bun run dev` | Run with hot reload |
|
||||
| `bun run start` | Run once (no watch) |
|
||||
| `bun test` | Run the test suite (see [Testing](#testing)) |
|
||||
| `bun run lint` | Type-check |
|
||||
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
||||
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
||||
| `make clean` | Remove `dist/` |
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
src/
|
||||
api/ Network + XML/RSS — client.ts, rss-parser.ts
|
||||
components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel…
|
||||
config/ App config: keybinds.jsonc, shortcuts, auth
|
||||
constants/ Static tables (sync formats, themes)
|
||||
context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext
|
||||
hooks/ useAudio, useMultimediaKeys, useCachedData
|
||||
native/ FFI glue + the built libcavacore.{dylib,so}
|
||||
pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings
|
||||
stores/ Zustand stores — app, feed, audio-nav, search, auth, progress…
|
||||
styles/ theme.css
|
||||
themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json
|
||||
types/ All shared interfaces (podcast, episode, feed, settings…)
|
||||
ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx
|
||||
utils/ Parser/persistence/audio helpers (audio-player, config-dir…)
|
||||
scripts/
|
||||
build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian
|
||||
tui-harness.tsx Headless harness for scripted interaction (see below)
|
||||
cava/ Vendored cavacore C source (MIT, from karlstav/cava)
|
||||
tests/ bun test suite + cavacore smoke test
|
||||
dist/ Build output (JS bundle + libs + tarballs)
|
||||
```
|
||||
|
||||
## Native libraries: how the FFI layer works
|
||||
|
||||
PodTui loads **two** native libraries at runtime:
|
||||
|
||||
1. **libopentui** — the OpenTUI renderer (shipped inside the
|
||||
`@opentui/core-<platform>-<arch>` npm packages, copied to `dist/` by
|
||||
`build.ts`).
|
||||
2. **libcavacore** — the audio spectrum renderer, built from C. The source is
|
||||
vendored under `cava/` (it must stay committed — every CI runner builds it).
|
||||
`libfftw3` is needed to build it:
|
||||
- macOS: `brew install fftw`
|
||||
- Debian/Ubuntu: `apt-get install libfftw3-dev`
|
||||
(CI installs it for you; locally run `make native`.)
|
||||
|
||||
**Critical sibling rule**: both libraries are loaded *relative to the binary*,
|
||||
so `podtui`, `libopentui.*` and `libcavacore.*` must sit in the **same
|
||||
directory**. Never move a single binary out of the tarball. The Homebrew
|
||||
formula keeps all three in `libexec/` and exposes only a `podtui` symlink.
|
||||
|
||||
Cavacore smoke test: `bun tests/cavacore-smoke.ts`
|
||||
(FFI-calls `cava_init` / `cava_execute` / `cava_destroy` and prints results).
|
||||
|
||||
## Gotchas (read before touching anything)
|
||||
|
||||
1. **Never add a top-level `preload` to `bunfig.toml`.**
|
||||
A compiled PodTui binary's embedded runtime reads the *launching process's*
|
||||
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
|
||||
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
|
||||
`preload not found`. This is why `bunfig.toml` has **no** top-level
|
||||
`preload`; dev-mode preloading happens via explicit `--preload` flags in
|
||||
`package.json`. The `[test]` section *does* keep a preload — that only
|
||||
affects `bun test`.
|
||||
|
||||
2. **Smoke-test the compiled binary from a bunfig-free dir.**
|
||||
Because of (1), `./dist/podtui --version` run from the repo root launched
|
||||
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
|
||||
before booting. Do the same when testing a release build locally.
|
||||
|
||||
3. **Homebrew's dylib-repair warning is benign.**
|
||||
`brew install` may print “load commands do not fit in the header … needs
|
||||
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
|
||||
the warning is cosmetic; installs complete and the app boots.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun test # full suite (54 tests across 6 files today)
|
||||
```
|
||||
|
||||
The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane
|
||||
logic; plus `tests/cavacore-smoke.ts` asserting the native lib exports.
|
||||
|
||||
For scripted end-to-end interaction there's a **headless harness**,
|
||||
`scripts/tui-harness.tsx`: each invocation snapshot-rebuilds the app state
|
||||
into a sandboxed `.harness/` config dir, replays the saved action log
|
||||
(`.harness/actions.json`), executes one more key/action passed on the CLI, and
|
||||
prints the resulting frame + a style summary — all without a real terminal.
|
||||
Audio is a no-op during those snapshots. The last frame lands in
|
||||
`.harness/last-frame.{json,txt}` for inspection.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are built and published from **tags**
|
||||
|
||||
### Steps
|
||||
|
||||
1. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0` → `0.2.0`). Commit and push.
|
||||
2. Tag and push:
|
||||
|
||||
```bash
|
||||
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
|
||||
```
|
||||
|
||||
3. CI (`.github/workflows/release.yml`) runs four builds in parallel,
|
||||
each producing `podtui-<platform>-<arch>.tar.gz`:
|
||||
|
||||
| Runner | Platform/Arch |
|
||||
|---------------------|---------------|
|
||||
| `ubuntu-latest` | linux-x64 |
|
||||
| `ubuntu-24.04-arm` | linux-arm64 |
|
||||
| `macos-15-intel` | darwin-x64 |
|
||||
| `macos-14` | darwin-arm64 |
|
||||
|
||||
Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh`
|
||||
→ `make dist` → smoke-boots the binary from a temp dir → uploads the
|
||||
tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.)
|
||||
|
||||
4. A release is auto-created with all 4 tarballs attached. `brew` never
|
||||
sees the new version: the **tap self-updates**: the
|
||||
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
|
||||
polls GitHub releases, and when a new tag appears, rewrites
|
||||
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
||||
test: `brew install mikefreno/podtui/podtui`.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If you ever need to sync the tap by hand (or before the hourly job runs):
|
||||
|
||||
```bash
|
||||
cd <clone of mikefreno/homebrew-podtui>
|
||||
./scripts/sync-formula.sh 0.2.0
|
||||
git commit -am 'podtui 0.2.0' && git push
|
||||
```
|
||||
|
||||
### Local release build
|
||||
|
||||
```bash
|
||||
make dist # builds the binary + tarball for THIS machine only
|
||||
```
|
||||
|
||||
Bun cannot cross-compile — the other platforms come from CI.
|
||||
|
||||
---
|
||||
|
||||
## Open items / things to sort out
|
||||
|
||||
- **LICENSE**: `README.md` says "TBD — choose and document a license before
|
||||
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
|
||||
README footer.
|
||||
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||
ships from it directly until a full rebuild replaces it. On other hosts the
|
||||
`make native` build is required — see `scripts/build-cavacore.sh`.
|
||||
66
Makefile
Normal file
66
Makefile
Normal file
@@ -0,0 +1,66 @@
|
||||
# PodTui — Makefile
|
||||
#
|
||||
# Development targets:
|
||||
# make install install dependencies (bun install) + build native lib
|
||||
# make dev run with hot reload
|
||||
# make test run the test suite
|
||||
# make build produce the JS bundle + native libs in dist/
|
||||
# make native build the cavacore FFI library from C source
|
||||
# make lint run typecheck-style checks (lsp), not eslint
|
||||
#
|
||||
# Packaging / release targets:
|
||||
# make dist build a standalone compiled binary + tarball for the
|
||||
# CURRENT platform (see dist/ for podtui + libs + tarball)
|
||||
# make dist-mac alias for `dist` targeting macOS (run on macOS)
|
||||
# make dist-linux alias for `dist` targeting Linux (run on Linux)
|
||||
# make clean remove dist/ output
|
||||
#
|
||||
# Cross-platform binaries are produced by CI (GitHub Actions) with one runner
|
||||
# per OS/arch — Bun cannot cross-compile, so dist:mac / dist:linux only produce
|
||||
# the binary for the OS they run on. Each runner runs `make dist` and uploads
|
||||
# its podtui-<platform>-<arch>.tar.gz artifact.
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: install dev build native dist dist-mac dist-linux test clean
|
||||
|
||||
## Install dependencies and build the native runtime library.
|
||||
install:
|
||||
bun install
|
||||
make native
|
||||
|
||||
## Run the dev server with hot reload.
|
||||
dev:
|
||||
bun run dev
|
||||
|
||||
## Type-check the whole project. (See AGENTS.md: `bun run lint` points at a
|
||||
## nonexistent lint.ts; LSP diagnostics are the maintained clean bar.)
|
||||
lint:
|
||||
bun tsc --noEmit
|
||||
|
||||
## Build the JS bundle + native libs into dist/ (the `podtui` npm bin target).
|
||||
build:
|
||||
bun run build
|
||||
|
||||
## Build the cavacore FFI library from src/native/cavacore.c.
|
||||
native:
|
||||
scripts/build-cavacore.sh
|
||||
|
||||
## Standalone binary + native-libs tarball for the current platform.
|
||||
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
|
||||
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
|
||||
## from a bunfig-free dir (see release.yml).
|
||||
dist:
|
||||
bun run build.ts --compile
|
||||
|
||||
## macOS build (run on a macOS runner / host).
|
||||
dist-mac:
|
||||
bun run build.ts --compile
|
||||
|
||||
## Linux build (run on a Linux runner / host).
|
||||
dist-linux:
|
||||
bun run build.ts --compile
|
||||
|
||||
## Remove build artifacts.
|
||||
clean:
|
||||
rm -rf dist
|
||||
216
README.md
216
README.md
@@ -1,15 +1,221 @@
|
||||
# solid
|
||||
# PodTui
|
||||
|
||||
To install dependencies:
|
||||
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
|
||||
built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
||||
browse episodes in a three-pane file-manager layout, and play audio through an
|
||||
external player with full transport control — all from your terminal.
|
||||
|
||||
## Features
|
||||
|
||||
- **Vim/yazi-style navigation** — `j/k` to move, `h/l` to swipe between panes,
|
||||
`Enter` to open, `1–6` / `[` `]` to switch tabs. The tab list is the app root:
|
||||
at launch it fills the current pane, and drilling into a tab's contents slides
|
||||
it into the parent pane.
|
||||
- **Three-pane view** — parent / current / preview (Up | Current | Preview),
|
||||
mirroring yazi's pane model.
|
||||
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
||||
(My Shows, Discover, Feed tabs).
|
||||
- **Search** across your subscribed shows.
|
||||
- **Audio playback** through an external player with full transport control:
|
||||
play/pause, next/previous, seek, speed, and per-episode resume progress.
|
||||
- **Themeable** and **remappable keybindings**.
|
||||
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||
a system audio player.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
|
||||
tmux, GNOME Terminal, etc.).
|
||||
- An **audio player** on `PATH`. PodTui auto-detects in priority order:
|
||||
|
||||
| Player | Platforms | Seek | Speed | Position tracking |
|
||||
|----------|----------------|:----:|:-----:|:------------------|
|
||||
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
|
||||
| `ffplay` | any | ✔ | ✘ | ✘ |
|
||||
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
|
||||
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
|
||||
|
||||
Install `mpv` for the best experience (`brew install mpv`,
|
||||
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
|
||||
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
|
||||
|
||||
## Installation
|
||||
|
||||
PodTui distributes as a **self-contained binary** for macOS (arm64/x64) and
|
||||
Linux (arm64/x64). Pick whichever fits your platform.
|
||||
|
||||
### 1. Homebrew (macOS)
|
||||
|
||||
```sh
|
||||
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv
|
||||
```
|
||||
|
||||
> The formula installs the standalone binary plus its two native libraries
|
||||
> side by side (see [Packaging model](#packaging-model)). It does **not**
|
||||
> depend on Bun.
|
||||
|
||||
### 2. Standalone tarball (all platforms)
|
||||
|
||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
||||
[GitHub Release](https://github.com/mikefreno/podtui/releases), unpack it, and
|
||||
put `podtui` on your `PATH`:
|
||||
|
||||
```bash
|
||||
curl -sSL -o podtui.tar.gz \
|
||||
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
||||
tar -xzf podtui.tar.gz
|
||||
sudo install -m755 podtui /usr/local/bin/podtui
|
||||
```
|
||||
|
||||
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||
> binary alone), or the native FFI libraries won't load.
|
||||
>
|
||||
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
|
||||
> you launch from. If that file has a `preload` entry (as Bun project
|
||||
> directories often do), startup fails with `preload not found`. Launching
|
||||
> from a normal directory (home, `~/bin`, …) works fine.
|
||||
|
||||
### 3. Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
yay -S podtui
|
||||
```
|
||||
|
||||
or build from the PKGBUILD (`podtui-bin`). The package installs the released
|
||||
binary and its sibling libraries.
|
||||
|
||||
### 4. From source
|
||||
|
||||
Requires [Bun](https://bun.sh) ≥ 1.2.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mikefreno/podtui.git
|
||||
cd podtui
|
||||
bun install
|
||||
bun run build:native # build the cavacore FFI lib from C source
|
||||
bun run dev # run with hot reload, or: bun start
|
||||
```
|
||||
|
||||
To run:
|
||||
## Linux distribution notes
|
||||
|
||||
PodTUI deliberately does **not** ship `.deb`, `.rpm`, Flatpak, or Snap
|
||||
packages. For a terminal application that's overwhelmingly installed through
|
||||
repositories or archives, those formats add desktop-sandboxing overhead and a
|
||||
packaging tax with little benefit. Instead:
|
||||
|
||||
- **GitHub Release tarballs** are the universal path — one upload, works on
|
||||
any distro with `curl` + `tar`.
|
||||
- **AUR (`podtui-bin`)** covers Arch. Anyone on Arch/Manjaro gets the same
|
||||
binary through their native package manager.
|
||||
- **Nix / cross-distro** users can build from source (or a Nix flake can be
|
||||
added later).
|
||||
|
||||
This keeps maintenance to a single build per OS/arch and still reaches the
|
||||
vast majority of desktop Linux users through their preferred path.
|
||||
|
||||
## Usage
|
||||
|
||||
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~`
|
||||
for the in-app help.
|
||||
|
||||
### Command-line flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-v`, `--version` | Print the version and exit |
|
||||
| `-q`, `--query <term>` | Query feeds for a show title and print matching shows, without launching the TUI |
|
||||
| `-p`, `--play <term>` | Play the matching show, without launching the TUI |
|
||||
|
||||
### Keybindings
|
||||
|
||||
All keys are remappable — edit `~/.config/podtui/keybinds.jsonc`.
|
||||
|
||||
| Keys | Action |
|
||||
|------|--------|
|
||||
| `j` / `k` | Move cursor down / up |
|
||||
| `J` / `K` | Jump 5 lines |
|
||||
| `ctrl-d` / `ctrl-u` | Page down / up |
|
||||
| `gg` / `G` | Go to top / bottom |
|
||||
| `h` / `l` | Swipe to parent pane / preview pane |
|
||||
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
|
||||
| `Space` | Select / toggle selection |
|
||||
| `v` | Visual mode (multi-select) |
|
||||
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
||||
| `[` / `]` | Previous / next tab |
|
||||
| `P` (shift) | Play / pause |
|
||||
| `N` / `B` | Next / previous episode |
|
||||
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||
| `s` | Search (in a list) |
|
||||
| `f` | Filter |
|
||||
| `r` | Refresh |
|
||||
| `:` | Command bar |
|
||||
| `~`, `f1` | Help |
|
||||
| `q`, `ctrl-c` | Quit |
|
||||
| `Esc` | Escape / cancel |
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration lives under the XDG config directory — `~/.config/podtui` by
|
||||
default (`$XDG_CONFIG_HOME/podtui` if set).
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) |
|
||||
| `sources.json` | Custom feed sources |
|
||||
| `downloads.json` | Downloaded episode metadata |
|
||||
| `keybinds.jsonc` | Keybinding remaps (see above) |
|
||||
| `themes/` | Optional custom theme files |
|
||||
|
||||
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
|
||||
the same OpenTUI environment variables.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
bun install # install dependencies
|
||||
bun run dev # run with hot reload
|
||||
bun test # run the test suite
|
||||
bun run build # bundle JS + copy native libs into dist/
|
||||
make native # rebuild cavacore from C source
|
||||
make lint # type-check (tsc)
|
||||
```
|
||||
|
||||
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.
|
||||
### Releasing
|
||||
|
||||
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs
|
||||
to your GitHub Release automatically:
|
||||
|
||||
```bash
|
||||
make dist # build the standalone binary + tarball for THIS platform
|
||||
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
|
||||
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
|
||||
```
|
||||
|
||||
`make dist` emits a config-independent binary: Bun does not bake bunfig
|
||||
settings into `--compile` output, and the solid JSX transform is registered in
|
||||
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
|
||||
it from any normal directory.
|
||||
|
||||
## Packaging model
|
||||
|
||||
A release tarball is three files sitting side by side:
|
||||
|
||||
```
|
||||
podtui # standalone compiled binary (embeds the Bun runtime)
|
||||
libopentui.<dylib|so> # OpenTUI native renderer FFI library
|
||||
libcavacore.<dylib|so> # cavacore spectrum FFI library (built from C)
|
||||
```
|
||||
|
||||
PodTui loads its native libraries relative to the binary, so **keep them in
|
||||
the same directory**. The compiled binary embeds the Bun runtime, so it runs
|
||||
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
|
||||
is no cross-compilation.
|
||||
|
||||
## License
|
||||
|
||||
TBD — choose and document a license before first release.
|
||||
|
||||
## Related
|
||||
|
||||
- [OpenTUI](https://github.com/opentui/opentui) — the TUI framework driving the interface
|
||||
|
||||
163
build.ts
163
build.ts
@@ -1,62 +1,129 @@
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
||||
import { join, dirname } from "node:path"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin";
|
||||
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
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
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
outdir: "./dist",
|
||||
target: "bun",
|
||||
minify: true,
|
||||
sourcemap: "external",
|
||||
plugins: [solidPlugin],
|
||||
})
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
outdir: "./dist",
|
||||
target: "bun",
|
||||
minify: true,
|
||||
sourcemap: "external",
|
||||
plugins: [solidPlugin],
|
||||
});
|
||||
|
||||
// Copy the native library to dist for distribution
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
|
||||
// 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]
|
||||
// Copy the opentui native library to dist for distribution.
|
||||
const platformKey = `${platform}-${arch}`;
|
||||
const platformPkg = platformMap[platformKey];
|
||||
|
||||
if (platformPkg) {
|
||||
const libName = platform === "win32"
|
||||
? "opentui.dll"
|
||||
: platform === "darwin"
|
||||
? "libopentui.dylib"
|
||||
: "libopentui.so"
|
||||
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName)
|
||||
|
||||
if (existsSync(srcPath)) {
|
||||
const destPath = join("dist", libName)
|
||||
copyFileSync(srcPath, destPath)
|
||||
console.log(`Copied native library: ${libName}`)
|
||||
}
|
||||
const libName = `libopentui.${libExt}`;
|
||||
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName);
|
||||
|
||||
if (existsSync(srcPath)) {
|
||||
const destPath = join("dist", libName);
|
||||
copyFileSync(srcPath, destPath);
|
||||
console.log(`Copied native library: ${libName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy cavacore native library to dist
|
||||
const cavacoreLib = platform === "darwin"
|
||||
? "libcavacore.dylib"
|
||||
: platform === "win32"
|
||||
? "cavacore.dll"
|
||||
: "libcavacore.so"
|
||||
const cavacoreSrc = join("src", "native", cavacoreLib)
|
||||
const cavacoreLib = `libcavacore.${libExt}`;
|
||||
const cavacoreSrc = join("src", "native", cavacoreLib);
|
||||
|
||||
if (existsSync(cavacoreSrc)) {
|
||||
copyFileSync(cavacoreSrc, join("dist", cavacoreLib))
|
||||
console.log(`Copied cavacore library: ${cavacoreLib}`)
|
||||
copyFileSync(cavacoreSrc, join("dist", cavacoreLib));
|
||||
console.log(`Copied cavacore library: ${cavacoreLib}`);
|
||||
} else {
|
||||
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`)
|
||||
console.warn(
|
||||
`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Build complete")
|
||||
// ── Standalone compiled binary (dist/podtui + libs beside it) ──────────────
|
||||
// `bun run build.ts --compile` (or PODTUI_COMPILE=1). Embeds the Bun runtime
|
||||
// so end users need nothing installed; the two FFI libs are shipped as
|
||||
// SIBLING FILES next to the binary (both loaders already resolve them that
|
||||
// way: cavacore checks dirname(process.execPath); opentui embeds via its
|
||||
// bun-plugin and handles the embedded-file path itself).
|
||||
if (COMPILE) {
|
||||
const outfile = join("dist", "podtui");
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
target: "bun",
|
||||
minify: true,
|
||||
sourcemap: "external",
|
||||
plugins: [solidPlugin],
|
||||
compile: {
|
||||
outfile,
|
||||
},
|
||||
});
|
||||
console.log(`Compiled standalone binary: ${outfile}`);
|
||||
|
||||
// Ensure both native libs sit beside the binary.
|
||||
const opentuiSrc = join(
|
||||
"node_modules",
|
||||
`@opentui/core-${platformPkg}`,
|
||||
`libopentui.${libExt}`,
|
||||
);
|
||||
if (existsSync(opentuiSrc)) {
|
||||
copyFileSync(opentuiSrc, join("dist", `libopentui.${libExt}`));
|
||||
}
|
||||
if (!existsSync(join("dist", cavacoreLib))) {
|
||||
console.warn(
|
||||
`Warning: ${cavacoreLib} missing beside the binary — run scripts/build-cavacore.sh`,
|
||||
);
|
||||
}
|
||||
|
||||
// Tarball: podtui + the two native libs (drop the JS bundle dir)
|
||||
const tarRoot = join("dist", `podtui-${platform}-${arch}`);
|
||||
rmSync(tarRoot, { recursive: true, force: true });
|
||||
mkdirSync(tarRoot, { recursive: true });
|
||||
copyFileSync(outfile, join(tarRoot, "podtui"));
|
||||
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
|
||||
const s = join("dist", lib);
|
||||
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
|
||||
}
|
||||
const tar = Bun.spawnSync([
|
||||
"tar",
|
||||
"-czf",
|
||||
`${tarRoot}.tar.gz`,
|
||||
"-C",
|
||||
"dist",
|
||||
`podtui-${platform}-${arch}`,
|
||||
]);
|
||||
if (tar.exitCode !== 0) {
|
||||
console.error(tar.stderr.toString());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Tarball: ${tarRoot}.tar.gz`);
|
||||
rmSync(tarRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("Build complete");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
# NO top-level `preload` here — intentional. A compiled PodTUI binary's
|
||||
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a
|
||||
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the
|
||||
# standalone cannot resolve) makes the binary die at startup with
|
||||
# "preload not found". Dev/test still get the solid transform via explicit
|
||||
# `--preload` flags in package.json and the [test] section below.
|
||||
|
||||
[test]
|
||||
preload = "@opentui/solid/preload"
|
||||
|
||||
19
cava/LICENSE-cava.txt
Normal file
19
cava/LICENSE-cava.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2015 Karl Stavestrand <karl@stavestrand.no>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
588
cava/cavacore.c
Normal file
588
cava/cavacore.c
Normal file
@@ -0,0 +1,588 @@
|
||||
#include "cavacore.h"
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.1415926535897932385
|
||||
#endif
|
||||
#include <fftw3.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#ifdef __ANDROID__
|
||||
#include <jni.h>
|
||||
struct cava_plan *plan;
|
||||
double *cava_in;
|
||||
double *cava_out;
|
||||
#endif
|
||||
|
||||
static double amplitude_to_decibels(double value) {
|
||||
// Magic number 20 comes from converting amplitude ratios to decibels.
|
||||
return 20 * log10(value);
|
||||
}
|
||||
|
||||
struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels, int autosens,
|
||||
double noise_reduction, int low_cut_off, int high_cut_off,
|
||||
int scaling_mode) {
|
||||
struct cava_plan *p = malloc(sizeof(struct cava_plan));
|
||||
p->status = 0;
|
||||
|
||||
// sanity checks:
|
||||
if (channels < 1 || channels > 2) {
|
||||
snprintf(p->error_message, 1024,
|
||||
"cava_init called with illegal number of channels: %d, number of channels "
|
||||
"supported are "
|
||||
"1 and 2",
|
||||
channels);
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
if (rate < 1 || rate > 384000) {
|
||||
snprintf(p->error_message, 1024, "cava_init called with illegal sample rate: %d\n", rate);
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
|
||||
int fft_buffer_size = 512;
|
||||
|
||||
if (rate > 8125 && rate <= 16250)
|
||||
fft_buffer_size *= 2;
|
||||
else if (rate > 16250 && rate <= 32500)
|
||||
fft_buffer_size *= 4;
|
||||
else if (rate > 32500 && rate <= 75000)
|
||||
fft_buffer_size *= 8;
|
||||
else if (rate > 75000 && rate <= 150000)
|
||||
fft_buffer_size *= 16;
|
||||
else if (rate > 150000 && rate <= 300000)
|
||||
fft_buffer_size *= 32;
|
||||
else if (rate > 300000)
|
||||
fft_buffer_size *= 64;
|
||||
|
||||
if (number_of_bars < 1) {
|
||||
snprintf(p->error_message, 1024,
|
||||
"cava_init called with illegal number of bars: %d, number of channels must be "
|
||||
"positive integer\n",
|
||||
number_of_bars);
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
|
||||
if (number_of_bars > fft_buffer_size / 2 + 1) {
|
||||
snprintf(p->error_message, 1024,
|
||||
"cava_init called with illegal number of bars: %d, for %d sample rate number of "
|
||||
"bars can't be more than %d\n",
|
||||
number_of_bars, rate, fft_buffer_size / 2 + 1);
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
if (low_cut_off < 1 || high_cut_off < 1) {
|
||||
snprintf(p->error_message, 1024, "low_cut_off must be a positive value\n");
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
if (low_cut_off >= high_cut_off) {
|
||||
snprintf(p->error_message, 1024, "high_cut_off must be a higher than low_cut_off\n");
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
if ((unsigned int)high_cut_off > rate / 2) {
|
||||
snprintf(p->error_message, 1024,
|
||||
"high_cut_off can't be higher than sample rate / 2. (Nyquist Sampling Theorem)\n");
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
if (scaling_mode != CAVA_SCALING_LINEAR && scaling_mode != CAVA_SCALING_DECIBEL) {
|
||||
snprintf(p->error_message, 1024, "unknown scaling mode: %d\n", scaling_mode);
|
||||
p->status = -1;
|
||||
return p;
|
||||
}
|
||||
|
||||
p->number_of_bars = number_of_bars;
|
||||
p->audio_channels = channels;
|
||||
p->rate = rate;
|
||||
p->autosens = 1;
|
||||
p->sens_init = 1;
|
||||
p->sens = 1.0;
|
||||
p->autosens = autosens;
|
||||
p->framerate = 75;
|
||||
p->frame_skip = 1;
|
||||
p->noise_reduction = noise_reduction;
|
||||
p->scaling_mode = scaling_mode;
|
||||
|
||||
int fftw_flag = FFTW_MEASURE;
|
||||
#ifdef __ANDROID__
|
||||
fftw_flag = FFTW_ESTIMATE;
|
||||
#endif
|
||||
|
||||
p->FFTbassbufferSize = fft_buffer_size * 2;
|
||||
p->FFTbufferSize = fft_buffer_size;
|
||||
|
||||
p->input_buffer_size = p->FFTbassbufferSize * channels;
|
||||
|
||||
p->input_buffer = (double *)malloc(p->input_buffer_size * sizeof(double));
|
||||
|
||||
p->FFTbuffer_lower_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
|
||||
p->FFTbuffer_upper_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
|
||||
p->eq = (double *)malloc((number_of_bars + 1) * sizeof(double));
|
||||
p->cut_off_frequency = (float *)malloc((number_of_bars + 1) * sizeof(float));
|
||||
|
||||
p->cava_fall = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||
p->cava_mem = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||
p->cava_peak = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||
p->prev_cava_out = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||
|
||||
// Hann Window calculate multipliers
|
||||
p->bass_multiplier = (double *)malloc(p->FFTbassbufferSize * sizeof(double));
|
||||
p->multiplier = (double *)malloc(p->FFTbufferSize * sizeof(double));
|
||||
for (int i = 0; i < p->FFTbassbufferSize; i++) {
|
||||
p->bass_multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbassbufferSize - 1)));
|
||||
}
|
||||
for (int i = 0; i < p->FFTbufferSize; i++) {
|
||||
p->multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbufferSize - 1)));
|
||||
}
|
||||
|
||||
// BASS
|
||||
p->in_bass_l = fftw_alloc_real(p->FFTbassbufferSize);
|
||||
p->in_bass_l_raw = fftw_alloc_real(p->FFTbassbufferSize);
|
||||
p->out_bass_l = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
|
||||
p->p_bass_l =
|
||||
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_l, p->out_bass_l, fftw_flag);
|
||||
|
||||
// MID + TREBLE
|
||||
p->in_l = fftw_alloc_real(p->FFTbufferSize);
|
||||
p->in_l_raw = fftw_alloc_real(p->FFTbufferSize);
|
||||
p->out_l = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
|
||||
p->p_l = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_l, p->out_l, fftw_flag);
|
||||
|
||||
memset(p->in_bass_l, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||
memset(p->in_l, 0, sizeof(double) * p->FFTbufferSize);
|
||||
memset(p->in_bass_l_raw, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||
memset(p->in_l_raw, 0, sizeof(double) * p->FFTbufferSize);
|
||||
memset(p->out_bass_l, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||
memset(p->out_l, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||
if (p->audio_channels == 2) {
|
||||
// BASS
|
||||
p->in_bass_r = fftw_alloc_real(p->FFTbassbufferSize);
|
||||
p->in_bass_r_raw = fftw_alloc_real(p->FFTbassbufferSize);
|
||||
p->out_bass_r = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
|
||||
p->p_bass_r =
|
||||
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_r, p->out_bass_r, fftw_flag);
|
||||
|
||||
// MID + TREBLE
|
||||
p->in_r = fftw_alloc_real(p->FFTbufferSize);
|
||||
p->in_r_raw = fftw_alloc_real(p->FFTbufferSize);
|
||||
p->out_r = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
|
||||
|
||||
p->p_r = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_r, p->out_r, fftw_flag);
|
||||
|
||||
memset(p->in_bass_r, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||
memset(p->in_r, 0, sizeof(double) * p->FFTbufferSize);
|
||||
memset(p->in_bass_r_raw, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||
memset(p->in_r_raw, 0, sizeof(double) * p->FFTbufferSize);
|
||||
memset(p->out_bass_r, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||
memset(p->out_r, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||
}
|
||||
|
||||
memset(p->input_buffer, 0, sizeof(double) * p->input_buffer_size);
|
||||
|
||||
memset(p->cava_fall, 0, sizeof(double) * number_of_bars * channels);
|
||||
memset(p->cava_mem, 0, sizeof(double) * number_of_bars * channels);
|
||||
memset(p->cava_peak, 0, sizeof(double) * number_of_bars * channels);
|
||||
memset(p->prev_cava_out, 0, sizeof(double) * number_of_bars * channels);
|
||||
|
||||
// process: calculate cutoff frequencies and eq
|
||||
int lower_cut_off = low_cut_off;
|
||||
int upper_cut_off = high_cut_off;
|
||||
int bass_cut_off = 100;
|
||||
|
||||
// calculate frequency constant (used to distribute bars across the frequency band)
|
||||
double frequency_constant = log10((float)lower_cut_off / (float)upper_cut_off) /
|
||||
(1 / ((float)p->number_of_bars + 1) - 1);
|
||||
|
||||
float *relative_cut_off = (float *)malloc((p->number_of_bars + 1) * sizeof(float));
|
||||
|
||||
p->bass_cut_off_bar = 0;
|
||||
int first_bar = 1;
|
||||
|
||||
float min_bandwidth = p->rate / p->FFTbassbufferSize;
|
||||
|
||||
for (int n = 0; n < p->number_of_bars + 1; n++) {
|
||||
double bar_distribution_coefficient = frequency_constant * (-1);
|
||||
bar_distribution_coefficient +=
|
||||
((float)n + 1) / ((float)p->number_of_bars + 1) * frequency_constant;
|
||||
p->cut_off_frequency[n] = upper_cut_off * pow(10, bar_distribution_coefficient);
|
||||
|
||||
if (n > 0) {
|
||||
if (p->cut_off_frequency[n - 1] >= p->cut_off_frequency[n])
|
||||
p->cut_off_frequency[n] = p->cut_off_frequency[n - 1] + min_bandwidth;
|
||||
}
|
||||
|
||||
// remember nyquist!
|
||||
relative_cut_off[n] = p->cut_off_frequency[n] / (p->rate / 2);
|
||||
|
||||
if (p->cut_off_frequency[n] < bass_cut_off) {
|
||||
// BASS
|
||||
p->FFTbuffer_lower_cut_off[n] = relative_cut_off[n] * (p->FFTbassbufferSize / 2);
|
||||
p->bass_cut_off_bar++;
|
||||
if (p->bass_cut_off_bar > 1)
|
||||
first_bar = 0;
|
||||
|
||||
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbassbufferSize / 2) {
|
||||
p->FFTbuffer_lower_cut_off[n] = p->FFTbassbufferSize / 2;
|
||||
}
|
||||
} else {
|
||||
// MID + TREBLE
|
||||
p->FFTbuffer_lower_cut_off[n] =
|
||||
ceil(relative_cut_off[n] * (float)(p->FFTbufferSize / 2));
|
||||
if (n == p->bass_cut_off_bar) {
|
||||
first_bar = 1;
|
||||
if (n > 0) {
|
||||
p->FFTbuffer_upper_cut_off[n - 1] =
|
||||
relative_cut_off[n] * (p->FFTbassbufferSize / 2) - 1;
|
||||
}
|
||||
} else {
|
||||
first_bar = 0;
|
||||
}
|
||||
|
||||
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbufferSize / 2) {
|
||||
p->FFTbuffer_lower_cut_off[n] = p->FFTbufferSize / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (n > 0) {
|
||||
if (!first_bar) {
|
||||
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
|
||||
|
||||
// pushing the spectrum up if the exponential function gets "clumped" in the
|
||||
// bass and calculating new cut off frequencies
|
||||
if (p->FFTbuffer_lower_cut_off[n] <= p->FFTbuffer_lower_cut_off[n - 1]) {
|
||||
|
||||
// check if there is room for more first
|
||||
int room_for_more = 0;
|
||||
|
||||
if (n < p->bass_cut_off_bar) {
|
||||
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbassbufferSize / 2 + 1)
|
||||
room_for_more = 1;
|
||||
} else {
|
||||
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbufferSize / 2 + 1)
|
||||
room_for_more = 1;
|
||||
}
|
||||
|
||||
if (room_for_more) {
|
||||
// push the spectrum up
|
||||
p->FFTbuffer_lower_cut_off[n] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
|
||||
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (p->FFTbuffer_upper_cut_off[n - 1] < p->FFTbuffer_lower_cut_off[n - 1])
|
||||
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
|
||||
}
|
||||
}
|
||||
// calculate actual cut off frequency
|
||||
if (n < p->bass_cut_off_bar)
|
||||
relative_cut_off[n] =
|
||||
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbassbufferSize / 2);
|
||||
else
|
||||
relative_cut_off[n] =
|
||||
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbufferSize / 2);
|
||||
|
||||
p->cut_off_frequency[n] = relative_cut_off[n] * ((float)p->rate / 2);
|
||||
}
|
||||
|
||||
// hard coded eq
|
||||
for (int n = 0; n < p->number_of_bars; n++) {
|
||||
|
||||
// the numbers that come out of the FFT are very high
|
||||
// the EQ is used to "normalize" them by dividing with this very huge number
|
||||
p->eq[n] = 1 / pow(2, 28);
|
||||
|
||||
// need to boost the EQ for higher frequencies
|
||||
p->eq[n] *= pow(p->cut_off_frequency[n + 1], 0.85);
|
||||
|
||||
if (n < p->bass_cut_off_bar) {
|
||||
p->eq[n] /= log2(p->FFTbassbufferSize);
|
||||
} else {
|
||||
p->eq[n] /= log2(p->FFTbufferSize);
|
||||
}
|
||||
|
||||
p->eq[n] /= p->FFTbuffer_upper_cut_off[n] - p->FFTbuffer_lower_cut_off[n] + 1;
|
||||
}
|
||||
free(relative_cut_off);
|
||||
return p;
|
||||
}
|
||||
|
||||
void cava_execute(double *cava_in, int new_samples, double *cava_out, struct cava_plan *p) {
|
||||
|
||||
// do not overflow
|
||||
if (new_samples > p->input_buffer_size) {
|
||||
new_samples = p->input_buffer_size;
|
||||
}
|
||||
|
||||
int silence = 1;
|
||||
if (new_samples > 0) {
|
||||
// process: approximate actual framerate. This will be off by +10% at 60 fps, but should be
|
||||
// good enough for the autosens and smoothing algorithms to be adjusted accordingly if
|
||||
// framerate is a lot more or less.
|
||||
p->framerate -= p->framerate / 64.0;
|
||||
p->framerate +=
|
||||
(double)(p->rate * p->frame_skip) / (new_samples / p->audio_channels) / 64.0;
|
||||
p->frame_skip = 1;
|
||||
|
||||
// shifting input buffer
|
||||
for (int n = p->input_buffer_size - 1; n >= new_samples; n--) {
|
||||
p->input_buffer[n] = p->input_buffer[n - new_samples];
|
||||
}
|
||||
|
||||
// fill the input buffer
|
||||
for (int n = 0; n < new_samples; n++) {
|
||||
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||
// Audio signals come in the range [-32768, 32768], normalize to [-1, 1].
|
||||
p->input_buffer[new_samples - n - 1] = cava_in[n] / 32768.0;
|
||||
} else {
|
||||
p->input_buffer[new_samples - n - 1] = cava_in[n];
|
||||
}
|
||||
if (cava_in[n]) {
|
||||
silence = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p->frame_skip++;
|
||||
}
|
||||
|
||||
// fill the bass, mid and treble buffers
|
||||
for (int n = 0; n < p->FFTbassbufferSize; n++) {
|
||||
if (p->audio_channels == 2) {
|
||||
p->in_bass_r_raw[n] = p->input_buffer[n * 2];
|
||||
p->in_bass_l_raw[n] = p->input_buffer[n * 2 + 1];
|
||||
} else {
|
||||
p->in_bass_l_raw[n] = p->input_buffer[n];
|
||||
}
|
||||
}
|
||||
for (int n = 0; n < p->FFTbufferSize; n++) {
|
||||
if (p->audio_channels == 2) {
|
||||
p->in_r_raw[n] = p->input_buffer[n * 2];
|
||||
p->in_l_raw[n] = p->input_buffer[n * 2 + 1];
|
||||
} else {
|
||||
p->in_l_raw[n] = p->input_buffer[n];
|
||||
}
|
||||
}
|
||||
|
||||
// Hann Window
|
||||
for (int i = 0; i < p->FFTbassbufferSize; i++) {
|
||||
p->in_bass_l[i] = p->bass_multiplier[i] * p->in_bass_l_raw[i];
|
||||
if (p->audio_channels == 2)
|
||||
p->in_bass_r[i] = p->bass_multiplier[i] * p->in_bass_r_raw[i];
|
||||
}
|
||||
for (int i = 0; i < p->FFTbufferSize; i++) {
|
||||
p->in_l[i] = p->multiplier[i] * p->in_l_raw[i];
|
||||
if (p->audio_channels == 2)
|
||||
p->in_r[i] = p->multiplier[i] * p->in_r_raw[i];
|
||||
}
|
||||
|
||||
// process: execute FFT and sort frequency bands
|
||||
|
||||
fftw_execute(p->p_bass_l);
|
||||
fftw_execute(p->p_l);
|
||||
if (p->audio_channels == 2) {
|
||||
fftw_execute(p->p_bass_r);
|
||||
fftw_execute(p->p_r);
|
||||
}
|
||||
|
||||
// process: separate frequency bands
|
||||
for (int n = 0; n < p->number_of_bars; n++) {
|
||||
|
||||
double temp_l = 0;
|
||||
double temp_r = 0;
|
||||
|
||||
// process: add upp FFT values within bands
|
||||
for (int i = p->FFTbuffer_lower_cut_off[n]; i <= p->FFTbuffer_upper_cut_off[n]; i++) {
|
||||
|
||||
if (n < p->bass_cut_off_bar) {
|
||||
temp_l += hypot(p->out_bass_l[i][0], p->out_bass_l[i][1]);
|
||||
if (p->audio_channels == 2)
|
||||
temp_r += hypot(p->out_bass_r[i][0], p->out_bass_r[i][1]);
|
||||
|
||||
} else {
|
||||
temp_l += hypot(p->out_l[i][0], p->out_l[i][1]);
|
||||
if (p->audio_channels == 2)
|
||||
temp_r += hypot(p->out_r[i][0], p->out_r[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
// getting average and applying configured scaling
|
||||
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||
const double max_db = 70;
|
||||
temp_l = amplitude_to_decibels(temp_l) / max_db;
|
||||
if (!isfinite(temp_l)) {
|
||||
temp_l = 0;
|
||||
}
|
||||
} else {
|
||||
temp_l *= p->eq[n];
|
||||
}
|
||||
cava_out[n] = temp_l;
|
||||
|
||||
if (p->audio_channels == 2) {
|
||||
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||
const double max_db = 70;
|
||||
temp_r = amplitude_to_decibels(temp_r) / max_db;
|
||||
if (!isfinite(temp_r)) {
|
||||
temp_r = 0;
|
||||
}
|
||||
} else {
|
||||
temp_r *= p->eq[n];
|
||||
}
|
||||
cava_out[n + p->number_of_bars] = temp_r;
|
||||
}
|
||||
}
|
||||
|
||||
// applying sens or getting max value
|
||||
if (p->autosens) {
|
||||
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
|
||||
cava_out[n] *= p->sens;
|
||||
}
|
||||
}
|
||||
// process [smoothing]
|
||||
int overshoot = 0;
|
||||
|
||||
double framerate_mod = 66 / p->framerate;
|
||||
double gravity_mod = pow((framerate_mod), 2.5) * 2 / p->noise_reduction;
|
||||
double integral_mod = pow((framerate_mod), 0.1);
|
||||
|
||||
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
|
||||
|
||||
// process [smoothing]: falloff
|
||||
|
||||
if (cava_out[n] < p->prev_cava_out[n] && p->noise_reduction > 0.1) {
|
||||
cava_out[n] =
|
||||
p->cava_peak[n] * (1.0 - (p->cava_fall[n] * p->cava_fall[n] * gravity_mod));
|
||||
|
||||
if (cava_out[n] < 0.0)
|
||||
cava_out[n] = 0.0;
|
||||
p->cava_fall[n] += 0.028;
|
||||
} else {
|
||||
p->cava_peak[n] = cava_out[n];
|
||||
p->cava_fall[n] = 0.0;
|
||||
}
|
||||
p->prev_cava_out[n] = cava_out[n];
|
||||
|
||||
// process [smoothing]: integral
|
||||
cava_out[n] = p->cava_mem[n] * p->noise_reduction / integral_mod + cava_out[n];
|
||||
|
||||
p->cava_mem[n] = cava_out[n];
|
||||
if (p->autosens) {
|
||||
// check if we overshoot target height
|
||||
if (cava_out[n] > 1.0) {
|
||||
overshoot = 1;
|
||||
cava_out[n] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculating automatic sense adjustment
|
||||
if (p->autosens) {
|
||||
if (overshoot) {
|
||||
p->sens = p->sens * (1 - (0.02 * framerate_mod));
|
||||
p->sens_init = 0;
|
||||
} else {
|
||||
if (!silence) {
|
||||
p->sens = p->sens * (1 + (0.001 * framerate_mod * p->autosens));
|
||||
if (p->sens_init)
|
||||
p->sens = p->sens * (1 + (0.1 * framerate_mod));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cava_destroy(struct cava_plan *p) {
|
||||
|
||||
free(p->input_buffer);
|
||||
free(p->bass_multiplier);
|
||||
free(p->multiplier);
|
||||
free(p->eq);
|
||||
free(p->cut_off_frequency);
|
||||
free(p->FFTbuffer_lower_cut_off);
|
||||
free(p->FFTbuffer_upper_cut_off);
|
||||
free(p->cava_fall);
|
||||
free(p->cava_mem);
|
||||
free(p->cava_peak);
|
||||
free(p->prev_cava_out);
|
||||
|
||||
fftw_free(p->in_bass_l);
|
||||
fftw_free(p->in_bass_l_raw);
|
||||
fftw_free(p->out_bass_l);
|
||||
fftw_destroy_plan(p->p_bass_l);
|
||||
|
||||
fftw_free(p->in_l);
|
||||
fftw_free(p->in_l_raw);
|
||||
fftw_free(p->out_l);
|
||||
fftw_destroy_plan(p->p_l);
|
||||
|
||||
if (p->audio_channels == 2) {
|
||||
fftw_free(p->in_bass_r);
|
||||
fftw_free(p->in_bass_r_raw);
|
||||
fftw_free(p->out_bass_r);
|
||||
fftw_destroy_plan(p->p_bass_r);
|
||||
|
||||
fftw_free(p->in_r);
|
||||
fftw_free(p->out_r);
|
||||
fftw_free(p->in_r_raw);
|
||||
fftw_destroy_plan(p->p_r);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
JNIEXPORT jfloatArray JNICALL Java_com_karlstav_cava_MyGLRenderer_InitCava(
|
||||
JNIEnv *env, jobject thiz, jint number_of_bars_set, jint refresh_rate, jint lower_cut_off,
|
||||
jint higher_cut_off) {
|
||||
jfloatArray cuttOffFreq = (*env)->NewFloatArray(env, number_of_bars_set + 1);
|
||||
float noise_reduction = pow((float)refresh_rate / 130, 0.75);
|
||||
|
||||
plan = cava_init(number_of_bars_set, 44100, 1, 1, noise_reduction, lower_cut_off,
|
||||
higher_cut_off, CAVA_SCALING_LINEAR);
|
||||
cava_in = (double *)malloc(plan->FFTbassbufferSize * sizeof(double));
|
||||
cava_out = (double *)malloc(plan->number_of_bars * sizeof(double));
|
||||
(*env)->SetFloatArrayRegion(env, cuttOffFreq, 0, plan->number_of_bars + 1,
|
||||
plan->cut_off_frequency);
|
||||
return cuttOffFreq;
|
||||
}
|
||||
|
||||
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_MyGLRenderer_ExecCava(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jdoubleArray cava_input,
|
||||
jint new_samples) {
|
||||
|
||||
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
|
||||
|
||||
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
|
||||
|
||||
cava_execute(cava_in, new_samples, cava_out, plan);
|
||||
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
|
||||
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
|
||||
|
||||
return cavaReturn;
|
||||
}
|
||||
|
||||
JNIEXPORT int JNICALL Java_com_karlstav_cava_CavaCoreTest_InitCava(JNIEnv *env, jobject thiz,
|
||||
jint number_of_bars_set) {
|
||||
|
||||
plan = cava_init(number_of_bars_set, 44100, 1, 1, 0.7, 50, 10000, CAVA_SCALING_LINEAR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_CavaCoreTest_ExecCava(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jdoubleArray cava_input,
|
||||
jint new_samples) {
|
||||
|
||||
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
|
||||
|
||||
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
|
||||
|
||||
cava_execute(cava_in, new_samples, cava_out, plan);
|
||||
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
|
||||
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
|
||||
|
||||
return cavaReturn;
|
||||
}
|
||||
JNIEXPORT void JNICALL Java_com_karlstav_cava_MyGLRenderer_DestroyCava(JNIEnv *env, jobject thiz) {
|
||||
cava_destroy(plan);
|
||||
}
|
||||
#endif
|
||||
139
cava/cavacore.h
Normal file
139
cava/cavacore.h
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
Copyright (c) 2022 Karl Stavestrand <karl@stavestrand.no>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
#include <fftw3.h>
|
||||
|
||||
#define CAVA_SCALING_LINEAR 0
|
||||
#define CAVA_SCALING_DECIBEL 1
|
||||
|
||||
// cava_plan, parameters used internally by cavacore, do not modify these directly
|
||||
// only the cut off frequencies is of any potential interest to read out,
|
||||
// the rest should most likely be hidden somehow
|
||||
struct cava_plan {
|
||||
int FFTbassbufferSize;
|
||||
int FFTbufferSize;
|
||||
int number_of_bars;
|
||||
int audio_channels;
|
||||
int input_buffer_size;
|
||||
int rate;
|
||||
int bass_cut_off_bar;
|
||||
int sens_init;
|
||||
int autosens;
|
||||
int frame_skip;
|
||||
int status;
|
||||
int scaling_mode;
|
||||
char error_message[1024];
|
||||
|
||||
double sens;
|
||||
double framerate;
|
||||
double noise_reduction;
|
||||
|
||||
fftw_plan p_bass_l, p_bass_r;
|
||||
fftw_plan p_l, p_r;
|
||||
|
||||
fftw_complex *out_bass_l, *out_bass_r;
|
||||
fftw_complex *out_l, *out_r;
|
||||
|
||||
double *bass_multiplier;
|
||||
double *multiplier;
|
||||
|
||||
double *in_bass_r_raw, *in_bass_l_raw;
|
||||
double *in_r_raw, *in_l_raw;
|
||||
double *in_bass_r, *in_bass_l;
|
||||
double *in_r, *in_l;
|
||||
double *prev_cava_out, *cava_mem;
|
||||
double *input_buffer, *cava_peak;
|
||||
|
||||
double *eq;
|
||||
|
||||
float *cut_off_frequency;
|
||||
int *FFTbuffer_lower_cut_off;
|
||||
int *FFTbuffer_upper_cut_off;
|
||||
double *cava_fall;
|
||||
};
|
||||
|
||||
// cava_init, initialize visualization, takes the following parameters:
|
||||
|
||||
// number_of_bars, number of wanted bars per channel
|
||||
|
||||
// rate, sample rate of input signal
|
||||
|
||||
// channels, number of interleaved channels in input
|
||||
|
||||
// autosens, toggle automatic sensitivity adjustment 1 = on, 0 = off
|
||||
// on, gives a dynamically adjusted output signal from 0 to 1
|
||||
// the output is continuously adjusted to use the entire range
|
||||
// off, will pass the raw values from cava directly to the output
|
||||
// the max values will then be dependent on the input
|
||||
|
||||
// noise_reduction, adjust noise reduction filters. 0 - 1, recommended 0.77
|
||||
// the raw visualization is very noisy, this factor adjusts the integral
|
||||
// and gravity filters inside cavacore to keep the signal smooth
|
||||
// 1 will be very slow and smooth, 0 will be fast but noisy.
|
||||
|
||||
// low_cut_off, high_cut_off cut off frequencies for visualization in Hz
|
||||
// recommended: 50, 10000
|
||||
|
||||
// scaling_mode, output scaling mode:
|
||||
// CAVA_SCALING_LINEAR = legacy linear scaling
|
||||
// CAVA_SCALING_DECIBEL = dB-based logarithmic scaling
|
||||
|
||||
// returns a cava_plan to be used by cava_execute. If cava_plan.status is 0 all is OK.
|
||||
// If cava_plan.status is -1, cava_init was called with an illegal parameter, see error string in
|
||||
// cava_plan.error_message
|
||||
extern struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels,
|
||||
int autosens, double noise_reduction, int low_cut_off,
|
||||
int high_cut_off, int scaling_mode);
|
||||
|
||||
// cava_execute, executes visualization
|
||||
|
||||
// cava_in, input buffer can be any size. internal buffers in cavacore is
|
||||
// 4096 * number of channels at 44100 samples rate, if new_samples is greater
|
||||
// then samples will be discarded. However it is recommended to use less
|
||||
// new samples per execution as this determines your framerate.
|
||||
// 512 samples at 44100 sample rate mono, gives about 86 frames per second.
|
||||
|
||||
// new_samples, the number of samples in cava_in to be processed per execution
|
||||
// in case of async reading of data this number is allowed to vary from execution to execution
|
||||
|
||||
// cava_out, output buffer. Size must be number of bars * number of channels. Bars will
|
||||
// be sorted from lowest to highest frequency. If stereo input channels are configured
|
||||
// then all left channel bars will be first then the right.
|
||||
|
||||
// plan, the cava_plan struct returned from cava_init
|
||||
|
||||
// cava_execute assumes cava_in samples to be interleaved if more than one channel
|
||||
// only up to two channels are supported.
|
||||
extern void cava_execute(double *cava_in, int new_samples, double *cava_out,
|
||||
struct cava_plan *plan);
|
||||
|
||||
// cava_destroy, destroys the plan, frees up memory
|
||||
extern void cava_destroy(struct cava_plan *plan);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
405
discover/featured.json
Normal file
405
discover/featured.json
Normal file
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"version": 2,
|
||||
"podcasts": [
|
||||
{
|
||||
"id": "discover-daily",
|
||||
"title": "The Daily",
|
||||
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
|
||||
"feedUrl": "http://rss.art19.com/the-daily",
|
||||
"author": "The New York Times",
|
||||
"categories": ["News", "Politics"]
|
||||
},
|
||||
{
|
||||
"id": "discover-up-first",
|
||||
"title": "Up First",
|
||||
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
|
||||
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["News"]
|
||||
},
|
||||
{
|
||||
"id": "discover-npr-politics",
|
||||
"title": "The NPR Politics Podcast",
|
||||
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
|
||||
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["News", "Politics"]
|
||||
},
|
||||
{
|
||||
"id": "discover-code-switch",
|
||||
"title": "Code Switch",
|
||||
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
|
||||
"feedUrl": "https://feeds.npr.org/510312/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["News", "Culture", "Politics"]
|
||||
},
|
||||
{
|
||||
"id": "discover-rough-translation",
|
||||
"title": "Rough Translation",
|
||||
"description": "How are the things we're talking about covered in the rest of the world? NPR's Rough Translation takes you to far-off places and shows you the unexpected.",
|
||||
"feedUrl": "https://feeds.npr.org/510324/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["News", "Culture"]
|
||||
},
|
||||
{
|
||||
"id": "discover-crime-junkie",
|
||||
"title": "Crime Junkie",
|
||||
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
|
||||
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
|
||||
"author": "audiochuck",
|
||||
"categories": ["True Crime"]
|
||||
},
|
||||
{
|
||||
"id": "discover-serial",
|
||||
"title": "Serial",
|
||||
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
|
||||
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
|
||||
"author": "Serial Productions & The New York Times",
|
||||
"categories": ["True Crime", "Storytelling"]
|
||||
},
|
||||
{
|
||||
"id": "discover-intelligence-matters",
|
||||
"title": "Intelligence Matters",
|
||||
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
|
||||
"feedUrl": "https://rss.art19.com/intelligence-matters",
|
||||
"author": "CBS News",
|
||||
"categories": ["True Crime", "Politics", "News"]
|
||||
},
|
||||
{
|
||||
"id": "discover-smartless",
|
||||
"title": "SmartLess",
|
||||
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
|
||||
"feedUrl": "https://rss.art19.com/smartless",
|
||||
"author": "Jason Bateman, Sean Hayes, Will Arnett",
|
||||
"categories": ["Comedy", "Entertainment"]
|
||||
},
|
||||
{
|
||||
"id": "discover-this-past-weekend",
|
||||
"title": "This Past Weekend w/ Theo Von",
|
||||
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
|
||||
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
|
||||
"author": "Theo Von",
|
||||
"categories": ["Comedy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-joe-rogan",
|
||||
"title": "The Joe Rogan Experience",
|
||||
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
|
||||
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
|
||||
"author": "Joe Rogan",
|
||||
"categories": ["Comedy", "Entertainment"]
|
||||
},
|
||||
{
|
||||
"id": "discover-comedy-bang-bang",
|
||||
"title": "Comedy Bang Bang: The Podcast",
|
||||
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
|
||||
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
|
||||
"author": "Earwolf",
|
||||
"categories": ["Comedy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-office-ladies",
|
||||
"title": "Office Ladies",
|
||||
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
|
||||
"feedUrl": "https://rss.art19.com/office-ladies",
|
||||
"author": "Earwolf",
|
||||
"categories": ["Comedy", "Entertainment"]
|
||||
},
|
||||
{
|
||||
"id": "discover-how-did-this-get-made",
|
||||
"title": "How Did This Get Made?",
|
||||
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
|
||||
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
|
||||
"author": "Earwolf",
|
||||
"categories": ["Comedy", "Film"]
|
||||
},
|
||||
{
|
||||
"id": "discover-wait-wait",
|
||||
"title": "Wait Wait... Don't Tell Me!",
|
||||
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
|
||||
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["Comedy", "News"]
|
||||
},
|
||||
{
|
||||
"id": "discover-new-heights",
|
||||
"title": "New Heights with Jason & Travis Kelce",
|
||||
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
|
||||
"feedUrl": "https://rss.art19.com/new-heights",
|
||||
"author": "Jason & Travis Kelce",
|
||||
"categories": ["Sports", "Comedy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-bill-simmons",
|
||||
"title": "The Bill Simmons Podcast",
|
||||
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
|
||||
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
|
||||
"author": "The Ringer",
|
||||
"categories": ["Sports", "Entertainment"]
|
||||
},
|
||||
{
|
||||
"id": "discover-acquired",
|
||||
"title": "Acquired",
|
||||
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
|
||||
"feedUrl": "https://feeds.transistor.fm/acquired",
|
||||
"author": "Ben Gilbert & David Rosenthal",
|
||||
"categories": ["Business", "Technology"]
|
||||
},
|
||||
{
|
||||
"id": "discover-all-in",
|
||||
"title": "All-In Podcast",
|
||||
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
||||
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
|
||||
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
|
||||
"categories": ["Business", "Technology", "Politics"]
|
||||
},
|
||||
{
|
||||
"id": "discover-planet-money",
|
||||
"title": "Planet Money",
|
||||
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
|
||||
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["Business", "Economics"]
|
||||
},
|
||||
{
|
||||
"id": "discover-how-i-built-this",
|
||||
"title": "How I Built This with Guy Raz",
|
||||
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
|
||||
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
|
||||
"author": "NPR / Wondery",
|
||||
"categories": ["Business", "Technology"]
|
||||
},
|
||||
{
|
||||
"id": "discover-freakonomics",
|
||||
"title": "Freakonomics Radio",
|
||||
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
|
||||
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
|
||||
"author": "Stephen J. Dubner",
|
||||
"categories": ["Business", "Economics", "Society"]
|
||||
},
|
||||
{
|
||||
"id": "discover-darknet-diaries",
|
||||
"title": "Darknet Diaries",
|
||||
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
|
||||
"feedUrl": "https://podcast.darknetdiaries.com/",
|
||||
"author": "Jack Rhysider",
|
||||
"categories": ["Technology", "True Crime"]
|
||||
},
|
||||
{
|
||||
"id": "discover-changelog",
|
||||
"title": "The Changelog",
|
||||
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
|
||||
"feedUrl": "https://changelog.fm/rss",
|
||||
"author": "Changelog Media",
|
||||
"categories": ["Technology", "Software Engineering"]
|
||||
},
|
||||
{
|
||||
"id": "discover-twit",
|
||||
"title": "This Week in Tech (TWiT)",
|
||||
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
|
||||
"feedUrl": "https://feeds.twit.tv/twit.xml",
|
||||
"author": "TWiT",
|
||||
"categories": ["Technology"]
|
||||
},
|
||||
{
|
||||
"id": "discover-radiolab",
|
||||
"title": "Radiolab",
|
||||
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
|
||||
"feedUrl": "http://feeds.wnyc.org/radiolab",
|
||||
"author": "WNYC Studios",
|
||||
"categories": ["Science", "Storytelling"]
|
||||
},
|
||||
{
|
||||
"id": "discover-huberman-lab",
|
||||
"title": "Huberman Lab",
|
||||
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
|
||||
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
|
||||
"author": "Scicomm Media",
|
||||
"categories": ["Health", "Science"]
|
||||
},
|
||||
{
|
||||
"id": "discover-skeptics-guide",
|
||||
"title": "The Skeptics' Guide to the Universe",
|
||||
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
|
||||
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
|
||||
"author": "Steven Novella",
|
||||
"categories": ["Science", "Philosophy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-throughline",
|
||||
"title": "Throughline",
|
||||
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
|
||||
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["History", "Politics"]
|
||||
},
|
||||
{
|
||||
"id": "discover-hardcore-history",
|
||||
"title": "Dan Carlin's Hardcore History",
|
||||
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
|
||||
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
|
||||
"author": "Dan Carlin",
|
||||
"categories": ["History"]
|
||||
},
|
||||
{
|
||||
"id": "discover-history-of-rome",
|
||||
"title": "The History of Rome",
|
||||
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
|
||||
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
|
||||
"author": "Mike Duncan",
|
||||
"categories": ["History"]
|
||||
},
|
||||
{
|
||||
"id": "discover-philosophize-this",
|
||||
"title": "Philosophize This!",
|
||||
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
||||
"feedUrl": "https://philosophizethis.libsyn.com/rss",
|
||||
"author": "Stephen West",
|
||||
"categories": ["Philosophy", "Education"]
|
||||
},
|
||||
{
|
||||
"id": "discover-very-bad-wizards",
|
||||
"title": "Very Bad Wizards",
|
||||
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
||||
"feedUrl": "https://feeds.libsyn.com/474285/rss",
|
||||
"author": "Tamler Sommers & David Pizarro",
|
||||
"categories": ["Philosophy", "Science"]
|
||||
},
|
||||
{
|
||||
"id": "discover-big-picture",
|
||||
"title": "The Big Picture",
|
||||
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
|
||||
"feedUrl": "https://rss.art19.com/the-big-picture",
|
||||
"author": "The Ringer",
|
||||
"categories": ["Film", "Entertainment"]
|
||||
},
|
||||
{
|
||||
"id": "discover-all-songs-considered",
|
||||
"title": "All Songs Considered",
|
||||
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
|
||||
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
|
||||
"author": "NPR Music",
|
||||
"categories": ["Music"]
|
||||
},
|
||||
{
|
||||
"id": "discover-switched-on-pop",
|
||||
"title": "Switched on Pop",
|
||||
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
|
||||
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
|
||||
"author": "Vox Media / Panoply",
|
||||
"categories": ["Music"]
|
||||
},
|
||||
{
|
||||
"id": "discover-hit-parade",
|
||||
"title": "Hit Parade",
|
||||
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
|
||||
"feedUrl": "https://feeds.megaphone.fm/hitparade",
|
||||
"author": "Slate",
|
||||
"categories": ["Music", "History"]
|
||||
},
|
||||
{
|
||||
"id": "discover-song-exploder",
|
||||
"title": "Song Exploder",
|
||||
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
|
||||
"feedUrl": "https://songexploder.net/rss",
|
||||
"author": "Hrishikesh Hirway",
|
||||
"categories": ["Music", "Arts"]
|
||||
},
|
||||
{
|
||||
"id": "discover-blank-check",
|
||||
"title": "Blank Check with Griffin & David",
|
||||
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
|
||||
"feedUrl": "https://audioboom.com/channels/4278829.rss",
|
||||
"author": "Griffin Newman & David Sims",
|
||||
"categories": ["Film", "Comedy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-99-invisible",
|
||||
"title": "99% Invisible",
|
||||
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
|
||||
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
|
||||
"author": "Roman Mars",
|
||||
"categories": ["Design", "Arts", "Culture"]
|
||||
},
|
||||
{
|
||||
"id": "discover-gastropod",
|
||||
"title": "Gastropod",
|
||||
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
|
||||
"feedUrl": "https://gastropod.com/feed",
|
||||
"author": "Cynthia Graber & Nicola Twilley",
|
||||
"categories": ["Food", "Science", "History"]
|
||||
},
|
||||
{
|
||||
"id": "discover-this-american-life",
|
||||
"title": "This American Life",
|
||||
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
|
||||
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
|
||||
"author": "This American Life",
|
||||
"categories": ["Storytelling", "Culture"]
|
||||
},
|
||||
{
|
||||
"id": "discover-ted-talks-daily",
|
||||
"title": "TED Talks Daily",
|
||||
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
|
||||
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
|
||||
"author": "TED",
|
||||
"categories": ["Education", "Storytelling"]
|
||||
},
|
||||
{
|
||||
"id": "discover-tim-ferriss",
|
||||
"title": "The Tim Ferriss Show",
|
||||
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
|
||||
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
|
||||
"author": "Tim Ferriss",
|
||||
"categories": ["Self-Improvement", "Business"]
|
||||
},
|
||||
{
|
||||
"id": "discover-jordan-harbinger",
|
||||
"title": "The Jordan Harbinger Show",
|
||||
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
|
||||
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
|
||||
"author": "Jordan Harbinger",
|
||||
"categories": ["Self-Improvement"]
|
||||
},
|
||||
{
|
||||
"id": "discover-on-purpose",
|
||||
"title": "On Purpose with Jay Shetty",
|
||||
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
|
||||
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
|
||||
"author": "Jay Shetty",
|
||||
"categories": ["Self-Improvement", "Health"]
|
||||
},
|
||||
{
|
||||
"id": "discover-10-percent-happier",
|
||||
"title": "10% Happier with Dan Harris",
|
||||
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
|
||||
"feedUrl": "https://rss.art19.com/ten-percent-happier",
|
||||
"author": "Dan Harris",
|
||||
"categories": ["Self-Improvement", "Health", "Philosophy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-school-of-greatness",
|
||||
"title": "The School of Greatness",
|
||||
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
|
||||
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
|
||||
"author": "Lewis Howes",
|
||||
"categories": ["Self-Improvement", "Business"]
|
||||
},
|
||||
{
|
||||
"id": "discover-sysk",
|
||||
"title": "Stuff You Should Know",
|
||||
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
||||
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
|
||||
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
|
||||
"categories": ["Education", "Comedy"]
|
||||
},
|
||||
{
|
||||
"id": "discover-in-our-time",
|
||||
"title": "In Our Time",
|
||||
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
|
||||
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
|
||||
"author": "BBC Radio 4",
|
||||
"categories": ["History", "Education", "Philosophy"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,13 +8,13 @@
|
||||
"podtui": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun src/index.tsx",
|
||||
"dev": "bun --watch src/index.tsx",
|
||||
"start": "bun --preload @opentui/solid/preload src/index.tsx",
|
||||
"dev": "bun --preload @opentui/solid/preload --watch src/index.tsx",
|
||||
"build:native": "bash scripts/build-cavacore.sh",
|
||||
"build": "bun run build.ts",
|
||||
"dist": "bun dist/index.js",
|
||||
"test": "bun test",
|
||||
"lint": "bun run lint.ts"
|
||||
"lint": "bun tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
||||
@@ -19,36 +19,58 @@ mkdir -p "$OUT_DIR"
|
||||
OS="$(uname -s)"
|
||||
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 [ "$ARCH" = "arm64" ]; then
|
||||
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
|
||||
else
|
||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
|
||||
fi
|
||||
LIB_EXT="dylib"
|
||||
SHARED_FLAG="-dynamiclib"
|
||||
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
||||
LIB_EXT="dylib"
|
||||
SHARED_FLAG="-dynamiclib"
|
||||
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
||||
if [ "$ARCH" = "arm64" ]; then
|
||||
FFTW_HINTS="/opt/homebrew /usr/local"
|
||||
else
|
||||
FFTW_HINTS="/usr/local /opt/homebrew"
|
||||
fi
|
||||
else
|
||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr}"
|
||||
LIB_EXT="so"
|
||||
SHARED_FLAG="-shared"
|
||||
INSTALL_NAME=""
|
||||
LIB_EXT="so"
|
||||
SHARED_FLAG="-shared"
|
||||
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
|
||||
|
||||
FFTW_INCLUDE="$FFTW_PREFIX/include"
|
||||
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
|
||||
|
||||
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
|
||||
if [ ! -d "$FFTW_INCLUDE" ]; then
|
||||
FFTW_INCLUDE="$FFTW_PREFIX/include/$(basename "$(dirname "$FFTW_STATIC")")"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "Error: cavacore.c not found at $SRC"
|
||||
echo "Ensure the cava submodule is initialized: git submodule update --init"
|
||||
exit 1
|
||||
echo "Error: cavacore.c not found at $SRC"
|
||||
echo "The cava source is vendored under cava/ (from github.com/karlstav/cava, MIT)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT="$OUT_DIR/libcavacore.$LIB_EXT"
|
||||
@@ -59,21 +81,21 @@ echo " FFTW3: $FFTW_STATIC"
|
||||
echo " Output: $OUT"
|
||||
|
||||
cc -O2 \
|
||||
$SHARED_FLAG \
|
||||
$INSTALL_NAME \
|
||||
-fPIC \
|
||||
-I"$FFTW_INCLUDE" \
|
||||
-I"$ROOT/cava" \
|
||||
-o "$OUT" \
|
||||
"$SRC" \
|
||||
"$FFTW_STATIC" \
|
||||
-lm
|
||||
$SHARED_FLAG \
|
||||
$INSTALL_NAME \
|
||||
-fPIC \
|
||||
-I"$FFTW_INCLUDE" \
|
||||
-I"$ROOT/cava" \
|
||||
-o "$OUT" \
|
||||
"$SRC" \
|
||||
"$FFTW_STATIC" \
|
||||
-lm
|
||||
|
||||
echo "Built: $OUT"
|
||||
|
||||
# Verify exported symbols
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
echo ""
|
||||
echo "Exported symbols:"
|
||||
nm -gU "$OUT" | grep "cava_"
|
||||
echo ""
|
||||
echo "Exported symbols:"
|
||||
nm -gU "$OUT" | grep "cava_"
|
||||
fi
|
||||
|
||||
240
scripts/release-tag.sh
Executable file
240
scripts/release-tag.sh
Executable file
@@ -0,0 +1,240 @@
|
||||
#!/bin/bash
|
||||
|
||||
# release-tag.sh — PodTui version bump, commit, tag, and push.
|
||||
#
|
||||
# Mirrors the release flow from FlexLove's scripts/make-tag.sh, adapted for
|
||||
# PodTui's single version source (src/index.tsx) and dual remotes (gh, gt).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release-tag.sh interactive release
|
||||
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
|
||||
#
|
||||
# Pushing a v* tag to the `gh` remote triggers .github/workflows/release.yml
|
||||
# (4-platform tarball builds) — the release and the Homebrew tap update then
|
||||
# happen automatically and need no further local action.
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run | -n) DRY_RUN=1 ;;
|
||||
--help | -h)
|
||||
echo "Usage: scripts/release-tag.sh [--dry-run]"
|
||||
echo " --dry-run, -n show the plan without committing, tagging, or pushing"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: ${arg}${NC}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -d .git ] && [ ! -f .git ]; then
|
||||
echo -e "${RED}Error: Not in a git repository${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo -e "${YELLOW}You have uncommitted changes:${NC}"
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${RED}Aborted${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Current version from the latest tag; fall back to src/index.tsx.
|
||||
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
CURRENT_VERSION=$(grep -m 1 "^const VERSION" src/index.tsx | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo -e "${RED}Error: could not extract version from git tags or src/index.tsx${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${YELLOW}No tags found; using VERSION from src/index.tsx (${CURRENT_VERSION})${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
|
||||
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
|
||||
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
|
||||
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
|
||||
|
||||
echo -e "${CYAN}Select version bump type:${NC}"
|
||||
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH} → $((MAJOR + 1)).0.0"
|
||||
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.$((MINOR + 1)).0"
|
||||
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo " 4) Custom version"
|
||||
echo " 5) Cancel"
|
||||
echo ""
|
||||
read -p "Enter choice (1-5): " -n 1 -r CHOICE
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
case $CHOICE in
|
||||
1)
|
||||
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||
;;
|
||||
2)
|
||||
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||
;;
|
||||
3)
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
;;
|
||||
4)
|
||||
read -p "Enter custom version (e.g., 1.0.0-beta): " -r NEW_VERSION
|
||||
;;
|
||||
5)
|
||||
echo -e "${RED}Cancelled${NC}"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Version sanity check (tags are vMAJOR.MINOR.PATCH).
|
||||
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo -e "${RED}Error: ${NEW_VERSION} is not a valid X.Y.Z version (v tags only)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This will:${NC}"
|
||||
echo " 1. Set src/index.tsx → VERSION = \"${NEW_VERSION}\""
|
||||
echo " 2. Commit the bump"
|
||||
echo " 3. Create annotated tag v${NEW_VERSION}"
|
||||
echo " 4. Push master and the tag to every remote"
|
||||
REMOTES=$(git remote)
|
||||
for r in $REMOTES; do
|
||||
echo " → $r"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
||||
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}"
|
||||
echo ""
|
||||
read -p "Proceed? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo ""
|
||||
echo -e "${CYAN}[dry-run]${NC} would have:"
|
||||
echo " sed src/index.tsx: VERSION \"${CURRENT_VERSION}\" → \"${NEW_VERSION}\""
|
||||
echo " git commit -m \"bump VERSION to ${NEW_VERSION}\""
|
||||
echo " git tag -a v${NEW_VERSION} -m \"PodTUI v${NEW_VERSION}\""
|
||||
for r in $REMOTES; do echo " push $r master"; done
|
||||
for r in $REMOTES; do echo " push $r v${NEW_VERSION}"; done
|
||||
echo ""
|
||||
echo -e "${GREEN}Plan only — nothing written${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Apply the bump ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${CYAN}[1/4]${NC} Updating src/index.tsx..."
|
||||
sed -i.bak "s/const VERSION = \"[^\"]*\"/const VERSION = \"${NEW_VERSION}\"/" src/index.tsx
|
||||
rm -f src/index.tsx.bak
|
||||
echo -e "${GREEN}✓ src/index.tsx updated${NC}"
|
||||
|
||||
if git diff --quiet -- src/index.tsx; then
|
||||
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
|
||||
echo -e "${YELLOW}Already at ${NEW_VERSION} and tag v${NEW_VERSION} exists — nothing to release.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${YELLOW}VERSION is already ${NEW_VERSION} (bump already committed).${NC}"
|
||||
echo -e "${YELLOW}Will skip the commit and just create the missing tag + push.${NC}"
|
||||
read -p "Tag v${NEW_VERSION} on current HEAD and push? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
git add src/index.tsx
|
||||
echo -e "${GREEN}✓ staged${NC}"
|
||||
|
||||
echo -e "${CYAN}[2/4]${NC} Committing..."
|
||||
DEFAULT_COMMIT_MSG="bump VERSION to ${NEW_VERSION}"
|
||||
echo -e "Default commit message: ${CYAN}${DEFAULT_COMMIT_MSG}${NC}"
|
||||
read -p "Use default? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
read -p "Enter commit message: " -r COMMIT_MSG
|
||||
else
|
||||
COMMIT_MSG="$DEFAULT_COMMIT_MSG"
|
||||
fi
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo -e "${GREEN}✓ committed: ${COMMIT_MSG}${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}[3/4]${NC} Tagging..."
|
||||
git tag -a "v${NEW_VERSION}" -m "PodTUI v${NEW_VERSION}"
|
||||
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}[4/4]${NC} Pushing..."
|
||||
FAILED=""
|
||||
for r in $REMOTES; do
|
||||
if ! git push "$r" master; then
|
||||
FAILED="${FAILED}${r} (branch) "
|
||||
fi
|
||||
if ! git push "$r" tag "v${NEW_VERSION}"; then
|
||||
FAILED="${FAILED}${r} (tag) "
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||
echo -e "${RED}✗ Push failed for: ${FAILED}${NC}"
|
||||
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}The commit and tag exist locally. To retry:${NC}"
|
||||
for r in $REMOTES; do
|
||||
echo " git push ${r} master"
|
||||
echo " git push ${r} v${NEW_VERSION}"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}To undo:${NC}"
|
||||
echo " git tag -d v${NEW_VERSION}"
|
||||
echo " git reset --soft HEAD~1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}✓ PodTui v${NEW_VERSION} released${NC}"
|
||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION} → ${GREEN}${NEW_VERSION}${NC}"
|
||||
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
||||
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
||||
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula"
|
||||
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||
@@ -436,16 +436,9 @@ async function main() {
|
||||
}
|
||||
if (newAction) {
|
||||
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.
|
||||
delete process.env.PODTUI_AUDIO_BACKEND;
|
||||
await audioControls.switchBackend("mpv").catch(() => {});
|
||||
if (
|
||||
!audioControls.backendName() ||
|
||||
audioControls.backendName() === "none"
|
||||
) {
|
||||
await audioControls.switchBackend("afplay").catch(() => {});
|
||||
}
|
||||
}
|
||||
actions.push(newAction);
|
||||
saveActions(actions);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { emit } from "@/utils/event-bus";
|
||||
import { LayerGraph } from "@/utils/layer-graph";
|
||||
@@ -200,8 +199,16 @@ export function Shell() {
|
||||
|
||||
useKeyboard(
|
||||
(evt: any) => {
|
||||
// Input fields (search boxes, dialogs) own their keys.
|
||||
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return;
|
||||
// Input fields (search boxes, dialogs) own their keys — except Escape,
|
||||
// which defocuses the input so j/k/h navigation resumes (search: h back
|
||||
// to the tab root, j/k to move the recent-searches list).
|
||||
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
|
||||
if (evt.name === "escape") {
|
||||
evt.preventDefault();
|
||||
nav.setInputFocused(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nav.mode() === NavMode.COMMAND) {
|
||||
handleCommandKey(evt);
|
||||
return;
|
||||
@@ -460,8 +467,9 @@ export function playEpisodeAndSwitch(
|
||||
) {
|
||||
audio.play(episode);
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
|
||||
useAudioNavStore().setSource(AudioSource.FEED);
|
||||
}
|
||||
|
||||
// Re-export Episode type for callers building pane trees.
|
||||
export type { Episode };
|
||||
export type { Episode } from "@/types/episode";
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
|
||||
* PREVIEW flow (replaces the old fixed chrome tab column).
|
||||
*
|
||||
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
|
||||
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
|
||||
* `focused` only matters to the surrounding frame (the CURRENT column draws
|
||||
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
|
||||
* column (`muted`), the cursor highlight is suppressed and only the active ●
|
||||
* shows, so it reads as the read-only parent listing.
|
||||
* Renders one row per tab (digit + label) using the same selection UI every
|
||||
* other yazi pane uses: the CURSOR row (the one j/k hovers) gets a `❯` marker
|
||||
* and the focus background (`theme.primary` when this pane is the CURRENT
|
||||
* column, `theme.border` when it is the muted UP/parent column). The ACTIVE
|
||||
* tab (the one whose content is open) always carries a `●` marker in accent so
|
||||
* it stays readable in both positions.
|
||||
*
|
||||
* `muted` marks the parent-column rendering: the highlight is dimmed (border
|
||||
* bg, text fg) rather than suppressed, so the Up pane still shows the cursor
|
||||
* and active tab — matching how every other pane's parent column renders its
|
||||
* focused row.
|
||||
*/
|
||||
|
||||
import { For } from "solid-js";
|
||||
@@ -34,18 +39,32 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
const nav = useNavigation();
|
||||
|
||||
const cursor = () => nav.tabCursor();
|
||||
const active = () => nav.activeTab();
|
||||
const muted = () => props.muted ?? false;
|
||||
const activeTab = () => nav.activeTab();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
|
||||
// Same focus-bg / focus-fg contract every other pane uses.
|
||||
const focusBg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.primary
|
||||
: t === cursor()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active() ? theme.surface : theme.text;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab && !muted();
|
||||
const isActive = () => active() === tab;
|
||||
const fg = () =>
|
||||
const isCursor = () => cursor() === tab;
|
||||
const isActive = () => activeTab() === tab;
|
||||
// The active tab is only accented in the Up/parent position — when this
|
||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||
const labelFg = () =>
|
||||
isCursor()
|
||||
? theme.textSelectedPrimary
|
||||
: isActive()
|
||||
? focusFg(tab)
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
return (
|
||||
@@ -53,21 +72,13 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
backgroundColor={isCursor() ? theme.primary : "transparent"}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
|
||||
>
|
||||
{isActive() ? "●" : " "}
|
||||
</text>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
|
||||
>
|
||||
{tab}
|
||||
</text>
|
||||
<text fg={fg()} paddingLeft={1}>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 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.
|
||||
* carries the active-border focus ring when `focused` is truthy.
|
||||
* preview — detail of the hovered item in `current`; always muted border.
|
||||
*
|
||||
* The primitive is purely structural: callers pass their own JSX per column
|
||||
@@ -31,7 +31,7 @@
|
||||
* />
|
||||
*/
|
||||
|
||||
import { createMemo } from "solid-js";
|
||||
import { createMemo, Show } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
@@ -47,15 +47,19 @@ export type YaziPaneRowProps = {
|
||||
parent?: PaneContent;
|
||||
/** Current column content (the focused list). */
|
||||
current?: PaneContent;
|
||||
/** Preview column content (detail of the hovered item). */
|
||||
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||
preview?: PaneContent;
|
||||
parentLabel?: PaneLabel;
|
||||
currentLabel?: PaneLabel;
|
||||
previewLabel?: PaneLabel;
|
||||
/** Whether the current column carries the accent focus ring. Defaults to
|
||||
/** Whether the current column carries the active-border focus ring. Defaults to
|
||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
||||
* preview columns always render muted borders. */
|
||||
focused?: boolean | (() => boolean);
|
||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||
panes?: 2 | 3;
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -107,7 +111,12 @@ function YaziPane(props: {
|
||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||
|
||||
return (
|
||||
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
|
||||
<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>
|
||||
@@ -143,10 +152,10 @@ function YaziPane(props: {
|
||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
/** true → the current column gets the accent focus ring. */
|
||||
/** true → the current column gets the active-border focus ring. */
|
||||
const focused = createMemo(() => {
|
||||
const f = props.focused;
|
||||
return typeof f === "function" ? f() : f ?? true;
|
||||
return typeof f === "function" ? f() : (f ?? true);
|
||||
});
|
||||
|
||||
// Normalize static JSX and accessor children into reactive accessors
|
||||
@@ -159,6 +168,15 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
||||
|
||||
// 2-pane mode (parent|current) grows the current column to fill the
|
||||
// preview slot. Defaults to 3 (parent|current|preview).
|
||||
const panes = createMemo(() => props.panes ?? 3);
|
||||
const currentGrow = createMemo(() =>
|
||||
panes() === 2
|
||||
? PANE_RATIO.current + PANE_RATIO.preview
|
||||
: PANE_RATIO.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||
@@ -169,22 +187,24 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
|
||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.current}
|
||||
grow={currentGrow()}
|
||||
label={currentLabel}
|
||||
content={currentContent}
|
||||
borderColor={() => (focused() ? theme.accent : theme.border)}
|
||||
borderColor={() => (focused() ? theme.borderActive : 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}
|
||||
/>
|
||||
<Show when={panes() === 3}>
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
content={previewContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,31 +18,28 @@
|
||||
* nav model — which column is focused and where its list cursor lives. The
|
||||
* 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,
|
||||
* focusable pane at the left of every tab's content, just like in yazi.
|
||||
* Starting focus lives here; tab switches made from here keep focus here.
|
||||
* When it is focused, j/k moves the tab cursor (`tabCursor`) and
|
||||
* `l`/Enter opens the hovered tab into its content. Swiping left past
|
||||
* it goes out of the panes (inert — there is no pane beyond it).
|
||||
* • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
|
||||
* • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
|
||||
* that tab's content becomes CURRENT and its hovered item PREVIEW
|
||||
* (`enterTabContent`).
|
||||
* • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
|
||||
* own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
|
||||
* OUT of the 3-pane view.
|
||||
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
|
||||
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
|
||||
* stays (out of the panes — no-op).
|
||||
*
|
||||
* • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
|
||||
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The
|
||||
* parent column renders the previous depth's list (blank at depth 0); the
|
||||
* preview column renders the hovered item. `l`/Enter drills in (push a
|
||||
* frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus
|
||||
* to the tab list (TAB_PANE).
|
||||
* Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
|
||||
* ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
|
||||
* the parent/preview are derived. Search drills query→results; Player is a
|
||||
* single now-playing pane under the tab list (2-pane, no preview). Every
|
||||
* tab returns to the root via `h` at depth 0 (`backToTabRoot`).
|
||||
*
|
||||
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
|
||||
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the
|
||||
* 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).
|
||||
* Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
|
||||
* `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
|
||||
*/
|
||||
import { createSignal, batch } from "solid-js";
|
||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||
@@ -55,10 +52,9 @@ export enum NavMode {
|
||||
}
|
||||
|
||||
/** 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
|
||||
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
|
||||
* while focused on content resets `activePane` to this pane (unless already
|
||||
* on the tab list). */
|
||||
* (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
|
||||
* pane 0. A tab switch made while focused on content resets `activePane` to
|
||||
* this pane (unless already on the tab list). */
|
||||
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||
|
||||
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
||||
@@ -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
|
||||
* — 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. */
|
||||
/** 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
|
||||
|
||||
@@ -110,11 +99,11 @@ export function createNavigation() {
|
||||
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
||||
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
||||
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
||||
// App focus starts on the tab list (the app root). `activePane` drives the
|
||||
// fixed-pane pages (Search/Player) and each page's content focus ring;
|
||||
// depth-tab focus is instead described by the per-tab depth stack plus the
|
||||
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and
|
||||
// slides into the UP/parent pane once content is opened).
|
||||
// App focus starts on the tab list (the app root). `activePane` is always
|
||||
// DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
|
||||
// the `atRootTab` flag describe where focus sits (the tab is the CURRENT
|
||||
// pane when at the root, and slides into the UP/parent pane once content is
|
||||
// opened).
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
||||
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
||||
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
||||
@@ -128,9 +117,9 @@ export function createNavigation() {
|
||||
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
||||
);
|
||||
|
||||
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
|
||||
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
|
||||
// for pane 0 (DEPTH_CENTER_PANE) instead.
|
||||
// per-pane focused index map (unused by depth-tabs, which read/write the
|
||||
// top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
|
||||
// pages). Keyed by `${tab}:${pane}`.
|
||||
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
@@ -143,7 +132,7 @@ export function createNavigation() {
|
||||
const [commandBuffer, setCommandBuffer] = createSignal("");
|
||||
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 ensureStack = (tab: TABS) => {
|
||||
@@ -159,21 +148,17 @@ export function createNavigation() {
|
||||
* no-op (server build). Routing every tab change through this helper
|
||||
* keeps the behavior identical under both runtimes.
|
||||
*
|
||||
* - when switching to a special (fixed-pane) tab from the tab root, leave the
|
||||
* root — those tabs render only their content, never the tab-list view.
|
||||
* - keep focus on the tab root if it is focused (depth-tab switch),
|
||||
* otherwise recenter on the active tab's current/center pane
|
||||
* - a depth-tab switch from the root keeps the root (the tab list stays
|
||||
* CURRENT); switches made from inside content drop into the new tab's
|
||||
* current/center pane.
|
||||
* - clear mode/command/visual/count state */
|
||||
const applyTabSwitch = (tab: TABS) => {
|
||||
ensureStack(tab);
|
||||
batch(() => {
|
||||
// A depth-tab switch from the root keeps the root; switching to a
|
||||
// special (fixed-pane) tab always leaves it. Switches made from
|
||||
// inside content drop into the new tab's content pane.
|
||||
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) {
|
||||
setAtTabRoot(false);
|
||||
}
|
||||
if (!atRootTabSignal()) {
|
||||
if (atRootTabSignal()) {
|
||||
// a depth-tab switch from the root keeps the root (focus stays on
|
||||
// the tab list); only entering content (enterTabContent) leaves it.
|
||||
} else {
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
}
|
||||
setMode(NavMode.NORMAL);
|
||||
@@ -256,23 +241,14 @@ export function createNavigation() {
|
||||
// ── pane focus ──────────────────────────────────────────────────────────
|
||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||
|
||||
/** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` =
|
||||
* -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;
|
||||
});
|
||||
};
|
||||
// (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
|
||||
|
||||
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
||||
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
||||
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings)
|
||||
* participate; Search & Player are special and always show their content. */
|
||||
const atRootTab = (): boolean =>
|
||||
atRootTabSignal() && DEPTH_TABS.has(activeTab());
|
||||
* with nothing above it. Applies to every tab: a depth-tab switch from
|
||||
* the root keeps it; entering content (`enterTabContent`) clears it; `h`
|
||||
* at content depth 0 regains it via `backToTabRoot`. */
|
||||
const atRootTab = (): boolean => atRootTabSignal();
|
||||
|
||||
/** Open the active tab's content: the tab slides from CURRENT into the
|
||||
* UP/parent pane and focus lands on the content's current pane. */
|
||||
@@ -306,9 +282,9 @@ export function createNavigation() {
|
||||
// ── per-pane focus index ────────────────────────────────────────────────
|
||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
||||
|
||||
/** For depth-tabs, pane 0 (the center/current pane) reads/writes
|
||||
* the top frame's focus. Other panes and fixed-pane tabs use the
|
||||
* per-pane index map. */
|
||||
/** For depth-tabs (every tab), pane 1 (DEPTH_CENTER_PANE) reads/writes
|
||||
* the top frame's focus. Other panes fall back to the per-pane index
|
||||
* map (unused by current pages). */
|
||||
const focusedIndex = (pane: PaneId = activePane()): number => {
|
||||
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
||||
return topFrame()?.focus ?? 0;
|
||||
@@ -497,7 +473,6 @@ export function createNavigation() {
|
||||
activateTabCursor,
|
||||
// pane focus
|
||||
setActivePane: setPane,
|
||||
swipe,
|
||||
// focus index
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
|
||||
@@ -12,331 +12,368 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import {
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "../utils/audio-player"
|
||||
import { emit, on } from "../utils/event-bus"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useProgressStore } from "../stores/progress"
|
||||
import { useMediaRegistry } from "../utils/media-registry"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Feed } from "../types/feed"
|
||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "../utils/audio-player";
|
||||
import { emit, on } from "../utils/event-bus";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useProgressStore } from "../stores/progress";
|
||||
import { useMediaRegistry } from "../utils/media-registry";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
|
||||
export interface AudioControls {
|
||||
// Signals (reactive getters)
|
||||
isPlaying: () => boolean
|
||||
position: () => number
|
||||
duration: () => number
|
||||
volume: () => number
|
||||
speed: () => number
|
||||
backendName: () => BackendName
|
||||
error: () => string | null
|
||||
currentEpisode: () => Episode | null
|
||||
availablePlayers: () => DetectedPlayer[]
|
||||
// Signals (reactive getters)
|
||||
isPlaying: () => boolean;
|
||||
position: () => number;
|
||||
duration: () => number;
|
||||
volume: () => number;
|
||||
speed: () => number;
|
||||
backendName: () => BackendName;
|
||||
error: () => string | null;
|
||||
currentEpisode: () => Episode | null;
|
||||
availablePlayers: () => DetectedPlayer[];
|
||||
|
||||
// Actions
|
||||
play: (episode: Episode) => Promise<void>
|
||||
pause: () => Promise<void>
|
||||
resume: () => Promise<void>
|
||||
togglePlayback: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
seek: (seconds: number) => Promise<void>
|
||||
seekRelative: (delta: number) => Promise<void>
|
||||
setVolume: (volume: number) => Promise<void>
|
||||
setSpeed: (speed: number) => Promise<void>
|
||||
switchBackend: (name: BackendName) => Promise<void>
|
||||
prev: () => Promise<void>
|
||||
next: () => Promise<void>
|
||||
// Actions
|
||||
play: (episode: Episode) => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
resume: () => Promise<void>;
|
||||
togglePlayback: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seek: (seconds: number) => Promise<void>;
|
||||
seekRelative: (delta: number) => Promise<void>;
|
||||
setVolume: (volume: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
switchBackend: (name: BackendName) => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
next: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Singleton state — shared across all components that call useAudio()
|
||||
let backend: AudioBackend | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let refCount = 0
|
||||
let pollCount = 0 // Counts poll ticks for throttling progress saves
|
||||
let backend: AudioBackend | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let refCount = 0;
|
||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||
|
||||
const [isPlaying, setIsPlaying] = createSignal(false)
|
||||
const [position, setPosition] = createSignal(0)
|
||||
const [duration, setDuration] = createSignal(0)
|
||||
const [volume, setVolume] = createSignal(0.7)
|
||||
const [speed, setSpeed] = createSignal(1)
|
||||
const [backendName, setBackendName] = createSignal<BackendName>("none")
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
|
||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
|
||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
||||
const [position, setPosition] = createSignal(0);
|
||||
const [duration, setDuration] = createSignal(0);
|
||||
const [volume, setVolume] = createSignal(0.7);
|
||||
const [speed, setSpeed] = createSignal(1);
|
||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
function ensureBackend(): AudioBackend {
|
||||
if (!backend) {
|
||||
const detected = detectPlayers()
|
||||
setAvailablePlayers(detected)
|
||||
backend = createAudioBackend()
|
||||
setBackendName(backend.name)
|
||||
}
|
||||
return backend
|
||||
if (!backend) {
|
||||
const detected = detectPlayers();
|
||||
setAvailablePlayers(detected);
|
||||
backend = createAudioBackend();
|
||||
setBackendName(backend.name);
|
||||
registerExitTeardown();
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
||||
// player (mpv). Without this hook those child processes
|
||||
// survive the host and keep playing audio after the TUI has quit. The
|
||||
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
||||
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
||||
// `exit` listeners.
|
||||
let exitTeardownRegistered = false;
|
||||
function registerExitTeardown(): void {
|
||||
if (exitTeardownRegistered) return;
|
||||
exitTeardownRegistered = true;
|
||||
const teardown = (): void => {
|
||||
stopPolling();
|
||||
try {
|
||||
backend?.dispose();
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
try {
|
||||
useMediaRegistry().clearNowPlaying();
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
};
|
||||
process.on("exit", teardown);
|
||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||
process.on(sig, () => {
|
||||
teardown();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling()
|
||||
pollCount = 0
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!backend || !isPlaying()) return
|
||||
try {
|
||||
const pos = await backend.getPosition()
|
||||
const dur = await backend.getDuration()
|
||||
setPosition(pos)
|
||||
if (dur > 0) setDuration(dur)
|
||||
stopPolling();
|
||||
pollCount = 0;
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!backend || !isPlaying()) return;
|
||||
try {
|
||||
const pos = await backend.getPosition();
|
||||
const dur = await backend.getDuration();
|
||||
setPosition(pos);
|
||||
if (dur > 0) setDuration(dur);
|
||||
|
||||
// Save progress every ~5 seconds (10 ticks * 500ms)
|
||||
pollCount++
|
||||
if (pollCount % 10 === 0) {
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore()
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
||||
// Save progress every ~5 seconds (10 ticks * 500ms)
|
||||
pollCount++;
|
||||
if (pollCount % 10 === 0) {
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
|
||||
// Update platform media position
|
||||
const media = useMediaRegistry()
|
||||
media.setPosition(pos)
|
||||
}
|
||||
}
|
||||
// Update platform media position
|
||||
const media = useMediaRegistry();
|
||||
media.setPosition(pos);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if backend stopped playing (track ended)
|
||||
if (!backend.isPlaying() && isPlaying()) {
|
||||
setIsPlaying(false)
|
||||
stopPolling()
|
||||
// Save final position on track end
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore()
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend may have been disposed
|
||||
}
|
||||
}, 500)
|
||||
// Check if backend stopped playing (track ended)
|
||||
if (!backend.isPlaying() && isPlaying()) {
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
// Save final position on track end
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend may have been disposed
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function play(episode: Episode): Promise<void> {
|
||||
const b = ensureBackend()
|
||||
setError(null)
|
||||
const b = ensureBackend();
|
||||
setError(null);
|
||||
|
||||
if (!episode.audioUrl) {
|
||||
setError("No audio URL for this episode")
|
||||
return
|
||||
}
|
||||
if (!episode.audioUrl) {
|
||||
setError("No audio URL for this episode");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
const progressStore = useProgressStore()
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed
|
||||
const vol = volume()
|
||||
const spd = storeSpeed || speed()
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const progressStore = useProgressStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id)
|
||||
let startPos = 0
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position
|
||||
}
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
let startPos = 0;
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position;
|
||||
}
|
||||
|
||||
await b.play(episode.audioUrl, {
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
startPosition: startPos > 0 ? startPos : undefined,
|
||||
})
|
||||
await b.play(episode.audioUrl, {
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
startPosition: startPos > 0 ? startPos : undefined,
|
||||
});
|
||||
|
||||
setCurrentEpisode(episode)
|
||||
setIsPlaying(true)
|
||||
setPosition(startPos)
|
||||
setSpeed(spd)
|
||||
if (episode.duration) setDuration(episode.duration)
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(true);
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry()
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: episode.podcastId,
|
||||
duration: episode.duration,
|
||||
})
|
||||
media.setPlaybackState(true)
|
||||
if (startPos > 0) media.setPosition(startPos)
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(true);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
startPolling()
|
||||
emit("player.play", { episodeId: episode.id })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed")
|
||||
setIsPlaying(false)
|
||||
}
|
||||
startPolling();
|
||||
emit("player.play", { episodeId: episode.id });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
if (!backend) return
|
||||
try {
|
||||
await backend.pause()
|
||||
setIsPlaying(false)
|
||||
stopPolling()
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
// Save progress on pause
|
||||
const progressStore = useProgressStore()
|
||||
progressStore.update(ep.id, position(), duration(), speed())
|
||||
emit("player.pause", { episodeId: ep.id })
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.pause();
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
// Save progress on pause
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
|
||||
// Update platform media controls
|
||||
const media = useMediaRegistry()
|
||||
media.setPlaybackState(false)
|
||||
media.setPosition(position())
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pause failed")
|
||||
}
|
||||
// Update platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pause failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
if (!backend) return
|
||||
try {
|
||||
await backend.resume()
|
||||
setIsPlaying(true)
|
||||
startPolling()
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id })
|
||||
const media = useMediaRegistry()
|
||||
media.setPlaybackState(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Resume failed")
|
||||
}
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.resume();
|
||||
setIsPlaying(true);
|
||||
startPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Resume failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (isPlaying()) {
|
||||
await pause()
|
||||
} else if (currentEpisode()) {
|
||||
await resume()
|
||||
}
|
||||
if (isPlaying()) {
|
||||
await pause();
|
||||
} else if (currentEpisode()) {
|
||||
await resume();
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
if (!backend) return
|
||||
try {
|
||||
// Save progress before stopping
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore()
|
||||
progressStore.update(ep.id, position(), duration(), speed())
|
||||
}
|
||||
await backend.stop()
|
||||
setIsPlaying(false)
|
||||
setPosition(0)
|
||||
setCurrentEpisode(null)
|
||||
stopPolling()
|
||||
emit("player.stop", {})
|
||||
if (!backend) return;
|
||||
try {
|
||||
// Save progress before stopping
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
stopPolling();
|
||||
emit("player.stop", {});
|
||||
|
||||
// Clear platform media controls
|
||||
const media = useMediaRegistry()
|
||||
media.clearNowPlaying()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Stop failed")
|
||||
}
|
||||
// Clear platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Stop failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seek(seconds: number): Promise<void> {
|
||||
if (!backend) return
|
||||
const clamped = Math.max(0, Math.min(seconds, duration()))
|
||||
try {
|
||||
await backend.seek(clamped)
|
||||
setPosition(clamped)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Seek failed")
|
||||
}
|
||||
if (!backend) return;
|
||||
const clamped = Math.max(0, Math.min(seconds, duration()));
|
||||
try {
|
||||
await backend.seek(clamped);
|
||||
setPosition(clamped);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Seek failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seekRelative(delta: number): Promise<void> {
|
||||
await seek(position() + delta)
|
||||
await seek(position() + delta);
|
||||
}
|
||||
|
||||
async function doSetVolume(vol: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(1, vol))
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setVolume(clamped)
|
||||
} catch {
|
||||
// Some backends can't change volume at runtime
|
||||
}
|
||||
}
|
||||
setVolume(clamped)
|
||||
const clamped = Math.max(0, Math.min(1, vol));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setVolume(clamped);
|
||||
} catch {
|
||||
// Some backends can't change volume at runtime
|
||||
}
|
||||
}
|
||||
setVolume(clamped);
|
||||
}
|
||||
|
||||
async function doSetSpeed(spd: number): Promise<void> {
|
||||
const clamped = Math.max(0.25, Math.min(3, spd))
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setSpeed(clamped)
|
||||
} catch {
|
||||
// Some backends can't change speed at runtime
|
||||
}
|
||||
}
|
||||
setSpeed(clamped)
|
||||
const clamped = Math.max(0.25, Math.min(3, spd));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setSpeed(clamped);
|
||||
} catch {
|
||||
// Some backends can't change speed at runtime
|
||||
}
|
||||
}
|
||||
setSpeed(clamped);
|
||||
|
||||
// Sync back to app store
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
appStore.updateSettings({ playbackSpeed: clamped })
|
||||
} catch {
|
||||
// Store may not be available
|
||||
}
|
||||
// Sync back to app store
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ playbackSpeed: clamped });
|
||||
} catch {
|
||||
// Store may not be available
|
||||
}
|
||||
}
|
||||
|
||||
async function switchBackend(name: BackendName): Promise<void> {
|
||||
const wasPlaying = isPlaying()
|
||||
const ep = currentEpisode()
|
||||
const pos = position()
|
||||
const vol = volume()
|
||||
const spd = speed()
|
||||
const wasPlaying = isPlaying();
|
||||
const ep = currentEpisode();
|
||||
const pos = position();
|
||||
const vol = volume();
|
||||
const spd = speed();
|
||||
|
||||
// Stop current backend
|
||||
if (backend) {
|
||||
stopPolling()
|
||||
backend.dispose()
|
||||
backend = null
|
||||
}
|
||||
// Stop current backend
|
||||
if (backend) {
|
||||
stopPolling();
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
backend = createAudioBackend(name)
|
||||
setBackendName(backend.name)
|
||||
setAvailablePlayers(detectPlayers())
|
||||
// Create new backend
|
||||
backend = createAudioBackend(name);
|
||||
setBackendName(backend.name);
|
||||
setAvailablePlayers(detectPlayers());
|
||||
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
await backend.play(ep.audioUrl, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
})
|
||||
setIsPlaying(true)
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed")
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
await backend.play(ep.audioUrl, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
});
|
||||
setIsPlaying(true);
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,183 +383,187 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
* Registers event bus listeners and cleans them up with onCleanup.
|
||||
*/
|
||||
export function useAudio(): AudioControls {
|
||||
// Initialize backend on first use
|
||||
ensureBackend()
|
||||
// Initialize backend on first use
|
||||
ensureBackend();
|
||||
|
||||
// Sync initial speed from app store
|
||||
if (refCount === 0) {
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed)
|
||||
}
|
||||
} catch {
|
||||
// Store may not be available yet
|
||||
}
|
||||
}
|
||||
// Sync initial speed from app store
|
||||
if (refCount === 0) {
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
} catch {
|
||||
// Store may not be available yet
|
||||
}
|
||||
}
|
||||
|
||||
refCount++
|
||||
refCount++;
|
||||
|
||||
// Listen for event bus commands (e.g. from other components)
|
||||
const unsubPlay = on("player.play", async (data) => {
|
||||
// External play requests — currently just tracks episodeId.
|
||||
// Episode lookup would require feed store integration.
|
||||
})
|
||||
// Listen for event bus commands (e.g. from other components)
|
||||
const unsubPlay = on("player.play", async (data) => {
|
||||
// External play requests — currently just tracks episodeId.
|
||||
// Episode lookup would require feed store integration.
|
||||
});
|
||||
|
||||
const unsubStop = on("player.stop", async () => {
|
||||
if (backend && isPlaying()) {
|
||||
await backend.stop()
|
||||
setIsPlaying(false)
|
||||
setPosition(0)
|
||||
setCurrentEpisode(null)
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
const unsubStop = on("player.stop", async () => {
|
||||
if (backend && isPlaying()) {
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
stopPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||
const unsubMediaToggle = on("media.toggle", async () => {
|
||||
await togglePlayback()
|
||||
})
|
||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||
const unsubMediaToggle = on("media.toggle", async () => {
|
||||
await togglePlayback();
|
||||
});
|
||||
|
||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
|
||||
})
|
||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
|
||||
})
|
||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||
await seekRelative(10)
|
||||
})
|
||||
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||
await seekRelative(10);
|
||||
});
|
||||
|
||||
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||
await seekRelative(-10)
|
||||
})
|
||||
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||
await seekRelative(-10);
|
||||
});
|
||||
|
||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
|
||||
await doSetSpeed(next)
|
||||
})
|
||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||
await doSetSpeed(next);
|
||||
});
|
||||
|
||||
const audioNav = useAudioNavStore();
|
||||
const feedStore = useFeedStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
} else {
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
} else {
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
||||
}
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.max(0, currentIndex - 1);
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.max(0, currentIndex - 1);
|
||||
|
||||
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.prev(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.prev(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
||||
}
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||
|
||||
if (newIndex >= 0 && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.next(newIndex);
|
||||
}
|
||||
}
|
||||
if (newIndex >= 0 && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.next(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
refCount--
|
||||
unsubPlay()
|
||||
unsubStop()
|
||||
unsubMediaToggle()
|
||||
unsubMediaVolUp()
|
||||
unsubMediaVolDown()
|
||||
unsubMediaSeekFwd()
|
||||
unsubMediaSeekBack()
|
||||
unsubMediaSpeed()
|
||||
onCleanup(() => {
|
||||
refCount--;
|
||||
unsubPlay();
|
||||
unsubStop();
|
||||
unsubMediaToggle();
|
||||
unsubMediaVolUp();
|
||||
unsubMediaVolDown();
|
||||
unsubMediaSeekFwd();
|
||||
unsubMediaSeekBack();
|
||||
unsubMediaSpeed();
|
||||
|
||||
if (refCount <= 0) {
|
||||
stopPolling()
|
||||
if (backend) {
|
||||
backend.dispose()
|
||||
backend = null
|
||||
}
|
||||
// Clear media registry on full teardown
|
||||
const media = useMediaRegistry()
|
||||
media.clearNowPlaying()
|
||||
if (refCount <= 0) {
|
||||
stopPolling();
|
||||
if (backend) {
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
// Clear media registry on full teardown
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
|
||||
refCount = 0
|
||||
}
|
||||
})
|
||||
refCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
speed,
|
||||
backendName,
|
||||
error,
|
||||
currentEpisode,
|
||||
availablePlayers,
|
||||
return {
|
||||
isPlaying,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
speed,
|
||||
backendName,
|
||||
error,
|
||||
currentEpisode,
|
||||
availablePlayers,
|
||||
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
stop,
|
||||
seek,
|
||||
seekRelative,
|
||||
setVolume: doSetVolume,
|
||||
setSpeed: doSetSpeed,
|
||||
switchBackend,
|
||||
prev,
|
||||
next,
|
||||
}
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
stop,
|
||||
seek,
|
||||
seekRelative,
|
||||
setVolume: doSetVolume,
|
||||
setSpeed: doSetSpeed,
|
||||
switchBackend,
|
||||
prev,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,43 +5,43 @@
|
||||
* regardless of which component is focused. Uses the event bus to
|
||||
* decouple key detection from audio control logic.
|
||||
*
|
||||
* Keys are only handled when an episode is loaded (or for play/pause,
|
||||
* always). This prevents accidental volume/seek changes when there's
|
||||
* nothing playing.
|
||||
* Volume and speed are app-level settings — adjustable with or without
|
||||
* an episode loaded (they apply to the next playback and persist). Seek
|
||||
* is playback-dependent, so it still requires a loaded episode.
|
||||
*/
|
||||
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { emit } from "../utils/event-bus"
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { emit } from "../utils/event-bus";
|
||||
|
||||
export type MediaKeyAction =
|
||||
| "media.toggle"
|
||||
| "media.volumeUp"
|
||||
| "media.volumeDown"
|
||||
| "media.seekForward"
|
||||
| "media.seekBackward"
|
||||
| "media.speedCycle"
|
||||
| "media.toggle"
|
||||
| "media.volumeUp"
|
||||
| "media.volumeDown"
|
||||
| "media.seekForward"
|
||||
| "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.
|
||||
}
|
||||
// Common terminal media keys — these overlap with Player.tsx local
|
||||
// bindings, but Player guards on `props.focused` so the global
|
||||
// handler fires independently when the player tab is *not* active.
|
||||
//
|
||||
// When Player IS focused both handlers fire, but since the audio
|
||||
// actions are idempotent (toggle = toggle, seek = additive) having
|
||||
// them called twice for the same keypress is avoided by the event
|
||||
// bus approach — the audio hook only processes event-bus events, and
|
||||
// Player.tsx calls audio methods directly. We therefore guard with
|
||||
// a "playerFocused" flag passed via options.
|
||||
};
|
||||
|
||||
export interface MultimediaKeysOptions {
|
||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||
playerFocused?: () => boolean
|
||||
/** When true, skip handling (text input has focus) */
|
||||
inputFocused?: () => boolean
|
||||
/** Whether an episode is currently loaded */
|
||||
hasEpisode?: () => boolean
|
||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||
playerFocused?: () => boolean;
|
||||
/** When true, skip handling (text input has focus) */
|
||||
inputFocused?: () => boolean;
|
||||
/** Whether an episode is currently loaded */
|
||||
hasEpisode?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,50 +49,47 @@ export interface MultimediaKeysOptions {
|
||||
* event bus. Call once at the app level (e.g. in App.tsx).
|
||||
*/
|
||||
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||
useKeyboard((key) => {
|
||||
// Don't intercept when a text input owns the keyboard
|
||||
if (options.inputFocused?.()) return
|
||||
useKeyboard((key) => {
|
||||
// Don't intercept when a text input owns the keyboard
|
||||
if (options.inputFocused?.()) return;
|
||||
|
||||
// Don't intercept when Player component handles its own keys
|
||||
if (options.playerFocused?.()) return
|
||||
// Don't intercept when Player component handles its own keys
|
||||
if (options.playerFocused?.()) return;
|
||||
|
||||
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||
if (key.ctrl || key.meta) return
|
||||
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||
if (key.ctrl || key.meta) return;
|
||||
|
||||
switch (key.name) {
|
||||
case "space":
|
||||
// Toggle play/pause — always valid (may start a loaded episode)
|
||||
emit("media.toggle", {})
|
||||
break
|
||||
switch (key.name) {
|
||||
case "space":
|
||||
// Toggle play/pause — always valid (may start a loaded episode)
|
||||
emit("media.toggle", {});
|
||||
break;
|
||||
|
||||
case "up":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.volumeUp", {})
|
||||
break
|
||||
case "up":
|
||||
emit("media.volumeUp", {});
|
||||
break;
|
||||
|
||||
case "down":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.volumeDown", {})
|
||||
break
|
||||
case "down":
|
||||
emit("media.volumeDown", {});
|
||||
break;
|
||||
|
||||
case "left":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.seekBackward", {})
|
||||
break
|
||||
case "left":
|
||||
if (!options.hasEpisode?.()) return;
|
||||
emit("media.seekBackward", {});
|
||||
break;
|
||||
|
||||
case "right":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.seekForward", {})
|
||||
break
|
||||
case "right":
|
||||
if (!options.hasEpisode?.()) return;
|
||||
emit("media.seekForward", {});
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.speedCycle", {})
|
||||
break
|
||||
case "s":
|
||||
emit("media.speedCycle", {});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not a media key — do nothing
|
||||
break
|
||||
}
|
||||
})
|
||||
default:
|
||||
// Not a media key — do nothing
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const VERSION = "0.1.0";
|
||||
const VERSION = "0.2.0";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
|
||||
Binary file not shown.
@@ -65,6 +65,12 @@ function DiscoverPage() {
|
||||
};
|
||||
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(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return categories()[i]?.id;
|
||||
@@ -181,7 +187,7 @@ function DiscoverPage() {
|
||||
<Show when={depth() === 0}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const lf = () => focusedCatIdx();
|
||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
<box
|
||||
@@ -189,19 +195,19 @@ function DiscoverPage() {
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
@@ -222,35 +228,37 @@ function DiscoverPage() {
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = focusedPodIdx();
|
||||
const lf = () => focusedPodIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text fg={index() === lf ? theme.surface : theme.success}>
|
||||
<text
|
||||
fg={index() === lf() ? theme.surface : theme.success}
|
||||
>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : muted()}
|
||||
fg={index() === lf() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* 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
|
||||
* virtual "All Feeds". Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||
* chronological). Parent pane = the feeds list (prev).
|
||||
* preview — detail of the hovered item in the current column.
|
||||
* depth 0 (current) — every episode from every feed, newest-first (the
|
||||
* combined view the old "All Feeds" virtual row used to
|
||||
* drill into). Parent pane shows the muted tab list.
|
||||
* preview — detail of the hovered episode.
|
||||
*
|
||||
* This page does NOT drill: the previous depth-1 "episodes of one feed" panel
|
||||
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
||||
* shows the full flat episodes list immediately.
|
||||
*
|
||||
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
||||
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
|
||||
* (push); `h` pops a depth (noop at 0). j/k move only within the current
|
||||
* column. The Shell router drives everything over `nav.action`; this page
|
||||
* only handles list/preview data.
|
||||
* 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";
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
NavMode,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
@@ -40,7 +40,6 @@ import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
@@ -52,57 +51,27 @@ function FeedPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// ── feeds list (depth 0) ─────────────────────────────────────────────────
|
||||
const feedList = createMemo<FeedListItem[]>(() => {
|
||||
const all: FeedListItem[] = [{ kind: "all" }];
|
||||
for (const f of feedStore.getFilteredFeeds())
|
||||
all.push({ kind: "feed", feed: f });
|
||||
return all;
|
||||
});
|
||||
const focusedFeedIdx = () =>
|
||||
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
|
||||
const focusedFeedItem = (): FeedListItem | undefined =>
|
||||
feedList()[focusedFeedIdx()];
|
||||
|
||||
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
|
||||
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
|
||||
const episodes = createMemo<EpItem[]>(() => {
|
||||
if (depth() < 1) return [];
|
||||
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 }));
|
||||
});
|
||||
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||
const episodes = createMemo<EpItem[]>(
|
||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||
);
|
||||
const focus = () => nav.depthFocus(0);
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
|
||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||
|
||||
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
|
||||
const curLen = () => episodes().length;
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
|
||||
nav.setDepthFocus(feedList().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
if (episodes().length > 0 && focus() >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 0);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) {
|
||||
const it = feedList()[i];
|
||||
return it?.kind === "feed" ? it.feed.podcast.id : "all";
|
||||
}
|
||||
return episodes()[i]?.episode.id;
|
||||
});
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -146,19 +115,9 @@ function FeedPage() {
|
||||
audioNav.setSource(AudioSource.FEED);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
// ── open ───────────────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const item = focusedFeedItem();
|
||||
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;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
|
||||
// ── nav.action handler ────────────────────────────────────────────────────
|
||||
@@ -173,16 +132,11 @@ function FeedPage() {
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
const item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
}
|
||||
const item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
},
|
||||
refresh: () => {
|
||||
const item = focusedFeedItem();
|
||||
if (item?.kind === "feed")
|
||||
feedStore.refreshFeed(item.feed.id).catch(() => {});
|
||||
else feedStore.refreshAllFeeds().catch(() => {});
|
||||
feedStore.refreshAllFeeds().catch(() => {});
|
||||
},
|
||||
};
|
||||
function step(delta: number) {
|
||||
@@ -205,7 +159,7 @@ function FeedPage() {
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
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) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
@@ -215,265 +169,132 @@ function FeedPage() {
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
|
||||
const feedLabel = (item: FeedListItem) =>
|
||||
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 = () => `Feed · ${episodes().length}`;
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Feeds · ${feedList().length - 1}`
|
||||
: `${(() => {
|
||||
const fi = focusedFeedItem();
|
||||
return fi?.kind === "feed"
|
||||
? fi.feed.customName || fi.feed.podcast.title
|
||||
: "All Episodes";
|
||||
})()} · ${episodes().length}`;
|
||||
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
|
||||
const parentContent = () => <TabListPane muted />;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
|
||||
// mounts/unmounts cleanly on depth change instead of swapping roots.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={feedList()}>
|
||||
// ── current pane: the flat episodes list (the only focusable column) ──────
|
||||
const currentContent = () => (
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
const fi = () => focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={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)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
|
||||
<text fg={muted()}>({feedCount(item)})</text>
|
||||
<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>
|
||||
);
|
||||
|
||||
// ── current pane: the current-depth list (the only focusable column) ──────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
||||
const previewContent = () => (
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</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>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── 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 (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
|
||||
parentLabel="Up"
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
|
||||
@@ -204,19 +204,19 @@ export function MyShowsPage() {
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
const lf = () => nav.depthFocus(0);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, false)}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), false)}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</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>
|
||||
</box>
|
||||
);
|
||||
@@ -242,26 +242,26 @@ export function MyShowsPage() {
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = focusedShowIdx();
|
||||
const lf = () => focusedShowIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
@@ -282,33 +282,33 @@ export function MyShowsPage() {
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = focusedEpIdx();
|
||||
const lf = () => focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.info}>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
|
||||
@@ -1,64 +1,85 @@
|
||||
import type { BackendName } from "../utils/audio-player"
|
||||
import { useTheme } from "@/context/ThemeContext"
|
||||
import type { BackendName } from "@/utils/audio-player";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
type PlaybackControlsProps = {
|
||||
isPlaying: boolean
|
||||
volume: number
|
||||
speed: number
|
||||
backendName?: BackendName
|
||||
hasAudioUrl?: boolean
|
||||
onToggle: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onVolumeChange: (value: number) => void
|
||||
onSpeedChange: (value: number) => void
|
||||
}
|
||||
isPlaying: boolean;
|
||||
volume: number;
|
||||
speed: number;
|
||||
backendName?: BackendName;
|
||||
hasAudioUrl?: boolean;
|
||||
onToggle: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onVolumeChange: (value: number) => void;
|
||||
onSpeedChange: (value: number) => void;
|
||||
};
|
||||
|
||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||
mpv: "mpv",
|
||||
ffplay: "ffplay",
|
||||
afplay: "afplay",
|
||||
system: "system",
|
||||
none: "none",
|
||||
}
|
||||
mpv: "mpv",
|
||||
none: "none",
|
||||
};
|
||||
|
||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}>
|
||||
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}>
|
||||
<text fg={theme.primary}>[Prev]</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
|
||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
|
||||
<text fg={theme.primary}>[Next]</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Vol</text>
|
||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Speed</text>
|
||||
<text fg={theme.text}>{props.speed}x</text>
|
||||
</box>
|
||||
{props.backendName && props.backendName !== "none" && (
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>via</text>
|
||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
||||
</box>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
border
|
||||
padding={1}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onPrev}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary}>[Prev]</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onToggle}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onNext}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary}>[Next]</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Vol</text>
|
||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||
<text fg={theme.textMuted}>↑↓</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Speed</text>
|
||||
<text fg={theme.text}>{props.speed}x</text>
|
||||
<text fg={theme.textMuted}>s</text>
|
||||
</box>
|
||||
{props.backendName && props.backendName !== "none" && (
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>via</text>
|
||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
||||
</box>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* 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
|
||||
* Shell router (P/N/B/</>). This page renders a single rich pane showing the
|
||||
* current episode, waveform, and playback controls. Panes/swipe do nothing
|
||||
* (PaneCount=1).
|
||||
* depth 0 (parent) — tab list (muted, read-only).
|
||||
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
||||
*
|
||||
* No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause,
|
||||
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
||||
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
||||
* tab root.
|
||||
*/
|
||||
|
||||
import { Show } from "solid-js";
|
||||
@@ -13,7 +16,9 @@ import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const PlayerPaneCount = 1;
|
||||
|
||||
@@ -23,9 +28,7 @@ export function PlayerPage() {
|
||||
const nav = useNavigation();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
|
||||
// Single pane — always active.
|
||||
const isActive = () => true;
|
||||
const border = () => theme.accent;
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
|
||||
const progressPercent = () => {
|
||||
const d = audio.duration();
|
||||
@@ -39,84 +42,87 @@ export function PlayerPage() {
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
{/* ── pane 0: now playing ─────────────────────────────────────────── */}
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Player</text>
|
||||
// ── parent pane: the tab list (muted) ──────────────────────────────────────
|
||||
const parentContent = () => <TabListPane muted />;
|
||||
|
||||
// ── current pane: now playing ───────────────────────────────────────────────
|
||||
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>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive()}
|
||||
border
|
||||
borderColor={border()}
|
||||
backgroundColor={theme.background}
|
||||
|
||||
<Show when={audio.error()}>
|
||||
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={audio.currentEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode loaded.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>Now Playing</strong>
|
||||
<strong>{ep().title}</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||
{progressPercent()}%)
|
||||
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||
</text>
|
||||
|
||||
<RealtimeWaveform
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
// bars is width-derived in RealtimeWaveform; pass only the
|
||||
// audio-processing params here.
|
||||
return {
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={audio.error()}>
|
||||
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||
</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)}
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={audio.currentEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode loaded.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(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 height={1} />
|
||||
<text fg={muted()}>
|
||||
{"P play/pause N next B prev ◀▶ seek h back"}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
parentLabel="Up"
|
||||
currentLabel="Player"
|
||||
panes={2}
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,32 +8,34 @@
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import {
|
||||
loadCavaCore,
|
||||
type CavaCore,
|
||||
type CavaCoreConfig,
|
||||
loadCavaCore,
|
||||
type CavaCore,
|
||||
type CavaCoreConfig,
|
||||
} from "@/utils/cavacore";
|
||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type RealtimeWaveformProps = {
|
||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||
};
|
||||
|
||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||
const BARS = [
|
||||
" ",
|
||||
"\u2581",
|
||||
"\u2582",
|
||||
"\u2583",
|
||||
"\u2584",
|
||||
"\u2585",
|
||||
"\u2586",
|
||||
"\u2587",
|
||||
"\u2588",
|
||||
" ",
|
||||
"\u2581",
|
||||
"\u2582",
|
||||
"\u2583",
|
||||
"\u2584",
|
||||
"\u2585",
|
||||
"\u2586",
|
||||
"\u2587",
|
||||
"\u2588",
|
||||
];
|
||||
|
||||
/** Target frame interval in ms (~30 fps) */
|
||||
@@ -45,212 +47,231 @@ const SAMPLES_PER_FRAME = 512;
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const { theme } = useTheme();
|
||||
const audio = useAudio();
|
||||
const { theme } = useTheme();
|
||||
const audio = useAudio();
|
||||
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
|
||||
// Track whether cavacore is available
|
||||
const [available, setAvailable] = createSignal(false);
|
||||
let cava: CavaCore | null = null;
|
||||
let reader: AudioStreamReader | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
|
||||
let cava: CavaCore | null = null;
|
||||
let reader: AudioStreamReader | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
// Bar count scales with terminal width so the waveform fills its pane.
|
||||
// The player is a 2-pane row: current column = (current+preview) of
|
||||
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
|
||||
// chrome (scrollbox border + box padding + waveform border + padding).
|
||||
// Falls back to 64 before the renderer reports a real size.
|
||||
const dimensions = useTerminalDimensions();
|
||||
const numBars = () => {
|
||||
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
|
||||
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
|
||||
const width = dimensions().width;
|
||||
if (!width) return 64;
|
||||
return Math.max(
|
||||
8,
|
||||
Math.min(256, Math.floor((width * current) / total) - 8),
|
||||
);
|
||||
};
|
||||
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
|
||||
const initCava = () => {
|
||||
if (cava) return true;
|
||||
const initCava = () => {
|
||||
if (cava) return true;
|
||||
|
||||
cava = loadCavaCore();
|
||||
if (!cava) {
|
||||
setAvailable(false);
|
||||
return false;
|
||||
}
|
||||
cava = loadCavaCore();
|
||||
if (!cava) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAvailable(true);
|
||||
return true;
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
|
||||
const startVisualization = (url: string, position: number, speed: number) => {
|
||||
stopVisualization();
|
||||
const startVisualization = (url: string, position: number, speed: number) => {
|
||||
stopVisualization();
|
||||
|
||||
if (!url || !initCava() || !cava) return;
|
||||
if (!url || !initCava() || !cava) return;
|
||||
|
||||
// Initialize cavacore with current resolution + any overrides
|
||||
const config: CavaCoreConfig = {
|
||||
bars: 32,
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
...props.visualizerConfig,
|
||||
};
|
||||
cava.init(config);
|
||||
// Initialize cavacore with current resolution + any overrides.
|
||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||
const config: CavaCoreConfig = {
|
||||
bars: numBars(),
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
...props.visualizerConfig,
|
||||
};
|
||||
cava.init(config);
|
||||
|
||||
// Pre-allocate sample read buffer
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||
// 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 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);
|
||||
};
|
||||
// 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;
|
||||
};
|
||||
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) ─────────────────────────────────
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||
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;
|
||||
// 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);
|
||||
// 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));
|
||||
};
|
||||
// Copy bar values to a new array for the signal
|
||||
setBarData(Array.from(output as Float64Array));
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
audio.isPlaying,
|
||||
() => audio.currentEpisode()?.audioUrl ?? "",
|
||||
audio.speed,
|
||||
numBars,
|
||||
],
|
||||
([playing, url, speed]) => {
|
||||
if (playing && url) {
|
||||
const pos = untrack(audio.position);
|
||||
startVisualization(url, pos, speed);
|
||||
} else {
|
||||
stopVisualization();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
||||
//
|
||||
// Watches position and restarts the reader (not the whole pipeline)
|
||||
// only on significant jumps (>2s), which indicate a user seek.
|
||||
// This is intentionally a separate effect — it should NOT trigger a
|
||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
||||
// ── 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;
|
||||
}
|
||||
let lastSyncPosition = 0;
|
||||
createEffect(
|
||||
on(audio.position, (pos) => {
|
||||
if (!audio.isPlaying || !reader?.running) {
|
||||
lastSyncPosition = pos;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Math.abs(pos - lastSyncPosition);
|
||||
lastSyncPosition = pos;
|
||||
const delta = Math.abs(pos - lastSyncPosition);
|
||||
lastSyncPosition = pos;
|
||||
|
||||
if (delta > 2) {
|
||||
reader.restart(pos, audio.speed() ?? 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopVisualization();
|
||||
if (reader) {
|
||||
reader.stop();
|
||||
reader = null;
|
||||
}
|
||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
||||
if (cava?.isReady) {
|
||||
cava.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
const playedRatio = () =>
|
||||
audio.duration() <= 0
|
||||
? 0
|
||||
: Math.min(1, audio.position() / audio.duration());
|
||||
const playedRatio = () =>
|
||||
audio.duration() <= 0
|
||||
? 0
|
||||
: Math.min(1, audio.position() / audio.duration());
|
||||
|
||||
const renderLine = () => {
|
||||
const bars = barData();
|
||||
const numBars = 32;
|
||||
const renderLine = () => {
|
||||
const bars = barData();
|
||||
const count = numBars();
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
// If no data yet, show empty placeholder
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(count);
|
||||
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 played = Math.floor(count * 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 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("");
|
||||
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>
|
||||
);
|
||||
};
|
||||
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);
|
||||
};
|
||||
const handleClick = (event: { x: number }) => {
|
||||
const count = numBars();
|
||||
const ratio = event.x / count;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
||||
);
|
||||
audio.seek(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
|
||||
{renderLine()}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={1}
|
||||
onMouseDown={handleClick}
|
||||
>
|
||||
{renderLine()}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* SearchPage — yazi-style 3-pane view.
|
||||
* SearchPage — yazi depth-stack view of podcast search.
|
||||
*
|
||||
* pane 1 (parent) — query input with recent-search history (clickable)
|
||||
* pane 2 (current) — search results list (navigate j/k)
|
||||
* pane 3 (preview) — detail of the focused search result
|
||||
* depth 0 (current) — query input row + recent-searches list (navigable
|
||||
* with j/k when the input is defocused). Parent pane
|
||||
* shows the tab list (muted); preview shows a hint.
|
||||
* depth 1 (current) — search results list. Parent pane shows the submitted
|
||||
* query (muted, read-only); preview shows the detail of
|
||||
* the focused result.
|
||||
*
|
||||
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
|
||||
* on tab enter so the user lands on the results pane. Swipe left (h) to pane
|
||||
* 1 to type a query — the Shell
|
||||
* router skips keys while `nav.inputFocused()` is true so the `<input>`
|
||||
* element captures typing natively. Press Enter (onSubmit) to search and
|
||||
* auto-swipe to the results pane.
|
||||
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||||
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||
* input (or on a focused recent at depth 0) submits the query and pushes to
|
||||
* depth 1 (results). `h` pops: results→query, query→tab root.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -23,137 +25,158 @@ import {
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const SearchPaneCount = 3;
|
||||
export const SearchPaneCount = 1;
|
||||
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
const feedStore = useFeedStore();
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const INPUT = PaneSlot.PARENT; // 1 (input row)
|
||||
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
|
||||
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// depth 1's ctx carries the submitted query string.
|
||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||
|
||||
// ── input focusing ────────────────────────────────────────────────────────
|
||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||||
//
|
||||
// Typing is the default only on the query depth (0); the results depth
|
||||
// (1) is always list-navigation. Drive `inputFocused` straight off
|
||||
// `depth()` rather than seeding it `true` on mount and patching on change:
|
||||
// the depth stack persists across tab switches, so re-mounting this page
|
||||
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
|
||||
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
|
||||
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
||||
// scroll responds.
|
||||
//
|
||||
// The effect only re-runs on a depth transition, so Escape (defocus) and
|
||||
// `s` (refocus) at the same depth are not clobbered.
|
||||
onMount(() => nav.setInputFocused(depth() === 0));
|
||||
onCleanup(() => nav.setInputFocused(false));
|
||||
createEffect(() => {
|
||||
nav.setInputFocused(depth() === 0);
|
||||
});
|
||||
|
||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||
const results = () => searchStore.results();
|
||||
|
||||
// The focused result tracks pane 1's focused row.
|
||||
const focusedResultIdx = () =>
|
||||
results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
|
||||
const focusedResult = createMemo(() => {
|
||||
const list = results();
|
||||
if (list.length === 0) return undefined;
|
||||
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1);
|
||||
return list[idx];
|
||||
return list[focusedResultIdx()];
|
||||
});
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by result id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => results()[i]?.podcast.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => results()[i]?.podcast.id,
|
||||
);
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
// ── recents (depth 0) ────────────────────────────────────────────────────
|
||||
const recents = () => searchStore.history();
|
||||
const curLen = () => (depth() === 0 ? recents().length : results().length);
|
||||
|
||||
// Keep results focus in range after searches complete.
|
||||
const ensureFocus = () => {
|
||||
const list = results();
|
||||
if (list.length === 0) return;
|
||||
const cur = nav.focusedIndex(RESULTS);
|
||||
if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
|
||||
if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
|
||||
nav.setDepthFocus(results().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
// ── input pane: set inputFocused so Shell router yields keys to <input> ─────
|
||||
createEffect(() => {
|
||||
const isInputPane = nav.activePane() === INPUT;
|
||||
nav.setInputFocused(isInputPane);
|
||||
});
|
||||
// Register a visual-mode resolver for the results list (depth 1).
|
||||
onMount(() => {
|
||||
onCleanup(() => nav.setInputFocused(false));
|
||||
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
||||
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
const handleSubmit = () => {
|
||||
const query = inputValue().trim();
|
||||
if (!query) return;
|
||||
searchStore.search(query).catch(() => {});
|
||||
nav.setFocusedIndex(RESULTS, 0);
|
||||
nav.setActivePane(RESULTS);
|
||||
const runSearch = (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
searchStore.search(q).catch(() => {});
|
||||
nav.pushDepth({
|
||||
kind: "search:results",
|
||||
ctx: q,
|
||||
focus: 0,
|
||||
} as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
};
|
||||
|
||||
const handleHistorySelect = (query: string) => {
|
||||
const handleSubmit = () => runSearch(inputValue());
|
||||
|
||||
const selectRecent = (query: string) => {
|
||||
setInputValue(query);
|
||||
searchStore.search(query).catch(() => {});
|
||||
nav.setFocusedIndex(RESULTS, 0);
|
||||
nav.setActivePane(RESULTS);
|
||||
runSearch(query);
|
||||
};
|
||||
|
||||
const handleSubscribe = (result: SearchResult) => {
|
||||
// Actually add the feed to the feed store, then mark the result subscribed
|
||||
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
||||
searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === RESULTS || p === DETAIL) {
|
||||
const result = focusedResult();
|
||||
if (result) handleSubscribe(result);
|
||||
}
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === RESULTS) {
|
||||
const result = focusedResult();
|
||||
if (result) nav.toggleSelected(result.podcast.id);
|
||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||
"move-down": () => step(1),
|
||||
"move-up": () => step(-1),
|
||||
"jump-down": () => step(5),
|
||||
"jump-up": () => step(-5),
|
||||
"page-down": () => step(10),
|
||||
"page-up": () => step(-10),
|
||||
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() === 1) {
|
||||
const r = focusedResult();
|
||||
if (r) nav.toggleSelected(r.podcast.id);
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
nav.setActivePane(INPUT);
|
||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||
if (depth() === 0) nav.setInputFocused(true);
|
||||
},
|
||||
refresh: () => {
|
||||
if (inputValue().trim()) {
|
||||
searchStore.search(inputValue().trim()).catch(() => {});
|
||||
}
|
||||
const q = submittedQuery() || inputValue().trim();
|
||||
if (q) searchStore.search(q).catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === RESULTS) return results().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
// Enter/l on a focused recent search → submit it and drill to results.
|
||||
const list = recents();
|
||||
const idx = Math.min(focus(0), list.length - 1);
|
||||
const q = list[idx];
|
||||
if (q) selectRecent(q);
|
||||
return;
|
||||
}
|
||||
if (depth() === 1) {
|
||||
const r = focusedResult();
|
||||
if (r) handleSubscribe(r);
|
||||
}
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
@@ -161,139 +184,146 @@ function SearchPage() {
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
onCleanup(() => off("nav.action", onAction));
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const inputActive = () => nav.inputFocused() && depth() === 0;
|
||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
: i === listFocus
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0: query input ──────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Search</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={false}
|
||||
border
|
||||
borderColor={border(INPUT)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={muted()}>Query:</text>
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onSubmit={() => handleSubmit()}
|
||||
placeholder="Enter podcast name..."
|
||||
focused={isActive(INPUT)}
|
||||
width={28}
|
||||
/>
|
||||
</box>
|
||||
<text fg={muted()}>Enter to search · h/l: panes</text>
|
||||
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text fg={theme.warning}>Searching...</text>
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text fg={theme.error}>{searchStore.error()}</text>
|
||||
</Show>
|
||||
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent</text>
|
||||
<Show
|
||||
when={searchStore.history().length > 0}
|
||||
fallback={<text fg={muted()}>No recent searches</text>}
|
||||
>
|
||||
<For each={searchStore.history().slice(0, 12)}>
|
||||
{(query) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
onMouseDown={() => handleHistorySelect(query)}
|
||||
>
|
||||
<text fg={muted()}>
|
||||
{">"} {query}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textSecondary}>Query</text>
|
||||
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>h: back to query</text>
|
||||
</box>
|
||||
</Show>
|
||||
);
|
||||
|
||||
{/* ── pane 1: results ──────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Results · {results().length}</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(RESULTS)}
|
||||
border
|
||||
borderColor={border(RESULTS)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
// ── current pane ────────────────────────────────────────────────────────────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
{/* query input row + recent searches */}
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={muted()}>Query:</text>
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onSubmit={() => handleSubmit()}
|
||||
placeholder="Enter podcast name..."
|
||||
focused={inputActive()}
|
||||
width={28}
|
||||
/>
|
||||
</box>
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text fg={theme.warning}>Searching...</text>
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text fg={theme.error}>{searchStore.error()}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent</text>
|
||||
<Show
|
||||
when={results().length > 0}
|
||||
when={recents().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{inputActive()
|
||||
? "Enter to search"
|
||||
: "s to type · Enter to search"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<For each={results()}>
|
||||
{(result, index) => (
|
||||
<For each={recents()}>
|
||||
{(query, index) => {
|
||||
const lf = () => focus(0);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
{inputActive()
|
||||
? "Enter to search · Esc to defocus"
|
||||
: "j/k recents · s to type · h back"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={depth() >= 1}>
|
||||
{/* results list */}
|
||||
<Show
|
||||
when={results().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={results()}>
|
||||
{(result, index) => {
|
||||
const fi = () => focusedResultIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), RESULTS)}
|
||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(RESULTS);
|
||||
nav.setFocusedIndex(RESULTS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), RESULTS)}>
|
||||
{index() === nav.focusedIndex(RESULTS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{index() === fi() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), RESULTS)}>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{result.podcast.title}
|
||||
</text>
|
||||
<Show when={result.podcast.isSubscribed}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: theme.success
|
||||
}
|
||||
fg={index() === fi() ? theme.surface : theme.success}
|
||||
>
|
||||
[+]
|
||||
</text>
|
||||
@@ -301,95 +331,103 @@ function SearchPage() {
|
||||
</box>
|
||||
<Show when={result.podcast.author}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {result.podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── preview pane ────────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>Search</strong>
|
||||
</text>
|
||||
<text fg={muted()}>Type a query, press Enter to search.</text>
|
||||
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||
<For each={recents().slice(0, 6)}>
|
||||
{(q) => <text fg={muted()}>‣ {q}</text>}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2: detail ───────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Detail</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(DETAIL)}
|
||||
border
|
||||
borderColor={border(DETAIL)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={focusedResult()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No result focused</text>
|
||||
) : (
|
||||
<Show
|
||||
when={focusedResult()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No result focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(result) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg={muted()}>by {result().podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{result().podcast.description!.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(result) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||
</text>
|
||||
<Show when={result().sourceName}>
|
||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg={muted()}>by {result().podcast.author}</text>
|
||||
</Show>
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Search · ${recents().length} recent`
|
||||
: `Results · ${results().length}`;
|
||||
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{result().podcast.description!.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(result().podcast.description?.length ?? 0) > 400
|
||||
? "…"
|
||||
: ""}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||
</text>
|
||||
|
||||
<Show when={result().sourceName}>
|
||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
||||
</Show>
|
||||
|
||||
<box height={1} />
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe h/l: panes</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,42 +2,37 @@ import { SourceType } from "@/types/source";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
type SourceBadgeProps = {
|
||||
sourceId: string;
|
||||
sourceName?: string;
|
||||
sourceType?: SourceType;
|
||||
sourceId: string;
|
||||
sourceName?: string;
|
||||
sourceType?: SourceType;
|
||||
};
|
||||
|
||||
const typeLabel = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return "API";
|
||||
if (sourceType === SourceType.RSS) return "RSS";
|
||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
||||
return "Source";
|
||||
};
|
||||
|
||||
const typeColor = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return theme.primary;
|
||||
if (sourceType === SourceType.RSS) return theme.success;
|
||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||
return theme.textMuted;
|
||||
if (sourceType === SourceType.API) return "API";
|
||||
if (sourceType === SourceType.RSS) return "RSS";
|
||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
||||
return "Source";
|
||||
};
|
||||
|
||||
// No module-level typeColor here — it needs the theme from the component.
|
||||
// The correct definition lives inside SourceBadge below.
|
||||
export function SourceBadge(props: SourceBadgeProps) {
|
||||
const { theme } = useTheme();
|
||||
const label = () => props.sourceName || props.sourceId;
|
||||
const { theme } = useTheme();
|
||||
const label = () => props.sourceName || props.sourceId;
|
||||
|
||||
const typeColor = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return theme.primary;
|
||||
if (sourceType === SourceType.RSS) return theme.success;
|
||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||
return theme.textMuted;
|
||||
};
|
||||
const typeColor = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return theme.primary;
|
||||
if (sourceType === SourceType.RSS) return theme.success;
|
||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
||||
return theme.textMuted;
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={1} padding={0}>
|
||||
<text fg={typeColor(props.sourceType)}>
|
||||
[{typeLabel(props.sourceType)}]
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{label()}</text>
|
||||
</box>
|
||||
);
|
||||
return (
|
||||
<box flexDirection="row" gap={1} padding={0}>
|
||||
<text fg={typeColor(props.sourceType)}>
|
||||
[{typeLabel(props.sourceType)}]
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{label()}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
*/
|
||||
|
||||
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { rgbToHex, type RGBA } from "@opentui/core";
|
||||
import { useTheme, type ThemeResolved } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
@@ -230,6 +231,15 @@ export function SettingsPage() {
|
||||
// ── render helpers ───────────────────────────────────────────────────────
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
|
||||
// Whether the currently-focused settings row is the Theme select — the
|
||||
// only item whose Detail pane carries a color breakdown below the help text.
|
||||
const isThemeItem = () => {
|
||||
const d = depth();
|
||||
if (d === 1) return focusedItem()?.id === "theme";
|
||||
if (d === 2) return editorItem()?.id === "theme";
|
||||
return false;
|
||||
};
|
||||
|
||||
// preview text for the right column
|
||||
const previewText = createMemo<string>(() => {
|
||||
const d = depth();
|
||||
@@ -278,7 +288,7 @@ export function SettingsPage() {
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
label={section.label}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={false}
|
||||
/>
|
||||
@@ -307,7 +317,7 @@ export function SettingsPage() {
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
label={section.label}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={isActive()}
|
||||
onMouseDown={() => {
|
||||
@@ -356,8 +366,13 @@ export function SettingsPage() {
|
||||
|
||||
// ── preview pane ──────────────────────────────────────────────────────────
|
||||
const previewContent = () => (
|
||||
<box padding={1}>
|
||||
<MultiLine text={previewText()} />
|
||||
<box padding={1} flexDirection="column">
|
||||
{/* Keep everything on a stable root so Solid re-resolves the swap
|
||||
between plain help text and the theme breakdown on focus move. */}
|
||||
<Show when={isThemeItem()} fallback={<MultiLine text={previewText()} />}>
|
||||
<MultiLine text={previewText()} />
|
||||
<ThemeBreakdown />
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -458,6 +473,49 @@ function GenericEditor(props: { item: SettingItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Curated theme color roles shown in the Theme breakdown. */
|
||||
const THEME_ROLES: Array<{ key: keyof ThemeResolved; label: string }> = [
|
||||
{ key: "primary", label: "Primary" },
|
||||
{ key: "secondary", label: "Secondary" },
|
||||
{ key: "accent", label: "Accent" },
|
||||
{ key: "text", label: "Text" },
|
||||
{ key: "textMuted", label: "Muted" },
|
||||
{ key: "background", label: "Background" },
|
||||
{ key: "surface", label: "Surface" },
|
||||
{ key: "border", label: "Border" },
|
||||
{ key: "error", label: "Error" },
|
||||
{ key: "warning", label: "Warning" },
|
||||
{ key: "success", label: "Success" },
|
||||
{ key: "info", label: "Info" },
|
||||
];
|
||||
|
||||
/** Color swatch breakdown (‹block› <Label> (<HEX>)) of the resolved theme. */
|
||||
function ThemeBreakdown() {
|
||||
const { theme, selected } = useTheme();
|
||||
return (
|
||||
<box flexDirection="column" paddingTop={1} gap={1}>
|
||||
<text fg={theme.accent}>Theme · {selected}</text>
|
||||
<For each={THEME_ROLES}>
|
||||
{(role) => {
|
||||
const color = theme[role.key] as RGBA | undefined;
|
||||
return (
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<box backgroundColor={color}>
|
||||
<text>{" "}</text>
|
||||
</box>
|
||||
<text fg={theme.text}>{role.label}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme.textMuted}>
|
||||
{color ? rgbToHex(color).toUpperCase() : "n/a"}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a string with `\n` newlines as stacked <text> lines. */
|
||||
function MultiLine(props: { text: string }) {
|
||||
const lines = () => props.text.split("\n");
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
|
||||
import type {
|
||||
AppSettings,
|
||||
AppState,
|
||||
ThemeColors,
|
||||
ThemeName,
|
||||
ThemeMode,
|
||||
UserPreferences,
|
||||
VisualizerSettings,
|
||||
AppSettings,
|
||||
AppState,
|
||||
ThemeColors,
|
||||
ThemeName,
|
||||
ThemeMode,
|
||||
UserPreferences,
|
||||
VisualizerSettings,
|
||||
} from "../types/settings";
|
||||
import { resolveTheme } from "../utils/theme-resolver";
|
||||
import type { ThemeJson } from "../types/theme-schema";
|
||||
import {
|
||||
loadAppStateFromFile,
|
||||
saveAppStateToFile,
|
||||
loadAppStateFromFile,
|
||||
saveAppStateToFile,
|
||||
} from "../utils/app-persistence";
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
bars: 32,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
bars: 64,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
};
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
settings: defaultSettings,
|
||||
preferences: defaultPreferences,
|
||||
customTheme: DEFAULT_THEME,
|
||||
settings: defaultSettings,
|
||||
preferences: defaultPreferences,
|
||||
customTheme: DEFAULT_THEME,
|
||||
};
|
||||
|
||||
export function createAppStore() {
|
||||
// Start with defaults; async load will update once ready
|
||||
const [state, setState] = createSignal<AppState>(defaultState);
|
||||
// Start with defaults; async load will update once ready
|
||||
const [state, setState] = createSignal<AppState>(defaultState);
|
||||
|
||||
// Fire-and-forget async initialisation
|
||||
const init = async () => {
|
||||
const loaded = await loadAppStateFromFile();
|
||||
setState(loaded);
|
||||
};
|
||||
init();
|
||||
// Fire-and-forget async initialisation
|
||||
const init = async () => {
|
||||
const loaded = await loadAppStateFromFile();
|
||||
setState(loaded);
|
||||
};
|
||||
init();
|
||||
|
||||
const saveState = (next: AppState) => {
|
||||
saveAppStateToFile(next).catch(() => {});
|
||||
};
|
||||
const saveState = (next: AppState) => {
|
||||
saveAppStateToFile(next).catch(() => {});
|
||||
};
|
||||
|
||||
const updateState = (next: AppState) => {
|
||||
setState(next);
|
||||
saveState(next);
|
||||
};
|
||||
const updateState = (next: AppState) => {
|
||||
setState(next);
|
||||
saveState(next);
|
||||
};
|
||||
|
||||
const updateSettings = (updates: Partial<AppSettings>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
settings: { ...state().settings, ...updates },
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
const updateSettings = (updates: Partial<AppSettings>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
settings: { ...state().settings, ...updates },
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
|
||||
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
preferences: { ...state().preferences, ...updates },
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
preferences: { ...state().preferences, ...updates },
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
|
||||
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
customTheme: { ...state().customTheme, ...updates },
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
customTheme: { ...state().customTheme, ...updates },
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
|
||||
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
||||
updateSettings({
|
||||
visualizer: { ...state().settings.visualizer, ...updates },
|
||||
});
|
||||
};
|
||||
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
||||
updateSettings({
|
||||
visualizer: { ...state().settings.visualizer, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
const setTheme = (theme: ThemeName) => {
|
||||
updateSettings({ theme });
|
||||
};
|
||||
const setTheme = (theme: ThemeName) => {
|
||||
updateSettings({ theme });
|
||||
};
|
||||
|
||||
const resolveThemeColors = (): ThemeColors => {
|
||||
const theme = state().settings.theme;
|
||||
if (theme === "custom") return state().customTheme;
|
||||
if (theme === "system") return DEFAULT_THEME;
|
||||
const json = THEME_JSON[theme];
|
||||
if (!json) return DEFAULT_THEME;
|
||||
return resolveTheme(
|
||||
json as ThemeJson,
|
||||
"dark" as ThemeMode,
|
||||
) as unknown as ThemeColors;
|
||||
};
|
||||
const resolveThemeColors = (): ThemeColors => {
|
||||
const theme = state().settings.theme;
|
||||
if (theme === "custom") return state().customTheme;
|
||||
if (theme === "system") return DEFAULT_THEME;
|
||||
const json = THEME_JSON[theme];
|
||||
if (!json) return DEFAULT_THEME;
|
||||
return resolveTheme(
|
||||
json as ThemeJson,
|
||||
"dark" as ThemeMode,
|
||||
) as unknown as ThemeColors;
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
updateSettings,
|
||||
updatePreferences,
|
||||
updateCustomTheme,
|
||||
updateVisualizer,
|
||||
setTheme,
|
||||
resolveTheme: resolveThemeColors,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
updateSettings,
|
||||
updatePreferences,
|
||||
updateCustomTheme,
|
||||
updateVisualizer,
|
||||
setTheme,
|
||||
resolveTheme: resolveThemeColors,
|
||||
};
|
||||
}
|
||||
|
||||
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
|
||||
|
||||
export function useAppStore() {
|
||||
if (!appStoreInstance) {
|
||||
appStoreInstance = createAppStore();
|
||||
}
|
||||
return appStoreInstance;
|
||||
if (!appStoreInstance) {
|
||||
appStoreInstance = createAppStore();
|
||||
}
|
||||
return appStoreInstance;
|
||||
}
|
||||
|
||||
@@ -1,215 +1,220 @@
|
||||
/**
|
||||
* Discover store for PodTUI
|
||||
* Manages trending/popular podcasts and category filtering
|
||||
* Manages trending/popular podcasts and category filtering.
|
||||
*
|
||||
* The featured-shows list is fetched at runtime from a JSON file hosted in the
|
||||
* GitHub repo (discover/featured.json on the `master` branch), so the list
|
||||
* can be updated without shipping a new release. The feed URL, de-duped set,
|
||||
* and version field act as the cache key — a fresh fetch only happens when the
|
||||
* version bumps or the cache window (24h) expires.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { createSignal } from "solid-js";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import { useFeedStore } from "./feed";
|
||||
|
||||
export interface DiscoverCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||
{ id: "all", name: "All", icon: "*" },
|
||||
{ id: "technology", name: "Technology", icon: ">" },
|
||||
{ id: "science", name: "Science", icon: "~" },
|
||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||
{ id: "news", name: "News", icon: "!" },
|
||||
{ id: "business", name: "Business", icon: "$" },
|
||||
{ id: "health", name: "Health", icon: "+" },
|
||||
{ id: "education", name: "Education", icon: "?" },
|
||||
{ id: "sports", name: "Sports", icon: "#" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||
{ id: "arts", name: "Arts", icon: "@" },
|
||||
]
|
||||
{ id: "all", name: "All", icon: "*" },
|
||||
{ id: "technology", name: "Technology", icon: ">" },
|
||||
{ id: "science", name: "Science", icon: "~" },
|
||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||
{ id: "news", name: "News", icon: "!" },
|
||||
{ id: "business", name: "Business", icon: "$" },
|
||||
{ id: "health", name: "Health", icon: "+" },
|
||||
{ id: "education", name: "Education", icon: "?" },
|
||||
{ id: "sports", name: "Sports", icon: "#" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||
{ id: "arts", name: "Arts", icon: "@" },
|
||||
];
|
||||
|
||||
/** Mock trending podcasts */
|
||||
const TRENDING_PODCASTS: Podcast[] = [
|
||||
{
|
||||
id: "trend-1",
|
||||
title: "AI Today",
|
||||
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||
feedUrl: "https://example.com/aitoday.rss",
|
||||
author: "Tech Futures",
|
||||
categories: ["Technology", "Science"],
|
||||
coverUrl: undefined,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-2",
|
||||
title: "The History Hour",
|
||||
description: "Fascinating stories from history that shaped our world today.",
|
||||
feedUrl: "https://example.com/historyhour.rss",
|
||||
author: "History Channel",
|
||||
categories: ["Education", "History"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-3",
|
||||
title: "Comedy Gold",
|
||||
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||
feedUrl: "https://example.com/comedygold.rss",
|
||||
author: "Laugh Factory",
|
||||
categories: ["Comedy", "Entertainment"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-4",
|
||||
title: "Market Watch",
|
||||
description: "Daily financial news, stock analysis, and investing tips.",
|
||||
feedUrl: "https://example.com/marketwatch.rss",
|
||||
author: "Finance Daily",
|
||||
categories: ["Business", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
{
|
||||
id: "trend-5",
|
||||
title: "Science Weekly",
|
||||
description: "Breaking science news and in-depth analysis of the latest research.",
|
||||
feedUrl: "https://example.com/scienceweekly.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-6",
|
||||
title: "True Crime Files",
|
||||
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||
feedUrl: "https://example.com/truecrime.rss",
|
||||
author: "Crime Network",
|
||||
categories: ["True Crime", "Documentary"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-7",
|
||||
title: "Wellness Journey",
|
||||
description: "Tips for mental and physical health, meditation, and mindful living.",
|
||||
feedUrl: "https://example.com/wellness.rss",
|
||||
author: "Health Media",
|
||||
categories: ["Health", "Self-Help"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-8",
|
||||
title: "Sports Talk Live",
|
||||
description: "Live commentary, analysis, and interviews from the world of sports.",
|
||||
feedUrl: "https://example.com/sportstalk.rss",
|
||||
author: "Sports Network",
|
||||
categories: ["Sports", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-9",
|
||||
title: "Creative Minds",
|
||||
description: "Interviews with artists, designers, and creative professionals.",
|
||||
feedUrl: "https://example.com/creativeminds.rss",
|
||||
author: "Arts Weekly",
|
||||
categories: ["Arts", "Culture"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-10",
|
||||
title: "Dev Talk",
|
||||
description: "Software development, programming tutorials, and tech career advice.",
|
||||
feedUrl: "https://example.com/devtalk.rss",
|
||||
author: "Code Academy",
|
||||
categories: ["Technology", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
]
|
||||
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
||||
// The raw GitHub URL serving discover/featured.json from the master branch.
|
||||
// Update this file in the repo (no release needed) to refresh the list.
|
||||
const FEATURED_JSON_URL =
|
||||
"https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
|
||||
|
||||
/** Cache window for the remote featured list (24 hours) */
|
||||
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Shape of a single entry in the remote JSON */
|
||||
interface FeaturedEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
feedUrl: string;
|
||||
author?: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
/** Shape of the remote JSON manifest */
|
||||
interface FeaturedManifest {
|
||||
version: number;
|
||||
podcasts: FeaturedEntry[];
|
||||
}
|
||||
|
||||
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
|
||||
function entryToPodcast(entry: FeaturedEntry): Podcast {
|
||||
return {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
description: entry.description,
|
||||
feedUrl: entry.feedUrl,
|
||||
author: entry.author,
|
||||
categories: entry.categories ?? [],
|
||||
coverUrl: undefined,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconcile isSubscribed state across the discover list against the feed store */
|
||||
function syncSubscriptionState(
|
||||
podcasts: Podcast[],
|
||||
subscribedUrls: Set<string>,
|
||||
subscribedIds: Set<string>,
|
||||
): Podcast[] {
|
||||
return podcasts.map((p) => ({
|
||||
...p,
|
||||
isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Create discover store */
|
||||
export function createDiscoverStore() {
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||
|
||||
/** Get filtered podcasts by category */
|
||||
const filteredPodcasts = () => {
|
||||
const category = selectedCategory()
|
||||
if (category === "all") {
|
||||
return podcasts()
|
||||
}
|
||||
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||
let cachedAt = 0;
|
||||
|
||||
return podcasts().filter((p) => {
|
||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
||||
})
|
||||
}
|
||||
/** Reconcile local isSubscribed flags with the feed store */
|
||||
const syncSubscriptions = () => {
|
||||
const feedStore = useFeedStore();
|
||||
const feeds = feedStore.feeds();
|
||||
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
|
||||
const ids = new Set(feeds.map((f) => f.podcast.id));
|
||||
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
|
||||
};
|
||||
|
||||
/** Subscribe to a podcast */
|
||||
const subscribe = (podcastId: string) => {
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
/** Fetch the featured-shows manifest from GitHub if stale */
|
||||
const refresh = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Skip if cache is still fresh
|
||||
const now = Date.now();
|
||||
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||
syncSubscriptions();
|
||||
return;
|
||||
}
|
||||
|
||||
/** Unsubscribe from a podcast */
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
const resp = await fetch(FEATURED_JSON_URL, {
|
||||
headers: { "User-Agent": "PodTUI/1.0" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
syncSubscriptions();
|
||||
return;
|
||||
}
|
||||
const manifest = (await resp.json()) as FeaturedManifest;
|
||||
if (!manifest?.podcasts?.length) {
|
||||
syncSubscriptions();
|
||||
return;
|
||||
}
|
||||
|
||||
/** Toggle subscription */
|
||||
const toggleSubscription = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId)
|
||||
if (podcast?.isSubscribed) {
|
||||
unsubscribe(podcastId)
|
||||
} else {
|
||||
subscribe(podcastId)
|
||||
}
|
||||
}
|
||||
// Build the podcast list from the manifest entries
|
||||
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||
cachedAt = now;
|
||||
setPodcasts(fetched);
|
||||
|
||||
/** Refresh trending podcasts (mock) */
|
||||
const refresh = async () => {
|
||||
setIsLoading(true)
|
||||
// Simulate network delay
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
// In real app, would fetch from API
|
||||
setIsLoading(false)
|
||||
}
|
||||
// Reflect current feed-store subscriptions
|
||||
syncSubscriptions();
|
||||
} catch {
|
||||
// Network failure — keep whatever we have (stale or empty)
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedCategory,
|
||||
isLoading,
|
||||
podcasts,
|
||||
filteredPodcasts,
|
||||
categories: DISCOVER_CATEGORIES,
|
||||
/** Get filtered podcasts by category */
|
||||
const filteredPodcasts = () => {
|
||||
const category = selectedCategory();
|
||||
if (category === "all") {
|
||||
return podcasts();
|
||||
}
|
||||
|
||||
// Actions
|
||||
setSelectedCategory,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
toggleSubscription,
|
||||
refresh,
|
||||
}
|
||||
return podcasts().filter((p) => {
|
||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
|
||||
return cats.some((c) =>
|
||||
c.includes(category.toLowerCase().replace("-", " ")),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/** Subscribe to a podcast */
|
||||
const subscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
// Actually add the feed to the feed store
|
||||
const feedStore = useFeedStore();
|
||||
feedStore.addFeed(podcast, "discover").catch(() => {});
|
||||
}
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Unsubscribe from a podcast */
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
// Remove the feed from the feed store
|
||||
const feedStore = useFeedStore();
|
||||
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||
}
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Toggle subscription */
|
||||
const toggleSubscription = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast?.isSubscribed) {
|
||||
unsubscribe(podcastId);
|
||||
} else {
|
||||
subscribe(podcastId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedCategory,
|
||||
isLoading,
|
||||
podcasts,
|
||||
filteredPodcasts,
|
||||
categories: DISCOVER_CATEGORIES,
|
||||
|
||||
// Actions
|
||||
setSelectedCategory,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
toggleSubscription,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton discover store */
|
||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||
|
||||
export function useDiscoverStore() {
|
||||
if (!discoverStoreInstance) {
|
||||
discoverStoreInstance = createDiscoverStore()
|
||||
}
|
||||
return discoverStoreInstance
|
||||
if (!discoverStoreInstance) {
|
||||
discoverStoreInstance = createDiscoverStore();
|
||||
}
|
||||
return discoverStoreInstance;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import { createSignal } from "solid-js";
|
||||
import { FeedVisibility } from "../types/feed";
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import type { Episode, EpisodeStatus } from "../types/episode";
|
||||
import type { PodcastSource, SourceType } from "../types/source";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import {
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
saveSourcesToFile,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
saveSourcesToFile,
|
||||
} from "../utils/feeds-persistence";
|
||||
import { useDownloadStore } from "./download";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
@@ -35,461 +35,491 @@ const episodeLoadCount = new Map<string, number>();
|
||||
|
||||
/** Save feeds to file (async, fire-and-forget) */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
saveFeedsToFile(feeds).catch(() => {});
|
||||
saveFeedsToFile(feeds).catch(() => {});
|
||||
}
|
||||
|
||||
/** Save sources to file (async, fire-and-forget) */
|
||||
function saveSources(sources: PodcastSource[]): void {
|
||||
saveSourcesToFile(sources).catch(() => {});
|
||||
saveSourcesToFile(sources).catch(() => {});
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
export function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
]);
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
});
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
]);
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
});
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
const authStore = useAuthStore();
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Filter by visibility
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||
} else if (f.visibility === "all") {
|
||||
// Only show private feeds if authenticated
|
||||
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated);
|
||||
}
|
||||
// Filter by visibility
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||
} else if (f.visibility === "all") {
|
||||
// Only show private feeds if authenticated
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.visibility === FeedVisibility.PUBLIC ||
|
||||
authStore.isAuthenticated,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
// Filter by source
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
case "title":
|
||||
return (
|
||||
sortDir *
|
||||
(a.customName || a.podcast.title).localeCompare(
|
||||
b.customName || b.podcast.title,
|
||||
)
|
||||
);
|
||||
case "episodeCount":
|
||||
return sortDir * (a.episodes.length - b.episodes.length);
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||
return sortDir * (aLatest - bLatest);
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||
}
|
||||
});
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
case "title":
|
||||
return (
|
||||
sortDir *
|
||||
(a.customName || a.podcast.title).localeCompare(
|
||||
b.customName || b.podcast.title,
|
||||
)
|
||||
);
|
||||
case "episodeCount":
|
||||
return sortDir * (a.episodes.length - b.episodes.length);
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||
return sortDir * (aLatest - bLatest);
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{
|
||||
episode: Episode;
|
||||
feed: Feed;
|
||||
}> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{
|
||||
episode: Episode;
|
||||
feed: Feed;
|
||||
}> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
for (const feed of feeds()) {
|
||||
for (const episode of feed.episodes) {
|
||||
allEpisodes.push({ episode, feed });
|
||||
}
|
||||
}
|
||||
for (const feed of feeds()) {
|
||||
for (const episode of feed.episodes) {
|
||||
allEpisodes.push({ episode, feed });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by publication date (newest first)
|
||||
allEpisodes.sort(
|
||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||
);
|
||||
// Sort by publication date (newest first)
|
||||
allEpisodes.sort(
|
||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||
);
|
||||
|
||||
return allEpisodes;
|
||||
};
|
||||
return allEpisodes;
|
||||
};
|
||||
|
||||
/** Sort episodes in reverse chronological order (newest first) */
|
||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||
return [...episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
/** Sort episodes in reverse chronological order (newest first) */
|
||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||
return [...episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
feedId?: string,
|
||||
): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feedUrl);
|
||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
feedId?: string,
|
||||
): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feedUrl);
|
||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||
|
||||
// Cache all parsed episodes for pagination
|
||||
if (feedId) {
|
||||
fullEpisodeCache.set(feedId, allEpisodes);
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||
}
|
||||
// Cache all parsed episodes for pagination
|
||||
if (feedId) {
|
||||
fullEpisodeCache.set(feedId, allEpisodes);
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||
}
|
||||
|
||||
return allEpisodes.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return allEpisodes.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||
const addFeed = async (
|
||||
podcast: Podcast,
|
||||
sourceId: string,
|
||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||
) => {
|
||||
const feedId = crypto.randomUUID();
|
||||
const episodes = await fetchEpisodes(
|
||||
podcast.feedUrl,
|
||||
MAX_EPISODES_SUBSCRIBE,
|
||||
feedId,
|
||||
);
|
||||
const newFeed: Feed = {
|
||||
id: feedId,
|
||||
podcast,
|
||||
episodes,
|
||||
visibility,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
setFeeds((prev) => {
|
||||
const updated = [...prev, newFeed];
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
return newFeed;
|
||||
};
|
||||
/** Check if a feed with this URL already exists */
|
||||
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||
};
|
||||
|
||||
/** Auto-download newest episodes for a feed */
|
||||
const autoDownloadEpisodes = (
|
||||
feedId: string,
|
||||
newEpisodes: Episode[],
|
||||
count: number,
|
||||
) => {
|
||||
try {
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
const sorted = [...newEpisodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
// count = 0 means download all new episodes
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Download store may not be available yet
|
||||
}
|
||||
};
|
||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||
const addFeed = async (
|
||||
podcast: Podcast,
|
||||
sourceId: string,
|
||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||
): Promise<Feed | null> => {
|
||||
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||
}
|
||||
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
const refreshFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||
const episodes = await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
const feedId = crypto.randomUUID();
|
||||
const episodes = await fetchEpisodes(
|
||||
podcast.feedUrl,
|
||||
MAX_EPISODES_SUBSCRIBE,
|
||||
feedId,
|
||||
);
|
||||
const newFeed: Feed = {
|
||||
id: feedId,
|
||||
podcast,
|
||||
episodes,
|
||||
visibility,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
setFeeds((prev) => {
|
||||
const updated = [...prev, newFeed];
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
return newFeed;
|
||||
};
|
||||
|
||||
// Auto-download new episodes if enabled for this feed
|
||||
if (feed.autoDownload) {
|
||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
||||
if (newEpisodes.length > 0) {
|
||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
/** Auto-download newest episodes for a feed */
|
||||
const autoDownloadEpisodes = (
|
||||
feedId: string,
|
||||
newEpisodes: Episode[],
|
||||
count: number,
|
||||
) => {
|
||||
try {
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
const sorted = [...newEpisodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
// count = 0 means download all new episodes
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Download store may not be available yet
|
||||
}
|
||||
};
|
||||
|
||||
/** Refresh all feeds */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingFeeds(false);
|
||||
}
|
||||
};
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
const refreshFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||
const episodes = await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
// Auto-download new episodes if enabled for this feed
|
||||
if (feed.autoDownload) {
|
||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
||||
if (newEpisodes.length > 0) {
|
||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId);
|
||||
episodeLoadCount.delete(feedId);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.id !== feedId);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Refresh all feeds */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingFeeds(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId);
|
||||
episodeLoadCount.delete(feedId);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.id !== feedId);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
|
||||
const removeFeedByUrl = (feedUrl: string) => {
|
||||
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
|
||||
if (feed) {
|
||||
fullEpisodeCache.delete(feed.id);
|
||||
episodeLoadCount.delete(feed.id);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Update a source */
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
/** Update a source */
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
};
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for a feed from the cache.
|
||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
}
|
||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
};
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
/** Load the next chunk of episodes for a feed from the cache.
|
||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
}
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
|
||||
/** Set auto-download settings for a feed */
|
||||
const setAutoDownload = (
|
||||
feedId: string,
|
||||
enabled: boolean,
|
||||
count: number = 0,
|
||||
) => {
|
||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||
};
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
return {
|
||||
// State
|
||||
feeds,
|
||||
sources,
|
||||
filter,
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
addFeed,
|
||||
removeFeed,
|
||||
updateFeed,
|
||||
togglePinned,
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
updateSource,
|
||||
setAutoDownload,
|
||||
};
|
||||
/** Set auto-download settings for a feed */
|
||||
const setAutoDownload = (
|
||||
feedId: string,
|
||||
enabled: boolean,
|
||||
count: number = 0,
|
||||
) => {
|
||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
feeds,
|
||||
sources,
|
||||
filter,
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
addFeed,
|
||||
hasFeedByUrl,
|
||||
removeFeed,
|
||||
removeFeedByUrl,
|
||||
updateFeed,
|
||||
togglePinned,
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
updateSource,
|
||||
setAutoDownload,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton feed store */
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||
|
||||
export function useFeedStore() {
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore();
|
||||
}
|
||||
return feedStoreInstance;
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore();
|
||||
}
|
||||
return feedStoreInstance;
|
||||
}
|
||||
|
||||
@@ -2,95 +2,95 @@ import type { RGBA } from "@opentui/core";
|
||||
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
|
||||
|
||||
export type ThemeName =
|
||||
| "system"
|
||||
| "catppuccin"
|
||||
| "gruvbox"
|
||||
| "tokyo"
|
||||
| "nord"
|
||||
| "custom";
|
||||
| "system"
|
||||
| "catppuccin"
|
||||
| "gruvbox"
|
||||
| "tokyo"
|
||||
| "nord"
|
||||
| "custom";
|
||||
|
||||
export type LayerBackgrounds = {
|
||||
layer0: ColorValue;
|
||||
layer1: ColorValue;
|
||||
layer2: ColorValue;
|
||||
layer3: ColorValue;
|
||||
layer0: ColorValue;
|
||||
layer1: ColorValue;
|
||||
layer2: ColorValue;
|
||||
layer3: ColorValue;
|
||||
};
|
||||
|
||||
export type ThemeColors = {
|
||||
background: ColorValue;
|
||||
surface: ColorValue;
|
||||
primary: ColorValue;
|
||||
secondary: ColorValue;
|
||||
accent: ColorValue;
|
||||
text: ColorValue;
|
||||
textPrimary?: ColorValue;
|
||||
textSecondary?: ColorValue;
|
||||
textTertiary?: ColorValue;
|
||||
textSelectedPrimary?: ColorValue;
|
||||
textSelectedSecondary?: ColorValue;
|
||||
textSelectedTertiary?: ColorValue;
|
||||
muted: ColorValue;
|
||||
warning: ColorValue;
|
||||
error: ColorValue;
|
||||
success: ColorValue;
|
||||
layerBackgrounds?: LayerBackgrounds;
|
||||
_hasSelectedListItemText?: boolean;
|
||||
thinkingOpacity?: number;
|
||||
selectedListItemText?: ColorValue;
|
||||
background: ColorValue;
|
||||
surface: ColorValue;
|
||||
primary: ColorValue;
|
||||
secondary: ColorValue;
|
||||
accent: ColorValue;
|
||||
text: ColorValue;
|
||||
textPrimary?: ColorValue;
|
||||
textSecondary?: ColorValue;
|
||||
textTertiary?: ColorValue;
|
||||
textSelectedPrimary?: ColorValue;
|
||||
textSelectedSecondary?: ColorValue;
|
||||
textSelectedTertiary?: ColorValue;
|
||||
muted: ColorValue;
|
||||
warning: ColorValue;
|
||||
error: ColorValue;
|
||||
success: ColorValue;
|
||||
layerBackgrounds?: LayerBackgrounds;
|
||||
_hasSelectedListItemText?: boolean;
|
||||
thinkingOpacity?: number;
|
||||
selectedListItemText?: ColorValue;
|
||||
};
|
||||
|
||||
export type ThemeVariant = {
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
};
|
||||
|
||||
export type ThemeToken = {
|
||||
[key: string]: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export type ResolvedTheme = Record<string, RGBA> & {
|
||||
layerBackgrounds: Record<string, RGBA>;
|
||||
_hasSelectedListItemText: boolean;
|
||||
thinkingOpacity: number;
|
||||
layerBackgrounds: Record<string, RGBA>;
|
||||
_hasSelectedListItemText: boolean;
|
||||
thinkingOpacity: number;
|
||||
};
|
||||
|
||||
export type DesktopTheme = {
|
||||
name: string;
|
||||
variants: ThemeVariant[];
|
||||
defaultVariant: string;
|
||||
tokens: ThemeToken;
|
||||
name: string;
|
||||
variants: ThemeVariant[];
|
||||
defaultVariant: string;
|
||||
tokens: ThemeToken;
|
||||
};
|
||||
|
||||
export type VisualizerSettings = {
|
||||
/** Number of frequency bars (8–128, default: 32) */
|
||||
bars: number;
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
sensitivity: number;
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction: number;
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff: number;
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff: number;
|
||||
/** Number of frequency bars (8–128, default: 64) */
|
||||
bars: number;
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
sensitivity: number;
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction: number;
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff: number;
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff: number;
|
||||
};
|
||||
|
||||
export type AppSettings = {
|
||||
theme: ThemeName;
|
||||
fontSize: number;
|
||||
playbackSpeed: number;
|
||||
downloadPath: string;
|
||||
visualizer: VisualizerSettings;
|
||||
theme: ThemeName;
|
||||
fontSize: number;
|
||||
playbackSpeed: number;
|
||||
downloadPath: string;
|
||||
visualizer: VisualizerSettings;
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
showExplicit: boolean;
|
||||
autoDownload: boolean;
|
||||
showExplicit: boolean;
|
||||
autoDownload: boolean;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
settings: AppSettings;
|
||||
preferences: UserPreferences;
|
||||
customTheme: ThemeColors;
|
||||
settings: AppSettings;
|
||||
preferences: UserPreferences;
|
||||
customTheme: ThemeColors;
|
||||
};
|
||||
|
||||
export type ThemeMode = "dark" | "light";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
For,
|
||||
Show,
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
For,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
||||
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
|
||||
@@ -21,313 +21,319 @@ import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
* Command option for the command palette.
|
||||
*/
|
||||
export type CommandOption = {
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Unique identifier */
|
||||
value: string;
|
||||
/** Description shown below title */
|
||||
description?: string;
|
||||
/** Category for grouping */
|
||||
category?: string;
|
||||
/** Keybind reference */
|
||||
keybind?: keyof KeybindsResolved;
|
||||
/** Whether this command is suggested */
|
||||
suggested?: boolean;
|
||||
/** Slash command configuration */
|
||||
slash?: {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
/** Whether to hide from command list */
|
||||
hidden?: boolean;
|
||||
/** Whether command is enabled */
|
||||
enabled?: boolean;
|
||||
/** Footer text (usually keybind display) */
|
||||
footer?: string;
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Unique identifier */
|
||||
value: string;
|
||||
/** Description shown below title */
|
||||
description?: string;
|
||||
/** Category for grouping */
|
||||
category?: string;
|
||||
/** Keybind reference */
|
||||
keybind?: keyof KeybindsResolved;
|
||||
/** Whether this command is suggested */
|
||||
suggested?: boolean;
|
||||
/** Slash command configuration */
|
||||
slash?: {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
};
|
||||
/** Whether to hide from command list */
|
||||
hidden?: boolean;
|
||||
/** Whether command is enabled */
|
||||
enabled?: boolean;
|
||||
/** Footer text (usually keybind display) */
|
||||
footer?: string;
|
||||
/** Handler when command is selected */
|
||||
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
|
||||
};
|
||||
|
||||
type CommandContext = ReturnType<typeof init>;
|
||||
const ctx = createContext<CommandContext>();
|
||||
|
||||
function init() {
|
||||
const [registrations, setRegistrations] = createSignal<
|
||||
Accessor<CommandOption[]>[]
|
||||
>([]);
|
||||
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
const [registrations, setRegistrations] = createSignal<
|
||||
Accessor<CommandOption[]>[]
|
||||
>([]);
|
||||
const [suspendCount, setSuspendCount] = createSignal(0);
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x());
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}));
|
||||
});
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x());
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||
const isVisible = (option: CommandOption) =>
|
||||
isEnabled(option) && !option.hidden;
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false;
|
||||
const isVisible = (option: CommandOption) =>
|
||||
isEnabled(option) && !option.hidden;
|
||||
|
||||
const visibleOptions = createMemo(() =>
|
||||
entries().filter((option) => isVisible(option)),
|
||||
);
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
);
|
||||
const suspended = () => suspendCount() > 0;
|
||||
const visibleOptions = createMemo(() =>
|
||||
entries().filter((option) => isVisible(option)),
|
||||
);
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
);
|
||||
const suspended = () => suspendCount() > 0;
|
||||
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue;
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault();
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: option.value });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Handle keybind shortcuts
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue;
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault();
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: option.value });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = {
|
||||
/**
|
||||
* Trigger a command by its value.
|
||||
*/
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return;
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: name });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get all slash commands.
|
||||
*/
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash;
|
||||
if (!slash) return [];
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
};
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Enable/disable keybinds temporarily.
|
||||
*/
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||
},
|
||||
suspended,
|
||||
/**
|
||||
* Show the command palette dialog.
|
||||
*/
|
||||
show() {
|
||||
dialog.replace(() => (
|
||||
<CommandDialog
|
||||
options={visibleOptions()}
|
||||
suggestedOptions={suggestedOptions()}
|
||||
/>
|
||||
));
|
||||
},
|
||||
/**
|
||||
* Register commands. Returns cleanup function.
|
||||
*/
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb);
|
||||
setRegistrations((arr) => [results, ...arr]);
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions();
|
||||
},
|
||||
};
|
||||
return result;
|
||||
const result = {
|
||||
/**
|
||||
* Trigger a command by its value.
|
||||
*/
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return;
|
||||
option.onSelect?.(dialog);
|
||||
emit("command.execute", { command: name });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get all slash commands.
|
||||
*/
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash;
|
||||
if (!slash) return [];
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
};
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Enable/disable keybinds temporarily.
|
||||
*/
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1));
|
||||
},
|
||||
suspended,
|
||||
/**
|
||||
* Show the command palette dialog.
|
||||
*/
|
||||
show() {
|
||||
dialog.replace(() => (
|
||||
<CommandDialog
|
||||
options={visibleOptions()}
|
||||
suggestedOptions={suggestedOptions()}
|
||||
/>
|
||||
));
|
||||
},
|
||||
/**
|
||||
* Register commands. Returns cleanup function.
|
||||
*/
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb);
|
||||
setRegistrations((arr) => [results, ...arr]);
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions();
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx);
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider");
|
||||
}
|
||||
return value;
|
||||
const value = useContext(ctx);
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init();
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
const value = init();
|
||||
const dialog = useDialog();
|
||||
const keybind = useKeybinds();
|
||||
|
||||
// Open command palette on ctrl+p or command_list keybind
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
if (evt.defaultPrevented) return;
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault();
|
||||
value.show();
|
||||
return;
|
||||
}
|
||||
});
|
||||
// Open the command palette via the `command` keybind (bound to `:` in
|
||||
// keybinds.jsonc). The old hardcoded "command_list" name was never a
|
||||
// canonical action, so the palette was unreachable dead code.
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return;
|
||||
if (dialog.isOpen) return;
|
||||
if (evt.defaultPrevented) return;
|
||||
if (keybind.match("command", evt)) {
|
||||
evt.preventDefault();
|
||||
value.show();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command palette dialog component.
|
||||
*/
|
||||
function CommandDialog(props: {
|
||||
options: CommandOption[];
|
||||
suggestedOptions: CommandOption[];
|
||||
options: CommandOption[];
|
||||
suggestedOptions: CommandOption[];
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const [filter, setFilter] = createSignal("");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const dimensions = useTerminalDimensions();
|
||||
const [filter, setFilter] = createSignal("");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = filter().toLowerCase();
|
||||
if (!query) {
|
||||
return [...props.suggestedOptions, ...props.options];
|
||||
}
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.title.toLowerCase().includes(query) ||
|
||||
option.description?.toLowerCase().includes(query) ||
|
||||
option.category?.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = filter().toLowerCase();
|
||||
if (!query) {
|
||||
return [...props.suggestedOptions, ...props.options];
|
||||
}
|
||||
return props.options.filter(
|
||||
(option) =>
|
||||
option.title.toLowerCase().includes(query) ||
|
||||
option.description?.toLowerCase().includes(query) ||
|
||||
option.category?.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
// Reset selection when filter changes
|
||||
createMemo(() => {
|
||||
filter();
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
// Reset selection when filter changes
|
||||
createMemo(() => {
|
||||
filter();
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
dialog.clear();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
dialog.clear();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
const option = filteredOptions()[selectedIndex()];
|
||||
if (option) {
|
||||
option.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
const option = filteredOptions()[selectedIndex()];
|
||||
if (option) {
|
||||
option.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name);
|
||||
return;
|
||||
}
|
||||
// Handle text input
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFilter((f) => f + evt.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace") {
|
||||
setFilter((f) => f.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (evt.name === "backspace") {
|
||||
setFilter((f) => f.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||
const maxHeight = Math.floor(dimensions().height * 0.6);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
||||
{/* Search input */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.textMuted}>{"> "}</text>
|
||||
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||
</box>
|
||||
return (
|
||||
<box flexDirection="column" padding={1} borderColor={theme.border}>
|
||||
{/* Search input */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.textMuted}>{"> "}</text>
|
||||
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
|
||||
</box>
|
||||
|
||||
{/* Command list */}
|
||||
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
|
||||
<For each={filteredOptions().slice(0, 10)}>
|
||||
{(option, index) => (
|
||||
<SelectableBox
|
||||
selected={() => index() === selectedIndex()}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
const selectedOption = filteredOptions()[index()];
|
||||
if (selectedOption) {
|
||||
selectedOption.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{option.title}
|
||||
</SelectableText>
|
||||
<Show when={option.footer}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.footer}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
<Show when={option.description}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.description}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
</box>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
<Show when={filteredOptions().length === 0}>
|
||||
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
||||
No commands found
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
{/* Command list */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
maxHeight={maxHeight}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<For each={filteredOptions().slice(0, 10)}>
|
||||
{(option, index) => (
|
||||
<SelectableBox
|
||||
selected={() => index() === selectedIndex()}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
const selectedOption = filteredOptions()[index()];
|
||||
if (selectedOption) {
|
||||
selectedOption.onSelect?.(dialog);
|
||||
dialog.clear();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{option.title}
|
||||
</SelectableText>
|
||||
<Show when={option.footer}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.footer}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
<Show when={option.description}>
|
||||
<SelectableText
|
||||
selected={() => index() === selectedIndex()}
|
||||
tertiary
|
||||
>
|
||||
{option.description}
|
||||
</SelectableText>
|
||||
</Show>
|
||||
</box>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
<Show when={filteredOptions().length === 0}>
|
||||
<text fg={theme.textMuted} style={{ padding: 1 }}>
|
||||
No commands found
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* Cross-platform audio playback engine for PodTUI.
|
||||
* Audio playback engine for PodTUI.
|
||||
*
|
||||
* Backend priority:
|
||||
* 1. mpv — full IPC control (seek, volume, speed, position tracking)
|
||||
* 2. ffplay — basic control via process signals
|
||||
* 3. afplay — macOS built-in (no seek/speed, volume only)
|
||||
* 4. system — open/xdg-open/start (fire-and-forget, no control)
|
||||
*
|
||||
* All backends implement the AudioBackend interface so the Player
|
||||
* component doesn't need to care which one is active.
|
||||
* Single backend: mpv — full IPC control (seek, volume, speed, position
|
||||
* tracking), so speed/volume/seek changes apply instantly with no process
|
||||
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||
* surfaces "No audio player found" honestly rather than degrading through
|
||||
* players that can't change speed/volume without restarting.
|
||||
*/
|
||||
|
||||
import { platform } from "os";
|
||||
@@ -18,7 +15,7 @@ import { join } from "path";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none";
|
||||
export type BackendName = "mpv" | "none";
|
||||
|
||||
export interface AudioState {
|
||||
playing: boolean;
|
||||
@@ -78,7 +75,7 @@ function mpvSocketPath(): string {
|
||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
||||
|
||||
class MpvBackend implements AudioBackend {
|
||||
export class MpvBackend implements AudioBackend {
|
||||
readonly name: BackendName = "mpv";
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private socketPath = mpvSocketPath();
|
||||
@@ -381,467 +378,6 @@ class MpvBackend implements AudioBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ffplay Backend ───────────────────────────────────────────────────
|
||||
// ffplay has no IPC. We track duration from episode metadata and
|
||||
// position via elapsed wall-clock time. Seek requires restarting.
|
||||
|
||||
class FfplayBackend implements AudioBackend {
|
||||
readonly name: BackendName = "ffplay";
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private _playing = false;
|
||||
private _paused = false;
|
||||
private _position = 0;
|
||||
private _duration = 0;
|
||||
private _volume = 100;
|
||||
private _speed = 1;
|
||||
private _url = "";
|
||||
private startTime = 0;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
this._url = url;
|
||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||
this._speed = opts?.speed ?? 1;
|
||||
this._position = opts?.startPosition ?? 0;
|
||||
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
private spawnProcess(): void {
|
||||
const args = [
|
||||
"ffplay",
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
"-loglevel",
|
||||
"quiet",
|
||||
"-volume",
|
||||
String(this._volume),
|
||||
];
|
||||
|
||||
if (this._position > 0) {
|
||||
args.push("-ss", String(this._position));
|
||||
}
|
||||
|
||||
if (this._speed !== 1) {
|
||||
args.push("-af", `atempo=${this._speed}`);
|
||||
}
|
||||
|
||||
args.push("-i", this._url);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
this._paused = false;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.stopPolling();
|
||||
this.pollTimer = setInterval(() => {
|
||||
if (!this._playing) return;
|
||||
const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed;
|
||||
this._position = this._position + elapsed;
|
||||
this.startTime = Date.now();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.proc) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._paused = true;
|
||||
}
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (!this._url) return;
|
||||
if (this.proc && this._paused) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGCONT");
|
||||
} catch {}
|
||||
this._paused = false;
|
||||
this._playing = true;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this._playing = false;
|
||||
this._paused = false;
|
||||
this._position = 0;
|
||||
this._url = "";
|
||||
}
|
||||
|
||||
async seek(seconds: number): Promise<void> {
|
||||
this._position = seconds;
|
||||
if (this._playing && this._url) {
|
||||
// Restart at new position
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
this._volume = Math.round(volume * 100);
|
||||
// ffplay has no runtime IPC; volume will apply on next play/resume.
|
||||
// Restart the process to apply immediately if currently playing.
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
this._speed = speed;
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPosition(): Promise<number> {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
async getDuration(): Promise<number> {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── afplay Backend (macOS) ───────────────────────────────────────────
|
||||
// Built-in on macOS. Supports volume and rate but no seek or position.
|
||||
|
||||
class AfplayBackend implements AudioBackend {
|
||||
readonly name: BackendName = "afplay";
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private _playing = false;
|
||||
private _paused = false;
|
||||
private _position = 0;
|
||||
private _duration = 0;
|
||||
private _volume = 1;
|
||||
private _speed = 1;
|
||||
private _url = "";
|
||||
private startTime = 0;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
this._url = url;
|
||||
this._volume = opts?.volume ?? 1;
|
||||
this._speed = opts?.speed ?? 1;
|
||||
this._position = opts?.startPosition ?? 0;
|
||||
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
private spawnProcess(): void {
|
||||
// afplay supports --volume (0-1) and --rate
|
||||
const args = [
|
||||
"afplay",
|
||||
"--volume",
|
||||
String(this._volume),
|
||||
"--rate",
|
||||
String(this._speed),
|
||||
];
|
||||
|
||||
if (this._position > 0) {
|
||||
args.push(
|
||||
"--time",
|
||||
String(this._duration > 0 ? this._duration - this._position : 0),
|
||||
);
|
||||
}
|
||||
|
||||
args.push(this._url);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
this._paused = false;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.stopPolling();
|
||||
this.pollTimer = setInterval(() => {
|
||||
if (!this._playing) return;
|
||||
const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed;
|
||||
this._position = this._position + elapsed;
|
||||
this.startTime = Date.now();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.proc) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._paused = true;
|
||||
}
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (!this._url) return;
|
||||
if (this.proc && this._paused) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGCONT");
|
||||
} catch {}
|
||||
this._paused = false;
|
||||
this._playing = true;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this._playing = false;
|
||||
this._paused = false;
|
||||
this._position = 0;
|
||||
this._url = "";
|
||||
}
|
||||
|
||||
async seek(seconds: number): Promise<void> {
|
||||
this._position = seconds;
|
||||
if (this._playing && this._url) {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
this._volume = volume;
|
||||
// Restart the process with new volume to apply immediately
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
this._speed = speed;
|
||||
// Restart the process with new rate to apply immediately
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPosition(): Promise<number> {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
async getDuration(): Promise<number> {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── System Backend (open/xdg-open) ───────────────────────────────────
|
||||
// Fire-and-forget. Opens the URL in the default handler. No control.
|
||||
|
||||
class SystemBackend implements AudioBackend {
|
||||
readonly name: BackendName = "system";
|
||||
private _playing = false;
|
||||
|
||||
async play(url: string): Promise<void> {
|
||||
const os = platform();
|
||||
const cmd =
|
||||
os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
|
||||
|
||||
Bun.spawn([cmd, url], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
this._playing = false;
|
||||
}
|
||||
async resume(): Promise<void> {
|
||||
this._playing = true;
|
||||
}
|
||||
async stop(): Promise<void> {
|
||||
this._playing = false;
|
||||
}
|
||||
async seek(): Promise<void> {}
|
||||
async setVolume(): Promise<void> {}
|
||||
async setSpeed(): Promise<void> {}
|
||||
async getPosition(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
async getDuration(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
dispose(): void {
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── No-op Backend ────────────────────────────────────────────────────
|
||||
|
||||
class NoopBackend implements AudioBackend {
|
||||
@@ -896,53 +432,6 @@ export function detectPlayers(): DetectedPlayer[] {
|
||||
});
|
||||
}
|
||||
|
||||
const ffplayPath = which("ffplay");
|
||||
if (ffplayPath) {
|
||||
players.push({
|
||||
name: "ffplay",
|
||||
path: ffplayPath,
|
||||
capabilities: {
|
||||
seek: true,
|
||||
volume: true,
|
||||
speed: false,
|
||||
positionTracking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const os = platform();
|
||||
if (os === "darwin") {
|
||||
const afplayPath = which("afplay");
|
||||
if (afplayPath) {
|
||||
players.push({
|
||||
name: "afplay",
|
||||
path: afplayPath,
|
||||
capabilities: {
|
||||
seek: true,
|
||||
volume: true,
|
||||
speed: true,
|
||||
positionTracking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// System open is always available as fallback
|
||||
const openCmd =
|
||||
os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
|
||||
if (which(openCmd)) {
|
||||
players.push({
|
||||
name: "system",
|
||||
path: which(openCmd),
|
||||
capabilities: {
|
||||
seek: false,
|
||||
volume: false,
|
||||
speed: false,
|
||||
positionTracking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return players;
|
||||
}
|
||||
|
||||
@@ -953,14 +442,7 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
||||
// An explicit `preferred` argument still wins.
|
||||
if (!preferred) {
|
||||
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
|
||||
if (
|
||||
envPref &&
|
||||
(envPref === "mpv" ||
|
||||
envPref === "ffplay" ||
|
||||
envPref === "afplay" ||
|
||||
envPref === "system" ||
|
||||
envPref === "none")
|
||||
) {
|
||||
if (envPref && (envPref === "mpv" || envPref === "none")) {
|
||||
preferred = envPref;
|
||||
}
|
||||
}
|
||||
@@ -970,25 +452,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
||||
if (backend) return backend;
|
||||
}
|
||||
|
||||
// Auto-detect in priority order
|
||||
const players = detectPlayers();
|
||||
if (players.length === 0) return new NoopBackend();
|
||||
|
||||
return createBackendByName(players[0].name) ?? new NoopBackend();
|
||||
return which("mpv") ? new MpvBackend() : new NoopBackend();
|
||||
}
|
||||
|
||||
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||
switch (name) {
|
||||
case "mpv":
|
||||
return which("mpv") ? new MpvBackend() : null;
|
||||
case "ffplay":
|
||||
return which("ffplay") ? new FfplayBackend() : null;
|
||||
case "afplay":
|
||||
return platform() === "darwin" && which("afplay")
|
||||
? new AfplayBackend()
|
||||
: null;
|
||||
case "system":
|
||||
return new SystemBackend();
|
||||
case "none":
|
||||
return new NoopBackend();
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
*/
|
||||
|
||||
/** PCM output format constants */
|
||||
const SAMPLE_RATE = 44100
|
||||
const CHANNELS = 1
|
||||
const BYTES_PER_SAMPLE = 2 // s16le
|
||||
const SAMPLE_RATE = 44100;
|
||||
const CHANNELS = 1;
|
||||
const BYTES_PER_SAMPLE = 2; // s16le
|
||||
|
||||
/** How many samples to buffer (~1 second) */
|
||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE
|
||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE;
|
||||
|
||||
export interface AudioStreamReaderOptions {
|
||||
/** Audio URL or file path to decode */
|
||||
url: string
|
||||
/** Sample rate (default: 44100) */
|
||||
sampleRate?: number
|
||||
/** Audio URL or file path to decode */
|
||||
url: string;
|
||||
/** Sample rate (default: 44100) */
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,202 +30,232 @@ export interface AudioStreamReaderOptions {
|
||||
* Each start() increments this; the read loop checks it to know
|
||||
* if it's been superseded and should bail out.
|
||||
*/
|
||||
let globalGeneration = 0
|
||||
let globalGeneration = 0;
|
||||
|
||||
export class AudioStreamReader {
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null
|
||||
private ringBuffer: Float64Array
|
||||
private writePos = 0
|
||||
private totalSamplesWritten = 0
|
||||
private _running = false
|
||||
private generation = 0
|
||||
readonly url: string
|
||||
private sampleRate: number
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private ringBuffer: Float64Array;
|
||||
private writePos = 0;
|
||||
private totalSamplesWritten = 0;
|
||||
private _running = false;
|
||||
private generation = 0;
|
||||
readonly url: string;
|
||||
private sampleRate: number;
|
||||
|
||||
constructor(options: AudioStreamReaderOptions) {
|
||||
this.url = options.url
|
||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE
|
||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES)
|
||||
}
|
||||
constructor(options: AudioStreamReaderOptions) {
|
||||
this.url = options.url;
|
||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
||||
}
|
||||
|
||||
/** Whether the reader is actively reading samples. */
|
||||
get running(): boolean {
|
||||
return this._running
|
||||
}
|
||||
/** Whether the reader is actively reading samples. */
|
||||
get running(): boolean {
|
||||
return this._running;
|
||||
}
|
||||
|
||||
/** Total number of samples written since start(). */
|
||||
get samplesWritten(): number {
|
||||
return this.totalSamplesWritten
|
||||
}
|
||||
/** Total number of samples written since start(). */
|
||||
get samplesWritten(): number {
|
||||
return this.totalSamplesWritten;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ffmpeg decode process and begin reading PCM data.
|
||||
*
|
||||
* If already running, the previous process is killed first.
|
||||
* Uses a generation counter to guarantee that only one read loop
|
||||
* is ever active — stale loops from killed processes bail out
|
||||
* immediately.
|
||||
*
|
||||
* @param startPosition Seek position in seconds (default: 0).
|
||||
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
|
||||
* atempo filter so visualization stays in sync with audio.
|
||||
*/
|
||||
start(startPosition = 0, speed = 1): void {
|
||||
// Always kill the previous process first — no early return on _running
|
||||
this.killProcess()
|
||||
/**
|
||||
* Start the ffmpeg decode process and begin reading PCM data.
|
||||
*
|
||||
* If already running, the previous process is killed first.
|
||||
* Uses a generation counter to guarantee that only one read loop
|
||||
* is ever active — stale loops from killed processes bail out
|
||||
* immediately.
|
||||
*
|
||||
* @param startPosition Seek position in seconds (default: 0).
|
||||
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
|
||||
* atempo filter so visualization stays in sync with audio.
|
||||
*/
|
||||
start(startPosition = 0, speed = 1): void {
|
||||
// Always kill the previous process first — no early return on _running
|
||||
this.killProcess();
|
||||
|
||||
if (!Bun.which("ffmpeg")) {
|
||||
throw new Error("ffmpeg not found — required for audio visualization")
|
||||
}
|
||||
if (!Bun.which("ffmpeg")) {
|
||||
throw new Error("ffmpeg not found — required for audio visualization");
|
||||
}
|
||||
|
||||
// Increment generation so any lingering read loop from a previous
|
||||
// start() will see a mismatch and exit.
|
||||
this.generation = ++globalGeneration
|
||||
// Increment generation so any lingering read loop from a previous
|
||||
// start() will see a mismatch and exit.
|
||||
this.generation = ++globalGeneration;
|
||||
|
||||
const args = [
|
||||
"ffmpeg",
|
||||
"-loglevel", "quiet",
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
]
|
||||
const args = [
|
||||
"ffmpeg",
|
||||
"-loglevel",
|
||||
"quiet",
|
||||
// Read input at native frame rate so decoded PCM stays in sync with
|
||||
// real-time playback. Without -re, ffmpeg greedily decodes the whole
|
||||
// file as fast as possible: the ring buffer fills with audio seconds
|
||||
// ahead of the player (laggy bars), then the process exits when it
|
||||
// hits EOF (bars freeze ~10s in).
|
||||
"-re",
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"5",
|
||||
];
|
||||
|
||||
// Seek before input for network efficiency
|
||||
if (startPosition > 0) {
|
||||
args.push("-ss", String(startPosition))
|
||||
}
|
||||
// Seek before input for network efficiency
|
||||
if (startPosition > 0) {
|
||||
args.push("-ss", String(startPosition));
|
||||
}
|
||||
|
||||
args.push("-i", this.url)
|
||||
args.push("-i", this.url);
|
||||
|
||||
// Apply speed via atempo filter if not 1x.
|
||||
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
||||
if (speed !== 1 && speed > 0) {
|
||||
args.push("-af", buildAtempoChain(speed))
|
||||
}
|
||||
// Apply speed via atempo filter if not 1x.
|
||||
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
||||
if (speed !== 1 && speed > 0) {
|
||||
args.push("-af", buildAtempoChain(speed));
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-ac", String(CHANNELS),
|
||||
"-ar", String(this.sampleRate),
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-",
|
||||
)
|
||||
args.push(
|
||||
"-ac",
|
||||
String(CHANNELS),
|
||||
"-ar",
|
||||
String(this.sampleRate),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-",
|
||||
);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
})
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._running = true
|
||||
this.writePos = 0
|
||||
this.totalSamplesWritten = 0
|
||||
this._running = true;
|
||||
this.writePos = 0;
|
||||
this.totalSamplesWritten = 0;
|
||||
|
||||
// Capture generation for this run
|
||||
const myGeneration = this.generation
|
||||
// Capture generation for this run
|
||||
const myGeneration = this.generation;
|
||||
|
||||
// Start async reading loop
|
||||
this.readLoop(myGeneration)
|
||||
// Start async reading loop
|
||||
this.readLoop(myGeneration);
|
||||
|
||||
// Detect process exit
|
||||
this.proc.exited.then(() => {
|
||||
// Only clear _running if this is still the current generation
|
||||
if (this.generation === myGeneration) {
|
||||
this._running = false
|
||||
}
|
||||
}).catch(() => {
|
||||
if (this.generation === myGeneration) {
|
||||
this._running = false
|
||||
}
|
||||
})
|
||||
}
|
||||
// Detect process exit
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
// Only clear _running if this is still the current generation
|
||||
if (this.generation === myGeneration) {
|
||||
this._running = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.generation === myGeneration) {
|
||||
this._running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read available samples into the provided buffer.
|
||||
* Returns the number of samples actually copied.
|
||||
*
|
||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
||||
* @returns Number of samples written to `out`.
|
||||
*/
|
||||
read(out: Float64Array): number {
|
||||
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length)
|
||||
if (available <= 0) return 0
|
||||
/**
|
||||
* Read available samples into the provided buffer.
|
||||
* Returns the number of samples actually copied.
|
||||
*
|
||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
||||
* @returns Number of samples written to `out`.
|
||||
*/
|
||||
read(out: Float64Array): number {
|
||||
const available = Math.min(
|
||||
out.length,
|
||||
this.totalSamplesWritten,
|
||||
this.ringBuffer.length,
|
||||
);
|
||||
if (available <= 0) return 0;
|
||||
|
||||
// Read the most recent `available` samples from the ring buffer
|
||||
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length
|
||||
// Read the most recent `available` samples from the ring buffer
|
||||
const readStart =
|
||||
(this.writePos - available + this.ringBuffer.length) %
|
||||
this.ringBuffer.length;
|
||||
|
||||
if (readStart + available <= this.ringBuffer.length) {
|
||||
out.set(this.ringBuffer.subarray(readStart, readStart + available))
|
||||
} else {
|
||||
const firstChunk = this.ringBuffer.length - readStart
|
||||
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length))
|
||||
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk)
|
||||
}
|
||||
if (readStart + available <= this.ringBuffer.length) {
|
||||
out.set(this.ringBuffer.subarray(readStart, readStart + available));
|
||||
} else {
|
||||
const firstChunk = this.ringBuffer.length - readStart;
|
||||
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
|
||||
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ffmpeg process and clean up.
|
||||
* Safe to call multiple times. Guarantees the read loop exits.
|
||||
*/
|
||||
stop(): void {
|
||||
// Bump generation to invalidate any running read loop
|
||||
this.generation = ++globalGeneration
|
||||
this._running = false
|
||||
this.killProcess()
|
||||
this.writePos = 0
|
||||
this.totalSamplesWritten = 0
|
||||
}
|
||||
/**
|
||||
* Stop the ffmpeg process and clean up.
|
||||
* Safe to call multiple times. Guarantees the read loop exits.
|
||||
*/
|
||||
stop(): void {
|
||||
// Bump generation to invalidate any running read loop
|
||||
this.generation = ++globalGeneration;
|
||||
this._running = false;
|
||||
this.killProcess();
|
||||
this.writePos = 0;
|
||||
this.totalSamplesWritten = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the reader at a new position and/or speed.
|
||||
*/
|
||||
restart(startPosition = 0, speed = 1): void {
|
||||
this.start(startPosition, speed)
|
||||
}
|
||||
/**
|
||||
* Restart the reader at a new position and/or speed.
|
||||
*/
|
||||
restart(startPosition = 0, speed = 1): void {
|
||||
this.start(startPosition, speed);
|
||||
}
|
||||
|
||||
/** Kill the ffmpeg process without touching generation/state. */
|
||||
private killProcess(): void {
|
||||
if (this.proc) {
|
||||
try { this.proc.kill() } catch { /* ignore */ }
|
||||
this.proc = null
|
||||
}
|
||||
}
|
||||
/** Kill the ffmpeg process without touching generation/state. */
|
||||
private killProcess(): void {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||
private async readLoop(myGeneration: number): Promise<void> {
|
||||
const stdout = this.proc?.stdout
|
||||
if (!stdout || typeof stdout === "number") return
|
||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||
private async readLoop(myGeneration: number): Promise<void> {
|
||||
const stdout = this.proc?.stdout;
|
||||
if (!stdout || typeof stdout === "number") return;
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
||||
try {
|
||||
while (this.generation === myGeneration) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || this.generation !== myGeneration) break
|
||||
if (!value || value.byteLength === 0) continue
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||
try {
|
||||
while (this.generation === myGeneration) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || this.generation !== myGeneration) break;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
|
||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
|
||||
if (sampleCount === 0) continue
|
||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
||||
if (sampleCount === 0) continue;
|
||||
|
||||
const int16View = new Int16Array(
|
||||
value.buffer,
|
||||
value.byteOffset,
|
||||
sampleCount,
|
||||
)
|
||||
const int16View = new Int16Array(
|
||||
value.buffer,
|
||||
value.byteOffset,
|
||||
sampleCount,
|
||||
);
|
||||
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
this.ringBuffer[this.writePos] = int16View[i]
|
||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length
|
||||
this.totalSamplesWritten++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or process killed — expected during stop()
|
||||
} finally {
|
||||
try { reader.releaseLock() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
this.ringBuffer[this.writePos] = int16View[i];
|
||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
||||
this.totalSamplesWritten++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or process killed — expected during stop()
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,18 +264,18 @@ export class AudioStreamReader {
|
||||
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
|
||||
*/
|
||||
function buildAtempoChain(speed: number): string {
|
||||
const parts: string[] = []
|
||||
let remaining = Math.max(0.25, Math.min(4, speed))
|
||||
const parts: string[] = [];
|
||||
let remaining = Math.max(0.25, Math.min(4, speed));
|
||||
|
||||
while (remaining > 100) {
|
||||
parts.push("atempo=100.0")
|
||||
remaining /= 100
|
||||
}
|
||||
while (remaining < 0.5) {
|
||||
parts.push("atempo=0.5")
|
||||
remaining /= 0.5
|
||||
}
|
||||
parts.push(`atempo=${remaining}`)
|
||||
while (remaining > 100) {
|
||||
parts.push("atempo=100.0");
|
||||
remaining /= 100;
|
||||
}
|
||||
while (remaining < 0.5) {
|
||||
parts.push("atempo=0.5");
|
||||
remaining /= 0.5;
|
||||
}
|
||||
parts.push(`atempo=${remaining}`);
|
||||
|
||||
return parts.join(",")
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
@@ -16,170 +16,178 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
||||
import { existsSync } from "fs"
|
||||
import { join, dirname } from "path"
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||
import { existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CavaCoreConfig {
|
||||
/** Number of frequency bars (default: 32) */
|
||||
bars?: number
|
||||
/** Audio sample rate in Hz (default: 44100) */
|
||||
sampleRate?: number
|
||||
/** Number of audio channels (default: 1 = mono) */
|
||||
channels?: number
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
autosens?: number
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction?: number
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff?: number
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff?: number
|
||||
/** Number of frequency bars (default: 32) */
|
||||
bars?: number;
|
||||
/** Audio sample rate in Hz (default: 44100) */
|
||||
sampleRate?: number;
|
||||
/** Number of audio channels (default: 1 = mono) */
|
||||
channels?: number;
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
autosens?: number;
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction?: number;
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff?: number;
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff?: number;
|
||||
/** Output scaling mode: 0 = linear (default), 1 = decibel */
|
||||
scalingMode?: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<CavaCoreConfig> = {
|
||||
bars: 32,
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
autosens: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
bars: 32,
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
autosens: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
scalingMode: 0,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
|
||||
type CavaLib = {
|
||||
symbols: Record<string, (...args: any[]) => any>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
// ── Library resolution ───────────────────────────────────────────────
|
||||
|
||||
function findLibrary(): string | null {
|
||||
const platform = process.platform
|
||||
const libName = platform === "darwin"
|
||||
? "libcavacore.dylib"
|
||||
: platform === "win32"
|
||||
? "cavacore.dll"
|
||||
: "libcavacore.so"
|
||||
const platform = process.platform;
|
||||
const libName =
|
||||
platform === "darwin"
|
||||
? "libcavacore.dylib"
|
||||
: platform === "win32"
|
||||
? "cavacore.dll"
|
||||
: "libcavacore.so";
|
||||
|
||||
// Candidate paths, in priority order:
|
||||
// 1. src/native/ (development)
|
||||
// 2. Same directory as the running executable (dist bundle)
|
||||
// 3. dist/ relative to cwd
|
||||
const candidates = [
|
||||
join(import.meta.dir, "..", "native", libName),
|
||||
join(dirname(process.execPath), libName),
|
||||
join(process.cwd(), "dist", libName),
|
||||
]
|
||||
// Candidate paths, in priority order:
|
||||
// 1. src/native/ (development)
|
||||
// 2. Same directory as the running executable (dist bundle)
|
||||
// 3. dist/ relative to cwd
|
||||
const candidates = [
|
||||
join(import.meta.dir, "..", "native", libName),
|
||||
join(dirname(process.execPath), libName),
|
||||
join(process.cwd(), "dist", libName),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── CavaCore class ───────────────────────────────────────────────────
|
||||
|
||||
export class CavaCore {
|
||||
private lib: CavaLib
|
||||
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null
|
||||
private inputBuffer: Float64Array | null = null
|
||||
private outputBuffer: Float64Array | null = null
|
||||
private _bars = 0
|
||||
private _channels = 1
|
||||
private _destroyed = false
|
||||
private lib: CavaLib;
|
||||
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null;
|
||||
private inputBuffer: Float64Array | null = null;
|
||||
private outputBuffer: Float64Array | null = null;
|
||||
private _bars = 0;
|
||||
private _channels = 1;
|
||||
private _destroyed = false;
|
||||
|
||||
/** Use loadCavaCore() instead of constructing directly. */
|
||||
constructor(lib: CavaLib) {
|
||||
this.lib = lib
|
||||
}
|
||||
/** Use loadCavaCore() instead of constructing directly. */
|
||||
constructor(lib: CavaLib) {
|
||||
this.lib = lib;
|
||||
}
|
||||
|
||||
/** Number of frequency bars configured. */
|
||||
get bars(): number {
|
||||
return this._bars
|
||||
}
|
||||
/** Number of frequency bars configured. */
|
||||
get bars(): number {
|
||||
return this._bars;
|
||||
}
|
||||
|
||||
/** Whether this instance has been initialized (and not yet destroyed). */
|
||||
get isReady(): boolean {
|
||||
return this.plan !== null && !this._destroyed
|
||||
}
|
||||
/** Whether this instance has been initialized (and not yet destroyed). */
|
||||
get isReady(): boolean {
|
||||
return this.plan !== null && !this._destroyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cavacore engine with the given configuration.
|
||||
* Must be called before execute(). Can be called again after destroy()
|
||||
* to reinitialize with different parameters.
|
||||
*/
|
||||
init(config: CavaCoreConfig = {}): void {
|
||||
if (this.plan) {
|
||||
this.destroy()
|
||||
}
|
||||
/**
|
||||
* Initialize the cavacore engine with the given configuration.
|
||||
* Must be called before execute(). Can be called again after destroy()
|
||||
* to reinitialize with different parameters.
|
||||
*/
|
||||
init(config: CavaCoreConfig = {}): void {
|
||||
if (this.plan) {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
this._bars = cfg.bars
|
||||
this._channels = cfg.channels
|
||||
const cfg = { ...DEFAULTS, ...config };
|
||||
this._bars = cfg.bars;
|
||||
this._channels = cfg.channels;
|
||||
|
||||
this.plan = this.lib.symbols.cava_init(
|
||||
cfg.bars,
|
||||
cfg.sampleRate,
|
||||
cfg.channels,
|
||||
cfg.autosens,
|
||||
cfg.noiseReduction,
|
||||
cfg.lowCutOff,
|
||||
cfg.highCutOff,
|
||||
)
|
||||
this.plan = this.lib.symbols.cava_init(
|
||||
cfg.bars,
|
||||
cfg.sampleRate,
|
||||
cfg.channels,
|
||||
cfg.autosens,
|
||||
cfg.noiseReduction,
|
||||
cfg.lowCutOff,
|
||||
cfg.highCutOff,
|
||||
cfg.scalingMode,
|
||||
);
|
||||
|
||||
if (!this.plan) {
|
||||
throw new Error("cava_init returned null — initialization failed")
|
||||
}
|
||||
if (!this.plan) {
|
||||
throw new Error("cava_init returned null — initialization failed");
|
||||
}
|
||||
|
||||
// Pre-allocate output buffer (bars * channels)
|
||||
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels)
|
||||
this._destroyed = false
|
||||
}
|
||||
// Pre-allocate output buffer (bars * channels)
|
||||
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels);
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed PCM samples into cavacore and get frequency bar values back.
|
||||
*
|
||||
* @param samples - Float64Array of PCM samples (scaled ~±32768).
|
||||
* The array length determines the number of samples processed.
|
||||
* @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels).
|
||||
* Returns the same buffer reference each call (overwritten in place).
|
||||
*/
|
||||
execute(samples: Float64Array): Float64Array {
|
||||
if (!this.plan || !this.outputBuffer) {
|
||||
throw new Error("CavaCore not initialized — call init() first")
|
||||
}
|
||||
/**
|
||||
* Feed PCM samples into cavacore and get frequency bar values back.
|
||||
*
|
||||
* @param samples - Float64Array of PCM samples (scaled ~±32768).
|
||||
* The array length determines the number of samples processed.
|
||||
* @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels).
|
||||
* Returns the same buffer reference each call (overwritten in place).
|
||||
*/
|
||||
execute(samples: Float64Array): Float64Array {
|
||||
if (!this.plan || !this.outputBuffer) {
|
||||
throw new Error("CavaCore not initialized — call init() first");
|
||||
}
|
||||
|
||||
// Reuse input buffer if same size, otherwise allocate new
|
||||
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
||||
this.inputBuffer = new Float64Array(samples.length)
|
||||
}
|
||||
this.inputBuffer.set(samples)
|
||||
// Reuse input buffer if same size, otherwise allocate new
|
||||
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
||||
this.inputBuffer = new Float64Array(samples.length);
|
||||
}
|
||||
this.inputBuffer.set(samples);
|
||||
|
||||
this.lib.symbols.cava_execute(
|
||||
ptr(this.inputBuffer),
|
||||
samples.length,
|
||||
ptr(this.outputBuffer),
|
||||
this.plan,
|
||||
)
|
||||
this.lib.symbols.cava_execute(
|
||||
ptr(this.inputBuffer),
|
||||
samples.length,
|
||||
ptr(this.outputBuffer),
|
||||
this.plan,
|
||||
);
|
||||
|
||||
return this.outputBuffer
|
||||
}
|
||||
return this.outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all native resources. Safe to call multiple times.
|
||||
* After calling destroy(), init() can be called again to reuse the instance.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.plan && !this._destroyed) {
|
||||
this.lib.symbols.cava_destroy(this.plan)
|
||||
this.plan = null
|
||||
this._destroyed = true
|
||||
}
|
||||
this.inputBuffer = null
|
||||
this.outputBuffer = null
|
||||
}
|
||||
/**
|
||||
* Release all native resources. Safe to call multiple times.
|
||||
* After calling destroy(), init() can be called again to reuse the instance.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.plan && !this._destroyed) {
|
||||
this.lib.symbols.cava_destroy(this.plan);
|
||||
this.plan = null;
|
||||
this._destroyed = true;
|
||||
}
|
||||
this.inputBuffer = null;
|
||||
this.outputBuffer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────────────
|
||||
@@ -190,41 +198,42 @@ export class CavaCore {
|
||||
* to the static waveform display.
|
||||
*/
|
||||
export function loadCavaCore(): CavaCore | null {
|
||||
try {
|
||||
const libPath = findLibrary()
|
||||
if (!libPath) return null
|
||||
try {
|
||||
const libPath = findLibrary();
|
||||
if (!libPath) return null;
|
||||
|
||||
const lib = dlopen(libPath, {
|
||||
cava_init: {
|
||||
args: [
|
||||
FFIType.i32, // bars
|
||||
FFIType.u32, // rate
|
||||
FFIType.i32, // channels
|
||||
FFIType.i32, // autosens
|
||||
FFIType.double, // noise_reduction
|
||||
FFIType.i32, // low_cut_off
|
||||
FFIType.i32, // high_cut_off
|
||||
],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
cava_execute: {
|
||||
args: [
|
||||
FFIType.ptr, // cava_in (double*)
|
||||
FFIType.i32, // samples
|
||||
FFIType.ptr, // cava_out (double*)
|
||||
FFIType.ptr, // plan
|
||||
],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
cava_destroy: {
|
||||
args: [FFIType.ptr], // plan
|
||||
returns: FFIType.void,
|
||||
},
|
||||
})
|
||||
const lib = dlopen(libPath, {
|
||||
cava_init: {
|
||||
args: [
|
||||
FFIType.i32, // bars
|
||||
FFIType.u32, // rate
|
||||
FFIType.i32, // channels
|
||||
FFIType.i32, // autosens
|
||||
FFIType.double, // noise_reduction
|
||||
FFIType.i32, // low_cut_off
|
||||
FFIType.i32, // high_cut_off
|
||||
FFIType.i32, // scaling_mode
|
||||
],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
cava_execute: {
|
||||
args: [
|
||||
FFIType.ptr, // cava_in (double*)
|
||||
FFIType.i32, // samples
|
||||
FFIType.ptr, // cava_out (double*)
|
||||
FFIType.ptr, // plan
|
||||
],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
cava_destroy: {
|
||||
args: [FFIType.ptr], // plan
|
||||
returns: FFIType.void,
|
||||
},
|
||||
});
|
||||
|
||||
return new CavaCore(lib as CavaLib)
|
||||
} catch {
|
||||
// Library load failed — missing dylib, wrong arch, etc.
|
||||
return null
|
||||
}
|
||||
return new CavaCore(lib as CavaLib);
|
||||
} catch {
|
||||
// Library load failed — missing dylib, wrong arch, etc.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,10 @@
|
||||
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
|
||||
* switch tabs; focus keeps its context (root iff already at the root,
|
||||
* otherwise the content `DEPTH_CENTER_PANE`).
|
||||
* • `h`/`l` are `swipe-prev`/`swipe-next` in content:
|
||||
* - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth
|
||||
* when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
|
||||
* where the tab becomes CURRENT again.
|
||||
* - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to
|
||||
* [1, paneCount]; `h` on the first content pane stays (no tab overflow).
|
||||
* • `h`/`l` are `swipe-prev`/`swipe-next` in content (every tab is a
|
||||
* depth-tab): `l` at the current pane drills in (emits `open`); `h` pops
|
||||
* a depth when depth > 0; at depth 0 `h` returns to the tab root
|
||||
* (`backToTabRoot`), where the tab becomes CURRENT again.
|
||||
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
|
||||
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
|
||||
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
|
||||
@@ -36,7 +34,7 @@
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
|
||||
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
|
||||
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
|
||||
import { TABS, TabsCount } from "@/utils/navigation";
|
||||
import { emit } from "@/utils/event-bus";
|
||||
|
||||
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
|
||||
@@ -182,33 +180,23 @@ export function createDispatcher(deps: DispatcherDeps) {
|
||||
if (action === "swipe-prev") break;
|
||||
}
|
||||
// ── pane swipe / depth nav ──
|
||||
// Depth-tabs: l at the center drills in (emits `open`); h at the
|
||||
// center pops a depth; at depth 0 h returns to the tab root (the tab
|
||||
// becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
|
||||
// h/l swipe across [1, paneCount]; h on the first pane stays.
|
||||
// Every tab is a depth-tab: `l` at the center drills in (emits `open`);
|
||||
// `h` at the center pops a depth when depth > 0, and at depth 0 returns
|
||||
// to the tab root (the tab becomes CURRENT again).
|
||||
if (action === "swipe-prev") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
if (nav.currentDepth() > 0) nav.popDepth();
|
||||
else nav.backToTabRoot(); // depth 0 → tab root
|
||||
} else if (nav.activePane() > DEPTH_CENTER_PANE) {
|
||||
nav.swipe(-1, TabPaneCount[tab]); // content 1..n
|
||||
}
|
||||
// fixed first content pane (1): no-op, stays (special tabs)
|
||||
if (nav.currentDepth() > 0) nav.popDepth();
|
||||
else nav.backToTabRoot(); // depth 0 → tab root
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
emit("nav.action", {
|
||||
action: "open",
|
||||
tab,
|
||||
pane: DEPTH_CENTER_PANE,
|
||||
mode: nav.mode(),
|
||||
});
|
||||
} else {
|
||||
nav.swipe(1, TabPaneCount[tab]);
|
||||
}
|
||||
emit("nav.action", {
|
||||
action: "open",
|
||||
tab,
|
||||
pane: DEPTH_CENTER_PANE,
|
||||
mode: nav.mode(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
// ── audio transport (global) ──
|
||||
|
||||
@@ -14,12 +14,15 @@ export enum TABS {
|
||||
export const TabsCount = 6;
|
||||
|
||||
/** Tabs that use the yazi depth-stack model (prev | current | preview
|
||||
* columns, infinite drill via push/pop). Search and Player keep the legacy
|
||||
* fixed-pane model. */
|
||||
* columns, infinite drill via push/pop). Search drills query→results, and
|
||||
* Player drills into its single now-playing pane under the tab list (the
|
||||
* parent=/tabs, current=player, preview hidden). */
|
||||
export const DEPTH_TABS: ReadonlySet<TABS> = new Set([
|
||||
TABS.FEED,
|
||||
TABS.MYSHOWS,
|
||||
TABS.DISCOVER,
|
||||
TABS.SEARCH,
|
||||
TABS.PLAYER,
|
||||
TABS.SETTINGS,
|
||||
]);
|
||||
|
||||
@@ -35,6 +38,10 @@ export function rootFrameFor(
|
||||
return { kind: "shows", focus: 0 };
|
||||
case TABS.DISCOVER:
|
||||
return { kind: "discover:categories", focus: 0 };
|
||||
case TABS.SEARCH:
|
||||
return { kind: "search:query", focus: 0 };
|
||||
case TABS.PLAYER:
|
||||
return { kind: "player:nowplaying", focus: 0 };
|
||||
case TABS.SETTINGS:
|
||||
return { kind: "settings:sections", focus: 0 };
|
||||
default:
|
||||
@@ -62,16 +69,15 @@ export const PANE_RATIO = {
|
||||
// Number of *focusable* content panes per tab. The three visible columns
|
||||
// (parent | current | preview) are a *render* concern, NOT three panes — for
|
||||
// depth-tabs only the current column (index 0) is focusable, so this is 1.
|
||||
// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
|
||||
// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
|
||||
// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
|
||||
// here (after TABS) to avoid re-introducing the old NavigationContext
|
||||
// top-level-init circular deadlock.
|
||||
// Every tab is now a depth-tab: each drills with `l` (push) and pops with `h`
|
||||
// (returns to the tab root at depth 0) via the Shell dispatch. Defined here
|
||||
// (after TABS) to avoid re-introducing the old NavigationContext top-level-
|
||||
// init circular deadlock.
|
||||
export const TabPaneCount: Record<TABS, number> = {
|
||||
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
||||
[TABS.DISCOVER]: 1, // depth: categories → results → preview
|
||||
[TABS.SEARCH]: 3, // fixed: query | results | detail
|
||||
[TABS.PLAYER]: 1, // single pane
|
||||
[TABS.SEARCH]: 1, // depth: query → results, preview=detail
|
||||
[TABS.PLAYER]: 1, // depth: now-playing (2-pane, no preview)
|
||||
[TABS.SETTINGS]: 1, // depth: sections → items → editor
|
||||
};
|
||||
|
||||
38
tests/audio-dispose.test.ts
Normal file
38
tests/audio-dispose.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Audio backend dispose regression test.
|
||||
*
|
||||
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
||||
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
||||
* keep spawned players (mpv) from surviving the host, useAudio
|
||||
* registers a `process.on("exit")` handler that synchronously disposes the
|
||||
* backend. The exit handler's whole job is "kill the child process", so this
|
||||
* test pins the contract directly: a backend holding a real spawned subprocess
|
||||
* must have killed it once `dispose()` returns.
|
||||
*
|
||||
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
||||
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
||||
* mpv is the only real backend, and it uses the kill-on-dispose
|
||||
* exercising one is enough to guard the family.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { MpvBackend } from "../src/utils/audio-player";
|
||||
|
||||
test("MpvBackend.dispose() kills the spawned child process", async () => {
|
||||
const backend = new MpvBackend();
|
||||
// Inject a real long-lived subprocess as if mpv had been spawned.
|
||||
const child = Bun.spawn(["sleep", "60"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
(backend as unknown as { proc: typeof child }).proc = child;
|
||||
|
||||
// Sanity: the child is alive.
|
||||
expect(child.killed).toBe(false);
|
||||
|
||||
backend.dispose();
|
||||
|
||||
// dispose() sent SIGTERM (proc.kill()); wait for the child to exit.
|
||||
await child.exited;
|
||||
expect(child.killed).toBe(true);
|
||||
});
|
||||
@@ -2,57 +2,85 @@
|
||||
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
||||
* Run: bun tests/cavacore-smoke.ts
|
||||
*/
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
||||
import { join } from "path"
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||
import { join } from "path";
|
||||
|
||||
const libPath = join(import.meta.dir, "..", "src", "native", "libcavacore.dylib")
|
||||
const libPath = join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"src",
|
||||
"native",
|
||||
"libcavacore.dylib",
|
||||
);
|
||||
|
||||
const lib = dlopen(libPath, {
|
||||
cava_init: {
|
||||
args: [FFIType.i32, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.double, FFIType.i32, FFIType.i32],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
cava_execute: {
|
||||
args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
cava_destroy: {
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
})
|
||||
cava_init: {
|
||||
args: [
|
||||
FFIType.i32,
|
||||
FFIType.u32,
|
||||
FFIType.i32,
|
||||
FFIType.i32,
|
||||
FFIType.double,
|
||||
FFIType.i32,
|
||||
FFIType.i32,
|
||||
FFIType.i32,
|
||||
],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
cava_execute: {
|
||||
args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
cava_destroy: {
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
});
|
||||
|
||||
const bars = 10
|
||||
const rate = 44100
|
||||
const channels = 1
|
||||
const bars = 10;
|
||||
const rate = 44100;
|
||||
const channels = 1;
|
||||
|
||||
// Init
|
||||
const plan = lib.symbols.cava_init(bars, rate, channels, 1, 0.77, 50, 10000)
|
||||
const plan = lib.symbols.cava_init(
|
||||
bars,
|
||||
rate,
|
||||
channels,
|
||||
1,
|
||||
0.77,
|
||||
50,
|
||||
10000,
|
||||
0 /* CAVA_SCALING_LINEAR */,
|
||||
);
|
||||
if (!plan) {
|
||||
console.error("FAIL: cava_init returned null")
|
||||
process.exit(1)
|
||||
console.error("FAIL: cava_init returned null");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("cava_init OK, plan pointer:", plan)
|
||||
console.log("cava_init OK, plan pointer:", plan);
|
||||
|
||||
// Generate a 200Hz sine wave test signal
|
||||
const bufferSize = 512
|
||||
const cavaIn = new Float64Array(bufferSize)
|
||||
const cavaOut = new Float64Array(bars * channels)
|
||||
const bufferSize = 512;
|
||||
const cavaIn = new Float64Array(bufferSize);
|
||||
const cavaOut = new Float64Array(bars * channels);
|
||||
|
||||
for (let k = 0; k < 100; k++) {
|
||||
for (let n = 0; n < bufferSize; n++) {
|
||||
cavaIn[n] = Math.sin(2 * Math.PI * 200 / rate * (n + k * bufferSize)) * 20000
|
||||
}
|
||||
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan)
|
||||
for (let n = 0; n < bufferSize; n++) {
|
||||
cavaIn[n] =
|
||||
Math.sin(((2 * Math.PI * 200) / rate) * (n + k * bufferSize)) * 20000;
|
||||
}
|
||||
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan);
|
||||
}
|
||||
|
||||
console.log("cava_execute OK, output:", Array.from(cavaOut).map(v => v.toFixed(3)))
|
||||
console.log(
|
||||
"cava_execute OK, output:",
|
||||
Array.from(cavaOut).map((v) => v.toFixed(3)),
|
||||
);
|
||||
|
||||
// Check that bar 2 (200Hz) has the peak
|
||||
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut))
|
||||
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`)
|
||||
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut));
|
||||
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`);
|
||||
|
||||
// Destroy
|
||||
lib.symbols.cava_destroy(plan)
|
||||
console.log("cava_destroy OK")
|
||||
console.log("\nSMOKE TEST PASSED")
|
||||
lib.symbols.cava_destroy(plan);
|
||||
console.log("cava_destroy OK");
|
||||
console.log("\nSMOKE TEST PASSED");
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
|
||||
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
|
||||
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
|
||||
* • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
|
||||
* `h` on the first pane stays — never overflows to the tab root.
|
||||
* • Every tab is a depth-tab: `swipe-next` (l) at depth 0 emits `open`
|
||||
* (drill); `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the
|
||||
* tab root. Player has no deeper drill (single now-playing pane).
|
||||
* • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
|
||||
* tabs and preserve focus context (root stays root for depth-tabs, content
|
||||
* stays content).
|
||||
@@ -218,33 +219,50 @@ test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
|
||||
test("dispatch('swipe-prev') on Search at depth 0 returns to the tab root", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // fixed-pane
|
||||
nav.setActiveTab(TABS.SEARCH); // depth-tab, query root
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
dispatch("swipe-prev");
|
||||
// special tab: h on the first content pane does not return to the root.
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
// h at depth 0 returns to the tab root — so Search isn't a dead end.
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
// a second h at the root is inert (out of the panes).
|
||||
dispatch("swipe-prev");
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => {
|
||||
test("dispatch('swipe-prev') on the single-pane Player tab returns to the tab root", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // 3 panes
|
||||
nav.setActiveTab(TABS.PLAYER); // depth-tab, single now-playing pane
|
||||
nav.enterTabContent();
|
||||
nav.swipe(1, 3);
|
||||
nav.swipe(1, 3);
|
||||
expect(nav.activePane()).toBe(3);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
dispatch("swipe-prev");
|
||||
expect(nav.activePane()).toBe(2);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') at depth 1 (results) pops to depth 0 (query)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // depth-tab: query(0) → results(1)
|
||||
nav.enterTabContent();
|
||||
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
|
||||
dispatch("swipe-prev");
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.atRootTab()).toBe(false); // h at depth>0 stays in content
|
||||
});
|
||||
});
|
||||
|
||||
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
|
||||
test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => {
|
||||
test("tab-goto-N from the root keeps depth-tabs at the root", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
// focus starts on the tab root.
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
@@ -259,11 +277,12 @@ test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open",
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
// fixed-pane tab is special: switching from the root opens its content.
|
||||
// every tab is a depth-tab now: switching to Search from the root
|
||||
// keeps the root too (Enter/l opens content).
|
||||
dispatch("tab-goto-4"); // → Search
|
||||
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||
expect(nav.tabCursor()).toBe(TABS.SEARCH);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,7 +295,7 @@ test("tab-goto-N from content keeps focus in the active tab's content", () => {
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
|
||||
dispatch("tab-goto-4"); // → Search (depth-tab) lands its current pane
|
||||
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
|
||||
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
|
||||
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
|
||||
* for the fixed-pane Search/Player tabs).
|
||||
* for the fixed-pane Search/Player tabs (they clear `atRootTab` on switch
|
||||
* and regain it via `backToTabRoot`, the `h`-back-up path).
|
||||
* • the root tab list is a normal list: `tabCursor` is independent of
|
||||
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
|
||||
* the hovered tab + enters content, and direct tab switches re-sync it.
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
DEPTH_CENTER_PANE,
|
||||
NavMode,
|
||||
} from "../src/context/navigation-store";
|
||||
import { TABS, TabPaneCount } from "../src/utils/navigation";
|
||||
import { TABS } from "../src/utils/navigation";
|
||||
|
||||
/** Build a fresh nav graph inside a reactive root and run `fn` against it.
|
||||
* Disposes the root afterwards so effects/signals don't leak between tests. */
|
||||
@@ -115,15 +116,22 @@ test("tab switch keeps focus context: in content it stays in content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("switching to a Search/Player tab leaves the root (special content)", () => {
|
||||
test("switching to a Search/Player tab keeps the root (depth-tab)", () => {
|
||||
withNav((nav) => {
|
||||
// at root, opening Search is special: atRootTab() reports false because
|
||||
// Search has its own content and never renders the tab-list root view.
|
||||
// at root, opening Search keeps the root: every tab is a depth-tab now,
|
||||
// so Enter/l is required to drop into content. `h`-back-up still works.
|
||||
nav.setActiveTab(TABS.SEARCH);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
nav.backToTabRoot();
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
// Player is also a depth-tab now.
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
nav.enterTabContent();
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,43 +225,30 @@ test("tab-switch resets mode/visual/command state", () => {
|
||||
});
|
||||
|
||||
// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
|
||||
test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
|
||||
test("Search is a depth-tab: query root drills to results and back", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
|
||||
expect(TabPaneCount[TABS.SEARCH]).toBe(3);
|
||||
nav.setActiveTab(TABS.SEARCH);
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.topFrame()?.kind).toBe("search:query");
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(1);
|
||||
// swipe left stays at 1 (no pane 0).
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
// swipe right up through the columns, then hold the upper bound.
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(2);
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(3);
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(3); // never exceeds paneCount
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(2);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
// Enter on the query submits → push a results frame.
|
||||
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
// h at depth 1 pops back to the query.
|
||||
expect(nav.popDepth()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("swipe on a single-pane fixed tab stays at its one content pane", () => {
|
||||
test("Player is a single-depth depth-tab (now-playing only)", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.PLAYER); // single-pane
|
||||
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
|
||||
nav.enterTabContent(); // lands on its one content pane (1)
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
|
||||
expect(nav.activePane()).toBe(1); // upper bound
|
||||
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
|
||||
expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.topFrame()?.kind).toBe("player:nowplaying");
|
||||
nav.enterTabContent();
|
||||
expect(nav.currentDepth()).toBe(0); // no deeper drill
|
||||
expect(nav.popDepth()).toBe(false); // noop at depth 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* yazi-pages-depth.test.ts — task 03 page contract tests.
|
||||
*
|
||||
* The four depth-stack list tabs (Feed / MyShows / Discover / Settings) all
|
||||
* render through `<YaziPaneRow>` with the parent pane reading the
|
||||
* Every depth-stack tab (Feed / MyShows / Discover / Search / Player /
|
||||
* Settings) renders through `<YaziPaneRow>` with the parent pane reading the
|
||||
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
|
||||
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
|
||||
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
|
||||
@@ -37,8 +37,15 @@ function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
|
||||
});
|
||||
}
|
||||
|
||||
/** The depth-tabs that must render via <YaziPaneRow> (task 03 conversion). */
|
||||
const CONVERTED_TABS = [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS];
|
||||
/** The depth-tabs that render via <YaziPaneRow> (task 03 conversion). */
|
||||
const CONVERTED_TABS = [
|
||||
TABS.FEED,
|
||||
TABS.MYSHOWS,
|
||||
TABS.DISCOVER,
|
||||
TABS.SEARCH,
|
||||
TABS.PLAYER,
|
||||
TABS.SETTINGS,
|
||||
];
|
||||
|
||||
for (const tab of CONVERTED_TABS) {
|
||||
const name = TABS[tab];
|
||||
@@ -54,7 +61,11 @@ for (const tab of CONVERTED_TABS) {
|
||||
|
||||
// drill (l): page open() pushes a child frame — parent becomes
|
||||
// the previous-depth list.
|
||||
const child: DepthFrame = { kind: `${name.toLowerCase()}:child`, ctx: "c1", focus: 0 };
|
||||
const child: DepthFrame = {
|
||||
kind: `${name.toLowerCase()}:child`,
|
||||
ctx: "c1",
|
||||
focus: 0,
|
||||
};
|
||||
nav.pushDepth(child);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
@@ -65,7 +76,11 @@ for (const tab of CONVERTED_TABS) {
|
||||
|
||||
// drill again (l): push a second child — parent shows the first
|
||||
// child's list (the chain Settings exercises: sections→items→editor).
|
||||
const grandchild: DepthFrame = { kind: `${name.toLowerCase()}:grand`, ctx: "g1", focus: 0 };
|
||||
const grandchild: DepthFrame = {
|
||||
kind: `${name.toLowerCase()}:grand`,
|
||||
ctx: "g1",
|
||||
focus: 0,
|
||||
};
|
||||
nav.pushDepth(grandchild);
|
||||
expect(nav.currentDepth()).toBe(2);
|
||||
expect(nav.depthStack()).toHaveLength(3);
|
||||
@@ -97,9 +112,16 @@ for (const tab of CONVERTED_TABS) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── DEPTH_TABS covers exactly the four converted pages ───────────────────────
|
||||
test("DEPTH_TABS is exactly the four converted list tabs", () => {
|
||||
// ── DEPTH_TABS covers exactly the depth-stack pages ────────────────────────
|
||||
test("DEPTH_TABS is exactly the depth-stack tabs", () => {
|
||||
expect([...DEPTH_TABS].sort()).toEqual(
|
||||
[TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS].sort(),
|
||||
[
|
||||
TABS.FEED,
|
||||
TABS.MYSHOWS,
|
||||
TABS.DISCOVER,
|
||||
TABS.SEARCH,
|
||||
TABS.PLAYER,
|
||||
TABS.SETTINGS,
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,7 +106,12 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
const spans = setup.captureSpans() as unknown as Frame;
|
||||
return { spans, destroy: () => setup.renderer.destroy() };
|
||||
return {
|
||||
spans,
|
||||
destroy: async () => {
|
||||
setup.renderer.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cleanups: (() => void | Promise<void>)[] = [];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user