25 Commits

Author SHA1 Message Date
b0bfa41028 bump VERSION to 0.3.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m12s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-09 22:24:58 -04:00
25307f83e9 fix: up highlight made legible for transparent bg, bring back mouse nav 2026-08-09 22:21:30 -04:00
db285530b6 feat: private feeds, all input fields supersede keyboard nav 2026-08-09 15:39:02 -04:00
e1cdd6b2a5 finished hygenie 2026-08-09 14:38:33 -04:00
2abdbaa4e9 cleaning up code 2026-08-09 09:54:52 -04:00
1d06156b8b docs: repoint Homebrew tap to mikefreno/tap after repo rename 2026-08-09 09:19:05 -04:00
c63e9e1b9c bump VERSION to 0.2.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m15s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-08 08:02:53 -04:00
0facfff51b docs: document AUR packaging sync in release steps; CI uploads LICENSE asset 2026-08-08 07:17:27 -04:00
3ef19f80b8 drop oauth plan (copy config) 2026-08-08 07:10:59 -04:00
13a31aabdc docs: mark AUR package as pending registration reopen 2026-08-08 07:09:07 -04:00
8dbdebfd30 AUR: fetch LICENSE as release asset, install to licenses dir; sync .SRCINFO 2026-08-07 20:11:43 -04:00
529817323d add MIT license, podtui-bin AUR PKGBUILD, fix README install sections 2026-08-07 20:09:30 -04:00
ace883b505 docs: reference scripts/release-tag.sh in Releasing section 2026-08-07 19:38:20 -04:00
de01cedee0 add scripts/release-tag.sh: interactive vX.Y.Z bump, tag, dual-remote push
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m26s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-07 19:36:07 -04:00
2730fa3cae bump VERSION to 0.2.0 2026-08-07 19:32:48 -04:00
91a831c5f9 audio playback fixes 2026-08-07 19:28:57 -04:00
52e9ae0ab7 fix(discover): correct 5 wrong RSS feed URLs in featured.json
The original featured.json had 5 entries pointing at the wrong podcasts
because verification only checked the feed's <title> loosely matched.
Each wrong URL happened to share a slug/name with the intended show:

  - All-In Podcast: feeds.transistor.fm/all-in was a Verizon internal
    comms show (6 eps from 2020), not the Chamath/Sacks/Friedberg/
    Calacanis show. -> allinchamathjason.libsyn.com/rss (405 eps)
  - Code Switch: NPR feed ID 510352 was 'I\'ll Be Seeing You'.
    -> 510312 (correct Code Switch feed, 637 eps)
  - Stuff You Should Know: rss.art19.com/stuff-you-should-know was a
    different show by 'Podgo' (7 eps). -> iHeart omnycontent URL
    (2855 eps, Josh Clark & Chuck Bryant)
  - Philosophize This!: feed.podbean.com/... returned 0 items.
    -> philosophizethis.libsyn.com/rss (189 eps, Stephen West)
  - Very Bad Wizards: feed.podbean.com/... returned 0 items.
    -> feeds.libsyn.com/474285/rss (345 eps, official feed)

Bumps version 1 -> 2 so the discover store re-fetches immediately.
All 5 corrected URLs verified: 200 OK, valid RSS, matching <title>
and <itunes:author>, sensible episode counts, recent pubDates.
2026-08-07 19:12:08 -04:00
64d8b40e61 actual featured page 2026-08-07 18:59:25 -04:00
0cc15c8d90 fix lint: make bun tsc --noEmit actually pass (was a TS crash + latent errors)
tsconfig used jsx:"preserve"+jsxImportSource, which trips a TS 5.9
internal crash ("Expected sourceFile.imports[0] to be the synthesized JSX
runtime import") so tsc could never run clean. Switch to jsx:"react-jsx"
with the package's real jsx-runtime types, and fix the real errors that
surfaced:
- delete dead src/components/Navigation.tsx (imported ./Tab that doesn't
  exist; the component has no importers)
- PlaybackControls: fix relative path to @/utils/audio-player
- SourceBadge: drop dead module-level typeColor (bare 'theme')
- command palette: bind to the real 'command' keybind (:) instead of the
  never-defined 'command_list' action (palette was unreachable)
- yazi-pane-row test: destroy() must return Promise<void> as typed

Also fold in the package.json lint fix (bun tsc --noEmit) and doc polish.
2026-08-07 18:18:25 -04:00
1d3abd53d4 docs: add human-oriented CONTRIBUTING.md (repo map, FFI notes, gotchas, release & tap auto-sync workflow) 2026-08-07 18:09:51 -04:00
592cfd4093 fix search navigation 2026-08-07 17:46:27 -04:00
69e12cf5b9 fix: podtui binary crashed with 'preload not found' when launched from repo root
The embedded runtime reads the CWD bunfig.toml at startup; the repo's
top-level 'preload = ["@opentui/solid/preload"]' made the standalone die
because that module isn't bundled. Removed the ambient preload from
bunfig.toml and moved the solid transform registration to explicit
--preload flags in the start/dev scripts ([test] preload untouched).

Verified: binary boots from repo root, from a clean dir, and dev/start
still work; bun test 54 pass. No new release needed — the binaries were
never config-dependent; this was purely the repo's bunfig trap.
2026-08-07 17:36:11 -04:00
c9e3aa92ec fix release pipeline: vendored cava source, fftw in CI, runner arch, smoke test
- Vendor cava/cavacore.c + header (MIT, from karlstav/cava) — the FFI build
  referenced cava/cavacore.c which was never committed, so every CI runner
  failed at scripts/build-cavacore.sh and no release was possible.
- build-cavacore.sh: discover libfftw3.a across Homebrew and Debian/Ubuntu
  multiarch paths (FFTW_PREFIX override preserved).
- release.yml: install fftw before building cavacore; run the boot smoke test
  from a bunfig-free dir (the embedded runtime reads the CWD bunfig.toml and
  this repo's preload entry breaks it — 'preload not found'); use
  macos-15-intel for darwin-x64 (macos-latest is arm64).
- Makefile/build.ts: drop the no-op BUN_CONFIG=bunfig.standalone.toml compile
  dance (Bun never honored it; compile output is config-independent); delete
  bunfig.standalone.toml.
2026-08-07 14:27:35 -04:00
25fe7f6ac9 theme rendering 2026-08-07 13:42:03 -04:00
85cb9fba26 ui cleanup 2026-08-07 13:38:32 -04:00
129 changed files with 8031 additions and 8732 deletions

100
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
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
LICENSE

204
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,204 @@
# 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. Run `scripts/release-tag.sh` (interactive: pick major/minor/patch/custom,
confirms the plan, bumps `VERSION` in `src/index.tsx`, commits, tags
`vX.Y.Z`, and pushes branch + tag to every remote). If the version bump is
already committed but the tag is missing, it offers a tag-only path.
`--dry-run` prints the plan without doing anything.
2. Equivalent manual commands:
```bash
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
```
3. CI (`.github/workflows/release.yml`) runs four builds in parallel,
each producing `podtui-<platform>-<arch>.tar.gz`:
| Runner | Platform/Arch |
|---------------------|---------------|
| `ubuntu-latest` | linux-x64 |
| `ubuntu-24.04-arm` | linux-arm64 |
| `macos-15-intel` | darwin-x64 |
| `macos-14` | darwin-arm64 |
Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh`
→ `make dist` → smoke-boots the binary from a temp dir → uploads the
tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.)
4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/tap/podtui`.
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
staged, not yet published (AUR account registrations are closed; see the
README note in section 3). On each release, keep the AUR sources in sync
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
entries, keep the `LICENSE` asset source (the workflow above uploads
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
`bash packaging/aur/gen-srcinfo.sh`.
### Manual fallback
If you ever need to sync the tap by hand (or before the hourly job runs):
```bash
cd <clone of mikefreno/homebrew-tap>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
```
### Local release build
```bash
make dist # builds the binary + tarball for THIS machine only
```
Bun cannot cross-compile — the other platforms come from CI.
---
## 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`.

30
LICENSE Normal file
View File

@@ -0,0 +1,30 @@
MIT License
Copyright (c) 2026 Michael Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
This project bundles third-party components under their own licenses:
- **cava** (karlstav/cava, vendored under `cava/`) — MIT,
Copyright (c) 2015 Karl Stavestrand. See `cava/LICENSE-cava.txt`.
- **Bun runtime** (embedded in the standalone binary) — MIT.
- **OpenTUI** (`@opentui/core`) — MIT.

66
Makefile Normal file
View 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

232
README.md
View File

@@ -1,15 +1,237 @@
# 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, `16` / `[` `]` 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/tap/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 ```bash
curl -sS -o /tmp/podtui.tar.gz \
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
sudo mkdir -p /opt/podtui
sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1
sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
```
> The tarball contains `podtui` plus `libopentui.<ext>` and
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
> binary alone), or the native FFI libraries won't load.
>
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
> you launch from. If that file has a `preload` entry (as Bun project
> directories often do), startup fails with `preload not found`. Launching
> from a normal directory (home, `~/bin`, …) works fine.
### 3. Arch Linux (AUR)
```bash
# Status: PKGBUILD ready, not yet on the AUR (see note below)
yay -S podtui-bin # once published
```
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and
its two FFI sibling libraries into `/usr/lib/podtui/` with a `/usr/bin/podtui`
symlink, and pulls in `mpv` (the sole audio backend) as a dependency.
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready
> in `packaging/aur/` and can be built locally today:
>
> ```bash
> cd packaging/aur && makepkg -si
> ```
>
> Publishing is on hold until [AUR account registrations](https://aur.archlinux.org)
> reopen (suspended while the AUR team works on suspicious-package
> moderation). Once a key can be registered, push `PKGBUILD` + `.SRCINFO`
> with `git push ssh://aur@aur.archlinux.org/podtui-bin` and update this note.
### 4. From source
Requires [Bun](https://bun.sh) ≥ 1.2.
```bash
git clone https://github.com/mikefreno/podtui.git
cd podtui
bun install 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 16 (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 ```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
MIT. See [LICENSE](LICENSE).
## Related
- [OpenTUI](https://github.com/opentui/opentui) — the TUI framework driving the interface

163
build.ts
View File

@@ -1,62 +1,129 @@
import solidPlugin from "@opentui/solid/bun-plugin" import solidPlugin from "@opentui/solid/bun-plugin";
import { copyFileSync, existsSync, mkdirSync } from "node:fs" import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { join, dirname } from "node:path" import { join } from "node:path";
import { plugin } from "bun";
// Register the solid transform globally (dedup'd by name). This is what makes
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins
// that are registered via `plugin()`, not the `plugins:` array. The transform
// is fully embedded in the compiled binary.
plugin(solidPlugin);
const COMPILE =
process.argv.includes("--compile") || process.env.PODTUI_COMPILE === "1";
const platform = process.platform;
const arch = process.arch;
// Platform/arch → OpenTUI package name
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
};
const libExt =
platform === "win32" ? "dll" : platform === "darwin" ? "dylib" : "so";
// Build the JavaScript bundle // Build the JavaScript bundle
await Bun.build({ await Bun.build({
entrypoints: ["./src/index.tsx"], entrypoints: ["./src/index.tsx"],
outdir: "./dist", outdir: "./dist",
target: "bun", target: "bun",
minify: true, minify: true,
sourcemap: "external", sourcemap: "external",
plugins: [solidPlugin], plugins: [solidPlugin],
}) });
// Copy the native library to dist for distribution // Copy the opentui native library to dist for distribution.
const platform = process.platform const platformKey = `${platform}-${arch}`;
const arch = process.arch const platformPkg = platformMap[platformKey];
// Map platform/arch to OpenTUI package names
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
}
const platformKey = `${platform}-${arch}`
const platformPkg = platformMap[platformKey]
if (platformPkg) { if (platformPkg) {
const libName = platform === "win32" const libName = `libopentui.${libExt}`;
? "opentui.dll" const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName);
: platform === "darwin"
? "libopentui.dylib" if (existsSync(srcPath)) {
: "libopentui.so" const destPath = join("dist", libName);
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName) copyFileSync(srcPath, destPath);
console.log(`Copied native library: ${libName}`);
if (existsSync(srcPath)) { }
const destPath = join("dist", libName)
copyFileSync(srcPath, destPath)
console.log(`Copied native library: ${libName}`)
}
} }
// Copy cavacore native library to dist // Copy cavacore native library to dist
const cavacoreLib = platform === "darwin" const cavacoreLib = `libcavacore.${libExt}`;
? "libcavacore.dylib" const cavacoreSrc = join("src", "native", cavacoreLib);
: platform === "win32"
? "cavacore.dll"
: "libcavacore.so"
const cavacoreSrc = join("src", "native", cavacoreLib)
if (existsSync(cavacoreSrc)) { if (existsSync(cavacoreSrc)) {
copyFileSync(cavacoreSrc, join("dist", cavacoreLib)) copyFileSync(cavacoreSrc, join("dist", cavacoreLib));
console.log(`Copied cavacore library: ${cavacoreLib}`) console.log(`Copied cavacore library: ${cavacoreLib}`);
} else { } else {
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`) console.warn(
`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`,
);
} }
console.log("Build complete") // ── Standalone compiled binary (dist/podtui + libs beside it) ──────────────
// `bun run build.ts --compile` (or PODTUI_COMPILE=1). Embeds the Bun runtime
// so end users need nothing installed; the two FFI libs are shipped as
// SIBLING FILES next to the binary (both loaders already resolve them that
// way: cavacore checks dirname(process.execPath); opentui embeds via its
// bun-plugin and handles the embedded-file path itself).
if (COMPILE) {
const outfile = join("dist", "podtui");
await Bun.build({
entrypoints: ["./src/index.tsx"],
target: "bun",
minify: true,
sourcemap: "external",
plugins: [solidPlugin],
compile: {
outfile,
},
});
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");

BIN
bun.lockb

Binary file not shown.

View File

@@ -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] [test]
preload = "@opentui/solid/preload" preload = "@opentui/solid/preload"

19
cava/LICENSE-cava.txt Normal file
View 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
View 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
View File

@@ -0,0 +1,139 @@
/*
Copyright (c) 2022 Karl Stavestrand <karl@stavestrand.no>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#ifdef __cplusplus
extern "C" {
#endif
#pragma once
#include <stdint.h>
#include <fftw3.h>
#define CAVA_SCALING_LINEAR 0
#define CAVA_SCALING_DECIBEL 1
// cava_plan, parameters used internally by cavacore, do not modify these directly
// only the cut off frequencies is of any potential interest to read out,
// the rest should most likely be hidden somehow
struct cava_plan {
int FFTbassbufferSize;
int FFTbufferSize;
int number_of_bars;
int audio_channels;
int input_buffer_size;
int rate;
int bass_cut_off_bar;
int sens_init;
int autosens;
int frame_skip;
int status;
int scaling_mode;
char error_message[1024];
double sens;
double framerate;
double noise_reduction;
fftw_plan p_bass_l, p_bass_r;
fftw_plan p_l, p_r;
fftw_complex *out_bass_l, *out_bass_r;
fftw_complex *out_l, *out_r;
double *bass_multiplier;
double *multiplier;
double *in_bass_r_raw, *in_bass_l_raw;
double *in_r_raw, *in_l_raw;
double *in_bass_r, *in_bass_l;
double *in_r, *in_l;
double *prev_cava_out, *cava_mem;
double *input_buffer, *cava_peak;
double *eq;
float *cut_off_frequency;
int *FFTbuffer_lower_cut_off;
int *FFTbuffer_upper_cut_off;
double *cava_fall;
};
// cava_init, initialize visualization, takes the following parameters:
// number_of_bars, number of wanted bars per channel
// rate, sample rate of input signal
// channels, number of interleaved channels in input
// autosens, toggle automatic sensitivity adjustment 1 = on, 0 = off
// on, gives a dynamically adjusted output signal from 0 to 1
// the output is continuously adjusted to use the entire range
// off, will pass the raw values from cava directly to the output
// the max values will then be dependent on the input
// noise_reduction, adjust noise reduction filters. 0 - 1, recommended 0.77
// the raw visualization is very noisy, this factor adjusts the integral
// and gravity filters inside cavacore to keep the signal smooth
// 1 will be very slow and smooth, 0 will be fast but noisy.
// low_cut_off, high_cut_off cut off frequencies for visualization in Hz
// recommended: 50, 10000
// scaling_mode, output scaling mode:
// CAVA_SCALING_LINEAR = legacy linear scaling
// CAVA_SCALING_DECIBEL = dB-based logarithmic scaling
// returns a cava_plan to be used by cava_execute. If cava_plan.status is 0 all is OK.
// If cava_plan.status is -1, cava_init was called with an illegal parameter, see error string in
// cava_plan.error_message
extern struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels,
int autosens, double noise_reduction, int low_cut_off,
int high_cut_off, int scaling_mode);
// cava_execute, executes visualization
// cava_in, input buffer can be any size. internal buffers in cavacore is
// 4096 * number of channels at 44100 samples rate, if new_samples is greater
// then samples will be discarded. However it is recommended to use less
// new samples per execution as this determines your framerate.
// 512 samples at 44100 sample rate mono, gives about 86 frames per second.
// new_samples, the number of samples in cava_in to be processed per execution
// in case of async reading of data this number is allowed to vary from execution to execution
// cava_out, output buffer. Size must be number of bars * number of channels. Bars will
// be sorted from lowest to highest frequency. If stereo input channels are configured
// then all left channel bars will be first then the right.
// plan, the cava_plan struct returned from cava_init
// cava_execute assumes cava_in samples to be interleaved if more than one channel
// only up to two channels are supported.
extern void cava_execute(double *cava_in, int new_samples, double *cava_out,
struct cava_plan *plan);
// cava_destroy, destroys the plan, frees up memory
extern void cava_destroy(struct cava_plan *plan);
#ifdef __cplusplus
}
#endif

547
discover/featured.json Normal file
View File

@@ -0,0 +1,547 @@
{
"version": 3,
"podcasts": [
{
"id": "discover-daily",
"title": "The Daily",
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
"feedUrl": "http://rss.art19.com/the-daily",
"author": "The New York Times",
"categories": [
"News & Politics"
]
},
{
"id": "discover-up-first",
"title": "Up First",
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
"author": "NPR",
"categories": [
"News & Politics"
]
},
{
"id": "discover-npr-politics",
"title": "The NPR Politics Podcast",
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
"author": "NPR",
"categories": [
"News & Politics"
]
},
{
"id": "discover-ben-shapiro",
"title": "The Ben Shapiro Show",
"description": "Ben Shapiro delivers unapologetically conservative commentary on the biggest news stories of the day, blending sharp analysis with his trademark fact-based approach.",
"feedUrl": "https://feeds.megaphone.fm/benshow",
"author": "The Daily Wire",
"categories": [
"News & Politics"
]
},
{
"id": "discover-advisory-opinions",
"title": "Advisory Opinions",
"description": "Host Sarah Isgur and permanent guest David French have twice-weekly conversations about the law, the courts, their collision with politics, and why it all matters — from The Dispatch.",
"feedUrl": "https://feeds.megaphone.fm/DISPME4573820108",
"author": "The Dispatch",
"categories": [
"News & Politics"
]
},
{
"id": "discover-crime-junkie",
"title": "Crime Junkie",
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
"author": "audiochuck",
"categories": [
"True Crime"
]
},
{
"id": "discover-serial",
"title": "Serial",
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
"author": "Serial Productions & The New York Times",
"categories": [
"True Crime",
"Storytelling"
]
},
{
"id": "discover-intelligence-matters",
"title": "Intelligence Matters",
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
"feedUrl": "https://rss.art19.com/intelligence-matters",
"author": "CBS News",
"categories": [
"True Crime",
"News & Politics"
]
},
{
"id": "discover-smartless",
"title": "SmartLess",
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
"feedUrl": "https://rss.art19.com/smartless",
"author": "Jason Bateman, Sean Hayes, Will Arnett",
"categories": [
"Comedy",
"Entertainment"
]
},
{
"id": "discover-this-past-weekend",
"title": "This Past Weekend w/ Theo Von",
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
"author": "Theo Von",
"categories": [
"Comedy"
]
},
{
"id": "discover-joe-rogan",
"title": "The Joe Rogan Experience",
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
"author": "Joe Rogan",
"categories": [
"Comedy",
"Entertainment"
]
},
{
"id": "discover-comedy-bang-bang",
"title": "Comedy Bang Bang: The Podcast",
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
"author": "Earwolf",
"categories": [
"Comedy"
]
},
{
"id": "discover-office-ladies",
"title": "Office Ladies",
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
"feedUrl": "https://rss.art19.com/office-ladies",
"author": "Earwolf",
"categories": [
"Comedy",
"Entertainment"
]
},
{
"id": "discover-how-did-this-get-made",
"title": "How Did This Get Made?",
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
"author": "Earwolf",
"categories": [
"Comedy",
"Film"
]
},
{
"id": "discover-wait-wait",
"title": "Wait Wait... Don't Tell Me!",
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
"author": "NPR",
"categories": [
"Comedy",
"News & Politics"
]
},
{
"id": "discover-new-heights",
"title": "New Heights with Jason & Travis Kelce",
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
"feedUrl": "https://rss.art19.com/new-heights",
"author": "Jason & Travis Kelce",
"categories": [
"Sports",
"Comedy"
]
},
{
"id": "discover-bill-simmons",
"title": "The Bill Simmons Podcast",
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
"author": "The Ringer",
"categories": [
"Sports",
"Entertainment"
]
},
{
"id": "discover-acquired",
"title": "Acquired",
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
"feedUrl": "https://feeds.transistor.fm/acquired",
"author": "Ben Gilbert & David Rosenthal",
"categories": [
"Business",
"Technology"
]
},
{
"id": "discover-all-in",
"title": "All-In Podcast",
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
"categories": [
"Business",
"Technology",
"Politics"
]
},
{
"id": "discover-planet-money",
"title": "Planet Money",
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
"author": "NPR",
"categories": [
"Business",
"Economics"
]
},
{
"id": "discover-how-i-built-this",
"title": "How I Built This with Guy Raz",
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
"author": "NPR / Wondery",
"categories": [
"Business",
"Technology"
]
},
{
"id": "discover-freakonomics",
"title": "Freakonomics Radio",
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
"author": "Stephen J. Dubner",
"categories": [
"Business",
"Economics",
"Society"
]
},
{
"id": "discover-darknet-diaries",
"title": "Darknet Diaries",
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
"feedUrl": "https://podcast.darknetdiaries.com/",
"author": "Jack Rhysider",
"categories": [
"Technology",
"True Crime"
]
},
{
"id": "discover-changelog",
"title": "The Changelog",
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
"feedUrl": "https://changelog.fm/rss",
"author": "Changelog Media",
"categories": [
"Technology",
"Software Engineering"
]
},
{
"id": "discover-twit",
"title": "This Week in Tech (TWiT)",
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
"feedUrl": "https://feeds.twit.tv/twit.xml",
"author": "TWiT",
"categories": [
"Technology"
]
},
{
"id": "discover-radiolab",
"title": "Radiolab",
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
"feedUrl": "http://feeds.wnyc.org/radiolab",
"author": "WNYC Studios",
"categories": [
"Science",
"Storytelling"
]
},
{
"id": "discover-huberman-lab",
"title": "Huberman Lab",
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
"author": "Scicomm Media",
"categories": [
"Health",
"Science"
]
},
{
"id": "discover-skeptics-guide",
"title": "The Skeptics' Guide to the Universe",
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
"author": "Steven Novella",
"categories": [
"Science",
"Philosophy"
]
},
{
"id": "discover-throughline",
"title": "Throughline",
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
"author": "NPR",
"categories": [
"History",
"Politics"
]
},
{
"id": "discover-hardcore-history",
"title": "Dan Carlin's Hardcore History",
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
"author": "Dan Carlin",
"categories": [
"History"
]
},
{
"id": "discover-history-of-rome",
"title": "The History of Rome",
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
"author": "Mike Duncan",
"categories": [
"History"
]
},
{
"id": "discover-philosophize-this",
"title": "Philosophize This!",
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
"feedUrl": "https://philosophizethis.libsyn.com/rss",
"author": "Stephen West",
"categories": [
"Philosophy",
"Education"
]
},
{
"id": "discover-very-bad-wizards",
"title": "Very Bad Wizards",
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
"feedUrl": "https://feeds.libsyn.com/474285/rss",
"author": "Tamler Sommers & David Pizarro",
"categories": [
"Philosophy",
"Science"
]
},
{
"id": "discover-big-picture",
"title": "The Big Picture",
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
"feedUrl": "https://rss.art19.com/the-big-picture",
"author": "The Ringer",
"categories": [
"Film",
"Entertainment"
]
},
{
"id": "discover-all-songs-considered",
"title": "All Songs Considered",
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
"author": "NPR Music",
"categories": [
"Music"
]
},
{
"id": "discover-switched-on-pop",
"title": "Switched on Pop",
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
"author": "Vox Media / Panoply",
"categories": [
"Music"
]
},
{
"id": "discover-hit-parade",
"title": "Hit Parade",
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
"feedUrl": "https://feeds.megaphone.fm/hitparade",
"author": "Slate",
"categories": [
"Music",
"History"
]
},
{
"id": "discover-song-exploder",
"title": "Song Exploder",
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
"feedUrl": "https://songexploder.net/rss",
"author": "Hrishikesh Hirway",
"categories": [
"Music",
"Arts"
]
},
{
"id": "discover-blank-check",
"title": "Blank Check with Griffin & David",
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
"feedUrl": "https://audioboom.com/channels/4278829.rss",
"author": "Griffin Newman & David Sims",
"categories": [
"Film",
"Comedy"
]
},
{
"id": "discover-99-invisible",
"title": "99% Invisible",
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
"author": "Roman Mars",
"categories": [
"Design",
"Arts",
"Culture"
]
},
{
"id": "discover-gastropod",
"title": "Gastropod",
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
"feedUrl": "https://gastropod.com/feed",
"author": "Cynthia Graber & Nicola Twilley",
"categories": [
"Food",
"Science",
"History"
]
},
{
"id": "discover-this-american-life",
"title": "This American Life",
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
"author": "This American Life",
"categories": [
"Storytelling",
"Culture"
]
},
{
"id": "discover-ted-talks-daily",
"title": "TED Talks Daily",
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
"author": "TED",
"categories": [
"Education",
"Storytelling"
]
},
{
"id": "discover-tim-ferriss",
"title": "The Tim Ferriss Show",
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
"author": "Tim Ferriss",
"categories": [
"Self-Improvement",
"Business"
]
},
{
"id": "discover-jordan-harbinger",
"title": "The Jordan Harbinger Show",
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
"author": "Jordan Harbinger",
"categories": [
"Self-Improvement"
]
},
{
"id": "discover-on-purpose",
"title": "On Purpose with Jay Shetty",
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
"author": "Jay Shetty",
"categories": [
"Self-Improvement",
"Health"
]
},
{
"id": "discover-10-percent-happier",
"title": "10% Happier with Dan Harris",
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
"feedUrl": "https://rss.art19.com/ten-percent-happier",
"author": "Dan Harris",
"categories": [
"Self-Improvement",
"Health",
"Philosophy"
]
},
{
"id": "discover-school-of-greatness",
"title": "The School of Greatness",
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
"author": "Lewis Howes",
"categories": [
"Self-Improvement",
"Business"
]
},
{
"id": "discover-sysk",
"title": "Stuff You Should Know",
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
"categories": [
"Education",
"Comedy"
]
},
{
"id": "discover-in-our-time",
"title": "In Our Time",
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
"author": "BBC Radio 4",
"categories": [
"History",
"Education",
"Philosophy"
]
}
]
}

View File

@@ -1 +0,0 @@
- [ ] Audio play can survive quit out

View File

@@ -8,31 +8,25 @@
"podtui": "./dist/index.js" "podtui": "./dist/index.js"
}, },
"scripts": { "scripts": {
"start": "bun src/index.tsx", "start": "bun --preload @opentui/solid/preload src/index.tsx",
"dev": "bun --watch src/index.tsx", "dev": "bun --preload @opentui/solid/preload --watch src/index.tsx",
"build:native": "bash scripts/build-cavacore.sh", "build:native": "bash scripts/build-cavacore.sh",
"build": "bun run build.ts", "build": "bun run build.ts",
"dist": "bun dist/index.js", "dist": "bun dist/index.js",
"test": "bun test", "test": "bun test",
"lint": "bun run lint.ts" "lint": "bun tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@opentui/core": "^0.1.77", "@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77", "@opentui/solid": "^0.1.77",
"babel-preset-solid": "1.9.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"solid-js": "^1.9.9", "solid-js": "^1.9.9"
"uuid": "^13.0.0",
"zustand": "^5.0.11"
} }
} }

41
packaging/aur/.SRCINFO Normal file
View File

@@ -0,0 +1,41 @@
pkgbase = podtui-bin
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
pkgver = 0.2.0
pkgrel = 1
url = https://github.com/mikefreno/podtui
arch = x86_64
arch = aarch64
license = MIT
depends = mpv
provides = podtui
conflicts = podtui
options = !strip
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
pkgname = podtui-bin
pkgver = 0.2.0
pkgrel = 1
url = https://github.com/mikefreno/podtui
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
arch = x86_64
arch = aarch64
license = MIT
depends = mpv
provides = podtui
conflicts = podtui
options = !strip
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc

55
packaging/aur/PKGBUILD Normal file
View File

@@ -0,0 +1,55 @@
# Maintainer: Michael Freno <michael.freno@gmail.com>
# Contributor: Michael Freno <michael.freno@gmail.com>
# podtui-bin — TUI podcast/audiobook player with synchronized audio-waveform
# visualization. Serves the official standalone release binary and its two FFI
# sibling libraries (libcavacore.so + libopentui.so) from GitHub Releases.
#
# The embedded Bun runtime is statically linked into the binary — no Bun, no
# fftw needed at runtime (fftw3 is linked statically into libcavacore.so).
pkgname=podtui-bin
_pkgname=podtui
pkgver=0.2.0
pkgrel=1
pkgdesc="Terminal podcast and audio player with synchronized audio-waveform visualization"
url="https://github.com/mikefreno/podtui"
arch=('x86_64' 'aarch64')
license=('MIT')
depends=('mpv') # sole audio backend; no-op without it
provides=("${_pkgname}")
conflicts=("${_pkgname}")
options=('!strip') # standalone binary, pre-minified
source_x86_64=(
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-x64.tar.gz"
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
)
source_aarch64=(
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-arm64.tar.gz"
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
)
sha256sums_x86_64=(
'5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc'
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
)
sha256sums_aarch64=(
'c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5'
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
)
package() {
local libdir
case "$CARCH" in
x86_64) libdir="podtui-linux-x64" ;;
aarch64) libdir="podtui-linux-arm64" ;;
esac
# Binary + native FFI libs must stay side by side in /usr/lib/podtui/;
# a /usr/bin symlink works because the embedded Bun runtime resolves
# process.execPath through symlinks (verified against the compiled binary).
install -Dm755 "${srcdir}/${libdir}/podtui" "${pkgdir}/usr/lib/podtui/podtui"
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View File

@@ -0,0 +1,60 @@
#!/bin/bash
# gen-srcinfo.sh — emit .SRCINFO for the podtui-bin PKGBUILD without makepkg.
# Emits the same field set/ordering makepkg --printsrcinfo produces for this
# PKGBUILD shape (single package, per-arch source + sha256sums arrays).
set -euo pipefail
cd "$(dirname "$0")"
# shellcheck disable=SC1091
. ./PKGBUILD
emit() { printf '\t%s = %s\n' "$1" "$2"; }
emit_multi() { # $1 field, rest values
local f="$1"
shift
for v in "$@"; do emit "$f" "$v"; done
}
pkgbase_section() {
echo "pkgbase = ${pkgname}"
for f in pkgdesc pkgver pkgrel url; do
v="${!f}"
[ -n "${v:-}" ] && emit "$f" "$v"
done
[ -n "${install:-}" ] && emit install "$install"
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
emit_arch_arrays
}
emit_arch_arrays() {
for a in "${arch[@]}"; do
src_name="source_${a}"
sha_name="sha256sums_${a}"
src_val="${src_name}[@]"
sha_val="${sha_name}[@]"
[ "${#src_name}" -gt 0 ] && emit_multi "source_${a}" "${!src_val}"
emit_multi "sha256sums_${a}" "${!sha_val}"
done
}
pkgbase_section
echo ""
echo "pkgname = ${pkgname}"
for v in pkgver pkgrel url; do
val="${!v}"
[ -n "${val:-}" ] && emit "$v" "$val"
done
emit pkgdesc "$pkgdesc"
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
emit_arch_arrays

View File

@@ -19,36 +19,58 @@ mkdir -p "$OUT_DIR"
OS="$(uname -s)" OS="$(uname -s)"
ARCH="$(uname -m)" ARCH="$(uname -m)"
# Resolve fftw3 paths # Resolve fftw3 paths. The static archive lives in different places per
# platform: Homebrew (/opt/homebrew on arm64, /usr/local on Intel) and, on
# Debian/Ubuntu, the multiarch dir /usr/lib/<triplet> (e.g.
# x86_64-linux-gnu, aarch64-linux-gnu).
if [ "$OS" = "Darwin" ]; then if [ "$OS" = "Darwin" ]; then
if [ "$ARCH" = "arm64" ]; then LIB_EXT="dylib"
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}" SHARED_FLAG="-dynamiclib"
else INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}" if [ "$ARCH" = "arm64" ]; then
fi FFTW_HINTS="/opt/homebrew /usr/local"
LIB_EXT="dylib" else
SHARED_FLAG="-dynamiclib" FFTW_HINTS="/usr/local /opt/homebrew"
INSTALL_NAME="-install_name @rpath/libcavacore.dylib" fi
else else
FFTW_PREFIX="${FFTW_PREFIX:-/usr}" LIB_EXT="so"
LIB_EXT="so" SHARED_FLAG="-shared"
SHARED_FLAG="-shared" INSTALL_NAME=""
INSTALL_NAME="" FFTW_HINTS="/usr /usr/local"
fi
FFTW_PREFIX="${FFTW_PREFIX:-}"
FFTW_STATIC=""
if [ -n "$FFTW_PREFIX" ]; then
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
else
for hint in $FFTW_HINTS; do
for cand in "$hint/lib/libfftw3.a" "$hint/lib/${ARCH}-linux-gnu/libfftw3.a"; do
if [ -f "$cand" ]; then
FFTW_STATIC="$cand"
FFTW_PREFIX="$hint"
break 2
fi
done
done
fi
if [ -z "$FFTW_STATIC" ] || [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found (searched: ${FFTW_HINTS})"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
echo "or point FFTW_PREFIX at a prefix containing lib/libfftw3.a."
exit 1
fi fi
FFTW_INCLUDE="$FFTW_PREFIX/include" FFTW_INCLUDE="$FFTW_PREFIX/include"
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a" if [ ! -d "$FFTW_INCLUDE" ]; then
FFTW_INCLUDE="$FFTW_PREFIX/include/$(basename "$(dirname "$FFTW_STATIC")")"
if [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found at $FFTW_STATIC"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
exit 1
fi fi
if [ ! -f "$SRC" ]; then if [ ! -f "$SRC" ]; then
echo "Error: cavacore.c not found at $SRC" echo "Error: cavacore.c not found at $SRC"
echo "Ensure the cava submodule is initialized: git submodule update --init" echo "The cava source is vendored under cava/ (from github.com/karlstav/cava, MIT)."
exit 1 exit 1
fi fi
OUT="$OUT_DIR/libcavacore.$LIB_EXT" OUT="$OUT_DIR/libcavacore.$LIB_EXT"
@@ -59,21 +81,21 @@ echo " FFTW3: $FFTW_STATIC"
echo " Output: $OUT" echo " Output: $OUT"
cc -O2 \ cc -O2 \
$SHARED_FLAG \ $SHARED_FLAG \
$INSTALL_NAME \ $INSTALL_NAME \
-fPIC \ -fPIC \
-I"$FFTW_INCLUDE" \ -I"$FFTW_INCLUDE" \
-I"$ROOT/cava" \ -I"$ROOT/cava" \
-o "$OUT" \ -o "$OUT" \
"$SRC" \ "$SRC" \
"$FFTW_STATIC" \ "$FFTW_STATIC" \
-lm -lm
echo "Built: $OUT" echo "Built: $OUT"
# Verify exported symbols # Verify exported symbols
if [ "$OS" = "Darwin" ]; then if [ "$OS" = "Darwin" ]; then
echo "" echo ""
echo "Exported symbols:" echo "Exported symbols:"
nm -gU "$OUT" | grep "cava_" nm -gU "$OUT" | grep "cava_"
fi fi

240
scripts/release-tag.sh Executable file
View File

@@ -0,0 +1,240 @@
#!/bin/bash
# release-tag.sh — PodTui version bump, commit, tag, and push.
#
# Mirrors the release flow from FlexLove's scripts/make-tag.sh, adapted for
# PodTui's single version source (src/index.tsx) and dual remotes (gh, gt).
#
# Usage:
# scripts/release-tag.sh interactive release
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
#
# Pushing a v* tag to the `gh` remote triggers .github/workflows/release.yml
# (4-platform tarball builds) — the release and the Homebrew tap update then
# happen automatically and need no further local action.
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run | -n) DRY_RUN=1 ;;
--help | -h)
echo "Usage: scripts/release-tag.sh [--dry-run]"
echo " --dry-run, -n show the plan without committing, tagging, or pushing"
exit 0
;;
*)
echo -e "${RED}Unknown option: ${arg}${NC}" >&2
exit 2
;;
esac
done
if [ ! -d .git ] && [ ! -f .git ]; then
echo -e "${RED}Error: Not in a git repository${NC}"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo -e "${YELLOW}You have uncommitted changes:${NC}"
git status --short
echo ""
read -p "Continue anyway? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${RED}Aborted${NC}"
exit 1
fi
echo ""
fi
# Current version from the latest tag; fall back to src/index.tsx.
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
if [ -z "$CURRENT_VERSION" ]; then
CURRENT_VERSION=$(grep -m 1 "^const VERSION" src/index.tsx | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
if [ -z "$CURRENT_VERSION" ]; then
echo -e "${RED}Error: could not extract version from git tags or src/index.tsx${NC}"
exit 1
fi
echo -e "${YELLOW}No tags found; using VERSION from src/index.tsx (${CURRENT_VERSION})${NC}"
fi
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
echo ""
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
echo -e "${CYAN}Select version bump type:${NC}"
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH}$((MAJOR + 1)).0.0"
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.$((MINOR + 1)).0"
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.${MINOR}.$((PATCH + 1))"
echo " 4) Custom version"
echo " 5) Cancel"
echo ""
read -p "Enter choice (1-5): " -n 1 -r CHOICE
echo ""
echo ""
case $CHOICE in
1)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
2)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
3)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
4)
read -p "Enter custom version (e.g., 1.0.0-beta): " -r NEW_VERSION
;;
5)
echo -e "${RED}Cancelled${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
exit 1
;;
esac
# Version sanity check (tags are vMAJOR.MINOR.PATCH).
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo -e "${RED}Error: ${NEW_VERSION} is not a valid X.Y.Z version (v tags only)${NC}"
exit 1
fi
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
echo ""
echo -e "${YELLOW}This will:${NC}"
echo " 1. Set src/index.tsx → VERSION = \"${NEW_VERSION}\""
echo " 2. Commit the bump"
echo " 3. Create annotated tag v${NEW_VERSION}"
echo " 4. Push master and the tag to every remote"
REMOTES=$(git remote)
for r in $REMOTES; do
echo "$r"
done
echo ""
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
echo ""
read -p "Proceed? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Aborted — no changes made${NC}"
exit 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo ""
echo -e "${CYAN}[dry-run]${NC} would have:"
echo " sed src/index.tsx: VERSION \"${CURRENT_VERSION}\" → \"${NEW_VERSION}\""
echo " git commit -m \"bump VERSION to ${NEW_VERSION}\""
echo " git tag -a v${NEW_VERSION} -m \"PodTUI v${NEW_VERSION}\""
for r in $REMOTES; do echo " push $r master"; done
for r in $REMOTES; do echo " push $r v${NEW_VERSION}"; done
echo ""
echo -e "${GREEN}Plan only — nothing written${NC}"
exit 0
fi
# ── Apply the bump ───────────────────────────────────────────────────────────
echo ""
echo -e "${CYAN}[1/4]${NC} Updating src/index.tsx..."
sed -i.bak "s/const VERSION = \"[^\"]*\"/const VERSION = \"${NEW_VERSION}\"/" src/index.tsx
rm -f src/index.tsx.bak
echo -e "${GREEN}✓ src/index.tsx updated${NC}"
if git diff --quiet -- src/index.tsx; then
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
echo -e "${YELLOW}Already at ${NEW_VERSION} and tag v${NEW_VERSION} exists — nothing to release.${NC}"
exit 0
fi
echo -e "${YELLOW}VERSION is already ${NEW_VERSION} (bump already committed).${NC}"
echo -e "${YELLOW}Will skip the commit and just create the missing tag + push.${NC}"
read -p "Tag v${NEW_VERSION} on current HEAD and push? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Aborted — no changes made${NC}"
exit 0
fi
else
git add src/index.tsx
echo -e "${GREEN}✓ staged${NC}"
echo -e "${CYAN}[2/4]${NC} Committing..."
DEFAULT_COMMIT_MSG="bump VERSION to ${NEW_VERSION}"
echo -e "Default commit message: ${CYAN}${DEFAULT_COMMIT_MSG}${NC}"
read -p "Use default? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
read -p "Enter commit message: " -r COMMIT_MSG
else
COMMIT_MSG="$DEFAULT_COMMIT_MSG"
fi
git commit -m "$COMMIT_MSG"
echo -e "${GREEN}✓ committed: ${COMMIT_MSG}${NC}"
fi
echo -e "${CYAN}[3/4]${NC} Tagging..."
git tag -a "v${NEW_VERSION}" -m "PodTUI v${NEW_VERSION}"
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
echo ""
echo -e "${CYAN}[4/4]${NC} Pushing..."
FAILED=""
for r in $REMOTES; do
if ! git push "$r" master; then
FAILED="${FAILED}${r} (branch) "
fi
if ! git push "$r" tag "v${NEW_VERSION}"; then
FAILED="${FAILED}${r} (tag) "
fi
done
echo ""
if [ -n "$FAILED" ]; then
echo -e "${RED}═══════════════════════════════════════${NC}"
echo -e "${RED}✗ Push failed for: ${FAILED}${NC}"
echo -e "${RED}═══════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW}The commit and tag exist locally. To retry:${NC}"
for r in $REMOTES; do
echo " git push ${r} master"
echo " git push ${r} v${NEW_VERSION}"
done
echo ""
echo -e "${YELLOW}To undo:${NC}"
echo " git tag -d v${NEW_VERSION}"
echo " git reset --soft HEAD~1"
exit 1
fi
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo -e "${GREEN}✓ PodTui v${NEW_VERSION} released${NC}"
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION}${GREEN}${NEW_VERSION}${NC}"
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
echo ""
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
echo " URLs + sha256s); brew upgrade podtui afterwards."

View File

@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
} else if (a === "--from") { } else if (a === "--from") {
flags.from = rest[++i]; flags.from = rest[++i];
} else { } else {
flags[a.slice(2)] = rest[++i] ?? true; throw new Error(`unknown flag: ${a}`);
} }
} else { } else {
positional.push(a); positional.push(a);
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
return mods; return mods;
} }
function buildAction(cmd: string, positional: string[]): Action | null { // Per-command builders. Leading positional tokens that name a modifier
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
const mods = parseMods(positional); const mods = parseMods(positional);
const first = positional[0]; return mods.length ? mods : undefined;
switch (cmd) { };
case "key":
if (!first) throw new Error("key requires a <key> argument"); const BUILDERS: Record<string, (positional: string[]) => Action> = {
return { t: "key", k: first, mods: mods.length ? mods : undefined }; key: (p) => {
case "arrow": if (!p[0]) throw new Error("key requires a <key> argument");
if (!first || !["up", "down", "left", "right"].includes(first)) return { t: "key", k: p[0], mods: modsOrUndefined(p) };
throw new Error("arrow requires up|down|left|right"); },
return { arrow: (p) => {
t: "arrow", if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
d: first as any, throw new Error("arrow requires up|down|left|right");
mods: mods.length ? mods : undefined, return {
}; t: "arrow",
case "enter": d: p[0] as "up" | "down" | "left" | "right",
case "escape": mods: modsOrUndefined(p),
case "tab": };
case "space": },
case "backspace": enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
return { t: cmd, mods: mods.length ? mods : undefined }; escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
case "type": tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
if (first === undefined) throw new Error("type requires <text>"); space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
// Re-join the rest in case text had spaces; positional[0] already is first token, backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
// caller should quote. We join all positional as the text. type: (p) => {
return { t: "type", s: positional.join(" ") }; if (p[0] === undefined) throw new Error("type requires <text>");
case "wait": // Re-join the rest in case text had spaces; p[0] already is first token,
if (!first) throw new Error("wait requires <ms>"); // caller should quote. We join all positional as the text.
return { t: "wait", ms: parseInt(first, 10) || 0 }; return { t: "type", s: p.join(" ") };
case "resize": },
if (!first || !positional[1]) throw new Error("resize requires <w> <h>"); wait: (p) => {
return { if (!p[0]) throw new Error("wait requires <ms>");
t: "resize", return { t: "wait", ms: parseInt(p[0], 10) || 0 };
w: parseInt(first, 10) || 100, },
h: parseInt(positional[1], 10) || 30, resize: (p) => {
}; if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
case "frame": return {
case "state": t: "resize",
case "reset": w: parseInt(p[0], 10) || 100,
case "actions": h: parseInt(p[1], 10) || 30,
case "init": };
case "seed": },
return null; };
default:
throw new Error(`unknown command: ${cmd}`); function buildAction(cmd: string, positional: string[]): Action | null {
} const builder = BUILDERS[cmd];
if (builder) return builder(positional);
// Local-only commands return early in main before this is reached; keep
// the null contract so the public behavior is unchanged.
if (
cmd === "frame" ||
cmd === "state" ||
cmd === "reset" ||
cmd === "actions" ||
cmd === "init" ||
cmd === "seed"
)
return null;
// Single table-miss error for any unknown command.
throw new Error(`unknown command: ${cmd}`);
} }
// ── Execute one action against a mounted setup ────────────────────────────── // ── Execute one action against a mounted setup ──────────────────────────────
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
await new Promise((r) => setTimeout(r, 40)); await new Promise((r) => setTimeout(r, 40));
} }
// ── Main ─────────────────────────────────────────────────────────────────── // ── Mount, snapshot & output (extracted from main) ─────────────────────────
async function main() { // A line is "visually empty" if it's either fully blank OR contains only
activateSandbox(); // box-drawing chars + whitespace (i.e. empty-pane interior padding like
captureIssues(); // "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const argv = process.argv.slice(2); function trimFrame(plainFrame: string): string {
const cmd = argv[0] ?? "frame"; const lines = plainFrame
const { flags, positional } = parseFlags(argv.slice(1)); .replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
}
// Local-only commands that don't mount. // Local-only commands that don't mount. Returns true if handled (main returns).
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
if (cmd === "reset") { if (cmd === "reset") {
saveActions([]); saveActions([]);
console.log("✔ actions log cleared."); console.log("✔ actions log cleared.");
return; return true;
} }
if (cmd === "actions") { if (cmd === "actions") {
const a = loadActions(); const a = loadActions();
console.log(`Action log (${a.length}):`); console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2)); console.log(JSON.stringify(a, null, 2));
return; return true;
} }
if (cmd === "seed") { if (cmd === "seed") {
const from = String( const from = String(
@@ -349,9 +392,29 @@ async function main() {
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui"); const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true }); cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from}${dest}`); console.log(`✔ seeded sandbox config from ${from}${dest}`);
return; return true;
} }
return false;
}
type FrameCapture = {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
async function mountApp(
flags: Record<string, string | boolean>,
cmd: string,
positional: string[],
): Promise<{
setup: any;
spans: FrameCapture;
plainFrame: string;
audioControls: any;
actions: Action[];
}> {
// Size settings. // Size settings.
let width = 100; let width = 100;
let height = 30; let height = 30;
@@ -436,16 +499,9 @@ async function main() {
} }
if (newAction) { if (newAction) {
if (flags.audio && audioControls?.switchBackend) { if (flags.audio && audioControls?.switchBackend) {
// Re-detect: clear env so detection picks the best real backend.
delete process.env.PODTUI_AUDIO_BACKEND;
// Force (re)creation of a real backend; useAudio caches, switchBackend resets. // Force (re)creation of a real backend; useAudio caches, switchBackend resets.
delete process.env.PODTUI_AUDIO_BACKEND;
await audioControls.switchBackend("mpv").catch(() => {}); await audioControls.switchBackend("mpv").catch(() => {});
if (
!audioControls.backendName() ||
audioControls.backendName() === "none"
) {
await audioControls.switchBackend("afplay").catch(() => {});
}
} }
actions.push(newAction); actions.push(newAction);
saveActions(actions); saveActions(actions);
@@ -455,12 +511,7 @@ async function main() {
// Final settle + capture. // Final settle + capture.
await setup.renderOnce(); await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60)); await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as { const spans = setup.captureSpans() as FrameCapture;
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
const plainFrame = setup.captureCharFrame(); const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame. // Dump structured spans + plain frame.
@@ -469,6 +520,10 @@ async function main() {
writeFileSync(FRAME_TXT, plainFrame); writeFileSync(FRAME_TXT, plainFrame);
} catch {} } catch {}
return { setup, spans, plainFrame, audioControls, actions };
}
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
// Store state snapshot. // Store state snapshot.
const state: Record<string, unknown> = {}; const state: Record<string, unknown> = {};
try { try {
@@ -521,54 +576,31 @@ async function main() {
try { try {
writeFileSync(STATE_JSON, JSON.stringify(state)); writeFileSync(STATE_JSON, JSON.stringify(state));
} catch {} } catch {}
return state;
}
// ── Output ────────────────────────────────────────────────────────────── function emitOutput(p: {
spans: FrameCapture;
plainFrame: string;
state: Record<string, unknown>;
actions: Action[];
cmd: string;
flags: Record<string, string | boolean>;
positional: string[];
}): void {
// Compact by default: trimmed frame, one-line state per section, no styles // Compact by default: trimmed frame, one-line state per section, no styles
// block, no boilerplate footer. Use --styles / --verbose to opt back in. // block, no boilerplate footer. Use --styles / --verbose to opt back in.
const verbose = !!flags.verbose; const verbose = !!p.flags.verbose;
const scope = cmd === "state" ? String(positional[0] || "all") : "all"; const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
// A line is "visually empty" if it's either fully blank OR contains only
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const frameTrimmed = (() => {
const lines = plainFrame
.replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
})();
console.log( console.log(
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`, `FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
); );
console.log(frameTrimmed); console.log(trimFrame(p.plainFrame));
// ── distinct styles: opt-in only (--styles OR --verbose) ── // ── distinct styles: opt-in only (--styles OR --verbose) ──
if (scope === "all" && (flags.styles || verbose)) { if (scope === "all" && (p.flags.styles || verbose)) {
const styles = distinctStyles(spans); const styles = distinctStyles(p.spans);
if (styles.length) { if (styles.length) {
console.log("-- styles (top 20) --"); console.log("-- styles (top 20) --");
for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`); for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`);
@@ -579,9 +611,9 @@ async function main() {
const want = (k: string) => scope === "all" || scope === k; const want = (k: string) => scope === "all" || scope === k;
const compact = (obj: unknown): string => const compact = (obj: unknown): string =>
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj); verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
if (want("nav")) console.log("nav " + compact(state.nav)); if (want("nav")) console.log("nav " + compact(p.state.nav));
if (want("audio")) console.log("audio " + compact(state.audio)); if (want("audio")) console.log("audio " + compact(p.state.audio));
if (want("feed")) console.log("feed " + compact(state.feed)); if (want("feed")) console.log("feed " + compact(p.state.feed));
if (want("app")) console.log("app (not dumped in v1)"); if (want("app")) console.log("app (not dumped in v1)");
// ── issues: terse ── // ── issues: terse ──
@@ -593,12 +625,14 @@ async function main() {
} }
// Footer is identical every run — only print on init or --verbose. // Footer is identical every run — only print on init or --verbose.
if (cmd === "init" || verbose) { if (p.cmd === "init" || verbose) {
console.log( console.log(
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`, `(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
); );
} }
}
async function teardown(setup: any, audioControls: any): Promise<void> {
// Tear down child processes (audio backend) before exit to avoid orphans. // Tear down child processes (audio backend) before exit to avoid orphans.
try { try {
if (audioControls?.stop) await audioControls.stop().catch(() => {}); if (audioControls?.stop) await audioControls.stop().catch(() => {});
@@ -613,6 +647,32 @@ async function main() {
process.exit(0); process.exit(0);
} }
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
activateSandbox();
captureIssues();
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "frame";
const { flags, positional } = parseFlags(argv.slice(1));
// Local-only commands that don't mount.
if (runLocal(cmd, flags)) return;
const m = await mountApp(flags, cmd, positional);
const state = await snapshotState(m.audioControls);
emitOutput({
spans: m.spans,
plainFrame: m.plainFrame,
state,
actions: m.actions,
cmd,
flags,
positional,
});
await teardown(m.setup, m.audioControls);
}
main().catch((err) => { main().catch((err) => {
console.error("HARNESS FAILED:", err?.stack || err); console.error("HARNESS FAILED:", err?.stack || err);
process.exit(1); process.exit(1);

View File

@@ -1,6 +1,5 @@
import { ErrorBoundary } from "solid-js"; import { ErrorBoundary } from "solid-js";
import { useSelectionHandler, useRenderer } from "@opentui/solid"; import { useSelectionHandler, useRenderer } from "@opentui/solid";
import { useAuthStore } from "@/stores/auth";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys"; import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { Clipboard } from "@/utils/clipboard"; import { Clipboard } from "@/utils/clipboard";
@@ -19,7 +18,6 @@ const DEBUG = import.meta.env.DEBUG;
export function App() { export function App() {
const nav = useNavigation(); const nav = useNavigation();
const auth = useAuthStore();
const audio = useAudio(); const audio = useAudio();
const toast = useToast(); const toast = useToast();
const renderer = useRenderer(); const renderer = useRenderer();
@@ -52,6 +50,7 @@ export function App() {
}); });
const backgroundColor = () => const backgroundColor = () =>
themeContext.transparentBackground() ||
themeContext.selected === "system" themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface; : themeContext.theme.surface;

View File

@@ -1,73 +0,0 @@
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import type { PodcastSource } from "../types/source"
import { parseRSSFeed } from "@/api/rss-parser"
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl)
if (!response.ok) return []
const xml = await response.text()
return parseRSSFeed(xml, feedUrl).episodes
} catch {
return []
}
}
export const fetchFeeds = async (
sourceIds: string[],
sources: PodcastSource[]
): Promise<Feed[]> => {
const active = sources.filter((source) => sourceIds.includes(source.id))
const feeds: Feed[] = []
await Promise.all(
active.map(async (source) => {
try {
if (source.type === "rss") {
const rssFeeds = await handleRSSSource(source)
feeds.push(...rssFeeds)
} else if (source.type === "api") {
const apiFeeds = await handleAPISource(source, "")
feeds.push(...apiFeeds)
} else {
const customFeeds = await handleCustomSource(source, "")
feeds.push(...customFeeds)
}
} catch {
// ignore individual source errors
}
})
)
return feeds
}
export const searchPodcasts = async (
query: string,
sources: PodcastSource[]
): Promise<Podcast[]> => {
const results: Podcast[] = []
await Promise.all(
sources.map(async (source) => {
try {
if (source.type === "rss") {
const feeds = await handleRSSSource(source)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else if (source.type === "api") {
const feeds = await handleAPISource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else {
const feeds = await handleCustomSource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
}
} catch {
// ignore errors
}
})
)
return results
}

View File

@@ -1,94 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { PodcastSource } from "../types/source"
import type { Podcast } from "../types/podcast"
import { parseRSSFeed } from "./rss-parser"
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
return {
id: `${sourceId}-${podcast.id}`,
podcast,
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId,
lastUpdated: new Date(),
isPinned: false,
}
}
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
if (!source.baseUrl) return []
const response = await fetch(source.baseUrl)
if (!response.ok) return []
const xml = await response.text()
const parsed = parseRSSFeed(xml, source.baseUrl)
return [
{
id: `${source.id}-${parsed.feedUrl}`,
podcast: {
id: parsed.id,
title: parsed.title,
description: parsed.description,
feedUrl: parsed.feedUrl,
author: parsed.author,
categories: parsed.categories,
lastUpdated: parsed.lastUpdated,
isSubscribed: true,
},
episodes: parsed.episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: source.id,
lastUpdated: parsed.lastUpdated,
isPinned: false,
},
]
}
export const handleAPISource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
url.searchParams.set("term", query || "podcast")
url.searchParams.set("media", "podcast")
url.searchParams.set("entity", "podcast")
url.searchParams.set("country", source.country || "US")
url.searchParams.set("lang", source.language || "en_us")
const response = await fetch(url.toString())
if (!response.ok) return []
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
const results = data.results ?? []
return results
.filter((item) => item.collectionName && item.feedUrl)
.map((item) => {
const podcast: Podcast = {
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
title: item.collectionName || "Untitled Podcast",
description: item.collectionName || "",
feedUrl: item.feedUrl || "",
author: item.artistName,
lastUpdated: new Date(),
isSubscribed: false,
}
return buildFeedFromPodcast(podcast, source.id)
})
}
export const handleCustomSource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
if (!query) return []
const podcast: Podcast = {
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
title: `${query} Highlights`,
description: `Curated results for ${query}`,
feedUrl: source.baseUrl || "",
author: source.name,
lastUpdated: new Date(),
isSubscribed: false,
}
return [buildFeedFromPodcast(podcast, source.id)]
}

View File

@@ -1,180 +0,0 @@
/**
* Code validation component for PodTUI
* 8-character alphanumeric code input for sync authentication
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { AUTH_CONFIG } from "@/config/auth";
import { useTheme } from "@/context/ThemeContext";
interface CodeValidationProps {
focused?: boolean;
onBack?: () => void;
}
type FocusField = "code" | "submit" | "back";
export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [code, setCode] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("code");
const [codeError, setCodeError] = createSignal<string | null>(null);
const fields: FocusField[] = ["code", "submit", "back"];
/** Format code as user types (uppercase, alphanumeric only) */
const handleCodeInput = (value: string) => {
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
// Limit to max length
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
setCode(limited);
// Clear error when typing
if (codeError()) {
setCodeError(null);
}
};
const validateCode = (value: string): boolean => {
if (!value) {
setCodeError("Code is required");
return false;
}
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
setCodeError(
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
);
return false;
}
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
setCodeError("Code must contain only letters and numbers");
return false;
}
setCodeError(null);
return true;
};
const handleSubmit = async () => {
if (!validateCode(code())) {
return;
}
const success = await auth.validateCode(code());
if (!success && auth.error) {
setCodeError(auth.error.message);
}
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "tab") {
if (focusField() === "submit") {
handleSubmit();
} else if (focusField() === "back" && props.onBack) {
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack();
}
};
const codeProgress = () => {
const len = code().length;
const max = AUTH_CONFIG.codeValidation.codeLength;
return `${len}/${max}`;
};
const codeDisplay = () => {
const current = code();
const max = AUTH_CONFIG.codeValidation.codeLength;
const filled = current.split("");
const empty = Array(max - filled.length).fill("_");
return [...filled, ...empty].join(" ");
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Enter Sync Code</strong>
</text>
<box height={1} />
<text fg={theme.textMuted}>
Enter your 8-character sync code to link your account.
</text>
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
<box height={1} />
{/* Code display */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "code" ? theme.primary : undefined}>
Code ({codeProgress()}):
</text>
<box border padding={1} borderColor={theme.border}>
<text
fg={
code().length === AUTH_CONFIG.codeValidation.codeLength
? theme.success
: theme.warning
}
>
{codeDisplay()}
</text>
</box>
{/* Hidden input for actual typing */}
<input
value={code()}
onInput={handleCodeInput}
placeholder=""
focused={props.focused && focusField() === "code"}
width={30}
/>
{codeError() && <text fg={theme.error}>{codeError()}</text>}
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "submit" ? theme.primary : undefined}>
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
[Esc] Back to Login
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
</box>
);
}

View File

@@ -3,7 +3,6 @@ import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
//TODO: Watch for actual loading state (fetching feeds)
export function LoadingIndicator() { export function LoadingIndicator() {
const { theme } = useTheme(); const { theme } = useTheme();
const [index, setIndex] = createSignal(0); const [index, setIndex] = createSignal(0);

View File

@@ -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>
)
}

View File

@@ -1,17 +1,17 @@
/** /**
* YaziPaneRow the shared parent | current | preview 3-pane layout primitive. * PaneRow the shared parent | current | preview 3-pane layout primitive.
* *
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns * Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list * grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}` * tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
* so the ratio is exact regardless of content width a column's content can * so the ratio is exact regardless of content width a column's content can
* never stretch its slot. * never stretch its slot.
* *
* Column semantics (per the yazi depth model): * Column semantics (per the yazi depth model):
* parent the previous-depth list. Renders a muted `` placeholder and * parent the previous-depth list. Renders a muted `` placeholder and
* KEEPS its 1/7 slot when blank (never collapses to width 0). * KEEPS its 1/5 slot when blank (never collapses to width 0).
* current the current-depth list. The only focusable content column; it * 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. * preview detail of the hovered item in `current`; always muted border.
* *
* The primitive is purely structural: callers pass their own JSX per column * The primitive is purely structural: callers pass their own JSX per column
@@ -20,7 +20,7 @@
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane). * `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
* *
* Example: * Example:
* <YaziPaneRow * <PaneRow
* parent={parentList} * parent={parentList}
* current={currentList} * current={currentList}
* preview={detail} * preview={detail}
@@ -31,7 +31,7 @@
* /> * />
*/ */
import { createMemo } from "solid-js"; import { createMemo, Show } from "solid-js";
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import type { RGBA } from "@opentui/core"; import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
@@ -41,21 +41,25 @@ import { PANE_RATIO } from "@/utils/navigation";
type PaneContent = JSX.Element | (() => JSX.Element); type PaneContent = JSX.Element | (() => JSX.Element);
type PaneLabel = string | (() => string); type PaneLabel = string | (() => string);
export type YaziPaneRowProps = { export type PaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted /** Parent column content (previous-depth list, or null for a muted
* placeholder the 1/7 slot is always preserved). */ * placeholder the 1/5 slot is always preserved). */
parent?: PaneContent; parent?: PaneContent;
/** Current column content (the focused list). */ /** Current column content (the focused list). */
current?: PaneContent; current?: PaneContent;
/** Preview column content (detail of the hovered item). */ /** Preview column content (detail of the hovered item). Omit/undefined
* together with `panes={2}` to render a 2-pane parent|current row. */
preview?: PaneContent; preview?: PaneContent;
parentLabel?: PaneLabel; parentLabel?: PaneLabel;
currentLabel?: PaneLabel; currentLabel?: PaneLabel;
previewLabel?: 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 * true; pass `false` (or a signal) when the row is inactive. Parent and
* preview columns always render muted borders. */ * preview columns always render muted borders. */
focused?: boolean | (() => boolean); 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 ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -65,16 +69,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
} }
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor. /** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately do NOT use Solid's `children()` helper here: that helper * We deliberately avoid Solid's `children()` helper: it flattens accessor
* flattens accessor children into a stable resolved-nodes array and is the * children into a stable resolved-nodes array and won't re-resolve on a
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane * truthytruthy root swap (e.g. the current pane switching between a
* switching between a depth-1 list fragment and a depth-2 editor both * depth-1 list fragment and a depth-2 editor), freezing the previous
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy * subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
* root swap, freezing the previous subtree in place. Instead we hand the * expression a tracked `insert` effect that disposes the old subtree and
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below, * mounts the new whenever the accessor returns a different element identity. */
* which Solid compiles into a tracked `insert` effect that disposes the old
* subtree and mounts the new whenever the accessor returns a different
* element identity. */
function normalizeContent( function normalizeContent(
v: PaneContent | undefined, v: PaneContent | undefined,
): () => JSX.Element | undefined { ): () => JSX.Element | undefined {
@@ -91,14 +92,15 @@ function Placeholder(props: { color: () => RGBA }) {
} }
// ── Pane column ───────────────────────────────────────────────────────────── // ── Pane column ─────────────────────────────────────────────────────────────
function YaziPane(props: { function Pane(props: {
grow: number; grow: number;
label: () => string; label: () => string;
content: () => JSX.Element | undefined; content: () => JSX.Element | undefined;
borderColor: () => RGBA; borderColor: () => RGBA;
scrollFocused: () => boolean; scrollFocused: () => boolean;
}) { }) {
const { theme } = useTheme(); const themeContext = useTheme();
const theme = themeContext.theme;
const muted = () => theme.muted ?? theme.textMuted ?? theme.text; const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// Memoize accessor results so the prop expressions below stay reactive // Memoize accessor results so the prop expressions below stay reactive
@@ -107,9 +109,22 @@ function YaziPane(props: {
const scrollFocused = createMemo(() => props.scrollFocused()); const scrollFocused = createMemo(() => props.scrollFocused());
return ( 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 ─────────────────────────────────────────── */} {/* ── slim header label row ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}> <box
height={1}
paddingLeft={1}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
>
<text fg={theme.textSecondary}>{props.label()}</text> <text fg={theme.textSecondary}>{props.label()}</text>
</box> </box>
{/* ── bordered scrollbox ────────────────────────────────────────────── */} {/* ── bordered scrollbox ────────────────────────────────────────────── */}
@@ -118,35 +133,26 @@ function YaziPane(props: {
focused={scrollFocused()} focused={scrollFocused()}
border border
borderColor={borderColor()} borderColor={borderColor()}
backgroundColor={theme.background} backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
> >
{/* {props.content() ?? <Placeholder color={muted} />}
* Render the content accessor directly via a reactive expression.
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
* effect that re-runs whenever the accessor's tracked signals
* change (e.g. `depth()` swapping the root from a list fragment to
* an editor). Solid disposes the previously-rendered subtree and
* mounts the new element identity. `null`/`undefined` falls back
* to the muted placeholder so the parent pane keeps its 1/7 slot
* visibly blank at depth 0. This is the correct tool for root
* swapping unlike Solid's `children()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox> </scrollbox>
</box> </box>
); );
} }
// ── Row primitive ─────────────────────────────────────────────────────────── // ── Row primitive ───────────────────────────────────────────────────────────
export function YaziPaneRow(props: YaziPaneRowProps) { export function PaneRow(props: PaneRowProps) {
const { theme } = useTheme(); 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 focused = createMemo(() => {
const f = props.focused; 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 // Normalize static JSX and accessor children into reactive accessors
@@ -159,32 +165,43 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
const currentLabel = createMemo(() => resolveLabel(props.currentLabel)); const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
const previewLabel = createMemo(() => resolveLabel(props.previewLabel)); 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 ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} {/* ── parent (1/5) — previous-depth list; always muted ─────────────── */}
<YaziPane <Pane
grow={PANE_RATIO.parent} grow={PANE_RATIO.parent}
label={parentLabel} label={parentLabel}
content={parentContent} content={parentContent}
borderColor={() => theme.border} borderColor={() => theme.border}
scrollFocused={() => false} scrollFocused={() => false}
/> />
{/* ── current (3/7) — the focused list; accent ring when focused ───── */} {/* ── current — the focused list; active-border ring when focused ──────────── */}
<YaziPane <Pane
grow={PANE_RATIO.current} grow={currentGrow()}
label={currentLabel} label={currentLabel}
content={currentContent} content={currentContent}
borderColor={() => (focused() ? theme.accent : theme.border)} borderColor={() => (focused() ? theme.borderActive : theme.border)}
scrollFocused={() => focused()} scrollFocused={() => focused()}
/> />
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} {/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
<YaziPane <Show when={panes() === 3}>
grow={PANE_RATIO.preview} <Pane
label={previewLabel} grow={PANE_RATIO.preview}
content={previewContent} label={previewLabel}
borderColor={() => theme.border} content={previewContent}
scrollFocused={() => false} borderColor={() => theme.border}
/> scrollFocused={() => false}
/>
</Show>
</box> </box>
); );
} }

View File

@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
backgroundColor={ backgroundColor={
props.selected() props.selected()
? theme.primary ? theme.primary
: themeContext.selected === "system" : themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface : themeContext.theme.surface
} }

View File

@@ -12,21 +12,21 @@
*/ */
import { createSignal, Show, For } from "solid-js"; import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid"; import { useKeyboard, useRenderer } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext"; import { useNavigation, NavMode } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import type { Episode } from "@/types/episode"; import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus"; import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph"; import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation"; import { TABS, TabPaneCount } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch"; import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { PaneRow } from "@/components/PaneRow";
const TAB_LABEL: Record<TABS, string> = { const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed", [TABS.FEED]: "Feed",
@@ -43,12 +43,25 @@ export function Shell() {
const nav = useNavigation(); const nav = useNavigation();
const k = useKeybinds(); const k = useKeybinds();
const audio = useAudio(); const audio = useAudio();
const renderer = useRenderer();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const toast = useToast(); const toast = useToast();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false); const [showHelp, setShowHelp] = createSignal(false);
// ── Auto jump to Player on podcast start ───────────────────────────────────
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
// "player.started" — distinct from "player.play", which also fires on
// resume), switch to the Player tab and drop into its content pane.
on("player.started", () => {
const app = useAppStore();
if (app.state().preferences.autoJumpToPlayer) {
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
}
});
/** Play the episode adjacent (offset ±1) to the currently-playing one, /** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */ * within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) { function advanceEpisode(offset: number) {
@@ -84,74 +97,60 @@ export function Shell() {
} }
// ── Command bar dispatch ──────────────────────────────────────────────────── // ── Command bar dispatch ────────────────────────────────────────────────────
const COMMANDS: Record<string, (arg: string) => void> = {
quit: () => process.exit(0),
exit: () => process.exit(0),
q: () => process.exit(0),
refresh: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
r: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
play: () => audio.togglePlayback().catch(() => {}),
pause: () => audio.togglePlayback().catch(() => {}),
p: () => audio.togglePlayback().catch(() => {}),
next: () => advanceEpisode(1),
n: () => advanceEpisode(1),
prev: () => advanceEpisode(-1),
seek: (arg) => {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
},
feed: () => nav.setActiveTab(TABS.FEED),
f: () => nav.setActiveTab(TABS.FEED),
shows: () => nav.setActiveTab(TABS.MYSHOWS),
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
discover: () => nav.setActiveTab(TABS.DISCOVER),
d: () => nav.setActiveTab(TABS.DISCOVER),
search: () => nav.setActiveTab(TABS.SEARCH),
player: () => nav.setActiveTab(TABS.PLAYER),
settings: () => nav.setActiveTab(TABS.SETTINGS),
set: () => nav.setActiveTab(TABS.SETTINGS),
help: () => setShowHelp((v) => !v),
h: () => setShowHelp((v) => !v),
};
function runCommand(raw: string) { function runCommand(raw: string) {
const cmd = raw.trim(); const cmd = raw.trim();
if (!cmd) return; if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase(); const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim(); const arg = cmd.slice(name.length).trim();
switch (name) { const unknownCommand = () => {
case "q": nav.setCommandError(`unknown command: ${name}`);
case "quit": // re-enter command mode so the user sees the error + can correct
case "exit": nav.enterCommand();
return process.exit(0); nav.setCommandBuffer(cmd);
case "refresh": };
case "r": (COMMANDS[name] ?? unknownCommand)(arg);
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
});
break;
case "play":
case "pause":
case "p":
audio.togglePlayback().catch(() => {});
break;
case "next":
case "n":
advanceEpisode(1);
break;
case "prev":
advanceEpisode(-1);
break;
case "seek": {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
break;
}
case "feed":
case "f":
nav.setActiveTab(TABS.FEED);
break;
case "shows":
case "myshows":
nav.setActiveTab(TABS.MYSHOWS);
break;
case "discover":
case "d":
nav.setActiveTab(TABS.DISCOVER);
break;
case "search":
nav.setActiveTab(TABS.SEARCH);
break;
case "player":
nav.setActiveTab(TABS.PLAYER);
break;
case "settings":
case "set":
nav.setActiveTab(TABS.SETTINGS);
break;
case "help":
case "h":
setShowHelp((v) => !v);
break;
default:
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
}
} }
// ── Command-mode key handling ─────────────────────────────────────────────── // ── Command-mode key handling ───────────────────────────────────────────────
@@ -200,8 +199,21 @@ export function Shell() {
useKeyboard( useKeyboard(
(evt: any) => { (evt: any) => {
// Input fields (search boxes, dialogs) own their keys. // Input fields (search boxes, dialogs) own their keys — except Escape,
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return; // which defocuses the input so j/k/h navigation resumes (search: h back
// to the tab root, j/k to move the recent-searches list).
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
if (evt.name === "escape") {
evt.preventDefault();
nav.setInputFocused(false);
// Actually blur the focused renderable too — setting the flag alone
// leaves the opentui input owning keys, so nav keys would still be
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
// (and re-blurs the SearchPage input via its `focused` prop).
renderer.currentFocusedRenderable?.blur();
}
return;
}
if (nav.mode() === NavMode.COMMAND) { if (nav.mode() === NavMode.COMMAND) {
handleCommandKey(evt); handleCommandKey(evt);
return; return;
@@ -229,11 +241,13 @@ export function Shell() {
return ( return (
<box <box
flexDirection="column" flexDirection="column"
width="100%" width="100%"
height="100%" height="100%"
backgroundColor={t.surface} backgroundColor={
> theme.transparentBackground() ? "transparent" : t.surface
}
>
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%"> <box flexDirection="row" flexGrow={1} width="100%">
<Show <Show
@@ -245,7 +259,7 @@ export function Shell() {
} }
> >
{/* app root: the tab list is the CURRENT pane, nothing in UP */} {/* app root: the tab list is the CURRENT pane, nothing in UP */}
<YaziPaneRow <PaneRow
parent={ parent={
<box padding={1}> <box padding={1}>
<text fg={t.textMuted}></text> <text fg={t.textMuted}></text>
@@ -269,7 +283,11 @@ export function Shell() {
flexDirection="row" flexDirection="row"
height={1} height={1}
width="100%" width="100%"
backgroundColor={t.backgroundPanel ?? t.background} backgroundColor={
theme.transparentBackground()
? "transparent"
: (t.backgroundPanel ?? t.background)
}
> >
<Show <Show
when={nav.mode() === NavMode.COMMAND} when={nav.mode() === NavMode.COMMAND}
@@ -451,17 +469,5 @@ function k_match_escape(evt: any): boolean {
); );
} }
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
* search) into the player tab. */
export function playEpisodeAndSwitch(
nav: ReturnType<typeof useNavigation>,
audio: ReturnType<typeof useAudio>,
episode: import("@/types/episode").Episode,
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees. // Re-export Episode type for callers building pane trees.
export type { Episode }; export type { Episode } from "@/types/episode";

View File

@@ -1,27 +0,0 @@
import { For } from "solid-js";
import { shortcuts } from "@/config/shortcuts";
import { useTheme } from "@/context/ThemeContext";
/** Yazi-style keybind reference. The Shell has its own overlay; this component
* is kept for embedding inside Settings or other surfaces. */
export function ShortcutHelp() {
const { theme } = useTheme();
return (
<box
border
title="Shortcuts"
style={{ flexDirection: "column", padding: 1 }}
>
<box style={{ flexDirection: "column" }}>
<For each={shortcuts}>
{(s) => (
<box style={{ flexDirection: "row" }} gap={2}>
<text fg={theme.accent}>{s.keys}</text>
<text fg={theme.text}>{s.action}</text>
</box>
)}
</For>
</box>
</box>
);
}

View File

@@ -1,55 +0,0 @@
import { useTheme } from "@/context/ThemeContext";
import { TABS, TabsCount } from "@/utils/navigation";
import { For } from "solid-js";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
export const tabs: TabDefinition[] = [
{ id: TABS.FEED, label: "Feed" },
{ id: TABS.MYSHOWS, label: "My Shows" },
{ id: TABS.DISCOVER, label: "Discover" },
{ id: TABS.SEARCH, label: "Search" },
{ id: TABS.PLAYER, label: "Player" },
{ id: TABS.SETTINGS, label: "Settings" },
];
export function TabNavigation() {
const { theme } = useTheme();
const { activeTab, setActiveTab, activeDepth } = useNavigation();
return (
<box
border
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
backgroundColor={"transparent"}
style={{
flexDirection: "column",
width: 12,
height: TabsCount * 3 + 2,
}}
>
<For each={tabs}>
{(tab) => (
<SelectableBox
border
height={3}
selected={() => tab.id == activeTab()}
onMouseDown={() => setActiveTab(tab.id)}
>
<SelectableText
selected={() => tab.id == activeTab()}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
)}
</For>
</box>
);
}
export type TabDefinition = {
id: TABS;
label: string;
};

View File

@@ -2,17 +2,23 @@
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT | * TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
* PREVIEW flow (replaces the old fixed chrome tab column). * PREVIEW flow (replaces the old fixed chrome tab column).
* *
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and * Renders one row per tab (digit + label) using the same selection UI every
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight. * other yazi pane uses: the CURSOR row (the one j/k hovers) gets a `` marker
* `focused` only matters to the surrounding frame (the CURRENT column draws * and the focus background (`theme.primary` when this pane is the CURRENT
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent * column, `theme.border` when it is the muted UP/parent column). The ACTIVE
* column (`muted`), the cursor highlight is suppressed and only the active ● * tab (the one whose content is open) always carries a `●` marker in accent so
* shows, so it reads as the read-only parent listing. * it stays readable in both positions.
*
* `muted` marks the parent-column rendering: the highlight is dimmed (border
* bg, text fg) rather than suppressed, so the Up pane still shows the cursor
* and active tab — matching how every other pane's parent column renders its
* focused row.
*/ */
import { For } from "solid-js"; import { For } from "solid-js";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import { useNavigation } from "@/context/NavigationContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { TABS } from "@/utils/navigation"; import { TABS } from "@/utils/navigation";
const TAB_LABEL: Record<TABS, string> = { const TAB_LABEL: Record<TABS, string> = {
@@ -34,40 +40,60 @@ export function TabListPane(props: { muted?: boolean }) {
const nav = useNavigation(); const nav = useNavigation();
const cursor = () => nav.tabCursor(); const cursor = () => nav.tabCursor();
const active = () => nav.activeTab(); const activeTab = () => nav.activeTab();
const muted = () => props.muted ?? false; /** `active=true` when this pane is the CURRENT column (Shell root);
* `false` when it is the muted UP/parent column (pages' parent pane). */
const active = () => !props.muted;
// 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
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
return ( return (
<For each={TAB_ORDER}> <For each={TAB_ORDER}>
{(tab) => { {(tab) => {
const isCursor = () => cursor() === tab && !muted(); const isCursor = () => cursor() === tab;
const isActive = () => active() === tab; const isActive = () => activeTab() === tab;
const fg = () => // 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() isCursor()
? theme.textSelectedPrimary ? focusFg(tab)
: isActive() : isActive() && !active()
? theme.accent ? theme.accent
: theme.text; : theme.text;
const ref = useScrollIntoView(isCursor);
return ( return (
<box <box
ref={ref}
width="100%" width="100%"
height={1} height={1}
flexDirection="row" flexDirection="row"
backgroundColor={isCursor() ? theme.primary : "transparent"} paddingRight={1}
backgroundColor={focusBg(tab)}
onMouseDown={() => {
// Click = hover + open, the yazi "open" of the row
// (switches to the tab and enters its content), the same
// as l/Enter. Restores mouse support the tab-strip
// refactor dropped.
nav.setTabCursor(tab);
nav.activateTabCursor();
}}
> >
<text {/* ── selection marker (j/k cursor) ─────────────────────────── */}
width={2} <text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text>
fg={isCursor() ? theme.textSelectedPrimary : "transparent"} <text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
> <text fg={labelFg()} paddingLeft={1}>
{isActive() ? "●" : " "}
</text>
<text
width={2}
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
>
{tab}
</text>
<text fg={fg()} paddingLeft={1}>
{TAB_LABEL[tab]} {TAB_LABEL[tab]}
</text> </text>
</box> </box>

View File

@@ -1,75 +0,0 @@
/**
* Authentication configuration for PodTUI
* Authentication is DISABLED by default - users can opt-in
*/
import { OAuthProvider, type OAuthProviderConfig } from "../types/auth"
/** Default auth enabled state - DISABLED by default */
export const DEFAULT_AUTH_ENABLED = false
/** Authentication configuration */
export const AUTH_CONFIG = {
/** Whether auth is enabled by default */
defaultEnabled: DEFAULT_AUTH_ENABLED,
/** Code validation settings */
codeValidation: {
/** Code length (8 characters) */
codeLength: 8,
/** Allowed characters (alphanumeric) */
allowedChars: /^[A-Z0-9]+$/,
/** Code expiration time in minutes */
expirationMinutes: 15,
},
/** Password requirements */
password: {
minLength: 8,
requireUppercase: false,
requireLowercase: false,
requireNumber: false,
requireSpecial: false,
},
/** Email validation */
email: {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
/** Local storage keys */
storage: {
authState: "podtui_auth_state",
user: "podtui_user",
lastLogin: "podtui_last_login",
},
} as const
/** OAuth provider configurations */
export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [
{
id: OAuthProvider.GOOGLE,
name: "Google",
enabled: false, // Not feasible in terminal
description: "Sign in with Google (requires browser redirect)",
},
{
id: OAuthProvider.APPLE,
name: "Apple",
enabled: false, // Not feasible in terminal
description: "Sign in with Apple (requires browser redirect)",
},
]
/** Terminal OAuth limitation message */
export const OAUTH_LIMITATION_MESSAGE = `
OAuth authentication (Google, Apple) is not directly available in terminal applications.
To use OAuth:
1. Visit the web portal in your browser
2. Sign in with your preferred provider
3. Generate a sync code
4. Enter the code here to link your account
Alternatively, use email/password authentication or file-based sync.
`.trim()

View File

@@ -11,7 +11,8 @@
// //
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select, // Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab, // v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl. // : / q command palette (q + Enter quits there), Q quick quit, ~ help.
// Audio transport kept on shifted keys / ctrl.
// ── Movement (within a pane) ───────────────────────────────────────────── // ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"], "move-down": ["j", "down"],
@@ -50,9 +51,11 @@
"tab-goto-5": ["5"], "tab-goto-5": ["5"],
"tab-goto-6": ["6"], "tab-goto-6": ["6"],
// ── Command bar & help & quit ──────────────────────────────────────────── // ── Command palette & help & quit ────────────────────────────────────────
"command": [":"], // q opens the command palette (neovim-style: type q + Enter to quit there).
"quit": ["q", "ctrl-c"], // Q (shift+q) is the instant quick quit. ctrl-c also quits.
"command": [":", "q"],
"quit": ["Q", "ctrl-c"],
"help": ["~", "f1"], "help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
@@ -61,6 +64,7 @@
"sort": [","], "sort": [","],
"toggle-hidden": ["."], "toggle-hidden": ["."],
"refresh": ["r"], "refresh": ["r"],
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
// ── Audio transport (preserved) ────────────────────────────────────────── // ── Audio transport (preserved) ──────────────────────────────────────────
// Kept on shifted single keys so they never collide with the yazi core // Kept on shifted single keys so they never collide with the yazi core

View File

@@ -1,26 +0,0 @@
/**
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
* Shown in help overlays; the canonical source remains keybinds.jsonc.
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
*/
export const shortcuts = [
{ keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "ctrl-d / u", action: "Half page down / up" },
{ keys: "g g / G", action: "Go to top / bottom of list" },
{ keys: "1-6", action: "Go to tab 1-6" },
{ keys: "[ / ]", action: "Previous / next tab" },
{ keys: "Enter", action: "Open / activate focused item" },
{ keys: "Space", action: "Toggle selection on item" },
{ keys: "v", action: "Enter visual (range) select mode" },
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
{ keys: "r / s / f", action: "Refresh / search / filter" },
{ keys: ", / .", action: "Sort / toggle hidden" },
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
{ keys: "< / >", action: "Seek backward / forward 10s" },
{ keys: "~ / F1", action: "Help" },
{ keys: "q", action: "Quit" },
] as const;

View File

@@ -1,12 +0,0 @@
export const syncFormats = {
json: {
version: "1.0",
extension: ".json",
},
xml: {
version: "1.0",
extension: ".xml",
},
}
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]

View File

@@ -67,24 +67,12 @@ export type KeybindActionName =
| "sort" | "sort"
| "toggle-hidden" | "toggle-hidden"
| "refresh" | "refresh"
| "unsubscribe"
| "audio-toggle" | "audio-toggle"
| "audio-next" | "audio-next"
| "audio-prev" | "audio-prev"
| "audio-seek-forward" | "audio-seek-forward"
| "audio-seek-backward" | "audio-seek-backward";
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
/** Resolved config: action -> list of alternative stroke-sequences. */ /** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>; export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
@@ -145,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
} }
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */ /** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: { function strokeFromEvent(evt: {
name: string; name: string;
ctrl?: boolean; ctrl?: boolean;
meta?: boolean; meta?: boolean;
@@ -153,7 +141,7 @@ export function strokeFromEvent(evt: {
}): Stroke { }): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize. // Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return { return {
key: (evt.name ?? "").toLowerCase(), key: evt.name.toLowerCase(),
ctrl: !!evt.ctrl, ctrl: !!evt.ctrl,
shift: !!evt.shift, shift: !!evt.shift,
meta: !!evt.meta, meta: !!evt.meta,
@@ -170,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
} }
/** A human label for a stroke, for the status bar / help. */ /** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string { function strokeLabel(s: Stroke): string {
let out = ""; let out = "";
if (s.ctrl) out += "C-"; if (s.ctrl) out += "C-";
if (s.meta) out += "M-"; if (s.meta) out += "M-";
@@ -179,7 +167,7 @@ export function strokeLabel(s: Stroke): string {
return out; return out;
} }
export function sequenceLabel(seq: Stroke[]): string { function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" "); return seq.map(strokeLabel).join(" ");
} }
@@ -337,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
return best; return best;
} }
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => { onMount(() => {
load().catch(() => {}); load().catch(() => {});
}); });
@@ -365,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
pending, pending,
match, match,
tryMatch, tryMatch,
isInverting,
print, print,
save, save,
load, load,

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { useRenderer } from "@opentui/solid"; import { useRenderer } from "@opentui/solid";
@@ -10,6 +11,7 @@ import {
generateSubtleSyntax, generateSubtleSyntax,
} from "../utils/syntax-highlighter"; } from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme"; import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { import {
setupThemeSignalHandler, setupThemeSignalHandler,
@@ -84,6 +86,8 @@ export type ThemeResolved = {
muted?: RGBA; muted?: RGBA;
surface?: RGBA; surface?: RGBA;
selectedListItemText?: RGBA; selectedListItemText?: RGBA;
/** Theme declares a transparent (terminal-bg-visible) background. */
transparent?: boolean;
layerBackgrounds?: { layerBackgrounds?: {
layer0: RGBA; layer0: RGBA;
layer1: RGBA; layer1: RGBA;
@@ -94,6 +98,61 @@ export type ThemeResolved = {
thinkingOpacity?: number; thinkingOpacity?: number;
}; };
/**
* A TerminalColors with no values — used to keep the "system" theme rendering
* with default ANSI colors + the detected dark/light mode when the terminal
* cannot answer OSC queries (e.g. inside tmux without OSC forwarding).
*/
const EMPTY_TERMINAL_COLORS: TerminalColors = {
palette: Array.from({ length: 16 }, () => null),
defaultForeground: null,
defaultBackground: null,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
};
/** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null;
/**
* Detect the terminal's dark/light mode.
*
* Priority:
* 1. The terminal's real background color (OSC 11 response) — terminal-specific.
* 2. The macOS appearance via `defaults read -g AppleInterfaceStyle` — works
* even inside tmux, where OSC queries are usually not forwarded.
* An unset value means light mode (macOS defaults to light).
* 3. null → keep whatever mode is currently active.
*/
function detectSystemMode(
colors: TerminalColors | null,
): "dark" | "light" | null {
const fromBg = detectModeFromBackground(colors?.defaultBackground);
if (fromBg) return fromBg;
if (process.platform === "darwin" && cachedOsMode === null) {
let style: string | null = null;
try {
style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
encoding: "utf8",
timeout: 2000,
})
.trim()
.toLowerCase();
} catch {
// Unset → light appearance (macOS default).
}
cachedOsMode = style?.includes("dark") ? "dark" : "light";
}
return cachedOsMode;
}
/** /**
* Theme context using the createSimpleContext pattern. * Theme context using the createSimpleContext pattern.
* *
@@ -195,6 +254,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
} }
} }
// ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
// real one from the terminal's background color (OSC 11) or, when that
// is unavailable (tmux without OSC forwarding), the OS appearance.
const detectedMode = detectSystemMode(colors);
if (detectedMode && detectedMode !== store.mode) {
setStore("mode", detectedMode);
emitThemeModeChanged(detectedMode);
}
const hasPalette = Boolean( const hasPalette = Boolean(
colors?.palette?.some((value) => Boolean(value)), colors?.palette?.some((value) => Boolean(value)),
); );
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
); );
if (!hasPalette && !hasDefaultColors) { if (!hasPalette && !hasDefaultColors) {
// No system colors available, fall back to default // No system colors available — the terminal can't answer OSC queries
// This happens when the terminal doesn't support OSC palette queries // (e.g. inside tmux, or unsupported terminals). Keep the "system"
// (e.g., running inside tmux, or on unsupported terminals) // theme anyway: the detected dark/light mode plus default ANSI colors
// still produce a usable, mode-correct palette.
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.active = "catppuccin"; draft.system = colors ?? EMPTY_TERMINAL_COLORS;
draft.ready = true; draft.ready = true;
}), }),
); );
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() { mode() {
return store.mode; return store.mode;
}, },
/** Whether the app background should be transparent (no solid fill):
* either the global preference is on, or the selected theme declares
* transparency (e.g. the system theme). */
transparentBackground() {
return (
appStore.state().settings.transparentBackground ||
values().transparent === true
);
},
setMode(mode: "dark" | "light") { setMode(mode: "dark" | "light") {
setStore("mode", mode); setStore("mode", mode);
emitThemeModeChanged(mode); emitThemeModeChanged(mode);

View File

@@ -13,36 +13,33 @@
* *
* parent | current | preview * parent | current | preview
* *
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in * Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable* * `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The * nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused. * parent/preview columns are always derived, never focused.
* *
* Two pane models coexist under a single TAB list: * The tab list is the app's ROOT and participates in the same pane flow as
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:
* *
* • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal, * • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
* focusable pane at the left of every tab's content, just like in yazi. * • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
* Starting focus lives here; tab switches made from here keep focus here. * that tab's content becomes CURRENT and its hovered item PREVIEW
* When it is focused, j/k moves the tab cursor (`tabCursor`) and * (`enterTabContent`).
* `l`/Enter opens the hovered tab into its content. Swiping left past * • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
* it goes out of the panes (inert — there is no pane beyond it). * own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
* OUT of the 3-pane view.
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
* stays (out of the panes — no-op).
* *
* Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE * Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The * ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
* parent column renders the previous depth's list (blank at depth 0); the * the parent/preview are derived. Search drills query→results; Player is a
* preview column renders the hovered item. `l`/Enter drills in (push a * single now-playing pane under the tab list (2-pane, no preview). Every
* frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus * tab returns to the root via `h` at depth 0 (`backToTabRoot`).
* to the tab list (TAB_PANE).
* *
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the * Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the * `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
* parent/current/preview columns with `h`/`l`, clamped to
* [1, paneCount]; `h` on the first content pane (1) moves focus to the tab
* list; `h` on the tab list stays out-of-panear (inert).
*
* Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
* Focus on the tab list persists across a tab switch; from there `l`/Enter
* drops into the active tab's content (panes 1..N).
*/ */
import { createSignal, batch } from "solid-js"; import { createSignal, batch } from "solid-js";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
@@ -55,10 +52,9 @@ export enum NavMode {
} }
/** The current content pane of the active tab, i.e. the focusable column /** The current content pane of the active tab, i.e. the focusable column
* (index 1) for depth-tabs, and the default landing pane for fixed-pane * (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made * pane 0. A tab switch made while focused on content resets `activePane` to
* while focused on content resets `activePane` to this pane (unless already * this pane (unless already on the tab list). */
* on the tab list). */
export const DEPTH_CENTER_PANE = 1 as PaneId; export const DEPTH_CENTER_PANE = 1 as PaneId;
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the /** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
@@ -67,13 +63,6 @@ export const DEPTH_CENTER_PANE = 1 as PaneId;
* swiping left past the first content pane returns to it. Swiping left again * swiping left past the first content pane returns to it. Swiping left again
* — beyond it — goes out of the panes (no-op). While it is focused, j/k * — beyond it — goes out of the panes (no-op). While it is focused, j/k
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */ * moves the tab cursor and `l`/Enter opens the hovered tab's content. */
/** Content pane slots for fixed-pane tabs (Search). Values are the global
* pane indices (content starts at 1). */
export enum PaneSlot {
PARENT = 1, // Search: input
CURRENT = 2, // Search: results
PREVIEW = 3, // Search: detail
}
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
@@ -110,11 +99,11 @@ export function createNavigation() {
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves // or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
// just like any other yazi list: j/k move the cursor, Enter/l open. // just like any other yazi list: j/k move the cursor, Enter/l open.
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED); const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
// App focus starts on the tab list (the app root). `activePane` drives the // App focus starts on the tab list (the app root). `activePane` is always
// fixed-pane pages (Search/Player) and each page's content focus ring; // DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
// depth-tab focus is instead described by the per-tab depth stack plus the // the `atRootTab` flag describe where focus sits (the tab is the CURRENT
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and // pane when at the root, and slides into the UP/parent pane once content is
// slides into the UP/parent pane once content is opened). // opened).
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE); const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
// Whether focus is on the tab-list root view — the tab is the CURRENT pane // Whether focus is on the tab-list root view — the tab is the CURRENT pane
// with nothing above it. Opening a tab moves it to UP; deeper goes back out. // with nothing above it. Opening a tab moves it to UP; deeper goes back out.
@@ -128,9 +117,9 @@ export function createNavigation() {
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] }, { [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
); );
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed // per-pane focused index map (unused by depth-tabs, which read/write the
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus` // top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
// for pane 0 (DEPTH_CENTER_PANE) instead. // pages). Keyed by `${tab}:${pane}`.
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>( const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
{}, {},
); );
@@ -143,7 +132,7 @@ export function createNavigation() {
const [commandBuffer, setCommandBuffer] = createSignal(""); const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null); const [commandError, setCommandError] = createSignal<string | null>(null);
/** Depth stack for a tab (empty for fixed-pane tabs). */ /** Depth stack for a tab (always non-empty — every tab is a depth-tab). */
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? []; const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
const ensureStack = (tab: TABS) => { const ensureStack = (tab: TABS) => {
@@ -159,21 +148,17 @@ export function createNavigation() {
* no-op (server build). Routing every tab change through this helper * no-op (server build). Routing every tab change through this helper
* keeps the behavior identical under both runtimes. * keeps the behavior identical under both runtimes.
* *
* - when switching to a special (fixed-pane) tab from the tab root, leave the * - a depth-tab switch from the root keeps the root (the tab list stays
* root — those tabs render only their content, never the tab-list view. * CURRENT); switches made from inside content drop into the new tab's
* - keep focus on the tab root if it is focused (depth-tab switch), * current/center pane.
* otherwise recenter on the active tab's current/center pane
* - clear mode/command/visual/count state */ * - clear mode/command/visual/count state */
const applyTabSwitch = (tab: TABS) => { const applyTabSwitch = (tab: TABS) => {
ensureStack(tab); ensureStack(tab);
batch(() => { batch(() => {
// A depth-tab switch from the root keeps the root; switching to a if (atRootTabSignal()) {
// special (fixed-pane) tab always leaves it. Switches made from // a depth-tab switch from the root keeps the root (focus stays on
// inside content drop into the new tab's content pane. // the tab list); only entering content (enterTabContent) leaves it.
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) { } else {
setAtTabRoot(false);
}
if (!atRootTabSignal()) {
setActivePane(DEPTH_CENTER_PANE); setActivePane(DEPTH_CENTER_PANE);
} }
setMode(NavMode.NORMAL); setMode(NavMode.NORMAL);
@@ -256,23 +241,14 @@ export function createNavigation() {
// ── pane focus ────────────────────────────────────────────────────────── // ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane); const setPane = (pane: PaneId) => setActivePane(pane);
/** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` = // (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
* -1 (left, toward parent) or +1 (right, toward preview). Clamped to
* [0, paneCount-1]. The root panel transition (from content pane 0 to
* (1..TabPaneCount) is handled by the dispatcher, not here. */
const swipe = (dir: -1 | 1, paneCount: number) => {
setActivePane((p) => {
const n = Math.max(1, Math.min(paneCount, p + dir));
return n;
});
};
// ── tab root (the app's outermost pane) ────────────────────────────────── // ── tab root (the app's outermost pane) ──────────────────────────────────
/** True while focus is on the tab list as the CURRENT pane — the app root, /** True while focus is on the tab list as the CURRENT pane — the app root,
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings) * with nothing above it. Applies to every tab: a depth-tab switch from
* participate; Search & Player are special and always show their content. */ * the root keeps it; entering content (`enterTabContent`) clears it; `h`
const atRootTab = (): boolean => * at content depth 0 regains it via `backToTabRoot`. */
atRootTabSignal() && DEPTH_TABS.has(activeTab()); const atRootTab = (): boolean => atRootTabSignal();
/** Open the active tab's content: the tab slides from CURRENT into the /** Open the active tab's content: the tab slides from CURRENT into the
* UP/parent pane and focus lands on the content's current pane. */ * UP/parent pane and focus lands on the content's current pane. */
@@ -291,6 +267,9 @@ export function createNavigation() {
/** The tab the root's cursor is hovering (independent of activeTab). */ /** The tab the root's cursor is hovering (independent of activeTab). */
const tabCursor = (): TABS => tabCursorSignal(); const tabCursor = (): TABS => tabCursorSignal();
/** Directly set the root's tab cursor (e.g. a mouse click on a tab row). */
const setTabCursorTo = (tab: TABS) => setTabCursor(tab);
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */ /** Move the root's cursor to the adjacent tab (clamped, no wrap). */
const moveTabCursor = (dir: -1 | 1) => { const moveTabCursor = (dir: -1 | 1) => {
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS); setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
@@ -306,9 +285,9 @@ export function createNavigation() {
// ── per-pane focus index ──────────────────────────────────────────────── // ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
/** For depth-tabs, pane 0 (the center/current pane) reads/writes /** For depth-tabs (every tab), pane 1 (DEPTH_CENTER_PANE) reads/writes
* the top frame's focus. Other panes and fixed-pane tabs use the * the top frame's focus. Other panes fall back to the per-pane index
* per-pane index map. */ * map (unused by current pages). */
const focusedIndex = (pane: PaneId = activePane()): number => { const focusedIndex = (pane: PaneId = activePane()): number => {
if (isDepthTab() && pane === DEPTH_CENTER_PANE) { if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
return topFrame()?.focus ?? 0; return topFrame()?.focus ?? 0;
@@ -493,11 +472,11 @@ export function createNavigation() {
enterTabContent, enterTabContent,
backToTabRoot, backToTabRoot,
tabCursor, tabCursor,
setTabCursor: setTabCursorTo,
moveTabCursor, moveTabCursor,
activateTabCursor, activateTabCursor,
// pane focus // pane focus
setActivePane: setPane, setActivePane: setPane,
swipe,
// focus index // focus index
focusedIndex, focusedIndex,
setFocusedIndex, setFocusedIndex,
@@ -513,11 +492,7 @@ export function createNavigation() {
exitVisual, exitVisual,
// modes // modes
setActiveTabSignal: setActiveTab, setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused, setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode, setMode,
enterCommand, enterCommand,
enterInput, enterInput,

View File

@@ -12,331 +12,363 @@
* ``` * ```
*/ */
import { createSignal, onCleanup } from "solid-js" import { createSignal, onCleanup } from "solid-js";
import { import {
createAudioBackend, createAudioBackend,
detectPlayers, detectPlayers,
type AudioBackend, type AudioBackend,
type BackendName, type BackendName,
type DetectedPlayer, type DetectedPlayer,
} from "../utils/audio-player" } from "../utils/audio-player";
import { emit, on } from "../utils/event-bus" import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app" import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress" import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry" import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode" import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed" import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav" import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useFeedStore } from "../stores/feed" import { useFeedStore } from "../stores/feed";
export interface AudioControls { export interface AudioControls {
// Signals (reactive getters) // Signals (reactive getters)
isPlaying: () => boolean isPlaying: () => boolean;
position: () => number position: () => number;
duration: () => number duration: () => number;
volume: () => number volume: () => number;
speed: () => number speed: () => number;
backendName: () => BackendName backendName: () => BackendName;
error: () => string | null error: () => string | null;
currentEpisode: () => Episode | null currentEpisode: () => Episode | null;
availablePlayers: () => DetectedPlayer[] availablePlayers: () => DetectedPlayer[];
// Actions // Actions
play: (episode: Episode) => Promise<void> play: (episode: Episode) => Promise<void>;
pause: () => Promise<void> pause: () => Promise<void>;
resume: () => Promise<void> resume: () => Promise<void>;
togglePlayback: () => Promise<void> togglePlayback: () => Promise<void>;
stop: () => Promise<void> stop: () => Promise<void>;
seek: (seconds: number) => Promise<void> seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void> seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void> setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void> setSpeed: (speed: number) => Promise<void>;
switchBackend: (name: BackendName) => Promise<void> switchBackend: (name: BackendName) => Promise<void>;
prev: () => Promise<void> prev: () => Promise<void>;
next: () => Promise<void> next: () => Promise<void>;
} }
// Singleton state — shared across all components that call useAudio() // Singleton state — shared across all components that call useAudio()
let backend: AudioBackend | null = null let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null;
let refCount = 0 let refCount = 0;
let pollCount = 0 // Counts poll ticks for throttling progress saves let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false) const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0) const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0) const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(0.7) const [volume, setVolume] = createSignal(0.7);
const [speed, setSpeed] = createSignal(1) const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none") const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null);
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null) const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([]) const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
function ensureBackend(): AudioBackend { function ensureBackend(): AudioBackend {
if (!backend) { if (!backend) {
const detected = detectPlayers() const detected = detectPlayers();
setAvailablePlayers(detected) setAvailablePlayers(detected);
backend = createAudioBackend() backend = createAudioBackend();
setBackendName(backend.name) setBackendName(backend.name);
} registerExitTeardown();
return backend }
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 { function startPolling(): void {
stopPolling() stopPolling();
pollCount = 0 pollCount = 0;
pollTimer = setInterval(async () => { pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return if (!backend || !isPlaying()) return;
try { try {
const pos = await backend.getPosition() const pos = await backend.getPosition();
const dur = await backend.getDuration() const dur = await backend.getDuration();
setPosition(pos) setPosition(pos);
if (dur > 0) setDuration(dur) if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms) // Save progress every ~5 seconds (10 ticks * 500ms)
pollCount++ pollCount++;
if (pollCount % 10 === 0) { if (pollCount % 10 === 0) {
const ep = currentEpisode() const ep = currentEpisode();
if (ep) { if (ep) {
const progressStore = useProgressStore() const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()) progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
// Update platform media position const media = useMediaRegistry();
const media = useMediaRegistry() media.setPosition(pos);
media.setPosition(pos) }
} }
}
// Check if backend stopped playing (track ended) // Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) { if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false) setIsPlaying(false);
stopPolling() stopPolling();
// Save final position on track end // Save final position on track end
const ep = currentEpisode() const ep = currentEpisode();
if (ep) { if (ep) {
const progressStore = useProgressStore() const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()) progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
} }
} }
} catch { } catch {
// Backend may have been disposed // Backend may have been disposed
} }
}, 500) }, 500);
} }
function stopPolling(): void { function stopPolling(): void {
if (pollTimer) { if (pollTimer) {
clearInterval(pollTimer) clearInterval(pollTimer);
pollTimer = null pollTimer = null;
} }
} }
async function play(episode: Episode): Promise<void> { async function play(episode: Episode): Promise<void> {
const b = ensureBackend() const b = ensureBackend();
setError(null) setError(null);
if (!episode.audioUrl) { if (!episode.audioUrl) {
setError("No audio URL for this episode") setError("No audio URL for this episode");
return return;
} }
try { try {
const appStore = useAppStore() const appStore = useAppStore();
const progressStore = useProgressStore() const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume() const vol = volume();
const spd = storeSpeed || speed() const spd = storeSpeed || speed();
// Resume from saved progress if available and not completed // Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id) const savedProgress = progressStore.get(episode.id);
let startPos = 0 let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) { if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position startPos = savedProgress.position;
} }
await b.play(episode.audioUrl, { await b.play(episode.audioUrl, {
volume: vol, volume: vol,
speed: spd, speed: spd,
startPosition: startPos > 0 ? startPos : undefined, startPosition: startPos > 0 ? startPos : undefined,
}) });
setCurrentEpisode(episode) setCurrentEpisode(episode);
setIsPlaying(true) setIsPlaying(true);
setPosition(startPos) setPosition(startPos);
setSpeed(spd) setSpeed(spd);
if (episode.duration) setDuration(episode.duration) if (episode.duration) setDuration(episode.duration);
// Register with platform media controls // Register with platform media controls
const media = useMediaRegistry() const media = useMediaRegistry();
media.setNowPlaying({ media.setNowPlaying({
title: episode.title, title: episode.title,
artist: episode.podcastId, artist: episode.podcastId,
duration: episode.duration, duration: episode.duration,
}) });
media.setPlaybackState(true) media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos) if (startPos > 0) media.setPosition(startPos);
startPolling() startPolling();
emit("player.play", { episodeId: episode.id }) emit("player.play", { episodeId: episode.id });
} catch (err) { // Distinct from "player.play" (which also fires on resume): signals a
setError(err instanceof Error ? err.message : "Playback failed") // fresh episode start so Shell can honor the auto-jump-to-player pref.
setIsPlaying(false) emit("player.started", { episodeId: episode.id });
} } catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
} }
async function pause(): Promise<void> { async function pause(): Promise<void> {
if (!backend) return if (!backend) return;
try { try {
await backend.pause() await backend.pause();
setIsPlaying(false) setIsPlaying(false);
stopPolling() stopPolling();
const ep = currentEpisode() const ep = currentEpisode();
if (ep) { if (ep) {
// Save progress on pause // Save progress on pause
const progressStore = useProgressStore() const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed()) progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id }) emit("player.pause", { episodeId: ep.id });
// Update platform media controls // Update platform media controls
const media = useMediaRegistry() const media = useMediaRegistry();
media.setPlaybackState(false) media.setPlaybackState(false);
media.setPosition(position()) media.setPosition(position());
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Pause failed") setError(err instanceof Error ? err.message : "Pause failed");
} }
} }
async function resume(): Promise<void> { async function resume(): Promise<void> {
if (!backend) return if (!backend) return;
try { try {
await backend.resume() await backend.resume();
setIsPlaying(true) setIsPlaying(true);
startPolling() startPolling();
const ep = currentEpisode() const ep = currentEpisode();
if (ep) { if (ep) {
emit("player.play", { episodeId: ep.id }) emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry() const media = useMediaRegistry();
media.setPlaybackState(true) media.setPlaybackState(true);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Resume failed") setError(err instanceof Error ? err.message : "Resume failed");
} }
} }
async function togglePlayback(): Promise<void> { async function togglePlayback(): Promise<void> {
if (isPlaying()) { if (isPlaying()) {
await pause() await pause();
} else if (currentEpisode()) { } else if (currentEpisode()) {
await resume() await resume();
} }
} }
async function stop(): Promise<void> { async function stop(): Promise<void> {
if (!backend) return if (!backend) return;
try { try {
// Save progress before stopping // Save progress before stopping
const ep = currentEpisode() const ep = currentEpisode();
if (ep) { if (ep) {
const progressStore = useProgressStore() const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed()) progressStore.update(ep.id, position(), duration(), speed());
} }
await backend.stop() await backend.stop();
setIsPlaying(false) setIsPlaying(false);
setPosition(0) setPosition(0);
setCurrentEpisode(null) setCurrentEpisode(null);
stopPolling() stopPolling();
emit("player.stop", {}) emit("player.stop", {});
// Clear platform media controls const media = useMediaRegistry();
const media = useMediaRegistry() media.clearNowPlaying();
media.clearNowPlaying() } catch (err) {
} catch (err) { setError(err instanceof Error ? err.message : "Stop failed");
setError(err instanceof Error ? err.message : "Stop failed") }
}
} }
async function seek(seconds: number): Promise<void> { async function seek(seconds: number): Promise<void> {
if (!backend) return if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration())) const clamped = Math.max(0, Math.min(seconds, duration()));
try { try {
await backend.seek(clamped) await backend.seek(clamped);
setPosition(clamped) setPosition(clamped);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Seek failed") setError(err instanceof Error ? err.message : "Seek failed");
} }
} }
async function seekRelative(delta: number): Promise<void> { async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta) await seek(position() + delta);
} }
async function doSetVolume(vol: number): Promise<void> { async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol)) const clamped = Math.max(0, Math.min(1, vol));
if (backend) { if (backend) {
try { try {
await backend.setVolume(clamped) await backend.setVolume(clamped);
} catch { } catch {
// Some backends can't change volume at runtime // Some backends can't change volume at runtime
} }
} }
setVolume(clamped) setVolume(clamped);
} }
async function doSetSpeed(spd: number): Promise<void> { async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd)) const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) { if (backend) {
try { try {
await backend.setSpeed(clamped) await backend.setSpeed(clamped);
} catch { } catch {
// Some backends can't change speed at runtime // Some backends can't change speed at runtime
} }
} }
setSpeed(clamped) setSpeed(clamped);
// Sync back to app store // Sync back to app store
try { const appStore = useAppStore();
const appStore = useAppStore() appStore.updateSettings({ playbackSpeed: clamped });
appStore.updateSettings({ playbackSpeed: clamped })
} catch {
// Store may not be available
}
} }
async function switchBackend(name: BackendName): Promise<void> { async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying() const wasPlaying = isPlaying();
const ep = currentEpisode() const ep = currentEpisode();
const pos = position() const pos = position();
const vol = volume() const vol = volume();
const spd = speed() const spd = speed();
// Stop current backend if (backend) {
if (backend) { stopPolling();
stopPolling() backend.dispose();
backend.dispose() backend = null;
backend = null }
}
// Create new backend backend = createAudioBackend(name);
backend = createAudioBackend(name) setBackendName(backend.name);
setBackendName(backend.name) setAvailablePlayers(detectPlayers());
setAvailablePlayers(detectPlayers())
// Resume playback if we were playing // Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) { if (wasPlaying && ep && ep.audioUrl) {
try { try {
await backend.play(ep.audioUrl, { await backend.play(ep.audioUrl, {
startPosition: pos, startPosition: pos,
volume: vol, volume: vol,
speed: spd, speed: spd,
}) });
setIsPlaying(true) setIsPlaying(true);
startPolling() startPolling();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed") setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false) setIsPlaying(false);
} }
} }
} }
/** /**
@@ -346,183 +378,183 @@ async function switchBackend(name: BackendName): Promise<void> {
* Registers event bus listeners and cleans them up with onCleanup. * Registers event bus listeners and cleans them up with onCleanup.
*/ */
export function useAudio(): AudioControls { export function useAudio(): AudioControls {
// Initialize backend on first use // Initialize backend on first use
ensureBackend() ensureBackend();
// Sync initial speed from app store // Sync initial speed from app store
if (refCount === 0) { if (refCount === 0) {
try { const appStore = useAppStore();
const appStore = useAppStore() const storeSpeed = appStore.state().settings.playbackSpeed;
const storeSpeed = appStore.state().settings.playbackSpeed if (storeSpeed && storeSpeed !== speed()) {
if (storeSpeed && storeSpeed !== speed()) { setSpeed(storeSpeed);
setSpeed(storeSpeed) }
} }
} catch {
// Store may not be available yet
}
}
refCount++ refCount++;
// Listen for event bus commands (e.g. from other components) // Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => { const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId. // External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration. // Episode lookup would require feed store integration.
}) });
const unsubStop = on("player.stop", async () => { const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) { if (backend && isPlaying()) {
await backend.stop() await backend.stop();
setIsPlaying(false) setIsPlaying(false);
setPosition(0) setPosition(0);
setCurrentEpisode(null) setCurrentEpisode(null);
stopPolling() stopPolling();
} }
}) });
// Listen for global multimedia key events (from useMultimediaKeys) // Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => { const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback() await togglePlayback();
}) });
const unsubMediaVolUp = on("media.volumeUp", async () => { const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2)))) await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
}) });
const unsubMediaVolDown = on("media.volumeDown", async () => { const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2)))) await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
}) });
const unsubMediaSeekFwd = on("media.seekForward", async () => { const unsubMediaSeekFwd = on("media.seekForward", async () => {
await seekRelative(10) await seekRelative(10);
}) });
const unsubMediaSeekBack = on("media.seekBackward", async () => { const unsubMediaSeekBack = on("media.seekBackward", async () => {
await seekRelative(-10) await seekRelative(-10);
}) });
const unsubMediaSpeed = on("media.speedCycle", async () => { const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2)) const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next) await doSetSpeed(next);
}) });
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
async function prev(): Promise<void> { async function prev(): Promise<void> {
const current = currentEpisode(); const current = currentEpisode();
if (!current) return; if (!current) return;
const currentPos = position(); const currentPos = position();
const currentDur = duration(); const currentDur = duration();
const NAV_START_THRESHOLD = 30; const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD); await seek(NAV_START_THRESHOLD);
} else { } else {
const source = audioNav.getSource(); const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = []; let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) { if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological(); episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) { } else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId(); const podcastId = audioNav.getPodcastId();
if (!podcastId) return; if (!podcastId) return;
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId); const feed = feedStore
if (!feed) return; .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 currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1); const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) { if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex]; const { episode } = episodes[newIndex];
await play(episode); await play(episode);
audioNav.prev(newIndex); audioNav.prev(newIndex);
} }
} }
} }
async function next(): Promise<void> { async function next(): Promise<void> {
const current = currentEpisode(); const current = currentEpisode();
if (!current) return; if (!current) return;
const source = audioNav.getSource(); const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = []; let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) { if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological(); episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) { } else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId(); const podcastId = audioNav.getPodcastId();
if (!podcastId) return; if (!podcastId) return;
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId); const feed = feedStore
if (!feed) return; .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 currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1); const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) { if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex]; const { episode } = episodes[newIndex];
await play(episode); await play(episode);
audioNav.next(newIndex); audioNav.next(newIndex);
} }
} }
onCleanup(() => { onCleanup(() => {
refCount-- refCount--;
unsubPlay() unsubPlay();
unsubStop() unsubStop();
unsubMediaToggle() unsubMediaToggle();
unsubMediaVolUp() unsubMediaVolUp();
unsubMediaVolDown() unsubMediaVolDown();
unsubMediaSeekFwd() unsubMediaSeekFwd();
unsubMediaSeekBack() unsubMediaSeekBack();
unsubMediaSpeed() unsubMediaSpeed();
if (refCount <= 0) { if (refCount <= 0) {
stopPolling() stopPolling();
if (backend) { if (backend) {
backend.dispose() backend.dispose();
backend = null backend = null;
} }
// Clear media registry on full teardown // Clear media registry on full teardown
const media = useMediaRegistry() const media = useMediaRegistry();
media.clearNowPlaying() media.clearNowPlaying();
refCount = 0 refCount = 0;
} }
}) });
return { return {
isPlaying, isPlaying,
position, position,
duration, duration,
volume, volume,
speed, speed,
backendName, backendName,
error, error,
currentEpisode, currentEpisode,
availablePlayers, availablePlayers,
play, play,
pause, pause,
resume, resume,
togglePlayback, togglePlayback,
stop, stop,
seek, seek,
seekRelative, seekRelative,
setVolume: doSetVolume, setVolume: doSetVolume,
setSpeed: doSetSpeed, setSpeed: doSetSpeed,
switchBackend, switchBackend,
prev, prev,
next, next,
} };
} }

View File

@@ -1,34 +0,0 @@
import { createSignal, onCleanup } from "solid-js"
type CacheOptions<T> = {
fetcher: () => Promise<T>
intervalMs?: number
}
export const useCachedData = <T,>(options: CacheOptions<T>) => {
const [data, setData] = createSignal<T | null>(null)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const refresh = async () => {
setLoading(true)
setError(null)
try {
const value = await options.fetcher()
setData(() => value)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data")
} finally {
setLoading(false)
}
}
refresh()
if (options.intervalMs) {
const interval = setInterval(refresh, options.intervalMs)
onCleanup(() => clearInterval(interval))
}
return { data, loading, error, refresh }
}

View File

@@ -0,0 +1,65 @@
/**
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
* focusable renderable) that holds the navigation store's `inputFocused`
* flag true while the renderable has focus.
*
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
* flag to each input's real focus/blur state fixes that.
*
* A module-level counter guards the blur→focus ordering gap that occurs when
* tabbing between two inputs in the same form (the old input blurs before the
* new one focuses) so the flag never flickers off mid-handoff.
*/
import { onCleanup } from "solid-js";
import { RenderableEvents } from "@opentui/core";
import { useNavigation } from "@/context/NavigationContext";
// Inputs (managed by this hook) currently holding focus.
let focusedCount = 0;
export function useInputFocusNav() {
const nav = useNavigation();
let current: any | undefined;
const onFocused = () => {
focusedCount++;
nav.setInputFocused(true);
};
const onBlurred = () => {
focusedCount = Math.max(0, focusedCount - 1);
if (focusedCount === 0) nav.setInputFocused(false);
};
const detach = (el: any) => {
el.off(RenderableEvents.FOCUSED, onFocused);
el.off(RenderableEvents.BLURRED, onBlurred);
// Treat a focused element being torn down as a blur so the counter
// doesn't leak and leave inputFocused stuck on.
if (el.focused) onBlurred();
};
const ref = (el: any) => {
if (current && current !== el) detach(current);
current = el;
if (el) {
el.on(RenderableEvents.FOCUSED, onFocused);
el.on(RenderableEvents.BLURRED, onBlurred);
// If the renderable is already focused when attached, count it.
if (el.focused) onFocused();
}
};
onCleanup(() => {
if (current) {
detach(current);
current = undefined;
}
});
return ref;
}

View File

@@ -5,43 +5,29 @@
* regardless of which component is focused. Uses the event bus to * regardless of which component is focused. Uses the event bus to
* decouple key detection from audio control logic. * decouple key detection from audio control logic.
* *
* Keys are only handled when an episode is loaded (or for play/pause, * Volume and speed are app-level settings — adjustable with or without
* always). This prevents accidental volume/seek changes when there's * an episode loaded (they apply to the next playback and persist). Seek
* nothing playing. * is playback-dependent, so it still requires a loaded episode.
*/ */
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { emit } from "../utils/event-bus" import { emit } from "../utils/event-bus";
export type MediaKeyAction = export type MediaKeyAction =
| "media.toggle" | "media.toggle"
| "media.volumeUp" | "media.volumeUp"
| "media.volumeDown" | "media.volumeDown"
| "media.seekForward" | "media.seekForward"
| "media.seekBackward" | "media.seekBackward"
| "media.speedCycle" | "media.speedCycle";
/** Key-to-action mappings for multimedia controls */
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
// Common terminal media keys — these overlap with Player.tsx local
// bindings, but Player guards on `props.focused` so the global
// handler fires independently when the player tab is *not* active.
//
// When Player IS focused both handlers fire, but since the audio
// actions are idempotent (toggle = toggle, seek = additive) having
// them called twice for the same keypress is avoided by the event
// bus approach — the audio hook only processes event-bus events, and
// Player.tsx calls audio methods directly. We therefore guard with
// a "playerFocused" flag passed via options.
}
export interface MultimediaKeysOptions { export interface MultimediaKeysOptions {
/** When true, skip handling (Player.tsx handles keys locally) */ /** When true, skip handling (Player.tsx handles keys locally) */
playerFocused?: () => boolean playerFocused?: () => boolean;
/** When true, skip handling (text input has focus) */ /** When true, skip handling (text input has focus) */
inputFocused?: () => boolean inputFocused?: () => boolean;
/** Whether an episode is currently loaded */ /** Whether an episode is currently loaded */
hasEpisode?: () => boolean hasEpisode?: () => boolean;
} }
/** /**
@@ -49,50 +35,47 @@ export interface MultimediaKeysOptions {
* event bus. Call once at the app level (e.g. in App.tsx). * event bus. Call once at the app level (e.g. in App.tsx).
*/ */
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) { export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
useKeyboard((key) => { useKeyboard((key) => {
// Don't intercept when a text input owns the keyboard // Don't intercept when a text input owns the keyboard
if (options.inputFocused?.()) return if (options.inputFocused?.()) return;
// Don't intercept when Player component handles its own keys // Don't intercept when Player component handles its own keys
if (options.playerFocused?.()) return if (options.playerFocused?.()) return;
// Ctrl/Meta combos are app-level shortcuts, not media keys // Ctrl/Meta combos are app-level shortcuts, not media keys
if (key.ctrl || key.meta) return if (key.ctrl || key.meta) return;
switch (key.name) { switch (key.name) {
case "space": case "space":
// Toggle play/pause — always valid (may start a loaded episode) // Toggle play/pause — always valid (may start a loaded episode)
emit("media.toggle", {}) emit("media.toggle", {});
break break;
case "up": case "up":
if (!options.hasEpisode?.()) return emit("media.volumeUp", {});
emit("media.volumeUp", {}) break;
break
case "down": case "down":
if (!options.hasEpisode?.()) return emit("media.volumeDown", {});
emit("media.volumeDown", {}) break;
break
case "left": case "left":
if (!options.hasEpisode?.()) return if (!options.hasEpisode?.()) return;
emit("media.seekBackward", {}) emit("media.seekBackward", {});
break break;
case "right": case "right":
if (!options.hasEpisode?.()) return if (!options.hasEpisode?.()) return;
emit("media.seekForward", {}) emit("media.seekForward", {});
break break;
case "s": case "s":
if (!options.hasEpisode?.()) return emit("media.speedCycle", {});
emit("media.speedCycle", {}) break;
break
default: default:
// Not a media key — do nothing // Not a media key — do nothing
break break;
} }
}) });
} }

View File

@@ -0,0 +1,115 @@
/**
* useScrollIntoView — keeps the ref'd row visible inside its enclosing
* `<scrollbox>` whenever the focus accessor is true.
*
* OpenTUI's `ScrollBoxRenderable` has built-in *keyboard* scrolling but does
* NOT auto-scroll to follow a programmatically-focused child (the app moves
* its own cursor via the yazi nav store, so the scrollbox never sees a key
* for row movement). Every scrollable panel therefore drifts out of view the
* moment the cursor crosses the viewport edge.
*
* Attach the returned `ref` callback to the element that represents the
* focused row of a scrollable list and call the hook with a `when()` that is
* true for exactly that row (e.g. `() => index() === focus()`). Whenever the
* accessor flips true, the nearest ScrollBoxRenderable is scrolled just enough
* to bring the element back into the viewport — a "nearest-edge" scroll:
* • scroll up only if the row's top is clipped above the viewport,
* • scroll down only if the row's bottom is clipped below the viewport,
* never snapping more than necessary (matches yazi list behaviour).
*
* Timing: for ordinary cursor movement (j/k) the list layout does not change
* — only background colour and the cursor glyph flip — so the focused row's
* Yoga-computed position is already valid when this effect fires, and the
* scroll is applied synchronously. On first mount / content population the
* layout for the new rows has not yet been computed, so the hook polls on a
* short timer until layout resolves (bounded so it can never loop forever).
*/
import { createEffect, onCleanup } from "solid-js";
/** Walk up the renderable parent chain to the nearest ScrollBoxRenderable,
* identified by its `viewport` + `content` + numeric `scrollTop`. */
function findScrollBox(node: any): any | null {
let p: any = node?.parent;
while (p) {
if (p.viewport && p.content && typeof p.scrollTop === "number") return p;
p = p.parent;
}
return null;
}
/** Maximum number of retries while waiting for Yoga layout to populate the
* row/viewport dimensions (handles the first-mount frame). */
const MAX_RETRIES = 12;
const RETRY_MS = 16;
export function useScrollIntoView(when: () => boolean) {
let el: any = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const ref = (node: any) => {
el = node;
};
const clearTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
/** Compute the target scrollTop that brings `el` into the viewport of its
* enclosing scrollbox, or `null` if no scroll is possible / needed yet.
* Returns the decision so the caller knows whether to poll again. */
const compute = (): { scroll: number | null; ready: boolean } => {
const node = el;
if (!node) return { scroll: null, ready: false };
const sb = findScrollBox(node);
if (!sb) return { scroll: null, ready: false };
const vp = sb.viewport;
const top: number = sb.scrollTop ?? 0;
const vpH: number = vp?.height ?? 0;
// The scrollbar's onChange sets `content.translateY = -scrollTop`, so
// the child's cumulative `.y` already includes `-scrollTop`; subtracting
// the viewport's stable `.y` and re-adding `scrollTop` recovers the
// row's layout-space offset within the content (scroll-independent).
const childTop: number = node.y ?? 0;
const childH: number = node.height ?? 0;
if (!vpH || !childH) return { scroll: null, ready: false };
const offset = childTop - (vp.y ?? 0) + top;
let target = top;
if (offset < top) target = offset;
else if (offset + childH > top + vpH) target = offset + childH - vpH;
const max = Math.max(0, (sb.scrollHeight ?? 0) - vpH);
if (target > max) target = max;
if (target < 0) target = 0;
target = Math.round(target);
if (target === Math.round(top)) return { scroll: null, ready: true };
return { scroll: target, ready: true };
};
const tryScroll = (retriesLeft: number) => {
const { scroll, ready } = compute();
if (!ready) {
if (retriesLeft > 0)
timer = setTimeout(() => tryScroll(retriesLeft - 1), RETRY_MS);
return;
}
if (scroll != null) {
const sb = findScrollBox(el);
if (sb) sb.scrollTo(scroll);
}
clearTimer();
};
createEffect(() => {
if (!when()) return;
clearTimer();
tryScroll(MAX_RETRIES);
});
onCleanup(() => {
clearTimer();
});
return ref;
}

View File

@@ -1,225 +1,254 @@
const VERSION = "0.1.0"; import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.3.0";
interface CliArgs { interface CliArgs {
version: boolean; version: boolean;
query: string | null; query: string | null;
play: string | null; play: string | null;
} }
function parseArgs(): CliArgs { function parseArgs(): CliArgs {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const result: CliArgs = { const result: CliArgs = {
version: false, version: false,
query: null, query: null,
play: null, play: null,
}; };
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i];
if (arg === "--version" || arg === "-v") { if (arg === "--version" || arg === "-v") {
result.version = true; result.version = true;
} else if (arg === "--query" || arg === "-q") { } else if (arg === "--query" || arg === "-q") {
result.query = args[i + 1] || ""; result.query = args[i + 1] || "";
i++; i++;
} else if (arg === "--play" || arg === "-p") { } else if (arg === "--play" || arg === "-p") {
result.play = args[i + 1] || ""; result.play = args[i + 1] || "";
i++; i++;
} }
} }
return result; return result;
} }
const cliArgs = parseArgs(); const cliArgs = parseArgs();
if (cliArgs.version) { if (cliArgs.version) {
console.log(`PodTUI version ${VERSION}`); console.log(`PodTUI version ${VERSION}`);
process.exit(0); process.exit(0);
}
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
let latest: { feed: Feed; episode: Episode } | null = null
let latestDate = 0
for (const feed of feeds) {
if (feed.episodes.length === 0) continue
const ep = feed.episodes[0]
const epDate =
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
if (epDate > latestDate) {
latestDate = epDate
latest = { feed, episode: ep }
}
}
return latest
}
/** Search feeds by title and print matching shows */
function handleQuery(feeds: Feed[], query: string): void {
const normalizedQuery = query.toLowerCase()
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase()
return title.includes(normalizedQuery)
})
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`)
if (feeds.length > 0) {
console.log("\nAvailable shows:")
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`)
})
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`)
}
}
process.exit(0)
}
if (matches.length === 1) {
const feed = matches[0]
console.log(`\n${feed.podcast.title}`)
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
)
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate)
console.log(` ${idx + 1}. ${ep.title} (${date})`)
})
process.exit(0)
}
console.log(`\nClosest matches for "${query}":`)
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`)
})
process.exit(0)
}
/** Resolve and play an episode from `arg` (title path or "latest") */
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const normalizedArg = arg.toLowerCase()
let feedResult: Feed | null = null
let episodeResult: Episode | null = null
if (normalizedArg === "latest") {
const latest = findLatestEpisode(feeds)
if (latest) {
feedResult = latest.feed
episodeResult = latest.episode
}
} else {
const parts = normalizedArg.split("/")
const showQuery = parts[0]
const episodeQuery = parts[1]
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
)
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`)
process.exit(1)
}
const feed = matchingFeeds[0]
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
console.log(`No episodes available for: ${feed.podcast.title}`)
process.exit(1)
}
} else if (episodeQuery === "latest") {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
)
if (matchingEpisode) {
feedResult = feed
episodeResult = matchingEpisode
} else {
console.log(`Episode not found: ${episodeQuery}`)
console.log(`Available episodes for ${feed.podcast.title}:`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`)
})
process.exit(1)
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play")
process.exit(1)
}
console.log(`\nPlaying: ${episodeResult.title}`)
console.log(`Show: ${feedResult.podcast.title}`)
try {
const { createAudioBackend } = await import("./utils/audio-player")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl)
console.log("Playback started (use the UI to control)")
} else {
console.log("No audio URL available for this episode")
process.exit(1)
}
} catch (err) {
console.error("Playback error:", err)
process.exit(1)
}
} }
if (cliArgs.query !== null || cliArgs.play !== null) { if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => { import("./utils/feeds-persistence")
const feeds = await loadFeedsFromFile(); .then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) { if (cliArgs.query !== null) {
const query = cliArgs.query; handleQuery(feeds, cliArgs.query)
const normalizedQuery = query.toLowerCase(); }
const matches = feeds.filter((feed) => { if (cliArgs.play !== null) {
const title = feed.podcast.title.toLowerCase(); await handlePlay(feeds, cliArgs.play)
return title.includes(normalizedQuery); }
}); })
.catch((err) => {
if (matches.length === 0) { console.error("Error:", err);
console.log(`No shows found matching: ${query}`); process.exit(1);
if (feeds.length > 0) { });
console.log("\nAvailable shows:");
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`);
});
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`);
}
}
process.exit(0);
}
if (matches.length === 1) {
const feed = matches[0];
console.log(`\n${feed.podcast.title}`);
if (feed.podcast.description) {
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
console.log(` ${idx + 1}. ${ep.title} (${date})`);
});
process.exit(0);
}
console.log(`\nClosest matches for "${query}":`);
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`);
});
process.exit(0);
}
if (cliArgs.play !== null) {
const playArg = cliArgs.play;
const normalizedArg = playArg.toLowerCase();
let feedResult: typeof feeds[0] | null = null;
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
if (normalizedArg === "latest") {
let latestFeed: typeof feeds[0] | null = null;
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
let latestDate = 0;
for (const feed of feeds) {
if (feed.episodes.length > 0) {
const ep = feed.episodes[0];
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
if (epDate > latestDate) {
latestDate = epDate;
latestFeed = feed;
latestEpisode = ep;
}
}
}
feedResult = latestFeed;
episodeResult = latestEpisode;
} else {
const parts = normalizedArg.split("/");
const showQuery = parts[0];
const episodeQuery = parts[1];
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery)
);
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`);
process.exit(1);
}
const feed = matchingFeeds[0];
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
console.log(`No episodes available for: ${feed.podcast.title}`);
process.exit(1);
}
} else if (episodeQuery === "latest") {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery)
);
if (matchingEpisode) {
feedResult = feed;
episodeResult = matchingEpisode;
} else {
console.log(`Episode not found: ${episodeQuery}`);
console.log(`Available episodes for ${feed.podcast.title}:`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`);
});
process.exit(1);
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play");
process.exit(1);
}
console.log(`\nPlaying: ${episodeResult.title}`);
console.log(`Show: ${feedResult.podcast.title}`);
try {
const { createAudioBackend } = await import("./utils/audio-player");
const backend = createAudioBackend();
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl);
console.log("Playback started (use the UI to control)");
} else {
console.log("No audio URL available for this episode");
process.exit(1);
}
} catch (err) {
console.error("Playback error:", err);
process.exit(1);
}
}
}).catch((err) => {
console.error("Error:", err);
process.exit(1);
});
} else { } else {
import("@opentui/solid").then(async ({ render, useRenderer }) => { import("@opentui/solid").then(async ({ render, useRenderer }) => {
const { App } = await import("./App"); const { App } = await import("./App");
const { ThemeProvider } = await import("./context/ThemeContext"); const { ThemeProvider } = await import("./context/ThemeContext");
const toast = await import("./ui/toast"); const toast = await import("./ui/toast");
const { KeybindProvider } = await import("./context/KeybindContext"); const { KeybindProvider } = await import("./context/KeybindContext");
const { NavigationProvider } = await import("./context/NavigationContext"); const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog"); const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command"); const { CommandProvider } = await import("./ui/command");
function RendererSetup(props: { children: unknown }) { function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer(); const renderer = useRenderer();
renderer.disableStdoutInterception(); renderer.disableStdoutInterception();
return props.children; return props.children;
} }
render( render(
() => ( () => (
<RendererSetup> <RendererSetup>
<toast.ToastProvider> <toast.ToastProvider>
<ThemeProvider mode="dark"> <ThemeProvider mode="dark">
<KeybindProvider> <KeybindProvider>
<NavigationProvider> <NavigationProvider>
<DialogProvider> <DialogProvider>
<CommandProvider> <CommandProvider>
<App /> <App />
<toast.Toast /> <toast.Toast />
</CommandProvider> </CommandProvider>
</DialogProvider> </DialogProvider>
</NavigationProvider> </NavigationProvider>
</KeybindProvider> </KeybindProvider>
</ThemeProvider> </ThemeProvider>
</toast.ToastProvider> </toast.ToastProvider>
</RendererSetup> </RendererSetup>
), ),
{ useThread: false }, { useThread: false },
); );
}); });
} }

Binary file not shown.

View File

@@ -2,13 +2,13 @@
* DiscoverPage — yazi depth-stack view of discoverable podcasts. * DiscoverPage — yazi depth-stack view of discoverable podcasts.
* *
* depth 0 (current) — category list. Parent pane shows the muted * depth 0 (current) — category list. Parent pane shows the muted
* placeholder (1/7 slot kept). * placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent * depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list. * pane = the categories list.
* preview — detail of the hovered item (category summary, or * preview — detail of the hovered item (category summary, or
* podcast detail + subscribe action). * podcast detail + subscribe action).
* *
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX * Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a * remains. `l`/Enter drills in (category → results) or subscribes (on a
* podcast); `h` pops a depth (noop at 0). j/k move only within the current * podcast); `h` pops a depth (noop at 0). j/k move only within the current
* column. Moving through categories at depth 0 updates the store's selected * column. Moving through categories at depth 0 updates the store's selected
@@ -28,8 +28,9 @@ import {
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const DiscoverPaneCount = 1; export const DiscoverPaneCount = 1;
@@ -39,7 +40,6 @@ function DiscoverPage() {
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const stack = nav.depthStack;
const depth = nav.currentDepth; const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d); const focus = (d: number = depth()) => nav.depthFocus(d);
@@ -65,6 +65,12 @@ function DiscoverPage() {
}; };
onMount(ensureFocus); onMount(ensureFocus);
// Auto-fetch the featured-shows manifest on first mount (network failure is
// non-fatal — the list stays empty until the user hits refresh).
onMount(() => {
discoverStore.refresh().catch(() => {});
});
onMount(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id; if (depth() === 0) return categories()[i]?.id;
@@ -140,7 +146,11 @@ function DiscoverPage() {
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
@@ -148,28 +158,32 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`; : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}> <Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => ( {(cat, index) => {
<box const lf = () => nav.depthFocus(0);
flexDirection="row" const ref = useScrollIntoView(() => index() === lf());
gap={1} return (
paddingLeft={1} <box
paddingRight={1} ref={ref}
backgroundColor={focusBg(index(), nav.depthFocus(0), false)} flexDirection="row"
> gap={1}
<text fg={focusFg(index(), nav.depthFocus(0), false)}> paddingLeft={1}
{index() === nav.depthFocus(0) ? "" : " "} paddingRight={1}
</text> backgroundColor={focusBg(index(), lf(), false)}
<text fg={focusFg(index(), nav.depthFocus(0), false)}> >
{cat.name} <text fg={focusFg(index(), nav.depthFocus(0), false)}>
</text> {index() === nav.depthFocus(0) ? "" : " "}
</box> </text>
)} <text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For> </For>
</Show> </Show>
); );
@@ -181,30 +195,26 @@ function DiscoverPage() {
<Show when={depth() === 0}> <Show when={depth() === 0}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => { {(cat, index) => {
const lf = focusedCatIdx(); const lf = () => focusedCatIdx();
const selected = () => cat.id === discoverStore.selectedCategory(); const ref = useScrollIntoView(() => index() === lf());
return ( return (
<box <box
ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())} backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id); discoverStore.setSelectedCategory(cat.id);
}} }}
> >
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf ? "" : " "} {index() === lf() ? "" : " "}
</text> </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>
</Show>
</box> </box>
); );
}} }}
@@ -222,35 +232,39 @@ function DiscoverPage() {
> >
<For each={podcasts()}> <For each={podcasts()}>
{(podcast, index) => { {(podcast, index) => {
const lf = focusedPodIdx(); const lf = () => focusedPodIdx();
const ref = useScrollIntoView(() => index() === lf());
return ( return (
<box <box
ref={ref}
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())} backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf ? "" : " "} {index() === lf() ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{podcast.title} {podcast.title}
</text> </text>
<Show when={podcast.isSubscribed}> <Show when={podcast.isSubscribed}>
<text fg={index() === lf ? theme.surface : theme.success}> <text
fg={index() === lf() ? theme.surface : theme.success}
>
[+] [+]
</text> </text>
</Show> </Show>
</box> </box>
<Show when={podcast.author}> <Show when={podcast.author}>
<text <text
fg={index() === lf ? theme.surface : muted()} fg={index() === lf() ? theme.surface : muted()}
paddingLeft={2} paddingLeft={2}
> >
by {podcast.author} by {podcast.author}
@@ -268,7 +282,7 @@ function DiscoverPage() {
// ── preview pane ─────────────────────────────────────────────────────────── // ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () => const previewContent = () =>
depth() === 0 ? ( depth() === 0 ? (
// depth 0 preview: hovered category // depth 0 preview: shows for the hovered category
<Show <Show
when={focusedCategory()} when={focusedCategory()}
fallback={ fallback={
@@ -278,16 +292,35 @@ function DiscoverPage() {
} }
> >
{(cat) => ( {(cat) => (
<box flexDirection="column" gap={1} padding={1}> <box flexDirection="column" gap={0} padding={1}>
<text fg={theme.textPrimary ?? theme.text}> <text fg={theme.textPrimary ?? theme.text}>
<strong>{cat().name}</strong> <strong>{cat().name}</strong>
</text> </text>
<text fg={theme.textSecondary}> <Show when={(cat() as any).description}>
{(cat() as any).description ?? <text fg={theme.textSecondary}>{(cat() as any).description}</text>
`Browse top podcasts in ${cat().name}.`} </Show>
</text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter/l: open · h: back</text> <Show
when={podcasts().length > 0}
fallback={
<text fg={muted()}>
No shows in this category yet. :refresh
</text>
}
>
<For each={podcasts()}>
{(pod) => (
<box flexDirection="column" gap={0}>
<text fg={theme.text}>{pod.title}</text>
<Show when={pod.author}>
<text fg={muted()} paddingLeft={2}>
by {pod.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</box> </box>
)} )}
</Show> </Show>
@@ -339,7 +372,7 @@ function DiscoverPage() {
); );
return ( return (
<YaziPaneRow <PaneRow
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}

View File

@@ -1,85 +0,0 @@
/**
* PodcastCard component - Reusable card for displaying podcast info
*/
import { Show, For } from "solid-js";
import type { Podcast } from "@/types/podcast";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type PodcastCardProps = {
podcast: Podcast;
selected: boolean;
compact?: boolean;
onSelect?: () => void;
onSubscribe?: () => void;
};
export function PodcastCard(props: PodcastCardProps) {
const { theme } = useTheme();
const handleSubscribeClick = () => {
props.onSubscribe?.();
};
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText selected={() => props.selected} primary>
<strong>{props.podcast.title}</strong>
</SelectableText>
<Show when={props.podcast.isSubscribed}>
<text fg={theme.success}>[+]</text>
</Show>
</box>
{/* Author */}
<Show when={props.podcast.author && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {props.podcast.author}
</SelectableText>
</Show>
{/* Description */}
<Show when={props.podcast.description && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
{props.podcast.description!.length > 80
? props.podcast.description!.slice(0, 80) + "..."
: props.podcast.description}
</SelectableText>
</Show>
{/**<box
flexDirection="row"
justifyContent="space-between"
marginTop={props.compact ? 0 : 1}
/>**/}
<box flexDirection="row" gap={1}>
<Show when={(props.podcast.categories ?? []).length > 0}>
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</Show>
</box>
<Show when={props.selected}>
<box onMouseDown={handleSubscribeClick}>
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,194 +0,0 @@
/**
* Feed detail view component for PodTUI
* Shows podcast info and episode list
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import type { Feed } from "@/types/feed";
import type { Episode } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedDetailProps {
feed: Feed;
focused?: boolean;
onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void;
}
export function FeedDetail(props: FeedDetailProps) {
const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [showInfo, setShowInfo] = createSignal(true);
const episodes = () => {
// Sort episodes by publication date (newest first)
return [...props.feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) {
return `${hrs}h ${mins % 60}m`;
}
return `${mins}m`;
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const handleKeyPress = (key: { name: string }) => {
const eps = episodes();
if (key.name === "escape" && props.onBack) {
props.onBack();
return;
}
if (key.name === "i") {
setShowInfo((v) => !v);
return;
}
if (key.name === "v") {
props.feed.podcast.onToggleVisibility?.(props.feed.id);
return;
}
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return") {
const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
return (
<box flexDirection="column" gap={1}>
{/* Header with back button */}
<box flexDirection="row" justifyContent="space-between">
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
</box>
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
</box>
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
</box>
</box>
{/* Podcast info section */}
<Show when={showInfo()}>
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<SelectableText selected={() => false} primary>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
{props.feed.podcast.author && (
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>by</SelectableText>
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
</box>
)}
<box height={1} />
<SelectableText selected={() => false} tertiary>
{props.feed.podcast.description?.slice(0, 200)}
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
</SelectableText>
<box height={1} />
<box flexDirection="row" gap={2}>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
</box>
<SelectableText selected={() => false} tertiary>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</SelectableText>
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
</box>
</box>
</Show>
{/* Episodes header */}
<box flexDirection="row" justifyContent="space-between">
<SelectableText selected={() => false} primary>
<strong>Episodes</strong>
</SelectableText>
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
</box>
{/* Episode list */}
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
<For each={episodes()}>
{(episode, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
if (props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
}}
>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{index() === selectedIndex() ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
{episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
</box>
</SelectableBox>
)}
</For>
</scrollbox>
{/* Help text */}
<text fg={theme.textMuted}>
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text>
</box>
);
}

View File

@@ -1,207 +0,0 @@
/**
* Feed filter component for PodTUI
* Toggle and filter options for feed list
*/
import { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedFilterProps {
filter: FeedFilter;
focused?: boolean;
onFilterChange: (filter: FeedFilter) => void;
}
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
export function FeedFilterComponent(props: FeedFilterProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "visibility") {
cycleVisibility();
} else if (focusField() === "sort") {
cycleSort();
} else if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
}
};
const cycleVisibility = () => {
const current = props.filter.visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
props.onFilterChange({ ...props.filter, visibility: next });
};
const cycleSort = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const currentIndex = sortOptions.indexOf(
props.filter.sortBy as FeedSortField,
);
const nextIndex = (currentIndex + 1) % sortOptions.length;
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
};
const togglePinned = () => {
props.onFilterChange({
...props.filter,
pinnedOnly: !props.filter.pinnedOnly,
});
};
const togglePrivate = () => {
props.onFilterChange({
...props.filter,
showPrivate: !props.filter.showPrivate,
});
};
const handleSearchInput = (value: string) => {
setSearchValue(value);
props.onFilterChange({ ...props.filter, searchQuery: value });
};
const visibilityLabel = () => {
const vis = props.filter.visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const visibilityColor = () => {
const vis = props.filter.visibility;
if (vis === "public") return theme.success;
if (vis === "private") return theme.warning;
return theme.text;
};
const sortLabel = () => {
const sort = props.filter.sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
case "updated":
default:
return "Updated";
}
};
return (
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Filter Feeds</strong>
</text>
<box flexDirection="row" gap={2} flexWrap="wrap">
{/* Visibility filter */}
<box
border
padding={0}
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
borderColor={theme.border}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
{/* Sort filter */}
<box
border
padding={0}
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
<text fg={theme.text}>{sortLabel()}</text>
</box>
</box>
{/* Pinned filter */}
<box
border
padding={0}
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</text>
</box>
</box>
{/* Private filter */}
<box
border
padding={0}
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
Private:
</text>
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
{props.filter.showPrivate ? "Yes" : "No"}
</text>
</box>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
<input
value={searchValue()}
onInput={handleSearchInput}
placeholder="Filter by name..."
focused={props.focused && focusField() === "search"}
width={25}
/>
</box>
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
</box>
);
}

View File

@@ -1,154 +0,0 @@
/**
* Feed item component for PodTUI
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedItemProps {
feed: Feed;
isSelected: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
compact?: boolean;
}
export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => {
return format(date, "MMM d");
};
const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length;
};
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]";
};
const visibilityColor = () => {
return props.feed.visibility === "public" ? theme.success : theme.warning;
};
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " ";
};
const { theme } = useTheme();
if (props.compact) {
// Compact single-line view
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => {}}
>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.feed.customName || props.feed.podcast.title}
</SelectableText>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
({episodeCount()})
</SelectableText>
)}
</SelectableBox>
);
}
// Full view with details
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {}}
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
secondary
>
{pinnedIndicator()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
</box>
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
{episodeCount()} episodes ({unplayedCount()} new)
</SelectableText>
)}
{props.showLastUpdated && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
Updated: {formatDate(props.feed.lastUpdated)}
</SelectableText>
)}
</box>
{props.feed.podcast.description && (
<SelectableText
selected={() => props.isSelected}
paddingLeft={4}
paddingTop={0}
tertiary
>
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</SelectableText>
)}
</SelectableBox>
);
}

View File

@@ -1,198 +0,0 @@
/**
* Feed list component for PodTUI
* Scrollable list of feeds with keyboard navigation and mouse support
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { FeedItem } from "./FeedItem";
import { useFeedStore } from "@/stores/feed";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedListProps {
focused?: boolean;
compact?: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
onSelectFeed?: (feed: Feed) => void;
onOpenFeed?: (feed: Feed) => void;
onFocusChange?: (focused: boolean) => void;
}
export function FeedList(props: FeedListProps) {
const { theme } = useTheme();
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false);
return;
}
const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return") {
const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") {
// Toggle pin on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
feedStore.togglePinned(feed.id);
}
} else if (key.name === "v") {
// Toggle visibility on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
feedStore.updateFeed(feed.id, { visibility: newVisibility });
}
} else if (key.name === "f") {
// Cycle visibility filter
cycleVisibilityFilter();
} else if (key.name === "s") {
// Cycle sort
cycleSortField();
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed);
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
};
const cycleSortField = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const current = feedStore.filter().sortBy as FeedSortField;
const idx = sortOptions.indexOf(current);
const next = sortOptions[(idx + 1) % sortOptions.length];
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
};
const visibilityLabel = () => {
const vis = feedStore.filter().visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const sortLabel = () => {
const sort = feedStore.filter().sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
}
};
const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index);
if (props.onSelectFeed) {
props.onSelectFeed(feed);
}
};
const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) {
props.onOpenFeed(feed);
}
};
return (
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text fg={theme.text}>
<strong>My Feeds</strong>
</text>
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
</box>
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
<text fg={theme.primary}>[s] {sortLabel()}</text>
</box>
</box>
</box>
{/* Feed list in scrollbox */}
<Show
when={filteredFeeds().length > 0}
fallback={
<box border padding={2} borderColor={theme.border}>
<text fg={theme.textMuted}>
No feeds found. Add podcasts from the Discover or Search tabs.
</text>
</box>
}
>
<scrollbox height={15} focused={props.focused}>
<For each={filteredFeeds()}>
{(feed, index) => (
<box onMouseDown={() => handleFeedClick(feed, index())}>
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
compact={props.compact}
showEpisodeCount={props.showEpisodeCount ?? true}
showLastUpdated={props.showLastUpdated ?? true}
/>
</box>
)}
</For>
</scrollbox>
</Show>
{/* Navigation help */}
<box paddingTop={0}>
<text fg={theme.textMuted}>
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
</text>
</box>
</box>
);
}

View File

@@ -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 * depth 0 (current) — every episode from every feed, newest-first (the
* virtual "All Feeds". Parent pane shows the muted * combined view the old "All Feeds" virtual row used to
* placeholder (1/7 slot kept). * drill into). Parent pane shows the muted tab list.
* depth 1 (current) — flat episodes list for the drilled feed (reverse * preview — detail of the hovered episode.
* chronological). Parent pane = the feeds list (prev).
* preview — detail of the hovered item in the current column.
* *
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview * This page does NOT drill: the previous depth-1 "episodes of one feed" panel
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in * duplicated My Shows (shows → episodes). Per design, the Feed tab now just
* (push); `h` pops a depth (noop at 0). j/k move only within the current * shows the full flat episodes list immediately.
* column. The Shell router drives everything over `nav.action`; this page *
* only handles list/preview data. * Renders entirely through `<PaneRow>` (the shared parent|current|preview
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
* root. j/k move only within the current column. The Shell router drives
* everything over `nav.action`; this page only handles list/preview data.
*/ */
import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -27,7 +28,6 @@ import {
NavMode, NavMode,
DEPTH_CENTER_PANE, DEPTH_CENTER_PANE,
type PaneId, type PaneId,
type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
@@ -35,12 +35,12 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
type EpItem = { episode: Episode; feed: Feed }; type EpItem = { episode: Episode; feed: Feed };
function FeedPage() { function FeedPage() {
@@ -52,57 +52,27 @@ function FeedPage() {
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const stack = nav.depthStack; // ── flat episode list (depth 0 — the only depth Feed has) ────────────────
const depth = nav.currentDepth; const episodes = createMemo<EpItem[]>(
const focus = (d: number = depth()) => nav.depthFocus(d); () => feedStore.getAllEpisodesChronological() as EpItem[],
);
// ── feeds list (depth 0) ───────────────────────────────────────────────── const focus = () => nav.depthFocus(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 }));
});
const focusedEpIdx = () => 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 focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
const curLen = () => episodes().length;
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
const ensureFocus = () => { const ensureFocus = () => {
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length) if (episodes().length > 0 && focus() >= episodes().length)
nav.setDepthFocus(feedList().length - 1, 0); nav.setDepthFocus(episodes().length - 1, 0);
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 1);
}; };
onMount(ensureFocus); onMount(ensureFocus);
onMount(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { nav.registerResolver(
if (depth() === 0) { `${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
const it = feedList()[i]; (i) => episodes()[i]?.episode.id,
return it?.kind === "feed" ? it.feed.podcast.id : "all"; );
}
return episodes()[i]?.episode.id;
});
}); });
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
@@ -146,19 +116,9 @@ function FeedPage() {
audioNav.setSource(AudioSource.FEED); audioNav.setSource(AudioSource.FEED);
}; };
// ── drill / open ─────────────────────────────────────────────────────────── // ── open ───────────────────────────────────────────────────────────────────
function open() { function open() {
if (depth() === 0) { playEpisode(focusedItem());
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());
}
} }
// ── nav.action handler ──────────────────────────────────────────────────── // ── nav.action handler ────────────────────────────────────────────────────
@@ -173,16 +133,11 @@ function FeedPage() {
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(), open: () => open(),
"toggle-select": () => { "toggle-select": () => {
if (depth() >= 1) { const item = focusedItem();
const item = focusedItem(); if (item) nav.toggleSelected(item.episode.id);
if (item) nav.toggleSelected(item.episode.id);
}
}, },
refresh: () => { refresh: () => {
const item = focusedFeedItem(); feedStore.refreshAllFeeds().catch(() => {});
if (item?.kind === "feed")
feedStore.refreshFeed(item.feed.id).catch(() => {});
else feedStore.refreshAllFeeds().catch(() => {});
}, },
}; };
function step(delta: number) { function step(delta: number) {
@@ -205,7 +160,7 @@ function FeedPage() {
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within a list. `active=true` only for the current pane. // Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) => const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active i === listFocus && active
? theme.primary ? theme.primary
@@ -213,267 +168,140 @@ function FeedPage() {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text; i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const feedLabel = (item: FeedListItem) => const currentLabel = () => `Feed · ${episodes().length}`;
item.kind === "all"
? "All Feeds"
: item.feed.customName || item.feed.podcast.title;
const feedCount = (item: FeedListItem) =>
item.kind === "all"
? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length;
const currentLabel = () => // ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
depth() === 0 const parentContent = () => <TabListPane muted />;
? `Feeds · ${feedList().length - 1}`
: `${(() => {
const fi = focusedFeedItem();
return fi?.kind === "feed"
? fi.feed.customName || fi.feed.podcast.title
: "All Episodes";
})()} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ────────── // ── current pane: the flat episodes list (the only focusable column) ──────
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list const currentContent = () => (
// mounts/unmounts cleanly on depth change instead of swapping roots. <Show
const parentContent = () => ( when={episodes().length > 0}
<Show when={depth() >= 1} fallback={<TabListPane muted />}> fallback={
<For each={feedList()}> <box padding={1}>
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => { {(item, index) => {
const lf = nav.depthFocus(0); const fi = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === fi());
return ( return (
<box <box
flexDirection="row" ref={ref}
gap={1} flexDirection="column"
gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, false)} backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
> >
<text fg={focusFg(index(), lf, false)}> <box flexDirection="row" gap={1}>
{index() === lf ? "" : " "} <text fg={focusFg(index(), fi(), isActive())}>
</text> {index() === fi() ? "" : " "}
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text> </text>
<text fg={muted()}>({feedCount(item)})</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> </box>
); );
}} }}
</For> </For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show> </Show>
); );
// ── current pane: the current-depth list (the only focusable column) ────── // ── preview pane: hovered-episode detail ───────────────────────────────────
const currentContent = () => ( const previewContent = () => (
<> <Show
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */} when={focusedItem()}
<Show when={depth() === 0}> fallback={
<Show <box padding={1}>
when={feedList().length > 1} <text fg={muted()}>No episode focused</text>
fallback={ </box>
<box padding={1}> }
<text fg={muted()}> >
No feeds. Subscribe from Discover/Search. {(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> </text>
</box> </Show>
} </box>
> <text fg={muted()}>
<For each={feedList()}> {item().feed.customName || item().feed.podcast.title}
{(item, index) => { </text>
const fi = focusedFeedIdx(); <Show when={item().feed.podcast.author}>
return ( <text fg={muted()}>by {item().feed.podcast.author}</text>
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
</Show>
<Show when={depth() >= 1}>
{/* depth ≥1: episodes */}
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show> </Show>
</Show> <box height={1} />
</Show> <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 ( return (
<YaziPaneRow <PaneRow
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")} parentLabel="Up"
currentLabel={currentLabel} currentLabel={currentLabel}
previewLabel="Detail" previewLabel="Detail"
focused={isActive} focused={isActive}

View File

@@ -2,11 +2,11 @@
* MyShowsPage — yazi depth-stack view of subscribed shows. * MyShowsPage — yazi depth-stack view of subscribed shows.
* *
* depth 0 (current) — subscribed shows. Parent pane shows the muted * depth 0 (current) — subscribed shows. Parent pane shows the muted
* placeholder (1/7 slot kept). * placeholder (1/5 slot kept).
* depth 1 (current) — episodes of the drilled show. Parent pane = shows. * depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column. * preview — detail of the hovered item in the current column.
* *
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX * Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column. * 0). j/k move only within the current column.
*/ */
@@ -31,8 +31,9 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const MyShowsPaneCount = 1; export const MyShowsPaneCount = 1;
@@ -164,6 +165,16 @@ export function MyShowsPage() {
const show = selectedShow(); const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {}); if (show) feedStore.refreshFeed(show.id).catch(() => {});
}, },
unsubscribe: () => {
if (depth() !== 0) return;
const show = selectedShow();
if (show) {
// unsubscribe = remove feed + purge its downloaded files
feedStore.removeFeed(show.id);
downloadStore.removeDownloadsForFeed(show.id).catch(() => {});
ensureFocus();
}
},
}; };
function step(delta: number) { function step(delta: number) {
nav.move(delta, curLen()); nav.move(delta, curLen());
@@ -188,7 +199,11 @@ export function MyShowsPage() {
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title; const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () => const currentLabel = () =>
@@ -197,26 +212,27 @@ export function MyShowsPage() {
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}> <Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={shows()}> <For each={shows()}>
{(feed, index) => { {(feed, index) => {
const lf = nav.depthFocus(0); const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
return ( return (
<box <box
ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, false)} backgroundColor={focusBg(index(), lf(), false)}
> >
<text fg={focusFg(index(), lf, false)}> <text fg={focusFg(index(), lf(), false)}>
{index() === lf ? "" : " "} {index() === lf() ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text> <text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text> <text fg={muted()}>({feed.episodes.length})</text>
</box> </box>
); );
@@ -242,26 +258,28 @@ export function MyShowsPage() {
> >
<For each={shows()}> <For each={shows()}>
{(feed, index) => { {(feed, index) => {
const lf = focusedShowIdx(); const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
return ( return (
<box <box
ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())} backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
}} }}
> >
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf ? "" : " "} {index() === lf() ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)} {showTitle(feed)}
</text> </text>
<text fg={index() === lf ? theme.surface : muted()}> <text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length}) ({feed.episodes.length})
</text> </text>
</box> </box>
@@ -282,33 +300,35 @@ export function MyShowsPage() {
> >
<For each={episodes()}> <For each={episodes()}>
{(ep, index) => { {(ep, index) => {
const lf = focusedEpIdx(); const lf = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === lf());
return ( return (
<box <box
ref={ref}
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())} backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{index() === lf ? "" : " "} {index() === lf() ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive())}> <text fg={focusFg(index(), lf(), isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title} {ep.title}
</text> </text>
</box> </box>
<box flexDirection="row" gap={2} paddingLeft={2}> <box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf ? theme.surface : theme.info}> <text fg={index() === lf() ? theme.surface : theme.info}>
{formatDate(ep.pubDate)} {formatDate(ep.pubDate)}
</text> </text>
<text fg={index() === lf ? theme.surface : muted()}> <text fg={index() === lf() ? theme.surface : muted()}>
{formatDuration(ep.duration)} {formatDuration(ep.duration)}
</text> </text>
<Show when={nav.isSelected(ep.id)}> <Show when={nav.isSelected(ep.id)}>
@@ -361,7 +381,7 @@ export function MyShowsPage() {
{show().podcast.description?.slice(0, 400) ?? "No description."} {show().podcast.description?.slice(0, 400) ?? "No description."}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter/l: open · h: back</text> <text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
</box> </box>
)} )}
</Show> </Show>
@@ -408,7 +428,7 @@ export function MyShowsPage() {
); );
return ( return (
<YaziPaneRow <PaneRow
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}

View File

@@ -1,64 +1,85 @@
import type { BackendName } from "../utils/audio-player" import type { BackendName } from "@/utils/audio-player";
import { useTheme } from "@/context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
type PlaybackControlsProps = { type PlaybackControlsProps = {
isPlaying: boolean isPlaying: boolean;
volume: number volume: number;
speed: number speed: number;
backendName?: BackendName backendName?: BackendName;
hasAudioUrl?: boolean hasAudioUrl?: boolean;
onToggle: () => void onToggle: () => void;
onPrev: () => void onPrev: () => void;
onNext: () => void onNext: () => void;
onVolumeChange: (value: number) => void onVolumeChange: (value: number) => void;
onSpeedChange: (value: number) => void onSpeedChange: (value: number) => void;
} };
const BACKEND_LABELS: Record<BackendName, string> = { const BACKEND_LABELS: Record<BackendName, string> = {
mpv: "mpv", mpv: "mpv",
ffplay: "ffplay", none: "none",
afplay: "afplay", };
system: "system",
none: "none",
}
export function PlaybackControls(props: PlaybackControlsProps) { export function PlaybackControls(props: PlaybackControlsProps) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}> <box
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}> flexDirection="row"
<text fg={theme.primary}>[Prev]</text> gap={1}
</box> alignItems="center"
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}> border
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text> padding={1}
</box> borderColor={theme.border}
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}> >
<text fg={theme.primary}>[Next]</text> <box
</box> border
<box flexDirection="row" gap={1} marginLeft={2}> padding={0}
<text fg={theme.textMuted}>Vol</text> onMouseDown={props.onPrev}
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text> borderColor={theme.border}
</box> >
<box flexDirection="row" gap={1} marginLeft={2}> <text fg={theme.primary}>[Prev]</text>
<text fg={theme.textMuted}>Speed</text> </box>
<text fg={theme.text}>{props.speed}x</text> <box
</box> border
{props.backendName && props.backendName !== "none" && ( padding={0}
<box flexDirection="row" gap={1} marginLeft={2}> onMouseDown={props.onToggle}
<text fg={theme.textMuted}>via</text> borderColor={theme.border}
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text> >
</box> <text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
)} </box>
{props.backendName === "none" && ( <box
<box marginLeft={2}> border
<text fg={theme.warning}>No audio player found</text> padding={0}
</box> onMouseDown={props.onNext}
)} borderColor={theme.border}
{props.hasAudioUrl === false && ( >
<box marginLeft={2}> <text fg={theme.primary}>[Next]</text>
<text fg={theme.warning}>No audio URL</text> </box>
</box> <box flexDirection="row" gap={1} marginLeft={2}>
)} <text fg={theme.textMuted}>Vol</text>
</box> <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>
);
} }

View File

@@ -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 * depth 0 (parent) — tab list (muted, read-only).
* Shell router (P/N/B/</>). This page renders a single rich pane showing the * depth 0 (current) — the single now-playing pane (rich view + controls).
* current episode, waveform, and playback controls. Panes/swipe do nothing *
* (PaneCount=1). * No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
* page only renders the now-playing surface. `h` at depth 0 returns to the
* tab root.
*/ */
import { Show } from "solid-js"; import { Show } from "solid-js";
@@ -13,7 +16,9 @@ import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
export const PlayerPaneCount = 1; export const PlayerPaneCount = 1;
@@ -23,9 +28,7 @@ export function PlayerPage() {
const nav = useNavigation(); const nav = useNavigation();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
// Single pane — always active. const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const isActive = () => true;
const border = () => theme.accent;
const progressPercent = () => { const progressPercent = () => {
const d = audio.duration(); const d = audio.duration();
@@ -39,84 +42,87 @@ export function PlayerPage() {
return `${m}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`;
}; };
return ( // ── parent pane: the tab list (muted) ──────────────────────────────────────
<box flexDirection="column" width="100%" height="100%"> const parentContent = () => <TabListPane muted />;
{/* ── pane 0: now playing ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}> // ── current pane: now playing ───────────────────────────────────────────────
<text fg={theme.textSecondary}>Player</text> const currentContent = () => (
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={muted()}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box> </box>
<scrollbox
height="100%" <Show when={audio.error()}>
focused={isActive()} {(err) => <text fg={theme.error}>{err()}</text>}
border </Show>
borderColor={border()}
backgroundColor={theme.background} <Show
when={audio.currentEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode loaded.</text>
</box>
}
> >
<box flexDirection="column" gap={1} padding={1}> {(ep) => (
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="column" gap={1}>
<text fg={theme.text}> <text fg={theme.text}>
<strong>Now Playing</strong> <strong>{ep().title}</strong>
</text> </text>
<text fg={muted()}> <text fg={muted()}>
{formatTime(audio.position())} / {formatTime(audio.duration())} ( {ep().description?.slice(0, 500) ?? "No description available."}
{progressPercent()}%)
</text> </text>
<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> </box>
)}
</Show>
<Show when={audio.error()}> <PlaybackControls
{(err) => <text fg={theme.error}>{err()}</text>} isPlaying={audio.isPlaying()}
</Show> volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
onToggle={audio.togglePlayback}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<Show <box height={1} />
when={audio.currentEpisode()} <text fg={muted()}>
fallback={ {"P play/pause N next B prev ◀▶ seek h back"}
<box padding={1}> </text>
<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> </box>
); );
return (
<PaneRow
parent={parentContent}
current={currentContent}
parentLabel="Up"
currentLabel="Player"
panes={2}
focused={isActive}
/>
);
} }

View File

@@ -8,32 +8,34 @@
*/ */
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js"; import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import { import {
loadCavaCore, loadCavaCore,
type CavaCore, type CavaCore,
type CavaCoreConfig, type CavaCoreConfig,
} from "@/utils/cavacore"; } from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader"; import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = { export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>; visualizerConfig?: Partial<CavaCoreConfig>;
}; };
/** Unicode lower block elements: space (silence) through full block (max) */ /** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [ const BARS = [
" ", " ",
"\u2581", "\u2581",
"\u2582", "\u2582",
"\u2583", "\u2583",
"\u2584", "\u2584",
"\u2585", "\u2585",
"\u2586", "\u2586",
"\u2587", "\u2587",
"\u2588", "\u2588",
]; ];
/** Target frame interval in ms (~30 fps) */ /** Target frame interval in ms (~30 fps) */
@@ -45,212 +47,226 @@ const SAMPLES_PER_FRAME = 512;
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) { export function RealtimeWaveform(props: RealtimeWaveformProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const audio = useAudio(); const audio = useAudio();
// Frequency bar values (0.01.0 per bar) // Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]); const [barData, setBarData] = createSignal<number[]>([]);
// Track whether cavacore is available let cava: CavaCore | null = null;
const [available, setAvailable] = createSignal(false); let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
let cava: CavaCore | null = null; // Bar count scales with terminal width so the waveform fills its pane.
let reader: AudioStreamReader | null = null; // The player is a 2-pane row: current column = (current+preview) of
let frameTimer: ReturnType<typeof setInterval> | null = null; // (parent+current+preview) of the terminal width. Subtract ~8 chars of
let sampleBuffer: Float64Array | null = null; // 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 = () => { const initCava = () => {
if (cava) return true; if (cava) return true;
cava = loadCavaCore(); cava = loadCavaCore();
if (!cava) { if (!cava) {
setAvailable(false); return false;
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) => { const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization(); stopVisualization();
if (!url || !initCava() || !cava) return; if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides // Initialize cavacore with current resolution + any overrides.
const config: CavaCoreConfig = { // bars is width-derived (see numBars); visualizerConfig supplies the
bars: 32, // audio-processing params (noise reduction, cutoffs, etc.).
sampleRate: 44100, const config: CavaCoreConfig = {
channels: 1, bars: numBars(),
...props.visualizerConfig, sampleRate: 44100,
}; channels: 1,
cava.init(config); ...props.visualizerConfig,
};
cava.init(config);
// Pre-allocate sample read buffer // Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME); sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new) // Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) { if (!reader || reader.url !== url) {
if (reader) reader.stop(); if (reader) reader.stop();
reader = new AudioStreamReader({ url }); reader = new AudioStreamReader({ url });
} }
reader.start(position, speed); reader.start(position, speed);
// Start render loop frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL); };
};
const stopVisualization = () => { const stopVisualization = () => {
if (frameTimer) { if (frameTimer) {
clearInterval(frameTimer); clearInterval(frameTimer);
frameTimer = null; frameTimer = null;
} }
if (reader) { if (reader) {
reader.stop(); reader.stop();
// Don't null reader — we reuse it across start/stop cycles // Don't null reader — we reuse it across start/stop cycles
} }
if (cava?.isReady) { if (cava?.isReady) {
cava.destroy(); cava.destroy();
} }
sampleBuffer = null; sampleBuffer = null;
}; };
// ── Render loop (called at ~30fps) ───────────────────────────────── // ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => { const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return; if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream const count = reader.read(sampleBuffer);
const count = reader.read(sampleBuffer); if (count === 0) return;
if (count === 0) return;
// Feed samples to cavacore → get frequency bars const input =
const input = count < sampleBuffer.length
count < sampleBuffer.length ? sampleBuffer.subarray(0, count)
? sampleBuffer.subarray(0, count) : sampleBuffer;
: sampleBuffer; const output = cava.execute(input);
const output = cava.execute(input);
// Copy bar values to a new array for the signal // Copy bar values to a new array for the signal
setBarData(Array.from(output)); setBarData(Array.from(output as Float64Array));
}; };
createEffect( createEffect(
on( on(
[ [
audio.isPlaying, audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here () => audio.currentEpisode()?.audioUrl ?? "",
audio.speed, audio.speed,
() => 32, numBars,
], ],
([playing, url, speed]) => { ([playing, url, speed]) => {
if (playing && url) { if (playing && url) {
const pos = untrack(audio.position); const pos = untrack(audio.position);
startVisualization(url, pos, speed); startVisualization(url, pos, speed);
} else { } else {
stopVisualization(); stopVisualization();
} }
}, },
), ),
); );
// ── Seek detection: lightweight effect for position jumps ────────── // ── Seek detection: lightweight effect for position jumps ──────────
// //
// Watches position and restarts the reader (not the whole pipeline) // Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek. // only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a // This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos. // full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0; let lastSyncPosition = 0;
createEffect( createEffect(
on(audio.position, (pos) => { on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) { if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos; lastSyncPosition = pos;
return; return;
} }
const delta = Math.abs(pos - lastSyncPosition); const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos; lastSyncPosition = pos;
if (delta > 2) { if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1); reader.restart(pos, audio.speed() ?? 1);
} }
}), }),
); );
// Cleanup on unmount onCleanup(() => {
onCleanup(() => { stopVisualization();
stopVisualization(); if (reader) {
if (reader) { reader.stop();
reader.stop(); reader = null;
reader = null; }
} // Don't null cava itself — it can be reused. But do destroy its plan.
// Don't null cava itself — it can be reused. But do destroy its plan. if (cava?.isReady) {
if (cava?.isReady) { cava.destroy();
cava.destroy(); }
} });
});
// ── Rendering ────────────────────────────────────────────────────── // ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () => const playedRatio = () =>
audio.duration() <= 0 audio.duration() <= 0
? 0 ? 0
: Math.min(1, audio.position() / audio.duration()); : Math.min(1, audio.position() / audio.duration());
const renderLine = () => { const renderLine = () => {
const bars = barData(); const bars = barData();
const numBars = 32; const count = numBars();
// If no data yet, show empty placeholder if (bars.length === 0) {
if (bars.length === 0) { const placeholder = ".".repeat(count);
const placeholder = ".".repeat(numBars); return (
return ( <box flexDirection="row" gap={0}>
<box flexDirection="row" gap={0}> <text fg="#3b4252">{placeholder}</text>
<text fg="#3b4252">{placeholder}</text> </box>
</box> );
); }
}
const played = Math.floor(numBars * playedRatio()); const played = Math.floor(count * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590"; const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252"; const futureColor = "#3b4252";
const playedChars = bars const playedChars = bars
.slice(0, played) .slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join(""); .join("");
const futureChars = bars const futureChars = bars
.slice(played) .slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join(""); .join("");
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text> <text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text> <text fg={futureColor}>{futureChars || " "}</text>
</box> </box>
); );
}; };
const handleClick = (event: { x: number }) => { const handleClick = (event: { x: number }) => {
const numBars = 32; const count = numBars();
const ratio = event.x / numBars; const ratio = event.x / count;
const next = Math.max( const next = Math.max(
0, 0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)), Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
); );
audio.seek(next); audio.seek(next);
}; };
return ( return (
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}> <box
{renderLine()} border
</box> borderColor={theme.border}
); padding={1}
} onMouseDown={handleClick}
>
{renderLine()}
</box>
);
}

View File

@@ -1,95 +0,0 @@
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type ResultCardProps = {
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const { theme } = useTheme();
const podcast = () => props.result.podcast;
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText
selected={() => props.selected}
primary
>
<strong>{podcast().title}</strong>
</SelectableText>
<SourceBadge
sourceId={props.result.sourceId}
sourceName={props.result.sourceName}
sourceType={props.result.sourceType}
/>
</box>
<Show when={podcast().isSubscribed}>
<text fg={theme.success}>[Subscribed]</text>
</Show>
</box>
<Show when={podcast().author}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {podcast().author}
</SelectableText>
</Show>
<Show when={podcast().description}>
{(description) => (
<SelectableText
selected={() => props.selected}
tertiary
>
{description().length > 120
? description().slice(0, 120) + "..."
: description()}
</SelectableText>
)}
</Show>
<Show when={(podcast().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(podcast().categories ?? []).slice(0, 3).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<Show when={!podcast().isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,75 +0,0 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
const { theme } = useTheme();
return (
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
<Show
when={props.result}
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
>
{(result) => (
<>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg={theme.textMuted}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textMuted}>{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
<text fg={theme.textMuted}>
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
</text>
<Show when={!result().podcast.isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={() => props.onSubscribe?.(result())}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -1,89 +0,0 @@
/**
* SearchHistory component for displaying and managing search history
*/
import { For, Show } from "solid-js"
import { useTheme } from "@/context/ThemeContext"
import { SelectableBox, SelectableText } from "@/components/Selectable"
type SearchHistoryProps = {
history: string[]
focused: boolean
selectedIndex: number
onSelect?: (query: string) => void
onRemove?: (query: string) => void
onClear?: () => void
onChange?: (index: number) => void
}
export function SearchHistory(props: SearchHistoryProps) {
const { theme } = useTheme();
const handleSearchClick = (index: number, query: string) => {
props.onChange?.(index)
props.onSelect?.(query)
}
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text fg={theme.error}>[Clear All]</text>
</box>
</Show>
</box>
<Show
when={props.history.length > 0}
fallback={
<box padding={1}>
<text fg={theme.textMuted}>No recent searches</text>
</box>
}
>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
const isSelected = () => index() === props.selectedIndex && props.focused
return (
<SelectableBox
selected={isSelected}
flexDirection="row"
justifyContent="space-between"
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => handleSearchClick(index(), query)}
>
<SelectableText
selected={isSelected}
tertiary
>
{">"}
</SelectableText>
<SelectableText
selected={isSelected}
primary
>
{query}
</SelectableText>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg={theme.error}>[x]</text>
</box>
</SelectableBox>
)
}}
</For>
</box>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -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) * depth 0 (current) — query input row + recent-searches list (navigable
* pane 2 (current) — search results list (navigate j/k) * with j/k when the input is defocused). Parent pane
* pane 3 (preview) — detail of the focused search result * 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) * Typed input owns its keys while `nav.inputFocused()` is true (the Shell
* on tab enter so the user lands on the results pane. Swipe left (h) to pane * router yields). Escape defocuses the input (handled in Shell) so j/k/h
* 1 to type a query — the Shell * navigation resumes; `s` (the `search` action) refocuses it. Enter on the
* router skips keys while `nav.inputFocused()` is true so the `<input>` * input (or on a focused recent at depth 0) submits the query and pushes to
* element captures typing natively. Press Enter (onSubmit) to search and * depth 1 (results). `h` pops: results→query, query→tab root.
* auto-swipe to the results pane.
*/ */
import { import {
@@ -23,137 +25,159 @@ import {
onCleanup, onCleanup,
} from "solid-js"; } from "solid-js";
import { useSearchStore } from "@/stores/search"; import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
useNavigation, useNavigation,
NavMode, NavMode,
PaneSlot, DEPTH_CENTER_PANE,
type PaneId, type PaneId,
type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source"; import type { SearchResult } from "@/types/source";
import { PANE_RATIO } from "@/utils/navigation"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const SearchPaneCount = 3; export const SearchPaneCount = 1;
function SearchPage() { function SearchPage() {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const feedStore = useFeedStore();
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const INPUT = PaneSlot.PARENT; // 1 (input row) const stack = nav.depthStack;
const RESULTS = PaneSlot.CURRENT; // 2 (results list) const depth = nav.currentDepth;
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview) 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(); const results = () => searchStore.results();
const focusedResultIdx = () =>
// The focused result tracks pane 1's focused row. results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
const focusedResult = createMemo(() => { const focusedResult = createMemo(() => {
const list = results(); const list = results();
if (list.length === 0) return undefined; if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1); return list[focusedResultIdx()];
return list[idx];
}); });
// Register a resolver so visual-mode range selection grows by result id. // ── recents (depth 0) ────────────────────────────────────────────────────
onMount(() => { const recents = () => searchStore.history();
nav.registerResolver( const curLen = () => (depth() === 0 ? recents().length : results().length);
`${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());
});
// Keep results focus in range after searches complete.
const ensureFocus = () => { const ensureFocus = () => {
const list = results(); if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
if (list.length === 0) return; nav.setDepthFocus(results().length - 1, 1);
const cur = nav.focusedIndex(RESULTS);
if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
}; };
onMount(ensureFocus); onMount(ensureFocus);
// ── input pane: set inputFocused so Shell router yields keys to <input> ───── // Register a visual-mode resolver for the results list (depth 1).
createEffect(() => {
const isInputPane = nav.activePane() === INPUT;
nav.setInputFocused(isInputPane);
});
onMount(() => { onMount(() => {
onCleanup(() => nav.setInputFocused(false)); const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
}); });
// ── helpers ───────────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubmit = () => { const runSearch = (query: string) => {
const query = inputValue().trim(); const q = query.trim();
if (!query) return; if (!q) return;
searchStore.search(query).catch(() => {}); searchStore.search(q).catch(() => {});
nav.setFocusedIndex(RESULTS, 0); nav.pushDepth({
nav.setActivePane(RESULTS); 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); setInputValue(query);
searchStore.search(query).catch(() => {}); runSearch(query);
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
}; };
const handleSubscribe = (result: SearchResult) => { 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); searchStore.markSubscribed(result.podcast.id);
}; };
// ── nav.action handler ────────────────────────────────────────────────────── // ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial< const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
Record<KeybindActionName, (pane: PaneId) => void> "move-down": () => step(1),
> = { "move-up": () => step(-1),
"move-down": (p) => step(p, 1), "jump-down": () => step(5),
"move-up": (p) => step(p, -1), "jump-up": () => step(-5),
"jump-down": (p) => step(p, 5), "page-down": () => step(10),
"jump-up": (p) => step(p, -5), "page-up": () => step(-10),
"page-down": (p) => step(p, 10), "goto-top": () => nav.gotoIndex(0, curLen()),
"page-up": (p) => step(p, -10), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
"goto-top": (p) => nav.gotoIndex(0, len(p)), open: () => open(),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), "toggle-select": () => {
open: (p) => { if (depth() === 1) {
if (p === RESULTS || p === DETAIL) { const r = focusedResult();
const result = focusedResult(); if (r) nav.toggleSelected(r.podcast.id);
if (result) handleSubscribe(result);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const result = focusedResult();
if (result) nav.toggleSelected(result.podcast.id);
} }
}, },
search: () => { search: () => {
nav.setActivePane(INPUT); // `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true);
}, },
refresh: () => { refresh: () => {
if (inputValue().trim()) { const q = submittedQuery() || inputValue().trim();
searchStore.search(inputValue().trim()).catch(() => {}); if (q) searchStore.search(q).catch(() => {});
}
}, },
}; };
function len(pane: PaneId): number { function step(delta: number) {
if (pane === RESULTS) return results().length; nav.move(delta, curLen());
return 0;
} }
function step(pane: PaneId, delta: number) { function open() {
nav.move(delta, len(pane)); 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: { const onAction = (data: {
@@ -161,139 +185,154 @@ function SearchPage() {
pane: PaneId; pane: PaneId;
mode: NavMode; mode: NavMode;
}) => { }) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus(); ensureFocus();
const handler = PAGE_ACTIONS[data.action]; PAGE_ACTIONS[data.action]?.();
if (handler) handler(data.pane);
}; };
onMount(() => { onMount(() => {
on("nav.action", onAction); on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction)); onCleanup(() => off("nav.action", onAction));
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); const inputActive = () => nav.inputFocused() && depth() === 0;
const focusBg = (i: number, listFocus: number, active: boolean) =>
const focusBg = (i: number, pane: PaneId) => i === listFocus && active
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary ? theme.primary
: i === nav.focusedIndex(pane) : i === listFocus
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, pane: PaneId) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
return ( // ── parent pane: previous-depth content (tab list at depth 0) ──────────────
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> const parentContent = () => (
{/* ── pane 0: query input ──────────────────────────────────────────────── */} <Show when={depth() >= 1} fallback={<TabListPane muted />}>
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%"> <box flexDirection="column" gap={1} padding={1}>
<box height={1} paddingLeft={1} backgroundColor={theme.background}> <text fg={theme.textSecondary}>Query</text>
<text fg={theme.textSecondary}>Search</text> <text fg={muted()}>{submittedQuery() || "(empty)"}</text>
</box> <box height={1} />
<scrollbox <text fg={muted()}>h: back to query</text>
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>
</box> </box>
</Show>
);
{/* ── pane 1: results ──────────────────────────────────────────────────── */} // ── current pane ────────────────────────────────────────────────────────────
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%"> const currentContent = () => (
<box height={1} paddingLeft={1} backgroundColor={theme.background}> <>
<text fg={theme.textSecondary}>Results · {results().length}</text> <Show when={depth() === 0}>
</box> {/* query input row + recent searches */}
<scrollbox <box flexDirection="column" gap={1} padding={1}>
height="100%" <box flexDirection="row" gap={1} alignItems="center">
focused={isActive(RESULTS)} <text fg={muted()}>Query:</text>
border <input
borderColor={border(RESULTS)} value={inputValue()}
backgroundColor={theme.background} 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 <Show
when={results().length > 0} when={recents().length > 0}
fallback={ fallback={
<box padding={1}> <text fg={muted()}>
<text fg={muted()}> {inputActive()
{searchStore.query() ? "Enter to search"
? "No results found" : "s to type · Enter to search"}
: "Enter a search term to find podcasts"} </text>
</text>
</box>
} }
> >
<For each={results()}> <For each={recents()}>
{(result, index) => ( {(query, index) => {
const lf = () => focus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
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();
const ref = useScrollIntoView(() => index() === fi());
return (
<box <box
ref={ref}
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)} backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(RESULTS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(RESULTS, index()); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}> <text fg={focusFg(index(), fi(), isActive())}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "} {index() === fi() ? "" : " "}
</text> </text>
<text fg={focusFg(index(), RESULTS)}> <text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title} {result.podcast.title}
</text> </text>
<Show when={result.podcast.isSubscribed}> <Show when={result.podcast.isSubscribed}>
<text <text
fg={ fg={index() === fi() ? theme.surface : theme.success}
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
> >
[+] [+]
</text> </text>
@@ -301,95 +340,102 @@ function SearchPage() {
</box> </box>
<Show when={result.podcast.author}> <Show when={result.podcast.author}>
<text <text
fg={ fg={index() === fi() ? theme.surface : muted()}
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2} paddingLeft={2}
> >
by {result.podcast.author} by {result.podcast.author}
</text> </text>
</Show> </Show>
</box> </box>
)} );
</For> }}
</Show> </For>
</scrollbox> </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> </box>
) : (
{/* ── pane 2: detail ───────────────────────────────────────────────────── */} <Show
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%"> when={focusedResult()}
<box height={1} paddingLeft={1} backgroundColor={theme.background}> fallback={
<text fg={theme.textSecondary}>Detail</text> <box padding={1}>
</box> <text fg={muted()}>No result focused</text>
<scrollbox </box>
height="100%" }
focused={isActive(DETAIL)} >
border {(result) => (
borderColor={border(DETAIL)} <box flexDirection="column" gap={1} padding={1}>
backgroundColor={theme.background} <text fg={theme.text}>
> <strong>{result().podcast.title}</strong>
<Show </text>
when={focusedResult()} <Show when={result().podcast.author}>
fallback={ <text fg={muted()}>by {result().podcast.author}</text>
<box padding={1}> </Show>
<text fg={muted()}>No result focused</text> <Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400)}
{(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> </box>
} </Show>
> <text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
{(result) => ( <text fg={muted()}>
<box flexDirection="column" gap={1} padding={1}> Updated: {formatDate(result().podcast.lastUpdated)}
<text fg={theme.text}> </text>
<strong>{result().podcast.title}</strong> <Show when={result().sourceName}>
</text> <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}> const currentLabel = () =>
<text fg={muted()}>by {result().podcast.author}</text> depth() === 0
</Show> ? `Search · ${recents().length} recent`
: `Results · ${results().length}`;
<Show when={result().podcast.description}> return (
<text fg={theme.textSecondary}> <PaneRow
{result().podcast.description!.slice(0, 400) ?? parent={parentContent}
"No description available."} current={currentContent}
{(result().podcast.description?.length ?? 0) > 400 preview={previewContent}
? "…" parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
: ""} currentLabel={currentLabel}
</text> previewLabel="Detail"
</Show> focused={isActive}
/>
<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>
); );
} }

View File

@@ -1,80 +0,0 @@
/**
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index);
};
return (
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
);
}

View File

@@ -1,43 +0,0 @@
import { SourceType } from "@/types/source";
import { useTheme } from "@/context/ThemeContext";
type SourceBadgeProps = {
sourceId: string;
sourceName?: string;
sourceType?: SourceType;
};
const typeLabel = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "API";
if (sourceType === SourceType.RSS) return "RSS";
if (sourceType === SourceType.CUSTOM) return "Custom";
return "Source";
};
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;
};
export function SourceBadge(props: SourceBadgeProps) {
const { theme } = useTheme();
const label = () => props.sourceName || props.sourceId;
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return theme.primary;
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg={theme.textMuted}>{label()}</text>
</box>
);
}

View File

@@ -0,0 +1,124 @@
/**
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
*
* • "Delete All Downloads" — action item; Enter wipes every download.
* • one item per show — action item; Enter deletes all that show's
* downloads (file + metadata, aborts in-flight).
* • one item per episode — action item; Enter deletes a single download.
*
* Titles resolve from the feed store at render time (reactive), falling back
* to the episode id when the feed is no longer loaded. Movement flows through
* nav.action — no own useKeyboard (matches the other panels).
*/
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import type { DownloadedEpisode } from "@/types/episode";
import type { SettingItem } from "./types";
/** Format a byte count as a compact human string. */
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
return `${n} B`;
}
/** Short status badge for an episode download. */
function statusLabel(s: DownloadStatus): string {
switch (s) {
case DownloadStatus.QUEUED:
return "queued";
case DownloadStatus.DOWNLOADING:
return "downloading";
case DownloadStatus.COMPLETED:
return "done";
case DownloadStatus.FAILED:
return "failed";
default:
return "";
}
}
/** Episode title for a download, resolved from the feed store (reactive). */
function episodeTitle(
feedStore: ReturnType<typeof useFeedStore>,
d: DownloadedEpisode,
): string {
const feed = feedStore.getFeed(d.feedId);
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
return ep?.title ?? d.episodeId;
}
/** Show title for a download's feed id. */
function feedTitle(
feedStore: ReturnType<typeof useFeedStore>,
feedId: string,
): string {
const feed = feedStore.getFeed(feedId);
return feed ? feed.customName || feed.podcast.title : feedId;
}
export function useDownloadItems(): SettingItem[] {
const downloadStore = useDownloadStore();
const feedStore = useFeedStore();
const downloads = () => downloadStore.getAllDownloads();
const items: SettingItem[] = [
{
id: "clear-all",
label: "Delete All Downloads",
kind: "action",
display: () => `${downloads().length} files`,
help: () =>
`Delete every downloaded episode (files + metadata) and clear the\nqueue. Enter to run.`,
run: () => {
for (const d of downloads()) {
downloadStore.cancelDownload(d.episodeId);
downloadStore.removeDownload(d.episodeId).catch(() => {});
}
},
},
];
// Group downloads by feed so each show gets a delete-by-show item.
const byFeed = new Map<string, DownloadedEpisode[]>();
for (const d of downloads()) {
const arr = byFeed.get(d.feedId) ?? [];
arr.push(d);
byFeed.set(d.feedId, arr);
}
for (const [feedId, eps] of byFeed) {
const size = eps.reduce((s, e) => s + e.fileSize, 0);
items.push({
id: `feed:${feedId}`,
label: `Show: ${feedTitle(feedStore, feedId)}`,
kind: "action",
display: () => `${eps.length} · ${fmtBytes(size)}`,
help: () =>
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
run: () => {
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
},
});
}
// One item per individual episode download.
for (const d of downloads()) {
items.push({
id: `ep:${d.episodeId}`,
label: episodeTitle(feedStore, d),
kind: "action",
display: () =>
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
help: () =>
`Delete this single download (file + metadata). Enter to run.`,
run: () => {
downloadStore.removeDownload(d.episodeId).catch(() => {});
},
});
}
return items;
}

View File

@@ -1,38 +1,55 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => { const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value let current = value;
return [() => current, (next) => { return [
current = next () => current,
}] (next) => {
} current = next;
},
];
};
import { SyncStatus } from "./SyncStatus" import { SyncStatus } from "./SyncStatus";
import { useTheme } from "@/context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export function ExportDialog() { export function ExportDialog() {
const { theme } = useTheme(); const { theme } = useTheme();
const filename = createSignal("podcast-sync.json") const filename = createSignal("podcast-sync.json");
const format = createSignal<"json" | "xml">("json") const format = createSignal<"json" | "xml">("json");
// Yield navigation keybinds to the Shell router while the input is focused.
const filenameRef = useInputFocusNav();
return ( return (
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}> <box
<box style={{ flexDirection: "row", gap: 1 }}> border
<text fg={theme.text}>File:</text> title="Export"
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} /> style={{ padding: 1, flexDirection: "column", gap: 1 }}
</box> >
<box style={{ flexDirection: "row", gap: 1 }}> <box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text> <text fg={theme.text}>File:</text>
<tab_select <input
options={[ ref={filenameRef}
{ name: "JSON", description: "Portable" }, value={filename[0]()}
{ name: "XML", description: "Structured" }, onInput={filename[1]}
]} style={{ width: 30 }}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")} />
/> </box>
</box> <box style={{ flexDirection: "row", gap: 1 }}>
<box border borderColor={theme.border}> <text fg={theme.text}>Format:</text>
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text> <tab_select
</box> options={[
<SyncStatus /> { name: "JSON", description: "Portable" },
</box> { name: "XML", description: "Structured" },
) ]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>
Export {format[0]()} to {filename[0]()}
</text>
</box>
<SyncStatus />
</box>
);
} }

View File

@@ -1,24 +1,28 @@
import { detectFormat } from "@/utils/file-detector"; import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = { type FilePickerProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
export function FilePicker(props: FilePickerProps) { export function FilePicker(props: FilePickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const format = detectFormat(props.value); // Yield navigation keybinds to the Shell router while the input is focused.
const inputRef = useInputFocusNav();
const format = detectFormat(props.value);
return ( return (
<box style={{ flexDirection: "column", gap: 1 }}> <box style={{ flexDirection: "column", gap: 1 }}>
<input <input
value={props.value} ref={inputRef}
onInput={props.onChange} value={props.value}
placeholder="/path/to/sync-file.json" onInput={props.onChange}
style={{ width: 40 }} placeholder="/path/to/sync-file.json"
/> style={{ width: 40 }}
<text fg={theme.text}>Format: {format}</text> />
</box> <text fg={theme.text}>Format: {format}</text>
); </box>
);
} }

View File

@@ -1,178 +0,0 @@
/**
* Login screen component for PodTUI
* Email/password login with links to code validation and OAuth
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { useTheme } from "@/context/ThemeContext";
import { AUTH_CONFIG } from "@/config/auth";
interface LoginScreenProps {
focused?: boolean;
onNavigateToCode?: () => void;
onNavigateToOAuth?: () => void;
}
type FocusField = "email" | "password" | "submit" | "code" | "oauth";
export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("email");
const [emailError, setEmailError] = createSignal<string | null>(null);
const [passwordError, setPasswordError] = createSignal<string | null>(null);
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
const validateEmail = (value: string): boolean => {
if (!value) {
setEmailError("Email is required");
return false;
}
if (!AUTH_CONFIG.email.pattern.test(value)) {
setEmailError("Invalid email format");
return false;
}
setEmailError(null);
return true;
};
const validatePassword = (value: string): boolean => {
if (!value) {
setPasswordError("Password is required");
return false;
}
if (value.length < AUTH_CONFIG.password.minLength) {
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
return false;
}
setPasswordError(null);
return true;
};
const handleSubmit = async () => {
const isEmailValid = validateEmail(email());
const isPasswordValid = validatePassword(password());
if (!isEmailValid || !isPasswordValid) {
return;
}
await auth.login({ email: email(), password: password() });
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "submit") {
handleSubmit();
} else if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode();
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
props.onNavigateToOAuth();
}
}
};
return (
<box flexDirection="column" border borderColor={theme.border} padding={2} gap={1}>
<text fg={theme.text}>
<strong>Sign In</strong>
</text>
<box height={1} />
{/* Email field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "email" ? theme.primary : theme.textMuted}>
Email:
</text>
<input
value={email()}
onInput={setEmail}
placeholder="your@email.com"
focused={props.focused && focusField() === "email"}
width={30}
/>
{emailError() && <text fg={theme.error}>{emailError()}</text>}
</box>
{/* Password field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "password" ? theme.primary : theme.textMuted}>
Password:
</text>
<input
value={password()}
onInput={setPassword}
placeholder="********"
focused={props.focused && focusField() === "password"}
width={30}
/>
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
</box>
<box height={1} />
{/* Submit button */}
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={
focusField() === "submit" ? theme.primary : undefined
}
>
<text fg={focusField() === "submit" ? theme.text : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
{/* Alternative auth options */}
<text fg={theme.textMuted}>Or authenticate with:</text>
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={focusField() === "code" ? theme.primary : undefined}
>
<text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
[C] Sync Code
</text>
</box>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
>
<text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
[O] OAuth Info
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box>
);
}

View File

@@ -1,123 +0,0 @@
/**
* OAuth placeholder component for PodTUI
* Displays OAuth limitations and alternative authentication methods
*/
import { createSignal } from "solid-js";
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
import { useTheme } from "@/context/ThemeContext";
interface OAuthPlaceholderProps {
focused?: boolean;
onBack?: () => void;
onNavigateToCode?: () => void;
}
type FocusField = "code" | "back";
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("code");
const fields: FocusField[] = ["code", "back"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode();
} else if (focusField() === "back" && props.onBack) {
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack();
}
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>OAuth Authentication</strong>
</text>
<box height={1} />
{/* OAuth providers list */}
<text fg={theme.primary}>Available OAuth Providers:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
{OAUTH_PROVIDERS.map((provider) => (
<box flexDirection="row" gap={1}>
<text fg={provider.enabled ? theme.success : theme.textMuted}>
{provider.enabled ? "[+]" : "[-]"} {provider.name}
</text>
<text fg={theme.textMuted}>- {provider.description}</text>
</box>
))}
</box>
<box height={1} />
{/* Limitation message */}
<box border padding={1} borderColor={theme.warning}>
<text fg={theme.warning}>Terminal Limitations</text>
</box>
<box paddingLeft={1}>
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
<text fg={theme.textMuted}>{line}</text>
))}
</box>
<box height={1} />
{/* Alternative options */}
<text fg={theme.primary}>Recommended Alternatives:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
<box flexDirection="row" gap={1}>
<text fg={theme.success}>[1]</text>
<text fg={theme.text}>Use a sync code from the web portal</text>
<text fg={theme.success}>[2]</text>
<text fg={theme.text}>Use email/password authentication</text>
<text fg={theme.success}>[3]</text>
<text fg={theme.text}>Use file-based sync (no account needed)</text>
</box>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "code" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "code" ? theme.primary : undefined}>
[C] Enter Sync Code
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
[Esc] Back to Login
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
</box>
);
}

View File

@@ -39,6 +39,19 @@ export function usePreferencesItems(): SettingItem[] {
app.setTheme(THEME_LABELS[next].value); app.setTheme(THEME_LABELS[next].value);
}, },
}, },
{
id: "transparentBackground",
label: "Transparent Background",
kind: "toggle",
display: () =>
settings().transparentBackground ? "On" : "Off",
help: () =>
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateSettings({
transparentBackground: !settings().transparentBackground,
}),
},
{ {
id: "fontSize", id: "fontSize",
label: "Font Size", label: "Font Size",
@@ -90,5 +103,17 @@ export function usePreferencesItems(): SettingItem[] {
autoDownload: !prefs().autoDownload, autoDownload: !prefs().autoDownload,
}), }),
}, },
{
id: "autoJumpToPlayer",
label: "Auto Jump to Player",
kind: "toggle",
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
help: () =>
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
]; ];
} }

View File

@@ -5,9 +5,9 @@
* depth 1 — the focused section's items as a navigable list * depth 1 — the focused section's items as a navigable list
* depth 2 — per-item editor (for editor-kind items) or value adjuster * depth 2 — per-item editor (for editor-kind items) or value adjuster
* *
* Renders entirely through `<YaziPaneRow>` (parent | current | preview): * Renders entirely through `<PaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2); * parent = previous depth's list (sections at depth 1, items at depth 2);
* blank placeholder at depth 0 (1/7 slot kept). * blank placeholder at depth 0 (1/5 slot kept).
* current = the current-depth list (or editor at depth 2); the only * current = the current-depth list (or editor at depth 2); the only
* focusable column. * focusable column.
* preview = help/preview text for the hovered item in current. * preview = help/preview text for the hovered item in current.
@@ -18,7 +18,8 @@
*/ */
import { For, Show, onMount, onCleanup, createMemo } from "solid-js"; 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 { import {
useNavigation, useNavigation,
NavMode, NavMode,
@@ -32,8 +33,10 @@ import { usePreferencesItems } from "./PreferencesPanel";
import { useVisualizerItems } from "./VisualizerSettings"; import { useVisualizerItems } from "./VisualizerSettings";
import { useSyncItems, closeSyncEditor } from "./SyncPanel"; import { useSyncItems, closeSyncEditor } from "./SyncPanel";
import { useSourceItems } from "./SourceManager"; import { useSourceItems } from "./SourceManager";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { useDownloadItems } from "./DownloadManager";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const SettingsPaneCount = 1; export const SettingsPaneCount = 1;
@@ -60,13 +63,12 @@ const SECTIONS: SettingsSectionDef[] = [
}, },
{ {
id: 4, id: 4,
label: "Account", label: "Downloads",
description: "Account login & OAuth (not yet implemented).", description: "Manage downloaded episodes — delete by show or individually.",
}, },
]; ];
/** Resolve the items for a section id at render time. Section 4 (Account) has /** Resolve the items for a section id at render time. */
* no items yet. */
function sectionItems(sectionId: number): SettingItem[] { function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) { switch (sectionId) {
case 0: case 0:
@@ -77,6 +79,8 @@ function sectionItems(sectionId: number): SettingItem[] {
return usePreferencesItems(); return usePreferencesItems();
case 3: case 3:
return useVisualizerItems(); return useVisualizerItems();
case 4:
return useDownloadItems();
default: default:
return []; return [];
} }
@@ -230,6 +234,15 @@ export function SettingsPage() {
// ── render helpers ─────────────────────────────────────────────────────── // ── render helpers ───────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; 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 // preview text for the right column
const previewText = createMemo<string>(() => { const previewText = createMemo<string>(() => {
const d = depth(); const d = depth();
@@ -278,7 +291,7 @@ export function SettingsPage() {
<For each={SECTIONS}> <For each={SECTIONS}>
{(section, index) => ( {(section, index) => (
<Row <Row
label={`${section.id + 1}. ${section.label}`} label={section.label}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={false} active={false}
/> />
@@ -307,7 +320,7 @@ export function SettingsPage() {
<For each={SECTIONS}> <For each={SECTIONS}>
{(section, index) => ( {(section, index) => (
<Row <Row
label={`${section.id + 1}. ${section.label}`} label={section.label}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={isActive()} active={isActive()}
onMouseDown={() => { onMouseDown={() => {
@@ -356,13 +369,18 @@ export function SettingsPage() {
// ── preview pane ────────────────────────────────────────────────────────── // ── preview pane ──────────────────────────────────────────────────────────
const previewContent = () => ( const previewContent = () => (
<box padding={1}> <box padding={1} flexDirection="column">
<MultiLine text={previewText()} /> {/* 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> </box>
); );
return ( return (
<YaziPaneRow <PaneRow
parent={parentContent} parent={parentContent}
current={currentContent} current={currentContent}
preview={previewContent} preview={previewContent}
@@ -406,9 +424,16 @@ function Row(props: {
: props.focused : props.focused
? theme.border ? theme.border
: undefined; : undefined;
const fg = () => (props.focused && props.active ? theme.surface : theme.text); const fg = () =>
props.focused && props.active
? theme.surface
: props.focused
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused);
return ( return (
<box <box
ref={ref}
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
@@ -458,6 +483,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. */ /** Renders a string with `\n` newlines as stacked <text> lines. */
function MultiLine(props: { text: string }) { function MultiLine(props: { text: string }) {
const lines = () => props.text.split("\n"); const lines = () => props.text.split("\n");

View File

@@ -14,6 +14,7 @@
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { SourceType } from "@/types/source"; import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source"; import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
@@ -61,6 +62,9 @@ function AddSourceForm() {
const [name, setName] = createSignal(""); const [name, setName] = createSignal("");
const [url, setUrl] = createSignal(""); const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
// Yield navigation keybinds to the Shell router while either input is focused.
const nameRef = useInputFocusNav();
const urlRef = useInputFocusNav();
const submit = () => { const submit = () => {
const u = url().trim(); const u = url().trim();
@@ -94,6 +98,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Name:</text> <text fg={theme.textMuted}>Name:</text>
<input <input
ref={nameRef}
value={name()} value={name()}
onInput={setName} onInput={setName}
placeholder="My Custom Feed" placeholder="My Custom Feed"
@@ -103,6 +108,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>URL:</text> <text fg={theme.textMuted}>URL:</text>
<input <input
ref={urlRef}
value={url()} value={url()}
onInput={(v) => { onInput={(v) => {
setUrl(v); setUrl(v);

View File

@@ -3,21 +3,13 @@
* Export dialogs render as depth-2 editors. No own useKeyboard. * Export dialogs render as depth-2 editors. No own useKeyboard.
*/ */
import { createSignal } from "solid-js";
import { ImportDialog } from "./ImportDialog"; import { ImportDialog } from "./ImportDialog";
import { ExportDialog } from "./ExportDialog"; import { ExportDialog } from "./ExportDialog";
import { SyncStatus } from "./SyncStatus";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
// Module-level state so the action items can open their dialogs as depth-2 // closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show. // (the syncEditor signal) was removed as dead — nothing ever read it.
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>( export function closeSyncEditor() {}
null,
);
export { syncEditor };
export function closeSyncEditor() {
setSyncEditor(null);
}
export function useSyncItems(): SettingItem[] { export function useSyncItems(): SettingItem[] {
return [ return [
@@ -49,9 +41,3 @@ export function useSyncItems(): SettingItem[] {
}, },
]; ];
} }
/** Renders the live sync status block (used by the Settings page header for the
* Sync section, when relevant). */
export function SyncStatusBlock() {
return <SyncStatus />;
}

View File

@@ -1,157 +0,0 @@
/**
* Sync profile component for PodTUI
* Displays user profile information and sync status
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
interface SyncProfileProps {
focused?: boolean;
onLogout?: () => void;
onManageSync?: () => void;
}
type FocusField = "sync" | "export" | "logout";
export function SyncProfile(props: SyncProfileProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("sync");
const [lastSyncTime] = createSignal<Date | null>(new Date());
const fields: FocusField[] = ["sync", "export", "logout"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "sync" && props.onManageSync) {
props.onManageSync();
} else if (focusField() === "logout" && props.onLogout) {
handleLogout();
}
}
};
const handleLogout = () => {
auth.logout();
if (props.onLogout) {
props.onLogout();
}
};
const formatDate = (date: Date | null | undefined): string => {
if (!date) return "Never";
return format(date, "MMM d, yyyy HH:mm");
};
const user = () => auth.state().user;
// Get user initials for avatar
const userInitials = () => {
const name = user()?.name || "?";
return name.slice(0, 2).toUpperCase();
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>User Profile</strong>
</text>
<box height={1} />
{/* User avatar and info */}
<box flexDirection="row" gap={2}>
{/* ASCII avatar */}
<box
border
padding={1}
width={8}
height={4}
justifyContent="center"
alignItems="center"
>
<text fg={theme.primary}>{userInitials()}</text>
</box>
{/* User details */}
<box flexDirection="column" gap={0}>
<text fg={theme.text}>{user()?.name || "Guest User"}</text>
<text fg={theme.textMuted}>{user()?.email || "No email"}</text>
<text fg={theme.textMuted}>Joined: {formatDate(user()?.createdAt)}</text>
</box>
</box>
<box height={1} />
{/* Sync status section */}
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<text fg={theme.primary}>Sync Status</text>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Status:</text>
<text fg={user()?.syncEnabled ? theme.success : theme.warning}>
{user()?.syncEnabled ? "Enabled" : "Disabled"}
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Last Sync:</text>
<text fg={theme.text}>{formatDate(lastSyncTime())}</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Method:</text>
<text fg={theme.text}>File-based (JSON/XML)</text>
</box>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "sync" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "sync" ? theme.primary : undefined}>
[S] Manage Sync
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "export" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "export" ? theme.primary : undefined}>
[E] Export Data
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "logout" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "logout" ? theme.error : theme.textMuted}>
[L] Logout
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box>
);
}

View File

@@ -1,130 +1,132 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"; import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
import type { import type {
AppSettings, AppSettings,
AppState, AppState,
ThemeColors, ThemeColors,
ThemeName, ThemeName,
ThemeMode, ThemeMode,
UserPreferences, UserPreferences,
VisualizerSettings, VisualizerSettings,
} from "../types/settings"; } from "../types/settings";
import { resolveTheme } from "../utils/theme-resolver"; import { resolveTheme } from "../utils/theme-resolver";
import type { ThemeJson } from "../types/theme-schema"; import type { ThemeJson } from "../types/theme-schema";
import { import {
loadAppStateFromFile, loadAppStateFromFile,
saveAppStateToFile, saveAppStateToFile,
} from "../utils/app-persistence"; } from "../utils/app-persistence";
const defaultVisualizerSettings: VisualizerSettings = { const defaultVisualizerSettings: VisualizerSettings = {
bars: 32, bars: 64,
sensitivity: 1, sensitivity: 1,
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
}; };
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings, transparentBackground: false,
visualizer: defaultVisualizerSettings,
}; };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
autoJumpToPlayer: true,
}; };
const defaultState: AppState = { const defaultState: AppState = {
settings: defaultSettings, settings: defaultSettings,
preferences: defaultPreferences, preferences: defaultPreferences,
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
}; };
export function createAppStore() { function createAppStore() {
// Start with defaults; async load will update once ready // Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState); const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation // Fire-and-forget async initialisation
const init = async () => { const init = async () => {
const loaded = await loadAppStateFromFile(); const loaded = await loadAppStateFromFile();
setState(loaded); setState(loaded);
}; };
init(); init();
const saveState = (next: AppState) => { const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {}); saveAppStateToFile(next);
}; };
const updateState = (next: AppState) => { const updateState = (next: AppState) => {
setState(next); setState(next);
saveState(next); saveState(next);
}; };
const updateSettings = (updates: Partial<AppSettings>) => { const updateSettings = (updates: Partial<AppSettings>) => {
const next = { const next = {
...state(), ...state(),
settings: { ...state().settings, ...updates }, settings: { ...state().settings, ...updates },
}; };
updateState(next); updateState(next);
}; };
const updatePreferences = (updates: Partial<UserPreferences>) => { const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = { const next = {
...state(), ...state(),
preferences: { ...state().preferences, ...updates }, preferences: { ...state().preferences, ...updates },
}; };
updateState(next); updateState(next);
}; };
const updateCustomTheme = (updates: Partial<ThemeColors>) => { const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = { const next = {
...state(), ...state(),
customTheme: { ...state().customTheme, ...updates }, customTheme: { ...state().customTheme, ...updates },
}; };
updateState(next); updateState(next);
}; };
const updateVisualizer = (updates: Partial<VisualizerSettings>) => { const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({ updateSettings({
visualizer: { ...state().settings.visualizer, ...updates }, visualizer: { ...state().settings.visualizer, ...updates },
}); });
}; };
const setTheme = (theme: ThemeName) => { const setTheme = (theme: ThemeName) => {
updateSettings({ theme }); updateSettings({ theme });
}; };
const resolveThemeColors = (): ThemeColors => { const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme; const theme = state().settings.theme;
if (theme === "custom") return state().customTheme; if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME; if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme]; const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME; if (!json) return DEFAULT_THEME;
return resolveTheme( return resolveTheme(
json as ThemeJson, json as ThemeJson,
"dark" as ThemeMode, "dark" as ThemeMode,
) as unknown as ThemeColors; ) as unknown as ThemeColors;
}; };
return { return {
state, state,
updateSettings, updateSettings,
updatePreferences, updatePreferences,
updateCustomTheme, updateCustomTheme,
updateVisualizer, updateVisualizer,
setTheme, setTheme,
resolveTheme: resolveThemeColors, resolveTheme: resolveThemeColors,
}; };
} }
let appStoreInstance: ReturnType<typeof createAppStore> | null = null; let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
export function useAppStore() { export function useAppStore() {
if (!appStoreInstance) { if (!appStoreInstance) {
appStoreInstance = createAppStore(); appStoreInstance = createAppStore();
} }
return appStoreInstance; return appStoreInstance;
} }

View File

@@ -5,122 +5,122 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { import {
loadAudioNavFromFile, loadAudioNavFromFile,
saveAudioNavToFile, saveAudioNavToFile,
} from "../utils/app-persistence"; } from "../utils/app-persistence";
/** Source type for audio navigation */ /** Source type for audio navigation */
export enum AudioSource { export enum AudioSource {
FEED = "feed", FEED = "feed",
MY_SHOWS = "my_shows", MY_SHOWS = "my_shows",
SEARCH = "search", SEARCH = "search",
} }
/** Audio navigation state */ /** Audio navigation state */
export interface AudioNavState { export interface AudioNavState {
/** Current source type */ /** Current source type */
source: AudioSource; source: AudioSource;
/** Index of current episode in the ordered list */ /** Index of current episode in the ordered list */
currentIndex: number; currentIndex: number;
/** Podcast ID for My Shows source */ /** Podcast ID for My Shows source */
podcastId?: string; podcastId?: string;
/** Timestamp when navigation state was last saved */ /** Timestamp when navigation state was last saved */
lastUpdated: Date; lastUpdated: Date;
} }
/** Default navigation state */ /** Default navigation state */
const defaultNavState: AudioNavState = { const defaultNavState: AudioNavState = {
source: AudioSource.FEED, source: AudioSource.FEED,
currentIndex: 0, currentIndex: 0,
lastUpdated: new Date(), lastUpdated: new Date(),
}; };
/** Create audio navigation store */ /** Create audio navigation store */
export function createAudioNavStore() { function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState); const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
/** Persist current navigation state to file (fire-and-forget) */ /** Persist current navigation state to file (fire-and-forget) */
function persist(): void { function persist(): void {
saveAudioNavToFile(navState()).catch(() => {}); saveAudioNavToFile(navState());
} }
/** Load navigation state from file */ /** Load navigation state from file */
async function init(): Promise<void> { async function init(): Promise<void> {
const loaded = await loadAudioNavFromFile<AudioNavState>(); const loaded = await loadAudioNavFromFile<AudioNavState>();
if (loaded) { if (loaded) {
setNavState(loaded); setNavState(loaded);
} }
} }
/** Fire-and-forget initialization */ /** Fire-and-forget initialization */
init(); init();
return { return {
/** Get current navigation state */ /** Get current navigation state */
get state(): AudioNavState { get state(): AudioNavState {
return navState(); return navState();
}, },
/** Update source type */ /** Update source type */
setSource: (source: AudioSource, podcastId?: string) => { setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({ setNavState((prev) => ({
...prev, ...prev,
source, source,
podcastId, podcastId,
lastUpdated: new Date(), lastUpdated: new Date(),
})); }));
persist(); persist();
}, },
/** Move to next episode */ /** Move to next episode */
next: (currentIndex: number) => { next: (currentIndex: number) => {
setNavState((prev) => ({ setNavState((prev) => ({
...prev, ...prev,
currentIndex, currentIndex,
lastUpdated: new Date(), lastUpdated: new Date(),
})); }));
persist(); persist();
}, },
/** Move to previous episode */ /** Move to previous episode */
prev: (currentIndex: number) => { prev: (currentIndex: number) => {
setNavState((prev) => ({ setNavState((prev) => ({
...prev, ...prev,
currentIndex, currentIndex,
lastUpdated: new Date(), lastUpdated: new Date(),
})); }));
persist(); persist();
}, },
/** Reset to default state */ /** Reset to default state */
reset: () => { reset: () => {
setNavState(defaultNavState); setNavState(defaultNavState);
persist(); persist();
}, },
/** Get current index */ /** Get current index */
getCurrentIndex: (): number => { getCurrentIndex: (): number => {
return navState().currentIndex; return navState().currentIndex;
}, },
/** Get current source */ /** Get current source */
getSource: (): AudioSource => { getSource: (): AudioSource => {
return navState().source; return navState().source;
}, },
/** Get current podcast ID */ /** Get current podcast ID */
getPodcastId: (): string | undefined => { getPodcastId: (): string | undefined => {
return navState().podcastId; return navState().podcastId;
}, },
}; };
} }
/** Singleton instance */ /** Singleton instance */
let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null; let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null;
export function useAudioNavStore() { export function useAudioNavStore() {
if (!audioNavInstance) { if (!audioNavInstance) {
audioNavInstance = createAudioNavStore(); audioNavInstance = createAudioNavStore();
} }
return audioNavInstance; return audioNavInstance;
} }

View File

@@ -1,244 +0,0 @@
/**
* Authentication store for PodTUI
* Uses Zustand for state management with localStorage persistence
* Authentication is DISABLED by default
*/
import { createSignal } from "solid-js"
import type {
User,
AuthState,
AuthError,
AuthErrorCode,
LoginCredentials,
AuthScreen,
} from "../types/auth"
import { AUTH_CONFIG, DEFAULT_AUTH_ENABLED } from "../config/auth"
/** Initial auth state */
const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
}
/** Load auth state from localStorage */
function loadAuthState(): AuthState {
if (typeof localStorage === "undefined") {
return initialState
}
try {
const stored = localStorage.getItem(AUTH_CONFIG.storage.authState)
if (stored) {
const parsed = JSON.parse(stored)
// Convert date strings back to Date objects
if (parsed.user?.createdAt) {
parsed.user.createdAt = new Date(parsed.user.createdAt)
}
if (parsed.user?.lastLoginAt) {
parsed.user.lastLoginAt = new Date(parsed.user.lastLoginAt)
}
return parsed
}
} catch {
// Ignore parse errors, use initial state
}
return initialState
}
/** Save auth state to localStorage */
function saveAuthState(state: AuthState): void {
if (typeof localStorage === "undefined") {
return
}
try {
localStorage.setItem(AUTH_CONFIG.storage.authState, JSON.stringify(state))
} catch {
// Ignore storage errors
}
}
/** Create auth store using Solid signals */
export function createAuthStore() {
const [state, setState] = createSignal<AuthState>(loadAuthState())
const [authEnabled, setAuthEnabled] = createSignal(DEFAULT_AUTH_ENABLED)
const [currentScreen, setCurrentScreen] = createSignal<AuthScreen>("login")
/** Update state and persist */
const updateState = (updates: Partial<AuthState>) => {
setState((prev) => {
const next = { ...prev, ...updates }
saveAuthState(next)
return next
})
}
/** Login with email/password (placeholder - no real backend) */
const login = async (credentials: LoginCredentials): Promise<boolean> => {
updateState({ isLoading: true, error: null })
// Simulate network delay
await new Promise((r) => setTimeout(r, 500))
// Validate email format
if (!AUTH_CONFIG.email.pattern.test(credentials.email)) {
updateState({
isLoading: false,
error: {
code: "INVALID_CREDENTIALS" as AuthErrorCode,
message: "Invalid email format",
},
})
return false
}
// Validate password length
if (credentials.password.length < AUTH_CONFIG.password.minLength) {
updateState({
isLoading: false,
error: {
code: "INVALID_CREDENTIALS" as AuthErrorCode,
message: `Password must be at least ${AUTH_CONFIG.password.minLength} characters`,
},
})
return false
}
// Create mock user (in real app, this would validate against backend)
const user: User = {
id: crypto.randomUUID(),
email: credentials.email,
name: credentials.email.split("@")[0],
createdAt: new Date(),
lastLoginAt: new Date(),
syncEnabled: true,
}
updateState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
})
return true
}
/** Logout and clear state */
const logout = () => {
updateState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
})
setCurrentScreen("login")
}
/** Validate 8-character code */
const validateCode = async (code: string): Promise<boolean> => {
updateState({ isLoading: true, error: null })
// Simulate network delay
await new Promise((r) => setTimeout(r, 500))
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "")
// Check code length
if (normalizedCode.length !== AUTH_CONFIG.codeValidation.codeLength) {
updateState({
isLoading: false,
error: {
code: "INVALID_CODE" as AuthErrorCode,
message: `Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
},
})
return false
}
// Check code format
if (!AUTH_CONFIG.codeValidation.allowedChars.test(normalizedCode)) {
updateState({
isLoading: false,
error: {
code: "INVALID_CODE" as AuthErrorCode,
message: "Code must contain only letters and numbers",
},
})
return false
}
// Mock successful code validation
const user: User = {
id: crypto.randomUUID(),
email: `sync-${normalizedCode.toLowerCase()}@podtui.local`,
name: `Sync User (${normalizedCode.slice(0, 4)})`,
createdAt: new Date(),
lastLoginAt: new Date(),
syncEnabled: true,
}
updateState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
})
return true
}
/** Clear error */
const clearError = () => {
updateState({ error: null })
}
/** Enable/disable auth */
const toggleAuthEnabled = () => {
setAuthEnabled((prev) => !prev)
}
return {
// State accessors (signals)
state,
authEnabled,
currentScreen,
// Actions
login,
logout,
validateCode,
clearError,
setCurrentScreen,
toggleAuthEnabled,
// Computed
get user() {
return state().user
},
get isAuthenticated() {
return state().isAuthenticated
},
get isLoading() {
return state().isLoading
},
get error() {
return state().error
},
}
}
/** Singleton auth store instance */
let authStoreInstance: ReturnType<typeof createAuthStore> | null = null
/** Get or create auth store */
export function useAuthStore() {
if (!authStoreInstance) {
authStoreInstance = createAuthStore()
}
return authStoreInstance
}

View File

@@ -1,215 +1,218 @@
/** /**
* Discover store for PodTUI * 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 { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast" import type { Podcast } from "../types/podcast";
import { useFeedStore } from "./feed";
export interface DiscoverCategory { export interface DiscoverCategory {
id: string id: string;
name: string name: string;
icon: string icon: string;
} }
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" }, { id: "all", name: "All", icon: "*" },
{ id: "technology", name: "Technology", icon: ">" }, { id: "technology", name: "Technology", icon: ">" },
{ id: "science", name: "Science", icon: "~" }, { id: "science", name: "Science", icon: "~" },
{ id: "comedy", name: "Comedy", icon: ")" }, { id: "comedy", name: "Comedy", icon: ")" },
{ id: "news", name: "News", icon: "!" }, { id: "news", name: "News", icon: "!" },
{ id: "business", name: "Business", icon: "$" }, { id: "business", name: "Business", icon: "$" },
{ id: "health", name: "Health", icon: "+" }, { id: "health", name: "Health", icon: "+" },
{ id: "education", name: "Education", icon: "?" }, { id: "education", name: "Education", icon: "?" },
{ id: "sports", name: "Sports", icon: "#" }, { id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" }, { id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" }, { id: "arts", name: "Arts", icon: "@" },
] ];
/** Mock trending podcasts */ // ── Remote featured-shows manifest ───────────────────────────────────────────
const TRENDING_PODCASTS: Podcast[] = [ // 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.
id: "trend-1", const FEATURED_JSON_URL =
title: "AI Today", "https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
feedUrl: "https://example.com/aitoday.rss", /** Cache window for the remote featured list (24 hours) */
author: "Tech Futures", const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
categories: ["Technology", "Science"],
coverUrl: undefined, /** Shape of a single entry in the remote JSON */
lastUpdated: new Date(), interface FeaturedEntry {
isSubscribed: false, id: string;
}, title: string;
{ description: string;
id: "trend-2", feedUrl: string;
title: "The History Hour", author?: string;
description: "Fascinating stories from history that shaped our world today.", categories?: string[];
feedUrl: "https://example.com/historyhour.rss", }
author: "History Channel",
categories: ["Education", "History"], /** Shape of the remote JSON manifest */
lastUpdated: new Date(), interface FeaturedManifest {
isSubscribed: false, version: number;
}, podcasts: FeaturedEntry[];
{ }
id: "trend-3",
title: "Comedy Gold", /** Convert a JSON entry to a runtime Podcast (adding derived fields) */
description: "Weekly stand-up comedy, sketches, and hilarious conversations.", function entryToPodcast(entry: FeaturedEntry): Podcast {
feedUrl: "https://example.com/comedygold.rss", return {
author: "Laugh Factory", id: entry.id,
categories: ["Comedy", "Entertainment"], title: entry.title,
lastUpdated: new Date(), description: entry.description,
isSubscribed: false, feedUrl: entry.feedUrl,
}, author: entry.author,
{ categories: entry.categories ?? [],
id: "trend-4", coverUrl: undefined,
title: "Market Watch", lastUpdated: new Date(),
description: "Daily financial news, stock analysis, and investing tips.", isSubscribed: false,
feedUrl: "https://example.com/marketwatch.rss", };
author: "Finance Daily", }
categories: ["Business", "News"],
lastUpdated: new Date(), /** Reconcile isSubscribed state across the discover list against the feed store */
isSubscribed: true, function syncSubscriptionState(
}, podcasts: Podcast[],
{ subscribedUrls: Set<string>,
id: "trend-5", subscribedIds: Set<string>,
title: "Science Weekly", ): Podcast[] {
description: "Breaking science news and in-depth analysis of the latest research.", return podcasts.map((p) => ({
feedUrl: "https://example.com/scienceweekly.rss", ...p,
author: "Science Network", isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
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,
},
]
/** Create discover store */ /** Create discover store */
export function createDiscoverStore() { export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all") const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS) const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
/** Get filtered podcasts by category */ // In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
const filteredPodcasts = () => { let cachedAt = 0;
const category = selectedCategory()
if (category === "all") {
return podcasts()
}
return podcasts().filter((p) => { /** Reconcile local isSubscribed flags with the feed store */
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [] const syncSubscriptions = () => {
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " "))) 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 */ /** Fetch the featured-shows manifest from GitHub if stale */
const subscribe = (podcastId: string) => { const refresh = async () => {
setPodcasts((prev) => setIsLoading(true);
prev.map((p) => try {
p.id === podcastId ? { ...p, isSubscribed: true } : p // Skip if cache is still fresh
) const now = Date.now();
) if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
} syncSubscriptions();
return;
}
/** Unsubscribe from a podcast */ const resp = await fetch(FEATURED_JSON_URL, {
const unsubscribe = (podcastId: string) => { headers: { "User-Agent": "PodTUI/1.0" },
setPodcasts((prev) => });
prev.map((p) => if (!resp.ok) {
p.id === podcastId ? { ...p, isSubscribed: false } : p syncSubscriptions();
) return;
) }
} const manifest = (await resp.json()) as FeaturedManifest;
if (!manifest?.podcasts?.length) {
syncSubscriptions();
return;
}
/** Toggle subscription */ const fetched = manifest.podcasts.map(entryToPodcast);
const toggleSubscription = (podcastId: string) => { cachedAt = now;
const podcast = podcasts().find((p) => p.id === podcastId) setPodcasts(fetched);
if (podcast?.isSubscribed) {
unsubscribe(podcastId)
} else {
subscribe(podcastId)
}
}
/** Refresh trending podcasts (mock) */ // Reflect current feed-store subscriptions
const refresh = async () => { syncSubscriptions();
setIsLoading(true) } catch {
// Simulate network delay // Network failure — keep whatever we have (stale or empty)
await new Promise((r) => setTimeout(r, 500)) } finally {
// In real app, would fetch from API setIsLoading(false);
setIsLoading(false) }
} };
return { /** Get filtered podcasts by category */
// State const filteredPodcasts = () => {
selectedCategory, const category = selectedCategory();
isLoading, if (category === "all") {
podcasts, return podcasts();
filteredPodcasts, }
categories: DISCOVER_CATEGORIES,
// Actions return podcasts().filter((p) => {
setSelectedCategory, const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
subscribe, return cats.some((c) =>
unsubscribe, c.includes(category.toLowerCase().replace("-", " ")),
toggleSubscription, );
refresh, });
} };
/** 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) {
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 */ /** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() { export function useDiscoverStore() {
if (!discoverStoreInstance) { if (!discoverStoreInstance) {
discoverStoreInstance = createDiscoverStore() discoverStoreInstance = createDiscoverStore();
} }
return discoverStoreInstance return discoverStoreInstance;
} }

View File

@@ -6,355 +6,371 @@
* download queue (max 2 concurrent). * download queue (max 2 concurrent).
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { DownloadStatus } from "../types/episode" import { DownloadStatus } from "../types/episode";
import type { DownloadedEpisode } from "../types/episode" import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode" import type { Episode } from "../types/episode";
import { downloadEpisode } from "../utils/episode-downloader" import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir" import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
import { backupConfigFile } from "../utils/config-backup"
const DOWNLOADS_FILE = "downloads.json" const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2 const MAX_CONCURRENT = 2;
/** Serializable download record for persistence */ /** Serializable download record for persistence */
interface DownloadRecord { interface DownloadRecord {
episodeId: string episodeId: string;
feedId: string feedId: string;
status: DownloadStatus status: DownloadStatus;
filePath: string | null filePath: string | null;
downloadedAt: string | null downloadedAt: string | null;
fileSize: number fileSize: number;
error: string | null error: string | null;
audioUrl: string audioUrl: string;
episodeTitle: string episodeTitle: string;
} }
/** Queue item for pending downloads */ /** Queue item for pending downloads */
interface QueueItem { interface QueueItem {
episodeId: string episodeId: string;
feedId: string feedId: string;
audioUrl: string audioUrl: string;
episodeTitle: string episodeTitle: string;
} }
/** Create download store */ /** Create download store */
export function createDownloadStore() { function createDownloadStore() {
const [downloads, setDownloads] = createSignal<Map<string, DownloadedEpisode>>(new Map()) const [downloads, setDownloads] = createSignal<
const [queue, setQueue] = createSignal<QueueItem[]>([]) Map<string, DownloadedEpisode>
const [activeCount, setActiveCount] = createSignal(0) >(new Map());
const [queue, setQueue] = createSignal<QueueItem[]>([]);
const [activeCount, setActiveCount] = createSignal(0);
/** Active AbortControllers keyed by episodeId */ /** Active AbortControllers keyed by episodeId */
const abortControllers = new Map<string, AbortController>() const abortControllers = new Map<string, AbortController>();
// Load persisted downloads on init (async () => {
;(async () => { const loaded = await loadDownloads();
const loaded = await loadDownloads() if (loaded.size > 0) setDownloads(loaded);
if (loaded.size > 0) setDownloads(loaded) // Resume any queued downloads from previous session
// Resume any queued downloads from previous session resumeIncomplete();
resumeIncomplete() })();
})()
/** Load downloads from JSON file */ /** Load downloads from JSON file */
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> { async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
try { try {
const filePath = getConfigFilePath(DOWNLOADS_FILE) const filePath = getConfigFilePath(DOWNLOADS_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return new Map() if (!(await file.exists())) return new Map();
const raw: DownloadRecord[] = await file.json() const raw: DownloadRecord[] = await file.json();
if (!Array.isArray(raw)) return new Map() if (!Array.isArray(raw)) return new Map();
const map = new Map<string, DownloadedEpisode>() const map = new Map<string, DownloadedEpisode>();
for (const rec of raw) { for (const rec of raw) {
map.set(rec.episodeId, { map.set(rec.episodeId, {
episodeId: rec.episodeId, episodeId: rec.episodeId,
feedId: rec.feedId, feedId: rec.feedId,
status: rec.status === DownloadStatus.DOWNLOADING ? DownloadStatus.QUEUED : rec.status, status:
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0, rec.status === DownloadStatus.DOWNLOADING
filePath: rec.filePath, ? DownloadStatus.QUEUED
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null, : rec.status,
speed: 0, progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
fileSize: rec.fileSize, filePath: rec.filePath,
error: rec.error, downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
}) speed: 0,
} fileSize: rec.fileSize,
return map error: rec.error,
} catch { });
return new Map() }
} return map;
} } catch {
return new Map();
}
}
/** Persist downloads to JSON file */ /** Persist downloads to JSON file */
async function saveDownloads(): Promise<void> { async function saveDownloads(): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(DOWNLOADS_FILE) const map = downloads();
const map = downloads() const records: DownloadRecord[] = [];
const records: DownloadRecord[] = [] for (const [, dl] of map) {
for (const [, dl] of map) { // Find the audioUrl from queue or use empty string
// Find the audioUrl from queue or use empty string const qItem = queue().find((q) => q.episodeId === dl.episodeId);
const qItem = queue().find((q) => q.episodeId === dl.episodeId) records.push({
records.push({ episodeId: dl.episodeId,
episodeId: dl.episodeId, feedId: dl.feedId,
feedId: dl.feedId, status: dl.status,
status: dl.status, filePath: dl.filePath,
filePath: dl.filePath, downloadedAt: dl.downloadedAt?.toISOString() ?? null,
downloadedAt: dl.downloadedAt?.toISOString() ?? null, fileSize: dl.fileSize,
fileSize: dl.fileSize, error: dl.error,
error: dl.error, audioUrl: qItem?.audioUrl ?? "",
audioUrl: qItem?.audioUrl ?? "", episodeTitle: qItem?.episodeTitle ?? "",
episodeTitle: qItem?.episodeTitle ?? "", });
}) }
} const filePath = getConfigFilePath(DOWNLOADS_FILE);
const filePath = getConfigFilePath(DOWNLOADS_FILE) await Bun.write(filePath, JSON.stringify(records, null, 2));
await Bun.write(filePath, JSON.stringify(records, null, 2)) } catch {
} catch { // Silently ignore write errors
// Silently ignore write errors }
} }
}
/** Resume incomplete downloads from a previous session */ /** Resume incomplete downloads from a previous session */
function resumeIncomplete(): void { function resumeIncomplete(): void {
const map = downloads() const map = downloads();
for (const [, dl] of map) { for (const [, dl] of map) {
if (dl.status === DownloadStatus.QUEUED) { if (dl.status === DownloadStatus.QUEUED) {
// Re-queue — but we lack audioUrl from persistence alone. // Re-queue — but we lack audioUrl from persistence alone.
// These will sit as QUEUED until the user re-triggers them. // These will sit as QUEUED until the user re-triggers them.
} }
} }
} }
/** Update a single download entry and trigger reactivity */ /** Update a single download entry and trigger reactivity */
function updateDownload(episodeId: string, updates: Partial<DownloadedEpisode>): void { function updateDownload(
setDownloads((prev) => { episodeId: string,
const next = new Map(prev) updates: Partial<DownloadedEpisode>,
const existing = next.get(episodeId) ): void {
if (existing) { setDownloads((prev) => {
next.set(episodeId, { ...existing, ...updates }) const next = new Map(prev);
} const existing = next.get(episodeId);
return next if (existing) {
}) next.set(episodeId, { ...existing, ...updates });
} }
return next;
});
}
/** Process the download queue — starts downloads up to MAX_CONCURRENT */ /** Process the download queue — starts downloads up to MAX_CONCURRENT */
function processQueue(): void { function processQueue(): void {
const current = activeCount() const current = activeCount();
const q = queue() const q = queue();
if (current >= MAX_CONCURRENT || q.length === 0) return if (current >= MAX_CONCURRENT || q.length === 0) return;
const slotsAvailable = MAX_CONCURRENT - current const slotsAvailable = MAX_CONCURRENT - current;
const toStart = q.slice(0, slotsAvailable) const toStart = q.slice(0, slotsAvailable);
// Remove started items from queue if (toStart.length > 0) {
if (toStart.length > 0) { setQueue((prev) => prev.slice(toStart.length));
setQueue((prev) => prev.slice(toStart.length)) }
}
for (const item of toStart) { for (const item of toStart) {
executeDownload(item) executeDownload(item);
} }
} }
/** Execute a single download */ /** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> { async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController() const controller = new AbortController();
abortControllers.set(item.episodeId, controller) abortControllers.set(item.episodeId, controller);
setActiveCount((c) => c + 1) setActiveCount((c) => c + 1);
updateDownload(item.episodeId, { updateDownload(item.episodeId, {
status: DownloadStatus.DOWNLOADING, status: DownloadStatus.DOWNLOADING,
progress: 0, progress: 0,
speed: 0, speed: 0,
error: null, error: null,
}) });
const result = await downloadEpisode( const result = await downloadEpisode(
item.audioUrl, item.audioUrl,
item.episodeTitle, item.episodeTitle,
item.feedId, item.feedId,
(progress) => { (progress) => {
updateDownload(item.episodeId, { updateDownload(item.episodeId, {
progress: progress.percent >= 0 ? progress.percent : 0, progress: progress.percent >= 0 ? progress.percent : 0,
speed: progress.speed, speed: progress.speed,
fileSize: progress.totalBytes, fileSize: progress.totalBytes,
}) });
}, },
controller.signal, controller.signal,
) );
abortControllers.delete(item.episodeId) abortControllers.delete(item.episodeId);
setActiveCount((c) => Math.max(0, c - 1)) setActiveCount((c) => Math.max(0, c - 1));
if (result.success) { if (result.success) {
updateDownload(item.episodeId, { updateDownload(item.episodeId, {
status: DownloadStatus.COMPLETED, status: DownloadStatus.COMPLETED,
progress: 100, progress: 100,
filePath: result.filePath, filePath: result.filePath,
fileSize: result.fileSize, fileSize: result.fileSize,
downloadedAt: new Date(), downloadedAt: new Date(),
speed: 0, speed: 0,
error: null, error: null,
}) });
} else { } else {
updateDownload(item.episodeId, { updateDownload(item.episodeId, {
status: DownloadStatus.FAILED, status: DownloadStatus.FAILED,
speed: 0, speed: 0,
error: result.error ?? "Unknown error", error: result.error ?? "Unknown error",
}) });
} }
saveDownloads().catch(() => {}) saveDownloads().catch(() => {});
// Process next items in queue // Process next items in queue
processQueue() processQueue();
} }
/** Get download status for an episode */ /** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => { const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
} };
/** Get download progress for an episode (0-100) */ /** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => { const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0 return downloads().get(episodeId)?.progress ?? 0;
} };
/** Get full download info for an episode */ /** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => { const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId) return downloads().get(episodeId);
} };
/** Get the local file path for a completed download */ /** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => { const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId) const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) { if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
return dl.filePath return dl.filePath;
} }
return null return null;
} };
/** Start downloading an episode */ /** Start downloading an episode */
const startDownload = (episode: Episode, feedId: string): void => { const startDownload = (episode: Episode, feedId: string): void => {
const existing = downloads().get(episode.id) const existing = downloads().get(episode.id);
if (existing?.status === DownloadStatus.DOWNLOADING || existing?.status === DownloadStatus.QUEUED) { if (
return // Already downloading or queued existing?.status === DownloadStatus.DOWNLOADING ||
} existing?.status === DownloadStatus.QUEUED
) {
return; // Already downloading or queued
}
// Create download entry const entry: DownloadedEpisode = {
const entry: DownloadedEpisode = { episodeId: episode.id,
episodeId: episode.id, feedId,
feedId, status: DownloadStatus.QUEUED,
status: DownloadStatus.QUEUED, progress: 0,
progress: 0, filePath: null,
filePath: null, downloadedAt: null,
downloadedAt: null, speed: 0,
speed: 0, fileSize: episode.fileSize ?? 0,
fileSize: episode.fileSize ?? 0, error: null,
error: null, };
}
setDownloads((prev) => { setDownloads((prev) => {
const next = new Map(prev) const next = new Map(prev);
next.set(episode.id, entry) next.set(episode.id, entry);
return next return next;
}) });
// Add to queue const queueItem: QueueItem = {
const queueItem: QueueItem = { episodeId: episode.id,
episodeId: episode.id, feedId,
feedId, audioUrl: episode.audioUrl,
audioUrl: episode.audioUrl, episodeTitle: episode.title,
episodeTitle: episode.title, };
} setQueue((prev) => [...prev, queueItem]);
setQueue((prev) => [...prev, queueItem])
saveDownloads().catch(() => {}) saveDownloads().catch(() => {});
processQueue() processQueue();
} };
/** Cancel a download */ /** Cancel a download */
const cancelDownload = (episodeId: string): void => { const cancelDownload = (episodeId: string): void => {
// Abort active download // Abort active download
const controller = abortControllers.get(episodeId) const controller = abortControllers.get(episodeId);
if (controller) { if (controller) {
controller.abort() controller.abort();
abortControllers.delete(episodeId) abortControllers.delete(episodeId);
} }
// Remove from queue setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
// Update status updateDownload(episodeId, {
updateDownload(episodeId, { status: DownloadStatus.NONE,
status: DownloadStatus.NONE, progress: 0,
progress: 0, speed: 0,
speed: 0, error: null,
error: null, });
})
saveDownloads().catch(() => {}) saveDownloads().catch(() => {});
} };
/** Remove a completed download (delete file and metadata) */ /** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => { const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId) const dl = downloads().get(episodeId);
if (dl?.filePath) { if (dl?.filePath) {
try { try {
const { unlink } = await import("fs/promises") const { unlink } = await import("fs/promises");
await unlink(dl.filePath) await unlink(dl.filePath);
} catch { } catch {
// File may already be gone // File may already be gone
} }
} }
setDownloads((prev) => { setDownloads((prev) => {
const next = new Map(prev) const next = new Map(prev);
next.delete(episodeId) next.delete(episodeId);
return next return next;
}) });
saveDownloads().catch(() => {}) saveDownloads().catch(() => {});
} };
/** Get all downloads as an array */ /** Remove every download (active/queued/completed) belonging to a feed —
const getAllDownloads = (): DownloadedEpisode[] => { * abort in-flight transfers, drop queued items, delete files + metadata. */
return Array.from(downloads().values()) const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
} const eps = Array.from(downloads().values()).filter(
(d) => d.feedId === feedId,
);
for (const d of eps) {
cancelDownload(d.episodeId);
await removeDownload(d.episodeId);
}
};
/** Get the current queue */ /** Get all downloads as an array */
const getQueue = (): QueueItem[] => { const getAllDownloads = (): DownloadedEpisode[] => {
return queue() return Array.from(downloads().values());
} };
/** Get count of active downloads */ /** Get the current queue */
const getActiveCount = (): number => { const getQueue = (): QueueItem[] => {
return activeCount() return queue();
} };
return { /** Get count of active downloads */
// Getters const getActiveCount = (): number => {
getDownloadStatus, return activeCount();
getDownloadProgress, };
getDownload,
getDownloadedFilePath,
getAllDownloads,
getQueue,
getActiveCount,
// Actions return {
startDownload, // Getters
cancelDownload, getDownloadStatus,
removeDownload, getDownloadProgress,
} getDownload,
getDownloadedFilePath,
getAllDownloads,
getQueue,
getActiveCount,
// Actions
startDownload,
cancelDownload,
removeDownload,
removeDownloadsForFeed,
};
} }
/** Singleton download store */ /** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() { export function useDownloadStore() {
if (!downloadStoreInstance) { if (!downloadStoreInstance) {
downloadStoreInstance = createDownloadStore() downloadStoreInstance = createDownloadStore();
} }
return downloadStoreInstance return downloadStoreInstance;
} }

View File

@@ -7,19 +7,18 @@ import { createSignal } from "solid-js";
import { FeedVisibility } from "../types/feed"; import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"; import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast"; import type { Podcast } from "../types/podcast";
import type { Episode, EpisodeStatus } from "../types/episode"; import type { Episode } from "../types/episode";
import type { PodcastSource, SourceType } from "../types/source"; import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source"; import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser"; import { parseRSSFeed } from "../api/rss-parser";
import { import {
loadFeedsFromFile, loadFeedsFromFile,
saveFeedsToFile, saveFeedsToFile,
loadSourcesFromFile, loadSourcesFromFile,
saveSourcesToFile, saveSourcesToFile,
} from "../utils/feeds-persistence"; } from "../utils/feeds-persistence";
import { useDownloadStore } from "./download"; import { useDownloadStore } from "./download";
import { DownloadStatus } from "../types/episode"; import { DownloadStatus } from "../types/episode";
import { useAuthStore } from "./auth";
/** Max episodes to load per page/chunk */ /** Max episodes to load per page/chunk */
const MAX_EPISODES_REFRESH = 50; const MAX_EPISODES_REFRESH = 50;
@@ -35,461 +34,473 @@ const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */ /** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void { function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds).catch(() => {}); saveFeedsToFile(feeds);
} }
/** Save sources to file (async, fire-and-forget) */ /** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void { function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources).catch(() => {}); saveSourcesToFile(sources);
} }
/** Create feed store */ /** Create feed store */
export function createFeedStore() { function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]); const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([ const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES, ...DEFAULT_SOURCES,
]); ]);
const [filter, setFilter] = createSignal<FeedFilter>({ const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all", visibility: "all",
sortBy: "updated" as FeedSortField, sortBy: "updated" as FeedSortField,
sortDirection: "desc", sortDirection: "desc",
}); });
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null); const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false); const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false); const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
/** Get filtered and sorted feeds */ /** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => { const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()]; let result = [...feeds()];
const f = filter(); const f = filter();
const authStore = useAuthStore();
// Filter by visibility if (f.visibility && f.visibility !== "all") {
if (f.visibility && f.visibility !== "all") { result = result.filter((feed) => feed.visibility === f.visibility);
result = result.filter((feed) => feed.visibility === f.visibility); }
} 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) {
if (f.sourceId) { result = result.filter((feed) => feed.sourceId === f.sourceId);
result = result.filter((feed) => feed.sourceId === f.sourceId); }
}
// Filter by pinned if (f.pinnedOnly) {
if (f.pinnedOnly) { result = result.filter((feed) => feed.isPinned);
result = result.filter((feed) => feed.isPinned); }
}
// Filter by search query if (f.searchQuery) {
if (f.searchQuery) { const query = f.searchQuery.toLowerCase();
const query = f.searchQuery.toLowerCase(); result = result.filter(
result = result.filter( (feed) =>
(feed) => feed.podcast.title.toLowerCase().includes(query) ||
feed.podcast.title.toLowerCase().includes(query) || feed.customName?.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) || feed.podcast.description?.toLowerCase().includes(query),
feed.podcast.description?.toLowerCase().includes(query), );
); }
}
// Sort by selected field const sortDir = f.sortDirection === "asc" ? 1 : -1;
const sortDir = f.sortDirection === "asc" ? 1 : -1; result.sort((a, b) => {
result.sort((a, b) => { switch (f.sortBy) {
switch (f.sortBy) { case "title":
case "title": return (
return ( sortDir *
sortDir * (a.customName || a.podcast.title).localeCompare(
(a.customName || a.podcast.title).localeCompare( b.customName || b.podcast.title,
b.customName || b.podcast.title, )
) );
); case "episodeCount":
case "episodeCount": return sortDir * (a.episodes.length - b.episodes.length);
return sortDir * (a.episodes.length - b.episodes.length); case "latestEpisode":
case "latestEpisode": const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0; const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0; return sortDir * (aLatest - bLatest);
return sortDir * (aLatest - bLatest); case "updated":
case "updated": default:
default: return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()); }
} });
});
// Pinned feeds always first result.sort((a, b) => {
result.sort((a, b) => { if (a.isPinned && !b.isPinned) return -1;
if (a.isPinned && !b.isPinned) return -1; if (!a.isPinned && b.isPinned) return 1;
if (!a.isPinned && b.isPinned) return 1; return 0;
return 0; });
});
return result; return result;
}; };
/** Get episodes in reverse chronological order across all feeds */ /** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{ const getAllEpisodesChronological = (): Array<{
episode: Episode; episode: Episode;
feed: Feed; feed: Feed;
}> => { }> => {
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []; const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
for (const feed of feeds()) { for (const feed of feeds()) {
for (const episode of feed.episodes) { for (const episode of feed.episodes) {
allEpisodes.push({ episode, feed }); allEpisodes.push({ episode, feed });
} }
} }
// Sort by publication date (newest first) // Sort by publication date (newest first)
allEpisodes.sort( allEpisodes.sort(
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), (a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
); );
return allEpisodes; return allEpisodes;
}; };
/** Sort episodes in reverse chronological order (newest first) */ /** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort( return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
); );
}; };
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async ( const fetchEpisodes = async (
feedUrl: string, feedUrl: string,
limit: number, limit: number,
feedId?: string, feedId?: string,
): Promise<Episode[]> => { ): Promise<Episode[]> => {
try { try {
const response = await fetch(feedUrl, { const response = await fetch(feedUrl, {
headers: { headers: {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*", Accept: "application/rss+xml, application/xml, text/xml, */*",
}, },
}); });
if (!response.ok) return []; if (!response.ok) return [];
const xml = await response.text(); const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl); const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes); const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
// Cache all parsed episodes for pagination // Cache all parsed episodes for pagination
if (feedId) { if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes); fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)); episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
} }
return allEpisodes.slice(0, limit); return allEpisodes.slice(0, limit);
} catch { } catch {
return []; return [];
} }
}; };
/** Add a new feed and auto-fetch latest 20 episodes */ /** Check if a feed with this URL already exists */
const addFeed = async ( const hasFeedByUrl = (feedUrl: string): boolean => {
podcast: Podcast, return feeds().some((f) => f.podcast.feedUrl === feedUrl);
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;
};
/** Auto-download newest episodes for a feed */ /** Add a new feed and auto-fetch latest 20 episodes */
const autoDownloadEpisodes = ( const addFeed = async (
feedId: string, podcast: Podcast,
newEpisodes: Episode[], sourceId: string,
count: number, visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => { ): Promise<Feed | null> => {
try { // Guard: don't add a feed we already have (matched by feedUrl)
const dlStore = useDownloadStore(); if (hasFeedByUrl(podcast.feedUrl)) {
// Sort by pubDate descending (newest first) return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
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 a single feed - re-fetch latest 50 episodes */ const feedId = crypto.randomUUID();
const refreshFeed = async (feedId: string) => { const episodes = await fetchEpisodes(
const feed = getFeed(feedId); podcast.feedUrl,
if (!feed) return; MAX_EPISODES_SUBSCRIBE,
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)); feedId,
const episodes = await fetchEpisodes( );
feed.podcast.feedUrl, const newFeed: Feed = {
MAX_EPISODES_REFRESH, id: feedId,
feedId, podcast,
); episodes,
setFeeds((prev) => { visibility,
const updated = prev.map((f) => sourceId,
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f, lastUpdated: new Date(),
); isPinned: false,
saveFeeds(updated); };
return updated; setFeeds((prev) => {
}); const updated = [...prev, newFeed];
saveFeeds(updated);
return updated;
});
return newFeed;
};
// Auto-download new episodes if enabled for this feed /** Auto-download newest episodes for a feed */
if (feed.autoDownload) { const autoDownloadEpisodes = (
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)); feedId: string,
if (newEpisodes.length > 0) { newEpisodes: Episode[],
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0); count: number,
} ) => {
} 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);
}
}
};
/** Refresh all feeds */ /** Refresh a single feed - re-fetch latest 50 episodes */
const refreshAllFeeds = async () => { const refreshFeed = async (feedId: string) => {
setIsLoadingFeeds(true); const feed = getFeed(feedId);
try { if (!feed) return;
const currentFeeds = feeds(); const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
for (const feed of currentFeeds) { const episodes = await fetchEpisodes(
await refreshFeed(feed.id); feed.podcast.feedUrl,
} MAX_EPISODES_REFRESH,
} finally { feedId,
setIsLoadingFeeds(false); );
} setFeeds((prev) => {
}; const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
(async () => { // Auto-download new episodes if enabled for this feed
const loadedFeeds = await loadFeedsFromFile(); if (feed.autoDownload) {
if (loadedFeeds.length > 0) setFeeds(loadedFeeds); const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
const loadedSources = await loadSourcesFromFile<PodcastSource>(); if (newEpisodes.length > 0) {
if (loadedSources && loadedSources.length > 0) setSources(loadedSources); autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
await refreshAllFeeds(); }
})(); }
};
/** Remove a feed */ /** Refresh all feeds */
const removeFeed = (feedId: string) => { const refreshAllFeeds = async () => {
fullEpisodeCache.delete(feedId); setIsLoadingFeeds(true);
episodeLoadCount.delete(feedId); try {
setFeeds((prev) => { const currentFeeds = feeds();
const updated = prev.filter((f) => f.id !== feedId); for (const feed of currentFeeds) {
saveFeeds(updated); await refreshFeed(feed.id);
return updated; }
}); } finally {
}; setIsLoadingFeeds(false);
}
};
/** Update a feed */ (async () => {
const updateFeed = (feedId: string, updates: Partial<Feed>) => { const loadedFeeds = await loadFeedsFromFile();
setFeeds((prev) => { if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
const updated = prev.map((f) => const loadedSources = await loadSourcesFromFile<PodcastSource>();
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f, if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
); await refreshAllFeeds();
saveFeeds(updated); })();
return updated;
});
};
/** Toggle feed pinned status */ /** Remove a feed */
const togglePinned = (feedId: string) => { const removeFeed = (feedId: string) => {
setFeeds((prev) => { fullEpisodeCache.delete(feedId);
const updated = prev.map((f) => episodeLoadCount.delete(feedId);
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f, setFeeds((prev) => {
); const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated); saveFeeds(updated);
return updated; return updated;
}); });
}; };
/** Add a source */ /** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
const addSource = (source: Omit<PodcastSource, "id">) => { const removeFeedByUrl = (feedUrl: string) => {
const newSource: PodcastSource = { const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
...source, if (feed) {
id: crypto.randomUUID(), fullEpisodeCache.delete(feed.id);
}; episodeLoadCount.delete(feed.id);
setSources((prev) => { setFeeds((prev) => {
const updated = [...prev, newSource]; const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
saveSources(updated); saveFeeds(updated);
return updated; return updated;
}); });
return newSource; }
}; };
/** Update a source */ /** Update a feed */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => { const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setSources((prev) => { setFeeds((prev) => {
const updated = prev.map((source) => const updated = prev.map((f) =>
source.id === sourceId ? { ...source, ...updates } : source, f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
); );
saveSources(updated); saveFeeds(updated);
return updated; return updated;
}); });
}; };
/** Remove a source */ /** Toggle feed pinned status */
const removeSource = (sourceId: string) => { const togglePinned = (feedId: string) => {
// Don't remove default sources setFeeds((prev) => {
if (sourceId === "itunes" || sourceId === "rss") return false; const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
);
saveFeeds(updated);
return updated;
});
};
setSources((prev) => { /** Add a source */
const updated = prev.filter((s) => s.id !== sourceId); const addSource = (source: Omit<PodcastSource, "id">) => {
saveSources(updated); const newSource: PodcastSource = {
return updated; ...source,
}); id: crypto.randomUUID(),
return true; };
}; setSources((prev) => {
const updated = [...prev, newSource];
saveSources(updated);
return updated;
});
return newSource;
};
/** Toggle source enabled status */ /** Update a source */
const toggleSource = (sourceId: string) => { const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => { setSources((prev) => {
const updated = prev.map((s) => const updated = prev.map((source) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s, source.id === sourceId ? { ...source, ...updates } : source,
); );
saveSources(updated); saveSources(updated);
return updated; return updated;
}); });
}; };
/** Get feed by ID */ /** Remove a source */
const getFeed = (feedId: string): Feed | undefined => { const removeSource = (sourceId: string) => {
return feeds().find((f) => f.id === feedId); // Don't remove default sources
}; if (sourceId === "itunes" || sourceId === "rss") return false;
/** Get selected feed */ setSources((prev) => {
const getSelectedFeed = (): Feed | undefined => { const updated = prev.filter((s) => s.id !== sourceId);
const id = selectedFeedId(); saveSources(updated);
return id ? getFeed(id) : undefined; return updated;
}; });
return true;
};
/** Check if a feed has more episodes available beyond what's currently loaded */ /** Toggle source enabled status */
const hasMoreEpisodes = (feedId: string): boolean => { const toggleSource = (sourceId: string) => {
const cached = fullEpisodeCache.get(feedId); setSources((prev) => {
if (!cached) return false; const updated = prev.map((s) =>
const loaded = episodeLoadCount.get(feedId) ?? 0; s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
return loaded < cached.length; );
}; saveSources(updated);
return updated;
});
};
/** Load the next chunk of episodes for a feed from the cache. /** Get feed by ID */
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ const getFeed = (feedId: string): Feed | undefined => {
const loadMoreEpisodes = async (feedId: string) => { return feeds().find((f) => f.id === feedId);
if (isLoadingMore()) return; };
const feed = getFeed(feedId);
if (!feed) return;
setIsLoadingMore(true); /** Get selected feed */
try { const getSelectedFeed = (): Feed | undefined => {
let cached = fullEpisodeCache.get(feedId); const id = selectedFeedId();
return id ? getFeed(id) : undefined;
};
// If no cache, re-fetch and parse the full feed /** Check if a feed has more episodes available beyond what's currently loaded */
if (!cached) { const hasMoreEpisodes = (feedId: string): boolean => {
const response = await fetch(feed.podcast.feedUrl, { const cached = fullEpisodeCache.get(feedId);
headers: { if (!cached) return false;
"Accept-Encoding": "identity", const loaded = episodeLoadCount.get(feedId) ?? 0;
Accept: "application/rss+xml, application/xml, text/xml, */*", return loaded < cached.length;
}, };
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; /** Load the next chunk of episodes for a feed from the cache.
const newCount = Math.min( * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
currentCount + MAX_EPISODES_REFRESH, const loadMoreEpisodes = async (feedId: string) => {
cached.length, 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); // If no cache, re-fetch and parse the full feed
const episodes = cached.slice(0, newCount); 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 currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const updated = prev.map((f) => const newCount = Math.min(
f.id === feedId ? { ...f, episodes } : f, currentCount + MAX_EPISODES_REFRESH,
); cached.length,
saveFeeds(updated); );
return updated;
});
} finally {
setIsLoadingMore(false);
}
};
/** Set auto-download settings for a feed */ if (newCount <= currentCount) return; // nothing more to load
const setAutoDownload = (
feedId: string,
enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
};
return { episodeLoadCount.set(feedId, newCount);
// State const episodes = cached.slice(0, newCount);
feeds,
sources,
filter,
selectedFeedId,
isLoadingMore,
// Computed setFeeds((prev) => {
getFilteredFeeds, const updated = prev.map((f) =>
getAllEpisodesChronological, f.id === feedId ? { ...f, episodes } : f,
getFeed, );
getSelectedFeed, saveFeeds(updated);
hasMoreEpisodes, return updated;
isLoadingFeeds, });
} finally {
setIsLoadingMore(false);
}
};
// Actions /** Set auto-download settings for a feed */
setFilter, const setAutoDownload = (
setSelectedFeedId, feedId: string,
addFeed, enabled: boolean,
removeFeed, count: number = 0,
updateFeed, ) => {
togglePinned, updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
refreshFeed, };
refreshAllFeeds,
loadMoreEpisodes, return {
addSource, // State
removeSource, feeds,
toggleSource, sources,
updateSource, filter,
setAutoDownload, 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 */ /** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null; let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() { export function useFeedStore() {
if (!feedStoreInstance) { if (!feedStoreInstance) {
feedStoreInstance = createFeedStore(); feedStoreInstance = createFeedStore();
} }
return feedStoreInstance; return feedStoreInstance;
} }

View File

@@ -8,8 +8,8 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import type { Progress } from "../types/episode"; import type { Progress } from "../types/episode";
import { import {
loadProgressFromFile, loadProgressFromFile,
saveProgressToFile, saveProgressToFile,
} from "../utils/app-persistence"; } from "../utils/app-persistence";
/** Threshold (fraction 0-1) at which an episode is considered completed */ /** Threshold (fraction 0-1) at which an episode is considered completed */
@@ -21,146 +21,146 @@ const MIN_POSITION_TO_SAVE = 5;
// --- Singleton store --- // --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>( const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
{}, {},
); );
/** Persist current progress map to file (fire-and-forget) */ /** Persist current progress map to file (fire-and-forget) */
function persist(): void { function persist(): void {
saveProgressToFile(progressMap()).catch(() => {}); saveProgressToFile(progressMap());
} }
/** Parse raw progress entries from file, reviving Date objects */ /** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries( function parseProgressEntries(
raw: Record<string, unknown>, raw: Record<string, unknown>,
): Record<string, Progress> { ): Record<string, Progress> {
const result: Record<string, Progress> = {}; const result: Record<string, Progress> = {};
for (const [key, value] of Object.entries(raw)) { for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown>; const p = value as Record<string, unknown>;
result[key] = { result[key] = {
episodeId: p.episodeId as string, episodeId: p.episodeId as string,
position: p.position as number, position: p.position as number,
duration: p.duration as number, duration: p.duration as number,
timestamp: new Date(p.timestamp as string), timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined, playbackSpeed: p.playbackSpeed as number | undefined,
}; };
} }
return result; return result;
} }
async function initProgress(): Promise<void> { async function initProgress(): Promise<void> {
const raw = await loadProgressFromFile(); const raw = await loadProgressFromFile();
const parsed = parseProgressEntries(raw as Record<string, unknown>); const parsed = parseProgressEntries(raw as Record<string, unknown>);
setProgressMap(parsed); setProgressMap(parsed);
} }
// Fire-and-forget init // Fire-and-forget init
initProgress(); initProgress();
function createProgressStore() { function createProgressStore() {
return { return {
/** /**
* Get progress for a specific episode. * Get progress for a specific episode.
*/ */
get(episodeId: string): Progress | undefined { get(episodeId: string): Progress | undefined {
return progressMap()[episodeId]; return progressMap()[episodeId];
}, },
/** /**
* Get all progress entries. * Get all progress entries.
*/ */
all(): Record<string, Progress> { all(): Record<string, Progress> {
return progressMap(); return progressMap();
}, },
/** /**
* Update progress for an episode. Only persists if position is meaningful. * Update progress for an episode. Only persists if position is meaningful.
*/ */
update( update(
episodeId: string, episodeId: string,
position: number, position: number,
duration: number, duration: number,
playbackSpeed?: number, playbackSpeed?: number,
): void { ): void {
if (position < MIN_POSITION_TO_SAVE && duration > 0) return; if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
setProgressMap((prev) => ({ setProgressMap((prev) => ({
...prev, ...prev,
[episodeId]: { [episodeId]: {
episodeId, episodeId,
position, position,
duration, duration,
timestamp: new Date(), timestamp: new Date(),
playbackSpeed, playbackSpeed,
}, },
})); }));
persist(); persist();
}, },
/** /**
* Check if an episode is completed. * Check if an episode is completed.
*/ */
isCompleted(episodeId: string): boolean { isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId]; const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false; if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD; return p.position / p.duration >= COMPLETION_THRESHOLD;
}, },
/** /**
* Get progress percentage (0-100) for an episode. * Get progress percentage (0-100) for an episode.
*/ */
getPercent(episodeId: string): number { getPercent(episodeId: string): number {
const p = progressMap()[episodeId]; const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0; if (!p || p.duration <= 0) return 0;
return Math.min(100, Math.round((p.position / p.duration) * 100)); return Math.min(100, Math.round((p.position / p.duration) * 100));
}, },
/** /**
* Mark an episode as completed (set position to duration). * Mark an episode as completed (set position to duration).
*/ */
markCompleted(episodeId: string): void { markCompleted(episodeId: string): void {
const p = progressMap()[episodeId]; const p = progressMap()[episodeId];
const duration = p?.duration ?? 0; const duration = p?.duration ?? 0;
setProgressMap((prev) => ({ setProgressMap((prev) => ({
...prev, ...prev,
[episodeId]: { [episodeId]: {
episodeId, episodeId,
position: duration, position: duration,
duration, duration,
timestamp: new Date(), timestamp: new Date(),
playbackSpeed: p?.playbackSpeed, playbackSpeed: p?.playbackSpeed,
}, },
})); }));
persist(); persist();
}, },
/** /**
* Remove progress for an episode (e.g. "mark as new"). * Remove progress for an episode (e.g. "mark as new").
*/ */
remove(episodeId: string): void { remove(episodeId: string): void {
setProgressMap((prev) => { setProgressMap((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[episodeId]; delete next[episodeId];
return next; return next;
}); });
persist(); persist();
}, },
/** /**
* Clear all progress data. * Clear all progress data.
*/ */
clear(): void { clear(): void {
setProgressMap({}); setProgressMap({});
persist(); persist();
}, },
}; };
} }
// Singleton instance // Singleton instance
let instance: ReturnType<typeof createProgressStore> | null = null; let instance: ReturnType<typeof createProgressStore> | null = null;
export function useProgressStore() { export function useProgressStore() {
if (!instance) { if (!instance) {
instance = createProgressStore(); instance = createProgressStore();
} }
return instance; return instance;
} }

View File

@@ -4,7 +4,7 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search"; import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source"; import type { SearchResult } from "../types/source";
@@ -80,10 +80,18 @@ export function createSearchStore() {
setIsSearching(true); setIsSearching(true);
setError(null); setError(null);
// Add to history
addToHistory(q); addToHistory(q);
try { try {
// A query that is a direct RSS feed URL (e.g. a private feed that
// isn't in any public directory) resolves to that feed directly,
// independent of enabled search sources.
const urlResults = await searchByFeedUrl(q);
if (urlResults.length > 0) {
setResults(applySubscribedStatus(urlResults));
return;
}
const sources = feedStore.sources(); const sources = feedStore.sources();
const enabledSourceIds = sources const enabledSourceIds = sources
.filter((s) => s.enabled) .filter((s) => s.enabled)
@@ -122,7 +130,6 @@ export function createSearchStore() {
/** Add query to history */ /** Add query to history */
const addToHistory = (q: string) => { const addToHistory = (q: string) => {
setHistory((prev) => { setHistory((prev) => {
// Remove duplicates and add to front
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()); const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY); const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated); saveHistory(updated);

View File

@@ -1,65 +0,0 @@
/**
* Authentication types for PodTUI
* Authentication is optional and disabled by default
*/
/** User profile information */
export interface User {
id: string
email: string
name: string
createdAt: Date
lastLoginAt?: Date
syncEnabled: boolean
}
/** Authentication state */
export interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
error: AuthError | null
}
/** Authentication error */
export interface AuthError {
code: AuthErrorCode
message: string
}
/** Error codes for authentication */
export enum AuthErrorCode {
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
INVALID_CODE = "INVALID_CODE",
CODE_EXPIRED = "CODE_EXPIRED",
NETWORK_ERROR = "NETWORK_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
/** Login credentials */
export interface LoginCredentials {
email: string
password: string
}
/** Code validation request */
export interface CodeValidationRequest {
code: string
}
/** OAuth provider types */
export enum OAuthProvider {
GOOGLE = "google",
APPLE = "apple",
}
/** OAuth provider configuration */
export interface OAuthProviderConfig {
id: OAuthProvider
name: string
enabled: boolean
description: string
}
/** Auth screen types for navigation */
export type AuthScreen = "login" | "code" | "oauth" | "profile"

View File

@@ -1,12 +1,4 @@
import type { import type { ThemeColors } from "../types/settings"
DesktopTheme,
ThemeColors,
ThemeDefinition,
ThemeName,
ThemeToken,
ThemeVariant,
} from "../types/settings"
import type { ColorValue } from "./theme-schema"
// Base theme colors // Base theme colors
export const BASE_THEME_COLORS: ThemeColors = { export const BASE_THEME_COLORS: ThemeColors = {
@@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
layer2: "#161b22", layer2: "#161b22",
layer3: "#0d1117", layer3: "#0d1117",
} }
// Theme tokens
export const BASE_THEME_TOKENS: ThemeToken = {
"background": "transparent",
"surface": "#1b1f27",
"primary": "#6fa8ff",
"secondary": "#a9b1d6",
"accent": "#f6c177",
"text": "#e6edf3",
"muted": "#7d8590",
"warning": "#f0b429",
"error": "#f47067",
"success": "#3fb950",
"layer0": "transparent",
"layer1": "#1e222e",
"layer2": "#161b22",
"layer3": "#0d1117",
}
// Desktop theme structure
export const THEMES_DESKTOP: DesktopTheme = {
name: "PodTUI",
variants: [
{
name: "catppuccin",
colors: {
background: "transparent",
surface: "#1e1e2e",
primary: "#89b4fa",
secondary: "#cba6f7",
accent: "#f9e2af",
text: "#cdd6f4",
textPrimary: "#cdd6f4",
textSecondary: "#cba6f7",
textTertiary: "#7f849c",
textSelectedPrimary: "#1e1e2e",
textSelectedSecondary: "#cdd6f4",
textSelectedTertiary: "#cba6f7",
muted: "#7f849c",
warning: "#fab387",
error: "#f38ba8",
success: "#a6e3a1",
layerBackgrounds: {
layer0: "transparent",
layer1: "#181825",
layer2: "#11111b",
layer3: "#0a0a0f",
},
},
},
{
name: "gruvbox",
colors: {
background: "transparent",
surface: "#282828",
primary: "#fabd2f",
secondary: "#83a598",
accent: "#fe8019",
text: "#ebdbb2",
textPrimary: "#ebdbb2",
textSecondary: "#83a598",
textTertiary: "#928374",
textSelectedPrimary: "#282828",
textSelectedSecondary: "#ebdbb2",
textSelectedTertiary: "#83a598",
muted: "#928374",
warning: "#fabd2f",
error: "#fb4934",
success: "#b8bb26",
layerBackgrounds: {
layer0: "transparent",
layer1: "#32302a",
layer2: "#1d2021",
layer3: "#0d0c0c",
},
},
},
{
name: "tokyo",
colors: {
background: "transparent",
surface: "#1a1b26",
primary: "#7aa2f7",
secondary: "#bb9af7",
accent: "#e0af68",
text: "#c0caf5",
textPrimary: "#c0caf5",
textSecondary: "#bb9af7",
textTertiary: "#565f89",
textSelectedPrimary: "#1a1b26",
textSelectedSecondary: "#c0caf5",
textSelectedTertiary: "#bb9af7",
muted: "#565f89",
warning: "#e0af68",
error: "#f7768e",
success: "#9ece6a",
layerBackgrounds: {
layer0: "transparent",
layer1: "#16161e",
layer2: "#0f0f15",
layer3: "#08080b",
},
},
},
{
name: "nord",
colors: {
background: "transparent",
surface: "#2e3440",
primary: "#88c0d0",
secondary: "#81a1c1",
accent: "#ebcb8b",
text: "#eceff4",
textPrimary: "#eceff4",
textSecondary: "#81a1c1",
textTertiary: "#4c566a",
textSelectedPrimary: "#2e3440",
textSelectedSecondary: "#eceff4",
textSelectedTertiary: "#81a1c1",
muted: "#4c566a",
warning: "#ebcb8b",
error: "#bf616a",
success: "#a3be8c",
layerBackgrounds: {
layer0: "transparent",
layer1: "#3b4252",
layer2: "#242933",
layer3: "#1a1c23",
},
},
},
],
defaultVariant: "catppuccin",
tokens: BASE_THEME_TOKENS,
}
// Helper function to get theme by name
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
}
// Helper function to get default theme
export function getDefaultTheme(): ThemeVariant {
return THEMES_DESKTOP.variants.find(
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
)!
}
export type ThemeJsonFile = ThemeDefinition
export function isColorReference(value: ColorValue): value is string {
return typeof value === "string" && !value.startsWith("#")
}

View File

@@ -97,14 +97,6 @@ export interface FeedListOptions {
compact: boolean compact: boolean
} }
/** Default feed list options */
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
showEpisodeCount: true,
showLastUpdated: true,
showSource: false,
compact: false,
}
/** Feed statistics */ /** Feed statistics */
export interface FeedStats { export interface FeedStats {
/** Total feed count */ /** Total feed count */

View File

@@ -2,95 +2,99 @@ import type { RGBA } from "@opentui/core";
import type { ColorValue, ThemeJson, Variant } from "./theme-schema"; import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
export type ThemeName = export type ThemeName =
| "system" | "system"
| "catppuccin" | "catppuccin"
| "gruvbox" | "gruvbox"
| "tokyo" | "tokyo"
| "nord" | "nord"
| "custom"; | "custom";
export type LayerBackgrounds = { export type LayerBackgrounds = {
layer0: ColorValue; layer0: ColorValue;
layer1: ColorValue; layer1: ColorValue;
layer2: ColorValue; layer2: ColorValue;
layer3: ColorValue; layer3: ColorValue;
}; };
export type ThemeColors = { export type ThemeColors = {
background: ColorValue; background: ColorValue;
surface: ColorValue; surface: ColorValue;
primary: ColorValue; primary: ColorValue;
secondary: ColorValue; secondary: ColorValue;
accent: ColorValue; accent: ColorValue;
text: ColorValue; text: ColorValue;
textPrimary?: ColorValue; textPrimary?: ColorValue;
textSecondary?: ColorValue; textSecondary?: ColorValue;
textTertiary?: ColorValue; textTertiary?: ColorValue;
textSelectedPrimary?: ColorValue; textSelectedPrimary?: ColorValue;
textSelectedSecondary?: ColorValue; textSelectedSecondary?: ColorValue;
textSelectedTertiary?: ColorValue; textSelectedTertiary?: ColorValue;
muted: ColorValue; muted: ColorValue;
warning: ColorValue; warning: ColorValue;
error: ColorValue; error: ColorValue;
success: ColorValue; success: ColorValue;
layerBackgrounds?: LayerBackgrounds; layerBackgrounds?: LayerBackgrounds;
_hasSelectedListItemText?: boolean; _hasSelectedListItemText?: boolean;
thinkingOpacity?: number; thinkingOpacity?: number;
selectedListItemText?: ColorValue; selectedListItemText?: ColorValue;
}; };
export type ThemeVariant = { export type ThemeVariant = {
name: string; name: string;
colors: ThemeColors; colors: ThemeColors;
}; };
export type ThemeToken = { export type ThemeToken = {
[key: string]: string; [key: string]: string;
}; };
export type ResolvedTheme = Record<string, RGBA> & { export type ResolvedTheme = Record<string, RGBA> & {
layerBackgrounds: Record<string, RGBA>; layerBackgrounds: Record<string, RGBA>;
_hasSelectedListItemText: boolean; _hasSelectedListItemText: boolean;
thinkingOpacity: number; thinkingOpacity: number;
}; };
export type DesktopTheme = { export type DesktopTheme = {
name: string; name: string;
variants: ThemeVariant[]; variants: ThemeVariant[];
defaultVariant: string; defaultVariant: string;
tokens: ThemeToken; tokens: ThemeToken;
}; };
export type VisualizerSettings = { export type VisualizerSettings = {
/** Number of frequency bars (8128, default: 32) */ /** Number of frequency bars (8128, default: 64) */
bars: number; bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
sensitivity: number; sensitivity: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */ /** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction: number; noiseReduction: number;
/** Low frequency cutoff in Hz (default: 50) */ /** Low frequency cutoff in Hz (default: 50) */
lowCutOff: number; lowCutOff: number;
/** High frequency cutoff in Hz (default: 10000) */ /** High frequency cutoff in Hz (default: 10000) */
highCutOff: number; highCutOff: number;
}; };
export type AppSettings = { export type AppSettings = {
theme: ThemeName; theme: ThemeName;
fontSize: number; fontSize: number;
playbackSpeed: number; playbackSpeed: number;
downloadPath: string; downloadPath: string;
visualizer: VisualizerSettings; /** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
visualizer: VisualizerSettings;
}; };
export type UserPreferences = { export type UserPreferences = {
showExplicit: boolean; showExplicit: boolean;
autoDownload: boolean; autoDownload: boolean;
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
}; };
export type AppState = { export type AppState = {
settings: AppSettings; settings: AppSettings;
preferences: UserPreferences; preferences: UserPreferences;
customTheme: ThemeColors; customTheme: ThemeColors;
}; };
export type ThemeMode = "dark" | "light"; export type ThemeMode = "dark" | "light";

View File

@@ -1,24 +0,0 @@
export type SyncData = {
version: string
lastSyncedAt: string
feeds: {
id: string
title: string
url: string
isPrivate: boolean
}[]
sources: {
id: string
name: string
url: string
}[]
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

View File

@@ -1,28 +0,0 @@
export type SyncDataXML = {
version: string
lastSyncedAt: string
feeds: {
feed: {
id: string
title: string
url: string
isPrivate: boolean
}[]
}
sources: {
source: {
id: string
name: string
url: string
}[]
}
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

View File

@@ -13,10 +13,12 @@ export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeJson = { export type ThemeJson = {
$schema?: string $schema?: string
defs?: Record<string, HexColor | RefName> defs?: Record<string, HexColor | RefName>
theme: Record<string, ColorValue> & { theme: Record<string, ColorValue | boolean> & {
selectedListItemText?: ColorValue selectedListItemText?: ColorValue
backgroundMenu?: ColorValue backgroundMenu?: ColorValue
thinkingOpacity?: number thinkingOpacity?: number
/** Render the app background transparent (let the terminal's own bg show). */
transparent?: boolean
} }
} }

View File

@@ -1,13 +1,13 @@
import { import {
createContext, createContext,
createMemo, createMemo,
createSignal, createSignal,
onCleanup, onCleanup,
useContext, useContext,
type Accessor, type Accessor,
type ParentProps, type ParentProps,
For, For,
Show, Show,
} from "solid-js"; } from "solid-js";
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"; import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext"; import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
@@ -21,313 +21,316 @@ import { SelectableBox, SelectableText } from "@/components/Selectable";
* Command option for the command palette. * Command option for the command palette.
*/ */
export type CommandOption = { export type CommandOption = {
/** Display title */ /** Display title */
title: string; title: string;
/** Unique identifier */ /** Unique identifier */
value: string; value: string;
/** Description shown below title */ /** Description shown below title */
description?: string; description?: string;
/** Category for grouping */ /** Category for grouping */
category?: string; category?: string;
/** Keybind reference */ /** Keybind reference */
keybind?: keyof KeybindsResolved; keybind?: keyof KeybindsResolved;
/** Whether this command is suggested */ /** Whether this command is suggested */
suggested?: boolean; suggested?: boolean;
/** Slash command configuration */ /** Slash command configuration */
slash?: { slash?: {
name: string; name: string;
aliases?: string[]; aliases?: string[];
}; };
/** Whether to hide from command list */ /** Whether to hide from command list */
hidden?: boolean; hidden?: boolean;
/** Whether command is enabled */ /** Whether command is enabled */
enabled?: boolean; enabled?: boolean;
/** Footer text (usually keybind display) */ /** Footer text (usually keybind display) */
footer?: string; footer?: string;
/** Handler when command is selected */ /** Handler when command is selected */
onSelect?: (dialog: ReturnType<typeof useDialog>) => void; onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
}; };
type CommandContext = ReturnType<typeof init>; type CommandContext = ReturnType<typeof init>;
const ctx = createContext<CommandContext>(); const ctx = createContext<CommandContext>();
function init() { function init() {
const [registrations, setRegistrations] = createSignal< const [registrations, setRegistrations] = createSignal<
Accessor<CommandOption[]>[] Accessor<CommandOption[]>[]
>([]); >([]);
const [suspendCount, setSuspendCount] = createSignal(0); const [suspendCount, setSuspendCount] = createSignal(0);
const dialog = useDialog(); const dialog = useDialog();
const keybind = useKeybinds(); const keybind = useKeybinds();
const entries = createMemo(() => { const entries = createMemo(() => {
const all = registrations().flatMap((x) => x()); const all = registrations().flatMap((x) => x());
return all.map((x) => ({ return all.map((x) => ({
...x, ...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined, footer: x.keybind ? keybind.print(x.keybind) : undefined,
})); }));
}); });
const isEnabled = (option: CommandOption) => option.enabled !== false; const isEnabled = (option: CommandOption) => option.enabled !== false;
const isVisible = (option: CommandOption) => const isVisible = (option: CommandOption) =>
isEnabled(option) && !option.hidden; isEnabled(option) && !option.hidden;
const visibleOptions = createMemo(() => const visibleOptions = createMemo(() =>
entries().filter((option) => isVisible(option)), entries().filter((option) => isVisible(option)),
); );
const suggestedOptions = createMemo(() => const suggestedOptions = createMemo(() =>
visibleOptions() visibleOptions()
.filter((option) => option.suggested) .filter((option) => option.suggested)
.map((option) => ({ .map((option) => ({
...option, ...option,
value: `suggested:${option.value}`, value: `suggested:${option.value}`,
category: "Suggested", category: "Suggested",
})), })),
); );
const suspended = () => suspendCount() > 0; const suspended = () => suspendCount() > 0;
// Handle keybind shortcuts useKeyboard((evt) => {
useKeyboard((evt) => { if (suspended()) return;
if (suspended()) return; if (dialog.isOpen) return;
if (dialog.isOpen) return; for (const option of entries()) {
for (const option of entries()) { if (!isEnabled(option)) continue;
if (!isEnabled(option)) continue; if (option.keybind && keybind.match(option.keybind, evt)) {
if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault();
evt.preventDefault(); option.onSelect?.(dialog);
option.onSelect?.(dialog); emit("command.execute", { command: option.value });
emit("command.execute", { command: option.value }); return;
return; }
} }
} });
});
const result = { const result = {
/** /**
* Trigger a command by its value. * Trigger a command by its value.
*/ */
trigger(name: string) { trigger(name: string) {
for (const option of entries()) { for (const option of entries()) {
if (option.value === name) { if (option.value === name) {
if (!isEnabled(option)) return; if (!isEnabled(option)) return;
option.onSelect?.(dialog); option.onSelect?.(dialog);
emit("command.execute", { command: name }); emit("command.execute", { command: name });
return; return;
} }
} }
}, },
/** /**
* Get all slash commands. * Get all slash commands.
*/ */
slashes() { slashes() {
return visibleOptions().flatMap((option) => { return visibleOptions().flatMap((option) => {
const slash = option.slash; const slash = option.slash;
if (!slash) return []; if (!slash) return [];
return { return {
display: "/" + slash.name, display: "/" + slash.name,
description: option.description ?? option.title, description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias), aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value), onSelect: () => result.trigger(option.value),
}; };
}); });
}, },
/** /**
* Enable/disable keybinds temporarily. * Enable/disable keybinds temporarily.
*/ */
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1)); setSuspendCount((count) => count + (enabled ? -1 : 1));
}, },
suspended, suspended,
/** /**
* Show the command palette dialog. * Show the command palette dialog.
*/ */
show() { show() {
dialog.replace(() => ( dialog.replace(() => (
<CommandDialog <CommandDialog
options={visibleOptions()} options={visibleOptions()}
suggestedOptions={suggestedOptions()} suggestedOptions={suggestedOptions()}
/> />
)); ));
}, },
/** /**
* Register commands. Returns cleanup function. * Register commands. Returns cleanup function.
*/ */
register(cb: () => CommandOption[]) { register(cb: () => CommandOption[]) {
const results = createMemo(cb); const results = createMemo(cb);
setRegistrations((arr) => [results, ...arr]); setRegistrations((arr) => [results, ...arr]);
onCleanup(() => { onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results)); setRegistrations((arr) => arr.filter((x) => x !== results));
}); });
}, },
/** /**
* Get all visible options. * Get all visible options.
*/ */
get options() { get options() {
return visibleOptions(); return visibleOptions();
}, },
}; };
return result; return result;
} }
export function useCommandDialog() { export function useCommandDialog() {
const value = useContext(ctx); const value = useContext(ctx);
if (!value) { if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider"); throw new Error("useCommandDialog must be used within a CommandProvider");
} }
return value; return value;
} }
export function CommandProvider(props: ParentProps) { export function CommandProvider(props: ParentProps) {
const value = init(); const value = init();
const dialog = useDialog(); const dialog = useDialog();
const keybind = useKeybinds(); const keybind = useKeybinds();
// Open command palette on ctrl+p or command_list keybind // Open the command palette via the `command` keybind (bound to `:` or `q`
useKeyboard((evt) => { // in keybinds.jsonc; the Shell router owns the action and runs it first).
if (value.suspended()) return; useKeyboard((evt) => {
if (dialog.isOpen) return; if (value.suspended()) return;
if (evt.defaultPrevented) return; if (dialog.isOpen) return;
if (keybind.match("command_list", evt)) { if (evt.defaultPrevented) return;
evt.preventDefault(); if (keybind.match("command", evt)) {
value.show(); evt.preventDefault();
return; 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. * Command palette dialog component.
*/ */
function CommandDialog(props: { function CommandDialog(props: {
options: CommandOption[]; options: CommandOption[];
suggestedOptions: CommandOption[]; suggestedOptions: CommandOption[];
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
const dialog = useDialog(); const dialog = useDialog();
const dimensions = useTerminalDimensions(); const dimensions = useTerminalDimensions();
const [filter, setFilter] = createSignal(""); const [filter, setFilter] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0); const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredOptions = createMemo(() => { const filteredOptions = createMemo(() => {
const query = filter().toLowerCase(); const query = filter().toLowerCase();
if (!query) { if (!query) {
return [...props.suggestedOptions, ...props.options]; return [...props.suggestedOptions, ...props.options];
} }
return props.options.filter( return props.options.filter(
(option) => (option) =>
option.title.toLowerCase().includes(query) || option.title.toLowerCase().includes(query) ||
option.description?.toLowerCase().includes(query) || option.description?.toLowerCase().includes(query) ||
option.category?.toLowerCase().includes(query), option.category?.toLowerCase().includes(query),
); );
}); });
// Reset selection when filter changes // Reset selection when filter changes
createMemo(() => { createMemo(() => {
filter(); filter();
setSelectedIndex(0); setSelectedIndex(0);
}); });
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "escape") { if (evt.name === "escape") {
dialog.clear(); dialog.clear();
evt.preventDefault(); evt.preventDefault();
return; return;
} }
if (evt.name === "return" || evt.name === "enter") { if (evt.name === "return" || evt.name === "enter") {
const option = filteredOptions()[selectedIndex()]; const option = filteredOptions()[selectedIndex()];
if (option) { if (option) {
option.onSelect?.(dialog); option.onSelect?.(dialog);
dialog.clear(); dialog.clear();
} }
evt.preventDefault(); evt.preventDefault();
return; return;
} }
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) { if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
setSelectedIndex((i) => Math.max(0, i - 1)); setSelectedIndex((i) => Math.max(0, i - 1));
evt.preventDefault(); evt.preventDefault();
return; return;
} }
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) { if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1)); setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
evt.preventDefault(); evt.preventDefault();
return; return;
} }
// Handle text input if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { setFilter((f) => f + evt.name);
setFilter((f) => f + evt.name); return;
return; }
}
if (evt.name === "backspace") { if (evt.name === "backspace") {
setFilter((f) => f.slice(0, -1)); setFilter((f) => f.slice(0, -1));
return; return;
} }
}); });
const maxHeight = Math.floor(dimensions().height * 0.6); const maxHeight = Math.floor(dimensions().height * 0.6);
return ( return (
<box flexDirection="column" padding={1} borderColor={theme.border}> <box flexDirection="column" padding={1} borderColor={theme.border}>
{/* Search input */} {/* Search input */}
<box marginBottom={1}> <box marginBottom={1}>
<text fg={theme.textMuted}>{"> "}</text> <text fg={theme.textMuted}>{"> "}</text>
<text fg={theme.text}>{filter() || "Type to search commands..."}</text> <text fg={theme.text}>{filter() || "Type to search commands..."}</text>
</box> </box>
{/* Command list */} {/* Command list */}
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}> <box
<For each={filteredOptions().slice(0, 10)}> flexDirection="column"
{(option, index) => ( maxHeight={maxHeight}
<SelectableBox borderColor={theme.border}
selected={() => index() === selectedIndex()} >
flexDirection="column" <For each={filteredOptions().slice(0, 10)}>
padding={1} {(option, index) => (
onMouseDown={() => { <SelectableBox
setSelectedIndex(index()); selected={() => index() === selectedIndex()}
const selectedOption = filteredOptions()[index()]; flexDirection="column"
if (selectedOption) { padding={1}
selectedOption.onSelect?.(dialog); onMouseDown={() => {
dialog.clear(); setSelectedIndex(index());
} const selectedOption = filteredOptions()[index()];
}} if (selectedOption) {
> selectedOption.onSelect?.(dialog);
<box flexDirection="column" flexGrow={1}> dialog.clear();
<SelectableText }
selected={() => index() === selectedIndex()} }}
primary >
> <box flexDirection="column" flexGrow={1}>
{option.title} <SelectableText
</SelectableText> selected={() => index() === selectedIndex()}
<Show when={option.footer}> primary
<SelectableText >
selected={() => index() === selectedIndex()} {option.title}
tertiary </SelectableText>
> <Show when={option.footer}>
{option.footer} <SelectableText
</SelectableText> selected={() => index() === selectedIndex()}
</Show> tertiary
<Show when={option.description}> >
<SelectableText {option.footer}
selected={() => index() === selectedIndex()} </SelectableText>
tertiary </Show>
> <Show when={option.description}>
{option.description} <SelectableText
</SelectableText> selected={() => index() === selectedIndex()}
</Show> tertiary
</box> >
</SelectableBox> {option.description}
)} </SelectableText>
</For> </Show>
<Show when={filteredOptions().length === 0}> </box>
<text fg={theme.textMuted} style={{ padding: 1 }}> </SelectableBox>
No commands found )}
</text> </For>
</Show> <Show when={filteredOptions().length === 0}>
</box> <text fg={theme.textMuted} style={{ padding: 1 }}>
</box> No commands found
); </text>
</Show>
</box>
</box>
);
} }

View File

@@ -12,7 +12,7 @@ export type DialogSize = "medium" | "large"
/** /**
* Dialog component that renders a modal overlay with content. * Dialog component that renders a modal overlay with content.
*/ */
export function Dialog( function Dialog(
props: ParentProps<{ props: ParentProps<{
size?: DialogSize size?: DialogSize
onClose: () => void onClose: () => void

View File

@@ -1,158 +1,153 @@
/** /**
* App state persistence via JSON file in XDG_CONFIG_HOME * App state persistence — settings, preferences, and custom theme are stored
* in the centralized `config.json` (see utils/config.ts). Playback progress
* and audio-nav state stay in separate files (they change on every seek and
* would thrash config.json).
* *
* Reads and writes app settings, preferences, and custom theme to a JSON file * No backups — writes always overwrite.
*/ */
import { ensureConfigDir, getConfigFilePath } from "./config-dir"; import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup"; import { loadConfig, updateConfig } from "./config";
import type { import type {
AppState, AppState,
AppSettings, AppSettings,
UserPreferences, UserPreferences,
VisualizerSettings, VisualizerSettings,
} from "../types/settings"; } from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes"; import { DEFAULT_THEME } from "../constants/themes";
const APP_STATE_FILE = "app-state.json";
const PROGRESS_FILE = "progress.json";
const AUDIO_NAV_FILE = "audio-nav.json";
// --- Defaults --- // --- Defaults ---
const defaultVisualizerSettings: VisualizerSettings = { const defaultVisualizerSettings: VisualizerSettings = {
bars: 32, bars: 32,
sensitivity: 1, sensitivity: 1,
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
}; };
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings, transparentBackground: false,
visualizer: defaultVisualizerSettings,
}; };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
autoJumpToPlayer: true,
}; };
const defaultState: AppState = { const defaultState: AppState = {
settings: defaultSettings, settings: defaultSettings,
preferences: defaultPreferences, preferences: defaultPreferences,
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
}; };
// --- App State --- // ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from JSON file */ /** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> { export async function loadAppStateFromFile(): Promise<AppState> {
try { try {
const filePath = getConfigFilePath(APP_STATE_FILE); const cfg = await loadConfig();
const file = Bun.file(filePath); if (!cfg || typeof cfg !== "object") return defaultState;
if (!(await file.exists())) return defaultState; return {
settings: { ...defaultSettings, ...cfg.settings },
const raw = await file.json(); preferences: { ...defaultPreferences, ...cfg.preferences },
if (!raw || typeof raw !== "object") return defaultState; customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
};
const parsed = raw as Partial<AppState>; } catch {
return { return defaultState;
settings: { ...defaultSettings, ...parsed.settings }, }
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
};
} catch {
return defaultState;
}
} }
/** Save app state to JSON file */ /** Save app state to config.json */
export async function saveAppStateToFile(state: AppState): Promise<void> { export function saveAppStateToFile(state: AppState): void {
try { updateConfig({
await ensureConfigDir(); settings: state.settings,
await backupConfigFile(APP_STATE_FILE); preferences: state.preferences,
const filePath = getConfigFilePath(APP_STATE_FILE); customTheme: state.customTheme,
await Bun.write(filePath, JSON.stringify(state, null, 2)); });
} catch {
// Silently ignore write errors
}
} }
// ── Playback Progress (separate file — changes on every seek) ───────────────
const PROGRESS_FILE = "progress.json";
interface ProgressEntry { interface ProgressEntry {
episodeId: string; episodeId: string;
position: number; position: number;
duration: number; duration: number;
timestamp: string | Date; timestamp: string | Date;
playbackSpeed?: number; playbackSpeed?: number;
} }
/** Load progress map from JSON file */ /** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise< export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry> Record<string, ProgressEntry>
> { > {
try { try {
const filePath = getConfigFilePath(PROGRESS_FILE); const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (!(await file.exists())) return {}; if (!(await file.exists())) return {};
const raw = await file.json(); const raw = await file.json();
if (!raw || typeof raw !== "object") return {}; if (!raw || typeof raw !== "object") return {};
return raw as Record<string, ProgressEntry>; return raw as Record<string, ProgressEntry>;
} catch { } catch {
return {}; return {};
} }
} }
/** Save progress map to JSON file */ /** Save progress map to JSON file (overwrite, no backup) */
export async function saveProgressToFile( export function saveProgressToFile(data: Record<string, unknown>): void {
data: Record<string, unknown>, (async () => {
): Promise<void> { try {
try { await ensureConfigDir();
await ensureConfigDir(); await Bun.write(
await backupConfigFile(PROGRESS_FILE); getConfigFilePath(PROGRESS_FILE),
const filePath = getConfigFilePath(PROGRESS_FILE); JSON.stringify(data, null, 2),
await Bun.write(filePath, JSON.stringify(data, null, 2)); );
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
})();
} }
interface AudioNavEntry { // ── Audio Nav State (separate file — changes on every track change) ──────────
source: string;
currentIndex: number; const AUDIO_NAV_FILE = "audio-nav.json";
podcastId?: string;
lastUpdated: string;
}
/** Load audio navigation state from JSON file */ /** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> { export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try { try {
const filePath = getConfigFilePath(AUDIO_NAV_FILE); const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
const file = Bun.file(filePath); if (!(await file.exists())) return null;
if (!(await file.exists())) return null;
const raw = await file.json(); const raw = await file.json();
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;
return raw as T; return raw as T;
} catch { } catch {
return null; return null;
} }
} }
/** Save audio navigation state to JSON file */ /** Save audio navigation state to JSON file (overwrite, no backup) */
export async function saveAudioNavToFile<T>( export function saveAudioNavToFile<T>(data: T): void {
data: T, (async () => {
): Promise<void> { try {
try { await ensureConfigDir();
await ensureConfigDir(); await Bun.write(
const filePath = getConfigFilePath(AUDIO_NAV_FILE); getConfigFilePath(AUDIO_NAV_FILE),
await Bun.write(filePath, JSON.stringify(data, null, 2)); JSON.stringify(data, null, 2),
} catch { );
// Silently ignore write errors } catch {
} // Silently ignore write errors
}
})();
} }

View File

@@ -1,14 +1,11 @@
/** /**
* Cross-platform audio playback engine for PodTUI. * Audio playback engine for PodTUI.
* *
* Backend priority: * Single backend: mpv — full IPC control (seek, volume, speed, position
* 1. mpv — full IPC control (seek, volume, speed, position tracking) * tracking), so speed/volume/seek changes apply instantly with no process
* 2. ffplay — basic control via process signals * restart. When mpv isn't installed there is no fallback: the no-op backend
* 3. afplay — macOS built-in (no seek/speed, volume only) * surfaces "No audio player found" honestly rather than degrading through
* 4. system — open/xdg-open/start (fire-and-forget, no control) * players that can't change speed/volume without restarting.
*
* All backends implement the AudioBackend interface so the Player
* component doesn't need to care which one is active.
*/ */
import { platform } from "os"; import { platform } from "os";
@@ -18,7 +15,7 @@ import { join } from "path";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none"; export type BackendName = "mpv" | "none";
export interface AudioState { export interface AudioState {
playing: boolean; playing: boolean;
@@ -78,7 +75,7 @@ function mpvSocketPath(): string {
// ── mpv Backend ────────────────────────────────────────────────────── // ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control. // Uses JSON IPC over a Unix socket for full bidirectional control.
class MpvBackend implements AudioBackend { export class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv"; readonly name: BackendName = "mpv";
private proc: ReturnType<typeof Bun.spawn> | null = null; private proc: ReturnType<typeof Bun.spawn> | null = null;
private socketPath = mpvSocketPath(); 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 ──────────────────────────────────────────────────── // ── No-op Backend ────────────────────────────────────────────────────
class NoopBackend implements AudioBackend { 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; return players;
} }
@@ -953,14 +442,7 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
// An explicit `preferred` argument still wins. // An explicit `preferred` argument still wins.
if (!preferred) { if (!preferred) {
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined; const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
if ( if (envPref && (envPref === "mpv" || envPref === "none")) {
envPref &&
(envPref === "mpv" ||
envPref === "ffplay" ||
envPref === "afplay" ||
envPref === "system" ||
envPref === "none")
) {
preferred = envPref; preferred = envPref;
} }
} }
@@ -970,25 +452,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend; if (backend) return backend;
} }
// Auto-detect in priority order return which("mpv") ? new MpvBackend() : new NoopBackend();
const players = detectPlayers();
if (players.length === 0) return new NoopBackend();
return createBackendByName(players[0].name) ?? new NoopBackend();
} }
function createBackendByName(name: BackendName): AudioBackend | null { function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) { switch (name) {
case "mpv": case "mpv":
return which("mpv") ? new MpvBackend() : null; 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": case "none":
return new NoopBackend(); return new NoopBackend();
} }

View File

@@ -11,18 +11,18 @@
*/ */
/** PCM output format constants */ /** PCM output format constants */
const SAMPLE_RATE = 44100 const SAMPLE_RATE = 44100;
const CHANNELS = 1 const CHANNELS = 1;
const BYTES_PER_SAMPLE = 2 // s16le const BYTES_PER_SAMPLE = 2; // s16le
/** How many samples to buffer (~1 second) */ /** How many samples to buffer (~1 second) */
const RING_BUFFER_SAMPLES = SAMPLE_RATE const RING_BUFFER_SAMPLES = SAMPLE_RATE;
export interface AudioStreamReaderOptions { export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */ /** Audio URL or file path to decode */
url: string url: string;
/** Sample rate (default: 44100) */ /** Sample rate (default: 44100) */
sampleRate?: number sampleRate?: number;
} }
/** /**
@@ -30,202 +30,230 @@ export interface AudioStreamReaderOptions {
* Each start() increments this; the read loop checks it to know * Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out. * if it's been superseded and should bail out.
*/ */
let globalGeneration = 0 let globalGeneration = 0;
export class AudioStreamReader { export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null private proc: ReturnType<typeof Bun.spawn> | null = null;
private ringBuffer: Float64Array private ringBuffer: Float64Array;
private writePos = 0 private writePos = 0;
private totalSamplesWritten = 0 private totalSamplesWritten = 0;
private _running = false private _running = false;
private generation = 0 private generation = 0;
readonly url: string readonly url: string;
private sampleRate: number private sampleRate: number;
constructor(options: AudioStreamReaderOptions) { constructor(options: AudioStreamReaderOptions) {
this.url = options.url this.url = options.url;
this.sampleRate = options.sampleRate ?? SAMPLE_RATE this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES) this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
} }
/** Whether the reader is actively reading samples. */ /** Whether the reader is actively reading samples. */
get running(): boolean { get running(): boolean {
return this._running return this._running;
} }
/** Total number of samples written since start(). */ /** Total number of samples written since start(). */
get samplesWritten(): number { get samplesWritten(): number {
return this.totalSamplesWritten return this.totalSamplesWritten;
} }
/** /**
* Start the ffmpeg decode process and begin reading PCM data. * Start the ffmpeg decode process and begin reading PCM data.
* *
* If already running, the previous process is killed first. * If already running, the previous process is killed first.
* Uses a generation counter to guarantee that only one read loop * Uses a generation counter to guarantee that only one read loop
* is ever active — stale loops from killed processes bail out * is ever active — stale loops from killed processes bail out
* immediately. * immediately.
* *
* @param startPosition Seek position in seconds (default: 0). * @param startPosition Seek position in seconds (default: 0).
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg * @param speed Playback speed multiplier (default: 1). Applies ffmpeg
* atempo filter so visualization stays in sync with audio. * atempo filter so visualization stays in sync with audio.
*/ */
start(startPosition = 0, speed = 1): void { start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running // Always kill the previous process first — no early return on _running
this.killProcess() this.killProcess();
if (!Bun.which("ffmpeg")) { if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization") throw new Error("ffmpeg not found — required for audio visualization");
} }
// Increment generation so any lingering read loop from a previous // Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit. // start() will see a mismatch and exit.
this.generation = ++globalGeneration this.generation = ++globalGeneration;
const args = [ const args = [
"ffmpeg", "ffmpeg",
"-loglevel", "quiet", "-loglevel",
"-reconnect", "1", "quiet",
"-reconnect_streamed", "1", // Read input at native frame rate so decoded PCM stays in sync with
"-reconnect_delay_max", "5", // 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 // Seek before input for network efficiency
if (startPosition > 0) { if (startPosition > 0) {
args.push("-ss", String(startPosition)) args.push("-ss", String(startPosition));
} }
args.push("-i", this.url) args.push("-i", this.url);
// Apply speed via atempo filter if not 1x. // Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes. // ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) { if (speed !== 1 && speed > 0) {
args.push("-af", buildAtempoChain(speed)) args.push("-af", buildAtempoChain(speed));
} }
args.push( args.push(
"-ac", String(CHANNELS), "-ac",
"-ar", String(this.sampleRate), String(CHANNELS),
"-f", "s16le", "-ar",
"-acodec", "pcm_s16le", String(this.sampleRate),
"-", "-f",
) "s16le",
"-acodec",
"pcm_s16le",
"-",
);
this.proc = Bun.spawn(args, { this.proc = Bun.spawn(args, {
stdout: "pipe", stdout: "pipe",
stderr: "ignore", stderr: "ignore",
stdin: "ignore", stdin: "ignore",
}) });
this._running = true this._running = true;
this.writePos = 0 this.writePos = 0;
this.totalSamplesWritten = 0 this.totalSamplesWritten = 0;
// Capture generation for this run const myGeneration = this.generation;
const myGeneration = this.generation
// Start async reading loop this.readLoop(myGeneration);
this.readLoop(myGeneration)
// Detect process exit // Detect process exit
this.proc.exited.then(() => { this.proc.exited
// Only clear _running if this is still the current generation .then(() => {
if (this.generation === myGeneration) { // Only clear _running if this is still the current generation
this._running = false if (this.generation === myGeneration) {
} this._running = false;
}).catch(() => { }
if (this.generation === myGeneration) { })
this._running = false .catch(() => {
} if (this.generation === myGeneration) {
}) this._running = false;
} }
});
}
/** /**
* Read available samples into the provided buffer. * Read available samples into the provided buffer.
* Returns the number of samples actually copied. * Returns the number of samples actually copied.
* *
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore). * @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
* @returns Number of samples written to `out`. * @returns Number of samples written to `out`.
*/ */
read(out: Float64Array): number { read(out: Float64Array): number {
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length) const available = Math.min(
if (available <= 0) return 0 out.length,
this.totalSamplesWritten,
this.ringBuffer.length,
);
if (available <= 0) return 0;
// Read the most recent `available` samples from the ring buffer // Read the most recent `available` samples from the ring buffer
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length const readStart =
(this.writePos - available + this.ringBuffer.length) %
this.ringBuffer.length;
if (readStart + available <= this.ringBuffer.length) { if (readStart + available <= this.ringBuffer.length) {
out.set(this.ringBuffer.subarray(readStart, readStart + available)) out.set(this.ringBuffer.subarray(readStart, readStart + available));
} else { } else {
const firstChunk = this.ringBuffer.length - readStart const firstChunk = this.ringBuffer.length - readStart;
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length)) out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk) out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
} }
return available return available;
} }
/** /**
* Stop the ffmpeg process and clean up. * Stop the ffmpeg process and clean up.
* Safe to call multiple times. Guarantees the read loop exits. * Safe to call multiple times. Guarantees the read loop exits.
*/ */
stop(): void { stop(): void {
// Bump generation to invalidate any running read loop // Bump generation to invalidate any running read loop
this.generation = ++globalGeneration this.generation = ++globalGeneration;
this._running = false this._running = false;
this.killProcess() this.killProcess();
this.writePos = 0 this.writePos = 0;
this.totalSamplesWritten = 0 this.totalSamplesWritten = 0;
} }
/** /**
* Restart the reader at a new position and/or speed. * Restart the reader at a new position and/or speed.
*/ */
restart(startPosition = 0, speed = 1): void { restart(startPosition = 0, speed = 1): void {
this.start(startPosition, speed) this.start(startPosition, speed);
} }
/** Kill the ffmpeg process without touching generation/state. */ /** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void { private killProcess(): void {
if (this.proc) { if (this.proc) {
try { this.proc.kill() } catch { /* ignore */ } try {
this.proc = null this.proc.kill();
} } catch {
} /* ignore */
}
this.proc = null;
}
}
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */ /** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
private async readLoop(myGeneration: number): Promise<void> { private async readLoop(myGeneration: number): Promise<void> {
const stdout = this.proc?.stdout const stdout = this.proc?.stdout;
if (!stdout || typeof stdout === "number") return if (!stdout || typeof stdout === "number") return;
const reader = (stdout as ReadableStream<Uint8Array>).getReader() const reader = (stdout as ReadableStream<Uint8Array>).getReader();
try { try {
while (this.generation === myGeneration) { while (this.generation === myGeneration) {
const { done, value } = await reader.read() const { done, value } = await reader.read();
if (done || this.generation !== myGeneration) break if (done || this.generation !== myGeneration) break;
if (!value || value.byteLength === 0) continue if (!value || value.byteLength === 0) continue;
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE) const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
if (sampleCount === 0) continue if (sampleCount === 0) continue;
const int16View = new Int16Array( const int16View = new Int16Array(
value.buffer, value.buffer,
value.byteOffset, value.byteOffset,
sampleCount, sampleCount,
) );
for (let i = 0; i < sampleCount; i++) { for (let i = 0; i < sampleCount; i++) {
this.ringBuffer[this.writePos] = int16View[i] this.ringBuffer[this.writePos] = int16View[i];
this.writePos = (this.writePos + 1) % this.ringBuffer.length this.writePos = (this.writePos + 1) % this.ringBuffer.length;
this.totalSamplesWritten++ this.totalSamplesWritten++;
} }
} }
} catch { } catch {
// Stream ended or process killed — expected during stop() // Stream ended or process killed — expected during stop()
} finally { } finally {
try { reader.releaseLock() } catch { /* ignore */ } try {
} reader.releaseLock();
} } catch {
/* ignore */
}
}
}
} }
/** /**
@@ -234,18 +262,18 @@ export class AudioStreamReader {
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5). * multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
*/ */
function buildAtempoChain(speed: number): string { function buildAtempoChain(speed: number): string {
const parts: string[] = [] const parts: string[] = [];
let remaining = Math.max(0.25, Math.min(4, speed)) let remaining = Math.max(0.25, Math.min(4, speed));
while (remaining > 100) { while (remaining > 100) {
parts.push("atempo=100.0") parts.push("atempo=100.0");
remaining /= 100 remaining /= 100;
} }
while (remaining < 0.5) { while (remaining < 0.5) {
parts.push("atempo=0.5") parts.push("atempo=0.5");
remaining /= 0.5 remaining /= 0.5;
} }
parts.push(`atempo=${remaining}`) parts.push(`atempo=${remaining}`);
return parts.join(",") return parts.join(",");
} }

View File

@@ -1,103 +0,0 @@
/**
* Audio waveform analysis for PodTUI
*
* Extracts amplitude data from audio files using ffmpeg (when available)
* Results are cache in-memory keyed by audio URL.
*/
/** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>();
/**
* Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails.
*/
async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try {
if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn(
[
"ffmpeg",
"-i",
audioUrl,
"-t",
"300",
"-ac",
"1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-",
],
{ stdout: "pipe", stderr: "ignore" },
);
const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
if (output.byteLength === 0) return null;
const samples = new Int16Array(output);
if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [];
for (let i = 0; i < resolution; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs;
}
// Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3)));
}
return data;
} catch {
return null;
}
}
/**
* Get waveform data for an audio URL.
*
* Returns cached data if available, otherwise attempts ffmpeg extraction
*/
export async function getWaveformData(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey);
if (cached) return cached;
const real = await extractWithFfmpeg(audioUrl, resolution);
if (real) {
waveformCache.set(cacheKey, real);
return real;
} else {
console.error("generation failure");
return [];
}
}
export function clearWaveformCache(): void {
waveformCache.clear();
}

View File

@@ -1,57 +0,0 @@
type CacheEntry<T> = {
value: T
timestamp: number
}
const CACHE_KEY = "podtui_cache"
const DEFAULT_TTL = 1000 * 60 * 60
const loadCache = (): Record<string, CacheEntry<unknown>> => {
if (typeof localStorage === "undefined") return {}
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
} catch {
return {}
}
}
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
const cache = loadCache()
export const cacheValue = <T,>(key: string, value: T) => {
cache[key] = { value, timestamp: Date.now() }
saveCache(cache)
}
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
const entry = cache[key] as CacheEntry<T> | undefined
if (!entry) return null
if (Date.now() - entry.timestamp > ttl) {
delete cache[key]
saveCache(cache)
return null
}
return entry.value
}
export const invalidateCache = (prefix?: string) => {
if (!prefix) {
Object.keys(cache).forEach((key) => delete cache[key])
saveCache(cache)
return
}
Object.keys(cache)
.filter((key) => key.startsWith(prefix))
.forEach((key) => delete cache[key])
saveCache(cache)
}

View File

@@ -16,170 +16,178 @@
* ``` * ```
*/ */
import { dlopen, FFIType, ptr } from "bun:ffi" import { dlopen, FFIType, ptr } from "bun:ffi";
import { existsSync } from "fs" import { existsSync } from "fs";
import { join, dirname } from "path" import { join, dirname } from "path";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
export interface CavaCoreConfig { export interface CavaCoreConfig {
/** Number of frequency bars (default: 32) */ /** Number of frequency bars (default: 32) */
bars?: number bars?: number;
/** Audio sample rate in Hz (default: 44100) */ /** Audio sample rate in Hz (default: 44100) */
sampleRate?: number sampleRate?: number;
/** Number of audio channels (default: 1 = mono) */ /** Number of audio channels (default: 1 = mono) */
channels?: number channels?: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
autosens?: number autosens?: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */ /** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction?: number noiseReduction?: number;
/** Low frequency cutoff in Hz (default: 50) */ /** Low frequency cutoff in Hz (default: 50) */
lowCutOff?: number lowCutOff?: number;
/** High frequency cutoff in Hz (default: 10000) */ /** High frequency cutoff in Hz (default: 10000) */
highCutOff?: number highCutOff?: number;
/** Output scaling mode: 0 = linear (default), 1 = decibel */
scalingMode?: number;
} }
const DEFAULTS: Required<CavaCoreConfig> = { const DEFAULTS: Required<CavaCoreConfig> = {
bars: 32, bars: 32,
sampleRate: 44100, sampleRate: 44100,
channels: 1, channels: 1,
autosens: 1, autosens: 1,
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
} scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 ─────────────────────────────────────────────── // ── Library resolution ───────────────────────────────────────────────
function findLibrary(): string | null { function findLibrary(): string | null {
const platform = process.platform const platform = process.platform;
const libName = platform === "darwin" const libName =
? "libcavacore.dylib" platform === "darwin"
: platform === "win32" ? "libcavacore.dylib"
? "cavacore.dll" : platform === "win32"
: "libcavacore.so" ? "cavacore.dll"
: "libcavacore.so";
// Candidate paths, in priority order: // Candidate paths, in priority order:
// 1. src/native/ (development) // 1. src/native/ (development)
// 2. Same directory as the running executable (dist bundle) // 2. Same directory as the running executable (dist bundle)
// 3. dist/ relative to cwd // 3. dist/ relative to cwd
const candidates = [ const candidates = [
join(import.meta.dir, "..", "native", libName), join(import.meta.dir, "..", "native", libName),
join(dirname(process.execPath), libName), join(dirname(process.execPath), libName),
join(process.cwd(), "dist", libName), join(process.cwd(), "dist", libName),
] ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (existsSync(candidate)) return candidate if (existsSync(candidate)) return candidate;
} }
return null return null;
} }
// ── CavaCore class ─────────────────────────────────────────────────── // ── CavaCore class ───────────────────────────────────────────────────
export class CavaCore { export class CavaCore {
private lib: CavaLib private lib: CavaLib;
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null;
private inputBuffer: Float64Array | null = null private inputBuffer: Float64Array | null = null;
private outputBuffer: Float64Array | null = null private outputBuffer: Float64Array | null = null;
private _bars = 0 private _bars = 0;
private _channels = 1 private _channels = 1;
private _destroyed = false private _destroyed = false;
/** Use loadCavaCore() instead of constructing directly. */ /** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) { constructor(lib: CavaLib) {
this.lib = lib this.lib = lib;
} }
/** Number of frequency bars configured. */ /** Number of frequency bars configured. */
get bars(): number { get bars(): number {
return this._bars return this._bars;
} }
/** Whether this instance has been initialized (and not yet destroyed). */ /** Whether this instance has been initialized (and not yet destroyed). */
get isReady(): boolean { get isReady(): boolean {
return this.plan !== null && !this._destroyed return this.plan !== null && !this._destroyed;
} }
/** /**
* Initialize the cavacore engine with the given configuration. * Initialize the cavacore engine with the given configuration.
* Must be called before execute(). Can be called again after destroy() * Must be called before execute(). Can be called again after destroy()
* to reinitialize with different parameters. * to reinitialize with different parameters.
*/ */
init(config: CavaCoreConfig = {}): void { init(config: CavaCoreConfig = {}): void {
if (this.plan) { if (this.plan) {
this.destroy() this.destroy();
} }
const cfg = { ...DEFAULTS, ...config } const cfg = { ...DEFAULTS, ...config };
this._bars = cfg.bars this._bars = cfg.bars;
this._channels = cfg.channels this._channels = cfg.channels;
this.plan = this.lib.symbols.cava_init( this.plan = this.lib.symbols.cava_init(
cfg.bars, cfg.bars,
cfg.sampleRate, cfg.sampleRate,
cfg.channels, cfg.channels,
cfg.autosens, cfg.autosens,
cfg.noiseReduction, cfg.noiseReduction,
cfg.lowCutOff, cfg.lowCutOff,
cfg.highCutOff, cfg.highCutOff,
) cfg.scalingMode,
);
if (!this.plan) { if (!this.plan) {
throw new Error("cava_init returned null — initialization failed") throw new Error("cava_init returned null — initialization failed");
} }
// Pre-allocate output buffer (bars * channels) // Pre-allocate output buffer (bars * channels)
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels) this.outputBuffer = new Float64Array(cfg.bars * cfg.channels);
this._destroyed = false this._destroyed = false;
} }
/** /**
* Feed PCM samples into cavacore and get frequency bar values back. * Feed PCM samples into cavacore and get frequency bar values back.
* *
* @param samples - Float64Array of PCM samples (scaled ~±32768). * @param samples - Float64Array of PCM samples (scaled ~±32768).
* The array length determines the number of samples processed. * The array length determines the number of samples processed.
* @returns Float64Array of bar values (0.01.0 range, length = bars * channels). * @returns Float64Array of bar values (0.01.0 range, length = bars * channels).
* Returns the same buffer reference each call (overwritten in place). * Returns the same buffer reference each call (overwritten in place).
*/ */
execute(samples: Float64Array): Float64Array { execute(samples: Float64Array): Float64Array {
if (!this.plan || !this.outputBuffer) { if (!this.plan || !this.outputBuffer) {
throw new Error("CavaCore not initialized — call init() first") throw new Error("CavaCore not initialized — call init() first");
} }
// Reuse input buffer if same size, otherwise allocate new // Reuse input buffer if same size, otherwise allocate new
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) { if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
this.inputBuffer = new Float64Array(samples.length) this.inputBuffer = new Float64Array(samples.length);
} }
this.inputBuffer.set(samples) this.inputBuffer.set(samples);
this.lib.symbols.cava_execute( this.lib.symbols.cava_execute(
ptr(this.inputBuffer), ptr(this.inputBuffer),
samples.length, samples.length,
ptr(this.outputBuffer), ptr(this.outputBuffer),
this.plan, this.plan,
) );
return this.outputBuffer return this.outputBuffer;
} }
/** /**
* Release all native resources. Safe to call multiple times. * Release all native resources. Safe to call multiple times.
* After calling destroy(), init() can be called again to reuse the instance. * After calling destroy(), init() can be called again to reuse the instance.
*/ */
destroy(): void { destroy(): void {
if (this.plan && !this._destroyed) { if (this.plan && !this._destroyed) {
this.lib.symbols.cava_destroy(this.plan) this.lib.symbols.cava_destroy(this.plan);
this.plan = null this.plan = null;
this._destroyed = true this._destroyed = true;
} }
this.inputBuffer = null this.inputBuffer = null;
this.outputBuffer = null this.outputBuffer = null;
} }
} }
// ── Factory ────────────────────────────────────────────────────────── // ── Factory ──────────────────────────────────────────────────────────
@@ -190,41 +198,42 @@ export class CavaCore {
* to the static waveform display. * to the static waveform display.
*/ */
export function loadCavaCore(): CavaCore | null { export function loadCavaCore(): CavaCore | null {
try { try {
const libPath = findLibrary() const libPath = findLibrary();
if (!libPath) return null if (!libPath) return null;
const lib = dlopen(libPath, { const lib = dlopen(libPath, {
cava_init: { cava_init: {
args: [ args: [
FFIType.i32, // bars FFIType.i32, // bars
FFIType.u32, // rate FFIType.u32, // rate
FFIType.i32, // channels FFIType.i32, // channels
FFIType.i32, // autosens FFIType.i32, // autosens
FFIType.double, // noise_reduction FFIType.double, // noise_reduction
FFIType.i32, // low_cut_off FFIType.i32, // low_cut_off
FFIType.i32, // high_cut_off FFIType.i32, // high_cut_off
], FFIType.i32, // scaling_mode
returns: FFIType.ptr, ],
}, returns: FFIType.ptr,
cava_execute: { },
args: [ cava_execute: {
FFIType.ptr, // cava_in (double*) args: [
FFIType.i32, // samples FFIType.ptr, // cava_in (double*)
FFIType.ptr, // cava_out (double*) FFIType.i32, // samples
FFIType.ptr, // plan FFIType.ptr, // cava_out (double*)
], FFIType.ptr, // plan
returns: FFIType.void, ],
}, returns: FFIType.void,
cava_destroy: { },
args: [FFIType.ptr], // plan cava_destroy: {
returns: FFIType.void, args: [FFIType.ptr], // plan
}, returns: FFIType.void,
}) },
});
return new CavaCore(lib as CavaLib) return new CavaCore(lib as CavaLib);
} catch { } catch {
// Library load failed — missing dylib, wrong arch, etc. // Library load failed — missing dylib, wrong arch, etc.
return null return null;
} }
} }

View File

@@ -106,7 +106,7 @@ export namespace Clipboard {
/** /**
* Read text from the clipboard. * Read text from the clipboard.
*/ */
export async function readText(): Promise<string | undefined> { async function readText(): Promise<string | undefined> {
const os = platform() const os = platform()
if (os === "darwin") { if (os === "darwin") {

View File

@@ -1,96 +0,0 @@
/**
* Config file backup utility for PodTUI
*
* Creates timestamped backups of config files before updates.
* Keeps the most recent N backups and cleans up older ones.
*/
import { readdir, unlink } from "fs/promises"
import path from "path"
import { getConfigDir, ensureConfigDir } from "./config-dir"
/** Maximum number of backup files to keep per config file */
const MAX_BACKUPS = 5
/**
* Generate a timestamped backup filename.
* Example: feeds.json -> feeds.json.2026-02-05T120000.backup
*/
function backupFilename(originalName: string): string {
const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
return `${originalName}.${ts}.backup`
}
/**
* Create a backup of a config file before overwriting it.
* No-op if the source file does not exist.
*/
export async function backupConfigFile(filename: string): Promise<boolean> {
try {
await ensureConfigDir()
const dir = getConfigDir()
const srcPath = path.join(dir, filename)
const srcFile = Bun.file(srcPath)
if (!(await srcFile.exists())) return false
const content = await srcFile.text()
if (!content || content.trim().length === 0) return false
const backupName = backupFilename(filename)
const backupPath = path.join(dir, backupName)
await Bun.write(backupPath, content)
// Clean up old backups
await pruneBackups(filename)
return true
} catch {
return false
}
}
/**
* Keep only the most recent MAX_BACKUPS backup files for a given config file.
*/
async function pruneBackups(filename: string): Promise<void> {
try {
const dir = getConfigDir()
const entries = await readdir(dir)
// Match pattern: filename.*.backup
const prefix = `${filename}.`
const suffix = ".backup"
const backups = entries
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
.sort() // Lexicographic sort works because timestamps are ISO-like
if (backups.length <= MAX_BACKUPS) return
const toRemove = backups.slice(0, backups.length - MAX_BACKUPS)
for (const name of toRemove) {
await unlink(path.join(dir, name)).catch(() => {})
}
} catch {
// Silently ignore cleanup errors
}
}
/**
* List existing backup files for a given config file, newest first.
*/
export async function listBackups(filename: string): Promise<string[]> {
try {
const dir = getConfigDir()
const entries = await readdir(dir)
const prefix = `${filename}.`
const suffix = ".backup"
return entries
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
.sort()
.reverse()
} catch {
return []
}
}

View File

@@ -13,7 +13,7 @@ import path from "path"
const APP_DIR_NAME = "podtui" const APP_DIR_NAME = "podtui"
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */ /** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
export function getXdgConfigHome(): string { function getXdgConfigHome(): string {
const xdg = process.env.XDG_CONFIG_HOME const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return xdg if (xdg) return xdg
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
} }
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */ /** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
export function getXdgDataHome(): string { function getXdgDataHome(): string {
const xdg = process.env.XDG_DATA_HOME const xdg = process.env.XDG_DATA_HOME
if (xdg) return xdg if (xdg) return xdg
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
} }
/** Get the application-specific data directory path */ /** Get the application-specific data directory path */
export function getDataDir(): string { function getDataDir(): string {
return path.join(getXdgDataHome(), APP_DIR_NAME) return path.join(getXdgDataHome(), APP_DIR_NAME)
} }
/** Get the downloads directory path */ /** Get the downloads directory path */
export function getDownloadsDir(): string { function getDownloadsDir(): string {
return path.join(getDataDir(), "downloads") return path.join(getDataDir(), "downloads")
} }

View File

@@ -1,150 +0,0 @@
/**
* Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single
*/
import { getConfigFilePath } from "./config-dir";
// --- Validation helpers ---
/** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
/** Validate AppState JSON structure */
export function validateAppState(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] };
}
// settings
if (data.settings !== undefined) {
if (!isObject(data.settings)) {
errors.push("settings must be an object");
} else {
const s = data.settings as Record<string, unknown>;
if (s.theme !== undefined && typeof s.theme !== "string")
errors.push("settings.theme must be a string");
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
errors.push("settings.fontSize must be a number");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
errors.push("settings.playbackSpeed must be a number");
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
errors.push("settings.downloadPath must be a string");
}
}
// preferences
if (data.preferences !== undefined) {
if (!isObject(data.preferences)) {
errors.push("preferences must be an object");
} else {
const p = data.preferences as Record<string, unknown>;
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
errors.push("preferences.showExplicit must be a boolean");
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
errors.push("preferences.autoDownload must be a boolean");
}
}
// customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object");
}
return { valid: errors.length === 0, errors };
}
/** Validate feeds JSON structure */
export function validateFeeds(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] };
}
for (let i = 0; i < data.length; i++) {
const feed = data[i];
if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`);
continue;
}
if (typeof feed.id !== "string")
errors.push(`feeds[${i}].id must be a string`);
if (!isObject(feed.podcast))
errors.push(`feeds[${i}].podcast must be an object`);
if (!Array.isArray(feed.episodes))
errors.push(`feeds[${i}].episodes must be an array`);
}
return { valid: errors.length === 0, errors };
}
/** Validate progress JSON structure */
export function validateProgress(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] };
}
for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`);
continue;
}
const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string")
errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.position !== "number")
errors.push(`progress["${key}"].position must be a number`);
if (typeof p.duration !== "number")
errors.push(`progress["${key}"].duration must be a number`);
}
return { valid: errors.length === 0, errors };
}
// --- Safe config file reading ---
/**
* Safely read and validate a config file.
* Returns the parsed data if valid, or null if the file is missing/corrupt.
*/
export async function safeReadConfigFile<T>(
filename: string,
validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> {
try {
const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath);
if (!(await file.exists())) {
return { data: null, errors: [] };
}
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { data: null, errors: [`${filename}: invalid JSON`] };
}
const result = validator(parsed);
if (!result.valid) {
return { data: null, errors: result.errors };
}
return { data: parsed as T, errors: [] };
} catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] };
}
}

Some files were not shown because too many files have changed in this diff Show More