Compare commits
17 Commits
25fe7f6ac9
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| c63e9e1b9c | |||
| 0facfff51b | |||
| 3ef19f80b8 | |||
| 13a31aabdc | |||
| 8dbdebfd30 | |||
| 529817323d | |||
| ace883b505 | |||
| de01cedee0 | |||
| 2730fa3cae | |||
| 91a831c5f9 | |||
| 52e9ae0ab7 | |||
| 64d8b40e61 | |||
| 0cc15c8d90 | |||
| 1d3abd53d4 | |||
| 592cfd4093 | |||
| 69e12cf5b9 | |||
| c9e3aa92ec |
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- os: ubuntu-24.04-arm
|
- os: ubuntu-24.04-arm
|
||||||
arch: arm64
|
arch: arm64
|
||||||
plat: linux
|
plat: linux
|
||||||
- os: macos-latest
|
- os: macos-15-intel
|
||||||
arch: x64
|
arch: x64
|
||||||
plat: darwin
|
plat: darwin
|
||||||
- os: macos-14
|
- os: macos-14
|
||||||
@@ -47,6 +47,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
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
|
- name: Build native cavacore library
|
||||||
run: scripts/build-cavacore.sh
|
run: scripts/build-cavacore.sh
|
||||||
|
|
||||||
@@ -57,8 +66,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||||
run: |
|
run: |
|
||||||
tar -xzf dist/$DIST_TAR -C dist
|
# The embedded runtime reads the launching process's CWD bunfig.toml.
|
||||||
./dist/podtui --version
|
# 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
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -80,4 +95,6 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: artifacts/**/*.tar.gz
|
files: |
|
||||||
|
artifacts/**/*.tar.gz
|
||||||
|
LICENSE
|
||||||
|
|||||||
204
CONTRIBUTING.md
Normal file
204
CONTRIBUTING.md
Normal 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-podtui` repo has a scheduled workflow (hourly) that
|
||||||
|
polls GitHub releases, and when a new tag appears, rewrites
|
||||||
|
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||||
|
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
||||||
|
test: `brew install mikefreno/podtui/podtui`.
|
||||||
|
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-podtui>
|
||||||
|
./scripts/sync-formula.sh 0.2.0
|
||||||
|
git commit -am 'podtui 0.2.0' && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local release build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dist # builds the binary + tarball for THIS machine only
|
||||||
|
```
|
||||||
|
|
||||||
|
Bun cannot cross-compile — the other platforms come from CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open items / things to sort out
|
||||||
|
|
||||||
|
- **LICENSE**: `README.md` says "TBD — choose and document a license before
|
||||||
|
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
|
||||||
|
README footer.
|
||||||
|
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||||
|
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||||
|
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||||
|
ships from it directly until a full rebuild replaces it. On other hosts the
|
||||||
|
`make native` build is required — see `scripts/build-cavacore.sh`.
|
||||||
30
LICENSE
Normal file
30
LICENSE
Normal 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.
|
||||||
11
Makefile
11
Makefile
@@ -47,18 +47,19 @@ native:
|
|||||||
scripts/build-cavacore.sh
|
scripts/build-cavacore.sh
|
||||||
|
|
||||||
## Standalone binary + native-libs tarball for the current platform.
|
## Standalone binary + native-libs tarball for the current platform.
|
||||||
## Compiles against an empty bunfig so the binary does not bake the
|
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
|
||||||
## @opentui/solid/preload entry (which would break the compiled executable).
|
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
|
||||||
|
## from a bunfig-free dir (see release.yml).
|
||||||
dist:
|
dist:
|
||||||
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
|
bun run build.ts --compile
|
||||||
|
|
||||||
## macOS build (run on a macOS runner / host).
|
## macOS build (run on a macOS runner / host).
|
||||||
dist-mac:
|
dist-mac:
|
||||||
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
|
bun run build.ts --compile
|
||||||
|
|
||||||
## Linux build (run on a Linux runner / host).
|
## Linux build (run on a Linux runner / host).
|
||||||
dist-linux:
|
dist-linux:
|
||||||
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
|
bun run build.ts --compile
|
||||||
|
|
||||||
## Remove build artifacts.
|
## Remove build artifacts.
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -61,24 +61,45 @@ Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
|||||||
put `podtui` on your `PATH`:
|
put `podtui` on your `PATH`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL -o podtui.tar.gz \
|
curl -sS -o /tmp/podtui.tar.gz \
|
||||||
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
||||||
tar -xzf podtui.tar.gz
|
sudo mkdir -p /opt/podtui
|
||||||
sudo install -m755 podtui /usr/local/bin/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
|
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||||
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||||
> binary alone), or the native FFI libraries won't load.
|
> binary alone), or the native FFI libraries won't load.
|
||||||
|
>
|
||||||
|
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
|
||||||
|
> you launch from. If that file has a `preload` entry (as Bun project
|
||||||
|
> directories often do), startup fails with `preload not found`. Launching
|
||||||
|
> from a normal directory (home, `~/bin`, …) works fine.
|
||||||
|
|
||||||
### 3. Arch Linux (AUR)
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S podtui
|
# Status: PKGBUILD ready, not yet on the AUR (see note below)
|
||||||
|
yay -S podtui-bin # once published
|
||||||
```
|
```
|
||||||
|
|
||||||
or build from the PKGBUILD (`podtui-bin`). The package installs the released
|
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
||||||
binary and its sibling libraries.
|
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
|
### 4. From source
|
||||||
|
|
||||||
@@ -187,9 +208,10 @@ make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
|
|||||||
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
|
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
`make dist` compiles against `bunfig.standalone.toml` (a preload-free Bun
|
`make dist` emits a config-independent binary: Bun does not bake bunfig
|
||||||
config) so the emitted binary doesn't bake in the dev-only `@opentui/solid`
|
settings into `--compile` output, and the solid JSX transform is registered in
|
||||||
preload. The solid JSX transform is registered in `build.ts` itself.
|
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
|
||||||
|
it from any normal directory.
|
||||||
|
|
||||||
## Packaging model
|
## Packaging model
|
||||||
|
|
||||||
@@ -208,7 +230,7 @@ is no cross-compilation.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TBD — choose and document a license before first release.
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
5
build.ts
5
build.ts
@@ -5,9 +5,8 @@ import { plugin } from "bun";
|
|||||||
|
|
||||||
// Register the solid transform globally (dedup'd by name). This is what makes
|
// Register the solid transform globally (dedup'd by name). This is what makes
|
||||||
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins
|
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins
|
||||||
// that are registered via `plugin()`, not the `plugins:` array. The compiled
|
// that are registered via `plugin()`, not the `plugins:` array. The transform
|
||||||
// binary is then built against an empty bunfig (PODTUI_COMPILE config) so the
|
// is fully embedded in the compiled binary.
|
||||||
// runtime bakes NO preload — the solid transform is already in the binary.
|
|
||||||
plugin(solidPlugin);
|
plugin(solidPlugin);
|
||||||
|
|
||||||
const COMPILE =
|
const COMPILE =
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Standalone compile config for `bun build --compile` / `make dist`.
|
|
||||||
#
|
|
||||||
# This file MUST stay free of a `preload` key: Bun bakes bunfig preloads into
|
|
||||||
# compiled binaries as launch metadata, and `@opentui/solid/preload` (used for
|
|
||||||
# `bun run` dev/test) isn't embedded in the standalone, so a baked-in preload
|
|
||||||
# makes the compiled binary fail at startup with:
|
|
||||||
# error: preload not found "@opentui/solid/preload"
|
|
||||||
#
|
|
||||||
# The solid JSX transform is registered in build.ts itself (`plugin(solidPlugin)`),
|
|
||||||
# so compiling against this config needs no global preload. Invoke as:
|
|
||||||
# BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
|
|
||||||
@@ -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
19
cava/LICENSE-cava.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2015 Karl Stavestrand <karl@stavestrand.no>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
588
cava/cavacore.c
Normal file
588
cava/cavacore.c
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
#include "cavacore.h"
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.1415926535897932385
|
||||||
|
#endif
|
||||||
|
#include <fftw3.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
#include <jni.h>
|
||||||
|
struct cava_plan *plan;
|
||||||
|
double *cava_in;
|
||||||
|
double *cava_out;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static double amplitude_to_decibels(double value) {
|
||||||
|
// Magic number 20 comes from converting amplitude ratios to decibels.
|
||||||
|
return 20 * log10(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels, int autosens,
|
||||||
|
double noise_reduction, int low_cut_off, int high_cut_off,
|
||||||
|
int scaling_mode) {
|
||||||
|
struct cava_plan *p = malloc(sizeof(struct cava_plan));
|
||||||
|
p->status = 0;
|
||||||
|
|
||||||
|
// sanity checks:
|
||||||
|
if (channels < 1 || channels > 2) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"cava_init called with illegal number of channels: %d, number of channels "
|
||||||
|
"supported are "
|
||||||
|
"1 and 2",
|
||||||
|
channels);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (rate < 1 || rate > 384000) {
|
||||||
|
snprintf(p->error_message, 1024, "cava_init called with illegal sample rate: %d\n", rate);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fft_buffer_size = 512;
|
||||||
|
|
||||||
|
if (rate > 8125 && rate <= 16250)
|
||||||
|
fft_buffer_size *= 2;
|
||||||
|
else if (rate > 16250 && rate <= 32500)
|
||||||
|
fft_buffer_size *= 4;
|
||||||
|
else if (rate > 32500 && rate <= 75000)
|
||||||
|
fft_buffer_size *= 8;
|
||||||
|
else if (rate > 75000 && rate <= 150000)
|
||||||
|
fft_buffer_size *= 16;
|
||||||
|
else if (rate > 150000 && rate <= 300000)
|
||||||
|
fft_buffer_size *= 32;
|
||||||
|
else if (rate > 300000)
|
||||||
|
fft_buffer_size *= 64;
|
||||||
|
|
||||||
|
if (number_of_bars < 1) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"cava_init called with illegal number of bars: %d, number of channels must be "
|
||||||
|
"positive integer\n",
|
||||||
|
number_of_bars);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (number_of_bars > fft_buffer_size / 2 + 1) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"cava_init called with illegal number of bars: %d, for %d sample rate number of "
|
||||||
|
"bars can't be more than %d\n",
|
||||||
|
number_of_bars, rate, fft_buffer_size / 2 + 1);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (low_cut_off < 1 || high_cut_off < 1) {
|
||||||
|
snprintf(p->error_message, 1024, "low_cut_off must be a positive value\n");
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (low_cut_off >= high_cut_off) {
|
||||||
|
snprintf(p->error_message, 1024, "high_cut_off must be a higher than low_cut_off\n");
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if ((unsigned int)high_cut_off > rate / 2) {
|
||||||
|
snprintf(p->error_message, 1024,
|
||||||
|
"high_cut_off can't be higher than sample rate / 2. (Nyquist Sampling Theorem)\n");
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (scaling_mode != CAVA_SCALING_LINEAR && scaling_mode != CAVA_SCALING_DECIBEL) {
|
||||||
|
snprintf(p->error_message, 1024, "unknown scaling mode: %d\n", scaling_mode);
|
||||||
|
p->status = -1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
p->number_of_bars = number_of_bars;
|
||||||
|
p->audio_channels = channels;
|
||||||
|
p->rate = rate;
|
||||||
|
p->autosens = 1;
|
||||||
|
p->sens_init = 1;
|
||||||
|
p->sens = 1.0;
|
||||||
|
p->autosens = autosens;
|
||||||
|
p->framerate = 75;
|
||||||
|
p->frame_skip = 1;
|
||||||
|
p->noise_reduction = noise_reduction;
|
||||||
|
p->scaling_mode = scaling_mode;
|
||||||
|
|
||||||
|
int fftw_flag = FFTW_MEASURE;
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
fftw_flag = FFTW_ESTIMATE;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
p->FFTbassbufferSize = fft_buffer_size * 2;
|
||||||
|
p->FFTbufferSize = fft_buffer_size;
|
||||||
|
|
||||||
|
p->input_buffer_size = p->FFTbassbufferSize * channels;
|
||||||
|
|
||||||
|
p->input_buffer = (double *)malloc(p->input_buffer_size * sizeof(double));
|
||||||
|
|
||||||
|
p->FFTbuffer_lower_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
|
||||||
|
p->FFTbuffer_upper_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
|
||||||
|
p->eq = (double *)malloc((number_of_bars + 1) * sizeof(double));
|
||||||
|
p->cut_off_frequency = (float *)malloc((number_of_bars + 1) * sizeof(float));
|
||||||
|
|
||||||
|
p->cava_fall = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
p->cava_mem = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
p->cava_peak = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
p->prev_cava_out = (double *)malloc(number_of_bars * channels * sizeof(double));
|
||||||
|
|
||||||
|
// Hann Window calculate multipliers
|
||||||
|
p->bass_multiplier = (double *)malloc(p->FFTbassbufferSize * sizeof(double));
|
||||||
|
p->multiplier = (double *)malloc(p->FFTbufferSize * sizeof(double));
|
||||||
|
for (int i = 0; i < p->FFTbassbufferSize; i++) {
|
||||||
|
p->bass_multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbassbufferSize - 1)));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < p->FFTbufferSize; i++) {
|
||||||
|
p->multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbufferSize - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASS
|
||||||
|
p->in_bass_l = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->in_bass_l_raw = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->out_bass_l = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
|
||||||
|
p->p_bass_l =
|
||||||
|
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_l, p->out_bass_l, fftw_flag);
|
||||||
|
|
||||||
|
// MID + TREBLE
|
||||||
|
p->in_l = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->in_l_raw = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->out_l = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
|
||||||
|
p->p_l = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_l, p->out_l, fftw_flag);
|
||||||
|
|
||||||
|
memset(p->in_bass_l, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_l, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->in_bass_l_raw, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_l_raw, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->out_bass_l, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
memset(p->out_l, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
// BASS
|
||||||
|
p->in_bass_r = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->in_bass_r_raw = fftw_alloc_real(p->FFTbassbufferSize);
|
||||||
|
p->out_bass_r = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
|
||||||
|
p->p_bass_r =
|
||||||
|
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_r, p->out_bass_r, fftw_flag);
|
||||||
|
|
||||||
|
// MID + TREBLE
|
||||||
|
p->in_r = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->in_r_raw = fftw_alloc_real(p->FFTbufferSize);
|
||||||
|
p->out_r = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
|
||||||
|
|
||||||
|
p->p_r = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_r, p->out_r, fftw_flag);
|
||||||
|
|
||||||
|
memset(p->in_bass_r, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_r, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->in_bass_r_raw, 0, sizeof(double) * p->FFTbassbufferSize);
|
||||||
|
memset(p->in_r_raw, 0, sizeof(double) * p->FFTbufferSize);
|
||||||
|
memset(p->out_bass_r, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
memset(p->out_r, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(p->input_buffer, 0, sizeof(double) * p->input_buffer_size);
|
||||||
|
|
||||||
|
memset(p->cava_fall, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
memset(p->cava_mem, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
memset(p->cava_peak, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
memset(p->prev_cava_out, 0, sizeof(double) * number_of_bars * channels);
|
||||||
|
|
||||||
|
// process: calculate cutoff frequencies and eq
|
||||||
|
int lower_cut_off = low_cut_off;
|
||||||
|
int upper_cut_off = high_cut_off;
|
||||||
|
int bass_cut_off = 100;
|
||||||
|
|
||||||
|
// calculate frequency constant (used to distribute bars across the frequency band)
|
||||||
|
double frequency_constant = log10((float)lower_cut_off / (float)upper_cut_off) /
|
||||||
|
(1 / ((float)p->number_of_bars + 1) - 1);
|
||||||
|
|
||||||
|
float *relative_cut_off = (float *)malloc((p->number_of_bars + 1) * sizeof(float));
|
||||||
|
|
||||||
|
p->bass_cut_off_bar = 0;
|
||||||
|
int first_bar = 1;
|
||||||
|
|
||||||
|
float min_bandwidth = p->rate / p->FFTbassbufferSize;
|
||||||
|
|
||||||
|
for (int n = 0; n < p->number_of_bars + 1; n++) {
|
||||||
|
double bar_distribution_coefficient = frequency_constant * (-1);
|
||||||
|
bar_distribution_coefficient +=
|
||||||
|
((float)n + 1) / ((float)p->number_of_bars + 1) * frequency_constant;
|
||||||
|
p->cut_off_frequency[n] = upper_cut_off * pow(10, bar_distribution_coefficient);
|
||||||
|
|
||||||
|
if (n > 0) {
|
||||||
|
if (p->cut_off_frequency[n - 1] >= p->cut_off_frequency[n])
|
||||||
|
p->cut_off_frequency[n] = p->cut_off_frequency[n - 1] + min_bandwidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember nyquist!
|
||||||
|
relative_cut_off[n] = p->cut_off_frequency[n] / (p->rate / 2);
|
||||||
|
|
||||||
|
if (p->cut_off_frequency[n] < bass_cut_off) {
|
||||||
|
// BASS
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = relative_cut_off[n] * (p->FFTbassbufferSize / 2);
|
||||||
|
p->bass_cut_off_bar++;
|
||||||
|
if (p->bass_cut_off_bar > 1)
|
||||||
|
first_bar = 0;
|
||||||
|
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbassbufferSize / 2) {
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = p->FFTbassbufferSize / 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MID + TREBLE
|
||||||
|
p->FFTbuffer_lower_cut_off[n] =
|
||||||
|
ceil(relative_cut_off[n] * (float)(p->FFTbufferSize / 2));
|
||||||
|
if (n == p->bass_cut_off_bar) {
|
||||||
|
first_bar = 1;
|
||||||
|
if (n > 0) {
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] =
|
||||||
|
relative_cut_off[n] * (p->FFTbassbufferSize / 2) - 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
first_bar = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbufferSize / 2) {
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = p->FFTbufferSize / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n > 0) {
|
||||||
|
if (!first_bar) {
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
|
||||||
|
|
||||||
|
// pushing the spectrum up if the exponential function gets "clumped" in the
|
||||||
|
// bass and calculating new cut off frequencies
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n] <= p->FFTbuffer_lower_cut_off[n - 1]) {
|
||||||
|
|
||||||
|
// check if there is room for more first
|
||||||
|
int room_for_more = 0;
|
||||||
|
|
||||||
|
if (n < p->bass_cut_off_bar) {
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbassbufferSize / 2 + 1)
|
||||||
|
room_for_more = 1;
|
||||||
|
} else {
|
||||||
|
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbufferSize / 2 + 1)
|
||||||
|
room_for_more = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room_for_more) {
|
||||||
|
// push the spectrum up
|
||||||
|
p->FFTbuffer_lower_cut_off[n] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (p->FFTbuffer_upper_cut_off[n - 1] < p->FFTbuffer_lower_cut_off[n - 1])
|
||||||
|
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// calculate actual cut off frequency
|
||||||
|
if (n < p->bass_cut_off_bar)
|
||||||
|
relative_cut_off[n] =
|
||||||
|
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbassbufferSize / 2);
|
||||||
|
else
|
||||||
|
relative_cut_off[n] =
|
||||||
|
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbufferSize / 2);
|
||||||
|
|
||||||
|
p->cut_off_frequency[n] = relative_cut_off[n] * ((float)p->rate / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// hard coded eq
|
||||||
|
for (int n = 0; n < p->number_of_bars; n++) {
|
||||||
|
|
||||||
|
// the numbers that come out of the FFT are very high
|
||||||
|
// the EQ is used to "normalize" them by dividing with this very huge number
|
||||||
|
p->eq[n] = 1 / pow(2, 28);
|
||||||
|
|
||||||
|
// need to boost the EQ for higher frequencies
|
||||||
|
p->eq[n] *= pow(p->cut_off_frequency[n + 1], 0.85);
|
||||||
|
|
||||||
|
if (n < p->bass_cut_off_bar) {
|
||||||
|
p->eq[n] /= log2(p->FFTbassbufferSize);
|
||||||
|
} else {
|
||||||
|
p->eq[n] /= log2(p->FFTbufferSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
p->eq[n] /= p->FFTbuffer_upper_cut_off[n] - p->FFTbuffer_lower_cut_off[n] + 1;
|
||||||
|
}
|
||||||
|
free(relative_cut_off);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cava_execute(double *cava_in, int new_samples, double *cava_out, struct cava_plan *p) {
|
||||||
|
|
||||||
|
// do not overflow
|
||||||
|
if (new_samples > p->input_buffer_size) {
|
||||||
|
new_samples = p->input_buffer_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
int silence = 1;
|
||||||
|
if (new_samples > 0) {
|
||||||
|
// process: approximate actual framerate. This will be off by +10% at 60 fps, but should be
|
||||||
|
// good enough for the autosens and smoothing algorithms to be adjusted accordingly if
|
||||||
|
// framerate is a lot more or less.
|
||||||
|
p->framerate -= p->framerate / 64.0;
|
||||||
|
p->framerate +=
|
||||||
|
(double)(p->rate * p->frame_skip) / (new_samples / p->audio_channels) / 64.0;
|
||||||
|
p->frame_skip = 1;
|
||||||
|
|
||||||
|
// shifting input buffer
|
||||||
|
for (int n = p->input_buffer_size - 1; n >= new_samples; n--) {
|
||||||
|
p->input_buffer[n] = p->input_buffer[n - new_samples];
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill the input buffer
|
||||||
|
for (int n = 0; n < new_samples; n++) {
|
||||||
|
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||||
|
// Audio signals come in the range [-32768, 32768], normalize to [-1, 1].
|
||||||
|
p->input_buffer[new_samples - n - 1] = cava_in[n] / 32768.0;
|
||||||
|
} else {
|
||||||
|
p->input_buffer[new_samples - n - 1] = cava_in[n];
|
||||||
|
}
|
||||||
|
if (cava_in[n]) {
|
||||||
|
silence = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p->frame_skip++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill the bass, mid and treble buffers
|
||||||
|
for (int n = 0; n < p->FFTbassbufferSize; n++) {
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
p->in_bass_r_raw[n] = p->input_buffer[n * 2];
|
||||||
|
p->in_bass_l_raw[n] = p->input_buffer[n * 2 + 1];
|
||||||
|
} else {
|
||||||
|
p->in_bass_l_raw[n] = p->input_buffer[n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int n = 0; n < p->FFTbufferSize; n++) {
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
p->in_r_raw[n] = p->input_buffer[n * 2];
|
||||||
|
p->in_l_raw[n] = p->input_buffer[n * 2 + 1];
|
||||||
|
} else {
|
||||||
|
p->in_l_raw[n] = p->input_buffer[n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hann Window
|
||||||
|
for (int i = 0; i < p->FFTbassbufferSize; i++) {
|
||||||
|
p->in_bass_l[i] = p->bass_multiplier[i] * p->in_bass_l_raw[i];
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
p->in_bass_r[i] = p->bass_multiplier[i] * p->in_bass_r_raw[i];
|
||||||
|
}
|
||||||
|
for (int i = 0; i < p->FFTbufferSize; i++) {
|
||||||
|
p->in_l[i] = p->multiplier[i] * p->in_l_raw[i];
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
p->in_r[i] = p->multiplier[i] * p->in_r_raw[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// process: execute FFT and sort frequency bands
|
||||||
|
|
||||||
|
fftw_execute(p->p_bass_l);
|
||||||
|
fftw_execute(p->p_l);
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
fftw_execute(p->p_bass_r);
|
||||||
|
fftw_execute(p->p_r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// process: separate frequency bands
|
||||||
|
for (int n = 0; n < p->number_of_bars; n++) {
|
||||||
|
|
||||||
|
double temp_l = 0;
|
||||||
|
double temp_r = 0;
|
||||||
|
|
||||||
|
// process: add upp FFT values within bands
|
||||||
|
for (int i = p->FFTbuffer_lower_cut_off[n]; i <= p->FFTbuffer_upper_cut_off[n]; i++) {
|
||||||
|
|
||||||
|
if (n < p->bass_cut_off_bar) {
|
||||||
|
temp_l += hypot(p->out_bass_l[i][0], p->out_bass_l[i][1]);
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
temp_r += hypot(p->out_bass_r[i][0], p->out_bass_r[i][1]);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
temp_l += hypot(p->out_l[i][0], p->out_l[i][1]);
|
||||||
|
if (p->audio_channels == 2)
|
||||||
|
temp_r += hypot(p->out_r[i][0], p->out_r[i][1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getting average and applying configured scaling
|
||||||
|
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||||
|
const double max_db = 70;
|
||||||
|
temp_l = amplitude_to_decibels(temp_l) / max_db;
|
||||||
|
if (!isfinite(temp_l)) {
|
||||||
|
temp_l = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
temp_l *= p->eq[n];
|
||||||
|
}
|
||||||
|
cava_out[n] = temp_l;
|
||||||
|
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
|
||||||
|
const double max_db = 70;
|
||||||
|
temp_r = amplitude_to_decibels(temp_r) / max_db;
|
||||||
|
if (!isfinite(temp_r)) {
|
||||||
|
temp_r = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
temp_r *= p->eq[n];
|
||||||
|
}
|
||||||
|
cava_out[n + p->number_of_bars] = temp_r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applying sens or getting max value
|
||||||
|
if (p->autosens) {
|
||||||
|
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
|
||||||
|
cava_out[n] *= p->sens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// process [smoothing]
|
||||||
|
int overshoot = 0;
|
||||||
|
|
||||||
|
double framerate_mod = 66 / p->framerate;
|
||||||
|
double gravity_mod = pow((framerate_mod), 2.5) * 2 / p->noise_reduction;
|
||||||
|
double integral_mod = pow((framerate_mod), 0.1);
|
||||||
|
|
||||||
|
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
|
||||||
|
|
||||||
|
// process [smoothing]: falloff
|
||||||
|
|
||||||
|
if (cava_out[n] < p->prev_cava_out[n] && p->noise_reduction > 0.1) {
|
||||||
|
cava_out[n] =
|
||||||
|
p->cava_peak[n] * (1.0 - (p->cava_fall[n] * p->cava_fall[n] * gravity_mod));
|
||||||
|
|
||||||
|
if (cava_out[n] < 0.0)
|
||||||
|
cava_out[n] = 0.0;
|
||||||
|
p->cava_fall[n] += 0.028;
|
||||||
|
} else {
|
||||||
|
p->cava_peak[n] = cava_out[n];
|
||||||
|
p->cava_fall[n] = 0.0;
|
||||||
|
}
|
||||||
|
p->prev_cava_out[n] = cava_out[n];
|
||||||
|
|
||||||
|
// process [smoothing]: integral
|
||||||
|
cava_out[n] = p->cava_mem[n] * p->noise_reduction / integral_mod + cava_out[n];
|
||||||
|
|
||||||
|
p->cava_mem[n] = cava_out[n];
|
||||||
|
if (p->autosens) {
|
||||||
|
// check if we overshoot target height
|
||||||
|
if (cava_out[n] > 1.0) {
|
||||||
|
overshoot = 1;
|
||||||
|
cava_out[n] = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculating automatic sense adjustment
|
||||||
|
if (p->autosens) {
|
||||||
|
if (overshoot) {
|
||||||
|
p->sens = p->sens * (1 - (0.02 * framerate_mod));
|
||||||
|
p->sens_init = 0;
|
||||||
|
} else {
|
||||||
|
if (!silence) {
|
||||||
|
p->sens = p->sens * (1 + (0.001 * framerate_mod * p->autosens));
|
||||||
|
if (p->sens_init)
|
||||||
|
p->sens = p->sens * (1 + (0.1 * framerate_mod));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cava_destroy(struct cava_plan *p) {
|
||||||
|
|
||||||
|
free(p->input_buffer);
|
||||||
|
free(p->bass_multiplier);
|
||||||
|
free(p->multiplier);
|
||||||
|
free(p->eq);
|
||||||
|
free(p->cut_off_frequency);
|
||||||
|
free(p->FFTbuffer_lower_cut_off);
|
||||||
|
free(p->FFTbuffer_upper_cut_off);
|
||||||
|
free(p->cava_fall);
|
||||||
|
free(p->cava_mem);
|
||||||
|
free(p->cava_peak);
|
||||||
|
free(p->prev_cava_out);
|
||||||
|
|
||||||
|
fftw_free(p->in_bass_l);
|
||||||
|
fftw_free(p->in_bass_l_raw);
|
||||||
|
fftw_free(p->out_bass_l);
|
||||||
|
fftw_destroy_plan(p->p_bass_l);
|
||||||
|
|
||||||
|
fftw_free(p->in_l);
|
||||||
|
fftw_free(p->in_l_raw);
|
||||||
|
fftw_free(p->out_l);
|
||||||
|
fftw_destroy_plan(p->p_l);
|
||||||
|
|
||||||
|
if (p->audio_channels == 2) {
|
||||||
|
fftw_free(p->in_bass_r);
|
||||||
|
fftw_free(p->in_bass_r_raw);
|
||||||
|
fftw_free(p->out_bass_r);
|
||||||
|
fftw_destroy_plan(p->p_bass_r);
|
||||||
|
|
||||||
|
fftw_free(p->in_r);
|
||||||
|
fftw_free(p->out_r);
|
||||||
|
fftw_free(p->in_r_raw);
|
||||||
|
fftw_destroy_plan(p->p_r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
JNIEXPORT jfloatArray JNICALL Java_com_karlstav_cava_MyGLRenderer_InitCava(
|
||||||
|
JNIEnv *env, jobject thiz, jint number_of_bars_set, jint refresh_rate, jint lower_cut_off,
|
||||||
|
jint higher_cut_off) {
|
||||||
|
jfloatArray cuttOffFreq = (*env)->NewFloatArray(env, number_of_bars_set + 1);
|
||||||
|
float noise_reduction = pow((float)refresh_rate / 130, 0.75);
|
||||||
|
|
||||||
|
plan = cava_init(number_of_bars_set, 44100, 1, 1, noise_reduction, lower_cut_off,
|
||||||
|
higher_cut_off, CAVA_SCALING_LINEAR);
|
||||||
|
cava_in = (double *)malloc(plan->FFTbassbufferSize * sizeof(double));
|
||||||
|
cava_out = (double *)malloc(plan->number_of_bars * sizeof(double));
|
||||||
|
(*env)->SetFloatArrayRegion(env, cuttOffFreq, 0, plan->number_of_bars + 1,
|
||||||
|
plan->cut_off_frequency);
|
||||||
|
return cuttOffFreq;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_MyGLRenderer_ExecCava(JNIEnv *env,
|
||||||
|
jobject thiz,
|
||||||
|
jdoubleArray cava_input,
|
||||||
|
jint new_samples) {
|
||||||
|
|
||||||
|
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
|
||||||
|
|
||||||
|
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
|
||||||
|
|
||||||
|
cava_execute(cava_in, new_samples, cava_out, plan);
|
||||||
|
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
|
||||||
|
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
|
||||||
|
|
||||||
|
return cavaReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT int JNICALL Java_com_karlstav_cava_CavaCoreTest_InitCava(JNIEnv *env, jobject thiz,
|
||||||
|
jint number_of_bars_set) {
|
||||||
|
|
||||||
|
plan = cava_init(number_of_bars_set, 44100, 1, 1, 0.7, 50, 10000, CAVA_SCALING_LINEAR);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_CavaCoreTest_ExecCava(JNIEnv *env,
|
||||||
|
jobject thiz,
|
||||||
|
jdoubleArray cava_input,
|
||||||
|
jint new_samples) {
|
||||||
|
|
||||||
|
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
|
||||||
|
|
||||||
|
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
|
||||||
|
|
||||||
|
cava_execute(cava_in, new_samples, cava_out, plan);
|
||||||
|
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
|
||||||
|
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
|
||||||
|
|
||||||
|
return cavaReturn;
|
||||||
|
}
|
||||||
|
JNIEXPORT void JNICALL Java_com_karlstav_cava_MyGLRenderer_DestroyCava(JNIEnv *env, jobject thiz) {
|
||||||
|
cava_destroy(plan);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
139
cava/cavacore.h
Normal file
139
cava/cavacore.h
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2022 Karl Stavestrand <karl@stavestrand.no>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <fftw3.h>
|
||||||
|
|
||||||
|
#define CAVA_SCALING_LINEAR 0
|
||||||
|
#define CAVA_SCALING_DECIBEL 1
|
||||||
|
|
||||||
|
// cava_plan, parameters used internally by cavacore, do not modify these directly
|
||||||
|
// only the cut off frequencies is of any potential interest to read out,
|
||||||
|
// the rest should most likely be hidden somehow
|
||||||
|
struct cava_plan {
|
||||||
|
int FFTbassbufferSize;
|
||||||
|
int FFTbufferSize;
|
||||||
|
int number_of_bars;
|
||||||
|
int audio_channels;
|
||||||
|
int input_buffer_size;
|
||||||
|
int rate;
|
||||||
|
int bass_cut_off_bar;
|
||||||
|
int sens_init;
|
||||||
|
int autosens;
|
||||||
|
int frame_skip;
|
||||||
|
int status;
|
||||||
|
int scaling_mode;
|
||||||
|
char error_message[1024];
|
||||||
|
|
||||||
|
double sens;
|
||||||
|
double framerate;
|
||||||
|
double noise_reduction;
|
||||||
|
|
||||||
|
fftw_plan p_bass_l, p_bass_r;
|
||||||
|
fftw_plan p_l, p_r;
|
||||||
|
|
||||||
|
fftw_complex *out_bass_l, *out_bass_r;
|
||||||
|
fftw_complex *out_l, *out_r;
|
||||||
|
|
||||||
|
double *bass_multiplier;
|
||||||
|
double *multiplier;
|
||||||
|
|
||||||
|
double *in_bass_r_raw, *in_bass_l_raw;
|
||||||
|
double *in_r_raw, *in_l_raw;
|
||||||
|
double *in_bass_r, *in_bass_l;
|
||||||
|
double *in_r, *in_l;
|
||||||
|
double *prev_cava_out, *cava_mem;
|
||||||
|
double *input_buffer, *cava_peak;
|
||||||
|
|
||||||
|
double *eq;
|
||||||
|
|
||||||
|
float *cut_off_frequency;
|
||||||
|
int *FFTbuffer_lower_cut_off;
|
||||||
|
int *FFTbuffer_upper_cut_off;
|
||||||
|
double *cava_fall;
|
||||||
|
};
|
||||||
|
|
||||||
|
// cava_init, initialize visualization, takes the following parameters:
|
||||||
|
|
||||||
|
// number_of_bars, number of wanted bars per channel
|
||||||
|
|
||||||
|
// rate, sample rate of input signal
|
||||||
|
|
||||||
|
// channels, number of interleaved channels in input
|
||||||
|
|
||||||
|
// autosens, toggle automatic sensitivity adjustment 1 = on, 0 = off
|
||||||
|
// on, gives a dynamically adjusted output signal from 0 to 1
|
||||||
|
// the output is continuously adjusted to use the entire range
|
||||||
|
// off, will pass the raw values from cava directly to the output
|
||||||
|
// the max values will then be dependent on the input
|
||||||
|
|
||||||
|
// noise_reduction, adjust noise reduction filters. 0 - 1, recommended 0.77
|
||||||
|
// the raw visualization is very noisy, this factor adjusts the integral
|
||||||
|
// and gravity filters inside cavacore to keep the signal smooth
|
||||||
|
// 1 will be very slow and smooth, 0 will be fast but noisy.
|
||||||
|
|
||||||
|
// low_cut_off, high_cut_off cut off frequencies for visualization in Hz
|
||||||
|
// recommended: 50, 10000
|
||||||
|
|
||||||
|
// scaling_mode, output scaling mode:
|
||||||
|
// CAVA_SCALING_LINEAR = legacy linear scaling
|
||||||
|
// CAVA_SCALING_DECIBEL = dB-based logarithmic scaling
|
||||||
|
|
||||||
|
// returns a cava_plan to be used by cava_execute. If cava_plan.status is 0 all is OK.
|
||||||
|
// If cava_plan.status is -1, cava_init was called with an illegal parameter, see error string in
|
||||||
|
// cava_plan.error_message
|
||||||
|
extern struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels,
|
||||||
|
int autosens, double noise_reduction, int low_cut_off,
|
||||||
|
int high_cut_off, int scaling_mode);
|
||||||
|
|
||||||
|
// cava_execute, executes visualization
|
||||||
|
|
||||||
|
// cava_in, input buffer can be any size. internal buffers in cavacore is
|
||||||
|
// 4096 * number of channels at 44100 samples rate, if new_samples is greater
|
||||||
|
// then samples will be discarded. However it is recommended to use less
|
||||||
|
// new samples per execution as this determines your framerate.
|
||||||
|
// 512 samples at 44100 sample rate mono, gives about 86 frames per second.
|
||||||
|
|
||||||
|
// new_samples, the number of samples in cava_in to be processed per execution
|
||||||
|
// in case of async reading of data this number is allowed to vary from execution to execution
|
||||||
|
|
||||||
|
// cava_out, output buffer. Size must be number of bars * number of channels. Bars will
|
||||||
|
// be sorted from lowest to highest frequency. If stereo input channels are configured
|
||||||
|
// then all left channel bars will be first then the right.
|
||||||
|
|
||||||
|
// plan, the cava_plan struct returned from cava_init
|
||||||
|
|
||||||
|
// cava_execute assumes cava_in samples to be interleaved if more than one channel
|
||||||
|
// only up to two channels are supported.
|
||||||
|
extern void cava_execute(double *cava_in, int new_samples, double *cava_out,
|
||||||
|
struct cava_plan *plan);
|
||||||
|
|
||||||
|
// cava_destroy, destroys the plan, frees up memory
|
||||||
|
extern void cava_destroy(struct cava_plan *plan);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
547
discover/featured.json
Normal file
547
discover/featured.json
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"podcasts": [
|
||||||
|
{
|
||||||
|
"id": "discover-daily",
|
||||||
|
"title": "The Daily",
|
||||||
|
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
|
||||||
|
"feedUrl": "http://rss.art19.com/the-daily",
|
||||||
|
"author": "The New York Times",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-up-first",
|
||||||
|
"title": "Up First",
|
||||||
|
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-npr-politics",
|
||||||
|
"title": "The NPR Politics Podcast",
|
||||||
|
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-ben-shapiro",
|
||||||
|
"title": "The Ben Shapiro Show",
|
||||||
|
"description": "Ben Shapiro delivers unapologetically conservative commentary on the biggest news stories of the day, blending sharp analysis with his trademark fact-based approach.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/benshow",
|
||||||
|
"author": "The Daily Wire",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-advisory-opinions",
|
||||||
|
"title": "Advisory Opinions",
|
||||||
|
"description": "Host Sarah Isgur and permanent guest David French have twice-weekly conversations about the law, the courts, their collision with politics, and why it all matters — from The Dispatch.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/DISPME4573820108",
|
||||||
|
"author": "The Dispatch",
|
||||||
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-crime-junkie",
|
||||||
|
"title": "Crime Junkie",
|
||||||
|
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
|
||||||
|
"author": "audiochuck",
|
||||||
|
"categories": [
|
||||||
|
"True Crime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-serial",
|
||||||
|
"title": "Serial",
|
||||||
|
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
|
||||||
|
"author": "Serial Productions & The New York Times",
|
||||||
|
"categories": [
|
||||||
|
"True Crime",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-intelligence-matters",
|
||||||
|
"title": "Intelligence Matters",
|
||||||
|
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
|
||||||
|
"feedUrl": "https://rss.art19.com/intelligence-matters",
|
||||||
|
"author": "CBS News",
|
||||||
|
"categories": [
|
||||||
|
"True Crime",
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-smartless",
|
||||||
|
"title": "SmartLess",
|
||||||
|
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
|
||||||
|
"feedUrl": "https://rss.art19.com/smartless",
|
||||||
|
"author": "Jason Bateman, Sean Hayes, Will Arnett",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-this-past-weekend",
|
||||||
|
"title": "This Past Weekend w/ Theo Von",
|
||||||
|
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
|
||||||
|
"author": "Theo Von",
|
||||||
|
"categories": [
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-joe-rogan",
|
||||||
|
"title": "The Joe Rogan Experience",
|
||||||
|
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
|
||||||
|
"author": "Joe Rogan",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-comedy-bang-bang",
|
||||||
|
"title": "Comedy Bang Bang: The Podcast",
|
||||||
|
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
|
||||||
|
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": [
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-office-ladies",
|
||||||
|
"title": "Office Ladies",
|
||||||
|
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
|
||||||
|
"feedUrl": "https://rss.art19.com/office-ladies",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-how-did-this-get-made",
|
||||||
|
"title": "How Did This Get Made?",
|
||||||
|
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
|
||||||
|
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
|
||||||
|
"author": "Earwolf",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Film"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-wait-wait",
|
||||||
|
"title": "Wait Wait... Don't Tell Me!",
|
||||||
|
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-new-heights",
|
||||||
|
"title": "New Heights with Jason & Travis Kelce",
|
||||||
|
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
|
||||||
|
"feedUrl": "https://rss.art19.com/new-heights",
|
||||||
|
"author": "Jason & Travis Kelce",
|
||||||
|
"categories": [
|
||||||
|
"Sports",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-bill-simmons",
|
||||||
|
"title": "The Bill Simmons Podcast",
|
||||||
|
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
|
||||||
|
"author": "The Ringer",
|
||||||
|
"categories": [
|
||||||
|
"Sports",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-acquired",
|
||||||
|
"title": "Acquired",
|
||||||
|
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
|
||||||
|
"feedUrl": "https://feeds.transistor.fm/acquired",
|
||||||
|
"author": "Ben Gilbert & David Rosenthal",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-all-in",
|
||||||
|
"title": "All-In Podcast",
|
||||||
|
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
||||||
|
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
|
||||||
|
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology",
|
||||||
|
"Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-planet-money",
|
||||||
|
"title": "Planet Money",
|
||||||
|
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Economics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-how-i-built-this",
|
||||||
|
"title": "How I Built This with Guy Raz",
|
||||||
|
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
|
||||||
|
"author": "NPR / Wondery",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-freakonomics",
|
||||||
|
"title": "Freakonomics Radio",
|
||||||
|
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
|
||||||
|
"author": "Stephen J. Dubner",
|
||||||
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Economics",
|
||||||
|
"Society"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-darknet-diaries",
|
||||||
|
"title": "Darknet Diaries",
|
||||||
|
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
|
||||||
|
"feedUrl": "https://podcast.darknetdiaries.com/",
|
||||||
|
"author": "Jack Rhysider",
|
||||||
|
"categories": [
|
||||||
|
"Technology",
|
||||||
|
"True Crime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-changelog",
|
||||||
|
"title": "The Changelog",
|
||||||
|
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
|
||||||
|
"feedUrl": "https://changelog.fm/rss",
|
||||||
|
"author": "Changelog Media",
|
||||||
|
"categories": [
|
||||||
|
"Technology",
|
||||||
|
"Software Engineering"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-twit",
|
||||||
|
"title": "This Week in Tech (TWiT)",
|
||||||
|
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
|
||||||
|
"feedUrl": "https://feeds.twit.tv/twit.xml",
|
||||||
|
"author": "TWiT",
|
||||||
|
"categories": [
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-radiolab",
|
||||||
|
"title": "Radiolab",
|
||||||
|
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
|
||||||
|
"feedUrl": "http://feeds.wnyc.org/radiolab",
|
||||||
|
"author": "WNYC Studios",
|
||||||
|
"categories": [
|
||||||
|
"Science",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-huberman-lab",
|
||||||
|
"title": "Huberman Lab",
|
||||||
|
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
|
||||||
|
"author": "Scicomm Media",
|
||||||
|
"categories": [
|
||||||
|
"Health",
|
||||||
|
"Science"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-skeptics-guide",
|
||||||
|
"title": "The Skeptics' Guide to the Universe",
|
||||||
|
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
|
||||||
|
"author": "Steven Novella",
|
||||||
|
"categories": [
|
||||||
|
"Science",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-throughline",
|
||||||
|
"title": "Throughline",
|
||||||
|
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
|
||||||
|
"author": "NPR",
|
||||||
|
"categories": [
|
||||||
|
"History",
|
||||||
|
"Politics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-hardcore-history",
|
||||||
|
"title": "Dan Carlin's Hardcore History",
|
||||||
|
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
|
||||||
|
"author": "Dan Carlin",
|
||||||
|
"categories": [
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-history-of-rome",
|
||||||
|
"title": "The History of Rome",
|
||||||
|
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
|
||||||
|
"author": "Mike Duncan",
|
||||||
|
"categories": [
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-philosophize-this",
|
||||||
|
"title": "Philosophize This!",
|
||||||
|
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
||||||
|
"feedUrl": "https://philosophizethis.libsyn.com/rss",
|
||||||
|
"author": "Stephen West",
|
||||||
|
"categories": [
|
||||||
|
"Philosophy",
|
||||||
|
"Education"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-very-bad-wizards",
|
||||||
|
"title": "Very Bad Wizards",
|
||||||
|
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
||||||
|
"feedUrl": "https://feeds.libsyn.com/474285/rss",
|
||||||
|
"author": "Tamler Sommers & David Pizarro",
|
||||||
|
"categories": [
|
||||||
|
"Philosophy",
|
||||||
|
"Science"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-big-picture",
|
||||||
|
"title": "The Big Picture",
|
||||||
|
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-big-picture",
|
||||||
|
"author": "The Ringer",
|
||||||
|
"categories": [
|
||||||
|
"Film",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-all-songs-considered",
|
||||||
|
"title": "All Songs Considered",
|
||||||
|
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
|
||||||
|
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
|
||||||
|
"author": "NPR Music",
|
||||||
|
"categories": [
|
||||||
|
"Music"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-switched-on-pop",
|
||||||
|
"title": "Switched on Pop",
|
||||||
|
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
|
||||||
|
"author": "Vox Media / Panoply",
|
||||||
|
"categories": [
|
||||||
|
"Music"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-hit-parade",
|
||||||
|
"title": "Hit Parade",
|
||||||
|
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
|
||||||
|
"feedUrl": "https://feeds.megaphone.fm/hitparade",
|
||||||
|
"author": "Slate",
|
||||||
|
"categories": [
|
||||||
|
"Music",
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-song-exploder",
|
||||||
|
"title": "Song Exploder",
|
||||||
|
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
|
||||||
|
"feedUrl": "https://songexploder.net/rss",
|
||||||
|
"author": "Hrishikesh Hirway",
|
||||||
|
"categories": [
|
||||||
|
"Music",
|
||||||
|
"Arts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-blank-check",
|
||||||
|
"title": "Blank Check with Griffin & David",
|
||||||
|
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
|
||||||
|
"feedUrl": "https://audioboom.com/channels/4278829.rss",
|
||||||
|
"author": "Griffin Newman & David Sims",
|
||||||
|
"categories": [
|
||||||
|
"Film",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-99-invisible",
|
||||||
|
"title": "99% Invisible",
|
||||||
|
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
|
||||||
|
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
|
||||||
|
"author": "Roman Mars",
|
||||||
|
"categories": [
|
||||||
|
"Design",
|
||||||
|
"Arts",
|
||||||
|
"Culture"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-gastropod",
|
||||||
|
"title": "Gastropod",
|
||||||
|
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
|
||||||
|
"feedUrl": "https://gastropod.com/feed",
|
||||||
|
"author": "Cynthia Graber & Nicola Twilley",
|
||||||
|
"categories": [
|
||||||
|
"Food",
|
||||||
|
"Science",
|
||||||
|
"History"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-this-american-life",
|
||||||
|
"title": "This American Life",
|
||||||
|
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
|
||||||
|
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
|
||||||
|
"author": "This American Life",
|
||||||
|
"categories": [
|
||||||
|
"Storytelling",
|
||||||
|
"Culture"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-ted-talks-daily",
|
||||||
|
"title": "TED Talks Daily",
|
||||||
|
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
|
||||||
|
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
|
||||||
|
"author": "TED",
|
||||||
|
"categories": [
|
||||||
|
"Education",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-tim-ferriss",
|
||||||
|
"title": "The Tim Ferriss Show",
|
||||||
|
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
|
||||||
|
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
|
||||||
|
"author": "Tim Ferriss",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Business"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-jordan-harbinger",
|
||||||
|
"title": "The Jordan Harbinger Show",
|
||||||
|
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
|
||||||
|
"author": "Jordan Harbinger",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-on-purpose",
|
||||||
|
"title": "On Purpose with Jay Shetty",
|
||||||
|
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
|
||||||
|
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
|
||||||
|
"author": "Jay Shetty",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Health"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-10-percent-happier",
|
||||||
|
"title": "10% Happier with Dan Harris",
|
||||||
|
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
|
||||||
|
"feedUrl": "https://rss.art19.com/ten-percent-happier",
|
||||||
|
"author": "Dan Harris",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Health",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-school-of-greatness",
|
||||||
|
"title": "The School of Greatness",
|
||||||
|
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
|
||||||
|
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
|
||||||
|
"author": "Lewis Howes",
|
||||||
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Business"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-sysk",
|
||||||
|
"title": "Stuff You Should Know",
|
||||||
|
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
||||||
|
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
|
||||||
|
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
|
||||||
|
"categories": [
|
||||||
|
"Education",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discover-in-our-time",
|
||||||
|
"title": "In Our Time",
|
||||||
|
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
|
||||||
|
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
|
||||||
|
"author": "BBC Radio 4",
|
||||||
|
"categories": [
|
||||||
|
"History",
|
||||||
|
"Education",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
notes.md
7
notes.md
@@ -1,7 +0,0 @@
|
|||||||
- [x] Audio play can survive quit out
|
|
||||||
- [x] Discover tab does not move highlight on jk, only moves a star, My Feeds tab
|
|
||||||
moves nothing, other tabs(and main tab panel) are the correct pattern
|
|
||||||
- [x] Weird focus colors happen at times, the search panel does not get the correct pane
|
|
||||||
border color when focused for instance
|
|
||||||
- [x] Feed tab needs to fully drop the depth 1 panel - its effectively a duplication
|
|
||||||
of My Shows - Just immediately go into the full list
|
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
"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",
|
||||||
|
|||||||
41
packaging/aur/.SRCINFO
Normal file
41
packaging/aur/.SRCINFO
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
pkgbase = podtui-bin
|
||||||
|
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||||
|
pkgver = 0.2.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/mikefreno/podtui
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = MIT
|
||||||
|
depends = mpv
|
||||||
|
provides = podtui
|
||||||
|
conflicts = podtui
|
||||||
|
options = !strip
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||||
|
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||||
|
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
|
||||||
|
pkgname = podtui-bin
|
||||||
|
pkgver = 0.2.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/mikefreno/podtui
|
||||||
|
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = MIT
|
||||||
|
depends = mpv
|
||||||
|
provides = podtui
|
||||||
|
conflicts = podtui
|
||||||
|
options = !strip
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||||
|
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||||
|
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
55
packaging/aur/PKGBUILD
Normal file
55
packaging/aur/PKGBUILD
Normal 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"
|
||||||
|
}
|
||||||
60
packaging/aur/gen-srcinfo.sh
Normal file
60
packaging/aur/gen-srcinfo.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# gen-srcinfo.sh — emit .SRCINFO for the podtui-bin PKGBUILD without makepkg.
|
||||||
|
# Emits the same field set/ordering makepkg --printsrcinfo produces for this
|
||||||
|
# PKGBUILD shape (single package, per-arch source + sha256sums arrays).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./PKGBUILD
|
||||||
|
|
||||||
|
emit() { printf '\t%s = %s\n' "$1" "$2"; }
|
||||||
|
emit_multi() { # $1 field, rest values
|
||||||
|
local f="$1"
|
||||||
|
shift
|
||||||
|
for v in "$@"; do emit "$f" "$v"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgbase_section() {
|
||||||
|
echo "pkgbase = ${pkgname}"
|
||||||
|
for f in pkgdesc pkgver pkgrel url; do
|
||||||
|
v="${!f}"
|
||||||
|
[ -n "${v:-}" ] && emit "$f" "$v"
|
||||||
|
done
|
||||||
|
[ -n "${install:-}" ] && emit install "$install"
|
||||||
|
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||||
|
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||||
|
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||||
|
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||||
|
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||||
|
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||||
|
emit_arch_arrays
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_arch_arrays() {
|
||||||
|
for a in "${arch[@]}"; do
|
||||||
|
src_name="source_${a}"
|
||||||
|
sha_name="sha256sums_${a}"
|
||||||
|
src_val="${src_name}[@]"
|
||||||
|
sha_val="${sha_name}[@]"
|
||||||
|
[ "${#src_name}" -gt 0 ] && emit_multi "source_${a}" "${!src_val}"
|
||||||
|
emit_multi "sha256sums_${a}" "${!sha_val}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgbase_section
|
||||||
|
echo ""
|
||||||
|
echo "pkgname = ${pkgname}"
|
||||||
|
for v in pkgver pkgrel url; do
|
||||||
|
val="${!v}"
|
||||||
|
[ -n "${val:-}" ] && emit "$v" "$val"
|
||||||
|
done
|
||||||
|
emit pkgdesc "$pkgdesc"
|
||||||
|
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||||
|
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||||
|
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||||
|
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||||
|
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||||
|
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||||
|
emit_arch_arrays
|
||||||
@@ -19,35 +19,57 @@ 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
|
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
|
|
||||||
else
|
|
||||||
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
|
|
||||||
fi
|
|
||||||
LIB_EXT="dylib"
|
LIB_EXT="dylib"
|
||||||
SHARED_FLAG="-dynamiclib"
|
SHARED_FLAG="-dynamiclib"
|
||||||
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
|
||||||
|
if [ "$ARCH" = "arm64" ]; then
|
||||||
|
FFTW_HINTS="/opt/homebrew /usr/local"
|
||||||
|
else
|
||||||
|
FFTW_HINTS="/usr/local /opt/homebrew"
|
||||||
|
fi
|
||||||
else
|
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
|
||||||
|
|
||||||
|
|||||||
240
scripts/release-tag.sh
Executable file
240
scripts/release-tag.sh
Executable file
@@ -0,0 +1,240 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# release-tag.sh — PodTui version bump, commit, tag, and push.
|
||||||
|
#
|
||||||
|
# Mirrors the release flow from FlexLove's scripts/make-tag.sh, adapted for
|
||||||
|
# PodTui's single version source (src/index.tsx) and dual remotes (gh, gt).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/release-tag.sh interactive release
|
||||||
|
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
|
||||||
|
#
|
||||||
|
# Pushing a v* tag to the `gh` remote triggers .github/workflows/release.yml
|
||||||
|
# (4-platform tarball builds) — the release and the Homebrew tap update then
|
||||||
|
# happen automatically and need no further local action.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
DRY_RUN=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run | -n) DRY_RUN=1 ;;
|
||||||
|
--help | -h)
|
||||||
|
echo "Usage: scripts/release-tag.sh [--dry-run]"
|
||||||
|
echo " --dry-run, -n show the plan without committing, tagging, or pushing"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unknown option: ${arg}${NC}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -d .git ] && [ ! -f .git ]; then
|
||||||
|
echo -e "${RED}Error: Not in a git repository${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff-index --quiet HEAD --; then
|
||||||
|
echo -e "${YELLOW}You have uncommitted changes:${NC}"
|
||||||
|
git status --short
|
||||||
|
echo ""
|
||||||
|
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${RED}Aborted${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Current version from the latest tag; fall back to src/index.tsx.
|
||||||
|
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
|
||||||
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
|
CURRENT_VERSION=$(grep -m 1 "^const VERSION" src/index.tsx | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
|
||||||
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
|
echo -e "${RED}Error: could not extract version from git tags or src/index.tsx${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}No tags found; using VERSION from src/index.tsx (${CURRENT_VERSION})${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
|
||||||
|
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
|
||||||
|
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
|
||||||
|
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
|
||||||
|
|
||||||
|
echo -e "${CYAN}Select version bump type:${NC}"
|
||||||
|
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH} → $((MAJOR + 1)).0.0"
|
||||||
|
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.$((MINOR + 1)).0"
|
||||||
|
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
echo " 4) Custom version"
|
||||||
|
echo " 5) Cancel"
|
||||||
|
echo ""
|
||||||
|
read -p "Enter choice (1-5): " -n 1 -r CHOICE
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case $CHOICE in
|
||||||
|
1)
|
||||||
|
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
read -p "Enter custom version (e.g., 1.0.0-beta): " -r NEW_VERSION
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
echo -e "${RED}Cancelled${NC}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Version sanity check (tags are vMAJOR.MINOR.PATCH).
|
||||||
|
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo -e "${RED}Error: ${NEW_VERSION} is not a valid X.Y.Z version (v tags only)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}This will:${NC}"
|
||||||
|
echo " 1. Set src/index.tsx → VERSION = \"${NEW_VERSION}\""
|
||||||
|
echo " 2. Commit the bump"
|
||||||
|
echo " 3. Create annotated tag v${NEW_VERSION}"
|
||||||
|
echo " 4. Push master and the tag to every remote"
|
||||||
|
REMOTES=$(git remote)
|
||||||
|
for r in $REMOTES; do
|
||||||
|
echo " → $r"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
||||||
|
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Proceed? (y/n) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[dry-run]${NC} would have:"
|
||||||
|
echo " sed src/index.tsx: VERSION \"${CURRENT_VERSION}\" → \"${NEW_VERSION}\""
|
||||||
|
echo " git commit -m \"bump VERSION to ${NEW_VERSION}\""
|
||||||
|
echo " git tag -a v${NEW_VERSION} -m \"PodTUI v${NEW_VERSION}\""
|
||||||
|
for r in $REMOTES; do echo " push $r master"; done
|
||||||
|
for r in $REMOTES; do echo " push $r v${NEW_VERSION}"; done
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Plan only — nothing written${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Apply the bump ───────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[1/4]${NC} Updating src/index.tsx..."
|
||||||
|
sed -i.bak "s/const VERSION = \"[^\"]*\"/const VERSION = \"${NEW_VERSION}\"/" src/index.tsx
|
||||||
|
rm -f src/index.tsx.bak
|
||||||
|
echo -e "${GREEN}✓ src/index.tsx updated${NC}"
|
||||||
|
|
||||||
|
if git diff --quiet -- src/index.tsx; then
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
|
||||||
|
echo -e "${YELLOW}Already at ${NEW_VERSION} and tag v${NEW_VERSION} exists — nothing to release.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}VERSION is already ${NEW_VERSION} (bump already committed).${NC}"
|
||||||
|
echo -e "${YELLOW}Will skip the commit and just create the missing tag + push.${NC}"
|
||||||
|
read -p "Tag v${NEW_VERSION} on current HEAD and push? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git add src/index.tsx
|
||||||
|
echo -e "${GREEN}✓ staged${NC}"
|
||||||
|
|
||||||
|
echo -e "${CYAN}[2/4]${NC} Committing..."
|
||||||
|
DEFAULT_COMMIT_MSG="bump VERSION to ${NEW_VERSION}"
|
||||||
|
echo -e "Default commit message: ${CYAN}${DEFAULT_COMMIT_MSG}${NC}"
|
||||||
|
read -p "Use default? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||||
|
read -p "Enter commit message: " -r COMMIT_MSG
|
||||||
|
else
|
||||||
|
COMMIT_MSG="$DEFAULT_COMMIT_MSG"
|
||||||
|
fi
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
echo -e "${GREEN}✓ committed: ${COMMIT_MSG}${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}[3/4]${NC} Tagging..."
|
||||||
|
git tag -a "v${NEW_VERSION}" -m "PodTUI v${NEW_VERSION}"
|
||||||
|
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${CYAN}[4/4]${NC} Pushing..."
|
||||||
|
FAILED=""
|
||||||
|
for r in $REMOTES; do
|
||||||
|
if ! git push "$r" master; then
|
||||||
|
FAILED="${FAILED}${r} (branch) "
|
||||||
|
fi
|
||||||
|
if ! git push "$r" tag "v${NEW_VERSION}"; then
|
||||||
|
FAILED="${FAILED}${r} (tag) "
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ -n "$FAILED" ]; then
|
||||||
|
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||||
|
echo -e "${RED}✗ Push failed for: ${FAILED}${NC}"
|
||||||
|
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}The commit and tag exist locally. To retry:${NC}"
|
||||||
|
for r in $REMOTES; do
|
||||||
|
echo " git push ${r} master"
|
||||||
|
echo " git push ${r} v${NEW_VERSION}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}To undo:${NC}"
|
||||||
|
echo " git tag -d v${NEW_VERSION}"
|
||||||
|
echo " git reset --soft HEAD~1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN}✓ PodTui v${NEW_VERSION} released${NC}"
|
||||||
|
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION} → ${GREEN}${NEW_VERSION}${NC}"
|
||||||
|
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||||
|
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
||||||
|
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
||||||
|
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula"
|
||||||
|
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||||
@@ -436,16 +436,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (newAction) {
|
if (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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Code validation component for PodTUI
|
|
||||||
* 8-character alphanumeric code input for sync authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { AUTH_CONFIG } from "@/config/auth";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface CodeValidationProps {
|
|
||||||
focused?: boolean;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusField = "code" | "submit" | "back";
|
|
||||||
|
|
||||||
export function CodeValidation(props: CodeValidationProps) {
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [code, setCode] = createSignal("");
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
|
||||||
const [codeError, setCodeError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const fields: FocusField[] = ["code", "submit", "back"];
|
|
||||||
|
|
||||||
/** Format code as user types (uppercase, alphanumeric only) */
|
|
||||||
const handleCodeInput = (value: string) => {
|
|
||||||
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
||||||
// Limit to max length
|
|
||||||
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
|
|
||||||
setCode(limited);
|
|
||||||
|
|
||||||
// Clear error when typing
|
|
||||||
if (codeError()) {
|
|
||||||
setCodeError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateCode = (value: string): boolean => {
|
|
||||||
if (!value) {
|
|
||||||
setCodeError("Code is required");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
|
||||||
setCodeError(
|
|
||||||
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
|
|
||||||
setCodeError("Code must contain only letters and numbers");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setCodeError(null);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!validateCode(code())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await auth.validateCode(code());
|
|
||||||
if (!success && auth.error) {
|
|
||||||
setCodeError(auth.error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return" || key.name === "tab") {
|
|
||||||
if (focusField() === "submit") {
|
|
||||||
handleSubmit();
|
|
||||||
} else if (focusField() === "back" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
} else if (key.name === "escape" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeProgress = () => {
|
|
||||||
const len = code().length;
|
|
||||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
|
||||||
return `${len}/${max}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeDisplay = () => {
|
|
||||||
const current = code();
|
|
||||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
|
||||||
const filled = current.split("");
|
|
||||||
const empty = Array(max - filled.length).fill("_");
|
|
||||||
return [...filled, ...empty].join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Enter Sync Code</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter your 8-character sync code to link your account.
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Code display */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
|
||||||
Code ({codeProgress()}):
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box border padding={1} borderColor={theme.border}>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
code().length === AUTH_CONFIG.codeValidation.codeLength
|
|
||||||
? theme.success
|
|
||||||
: theme.warning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{codeDisplay()}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Hidden input for actual typing */}
|
|
||||||
<input
|
|
||||||
value={code()}
|
|
||||||
onInput={handleCodeInput}
|
|
||||||
placeholder=""
|
|
||||||
focused={props.focused && focusField() === "code"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{codeError() && <text fg={theme.error}>{codeError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "submit" ? theme.primary : undefined}>
|
|
||||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
|
||||||
[Esc] Back to Login
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Auth error message */}
|
|
||||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 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, 3, 3]` 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/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
||||||
@@ -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}
|
||||||
@@ -41,7 +41,7 @@ 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/7 slot is always preserved). */
|
||||||
parent?: PaneContent;
|
parent?: PaneContent;
|
||||||
@@ -95,7 +95,7 @@ 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;
|
||||||
@@ -149,7 +149,7 @@ function YaziPane(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 active-border focus ring. */
|
/** true → the current column gets the active-border focus ring. */
|
||||||
@@ -180,7 +180,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
|||||||
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/7) — previous-depth list; always muted ─────────────── */}
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={parentLabel}
|
label={parentLabel}
|
||||||
content={parentContent}
|
content={parentContent}
|
||||||
@@ -188,7 +188,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
|||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={currentLabel}
|
label={currentLabel}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
@@ -197,7 +197,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
|||||||
/>
|
/>
|
||||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
label={previewLabel}
|
label={previewLabel}
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
@@ -25,7 +25,7 @@ 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",
|
||||||
@@ -252,7 +252,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>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
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> = {
|
||||||
@@ -67,8 +68,10 @@ export function TabListPane(props: { muted?: boolean }) {
|
|||||||
: isActive() && !active()
|
: 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"
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -61,6 +61,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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const shortcuts = [
|
|||||||
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
||||||
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
||||||
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
||||||
|
{ keys: "x", action: "Unsubscribe focused show (My Shows)" },
|
||||||
{ keys: ", / .", action: "Sort / toggle hidden" },
|
{ keys: ", / .", action: "Sort / toggle hidden" },
|
||||||
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
||||||
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export type KeybindActionName =
|
|||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "unsubscribe"
|
||||||
| "audio-toggle"
|
| "audio-toggle"
|
||||||
| "audio-next"
|
| "audio-next"
|
||||||
| "audio-prev"
|
| "audio-prev"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function ensureBackend(): AudioBackend {
|
|||||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||||
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
||||||
// player (mpv/ffplay/afplay). Without this hook those child processes
|
// player (mpv). Without this hook those child processes
|
||||||
// survive the host and keep playing audio after the TUI has quit. The
|
// survive the host and keep playing audio after the TUI has quit. The
|
||||||
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
||||||
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* 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"
|
||||||
@@ -19,7 +19,7 @@ export type MediaKeyAction =
|
|||||||
| "media.volumeDown"
|
| "media.volumeDown"
|
||||||
| "media.seekForward"
|
| "media.seekForward"
|
||||||
| "media.seekBackward"
|
| "media.seekBackward"
|
||||||
| "media.speedCycle"
|
| "media.speedCycle";
|
||||||
|
|
||||||
/** Key-to-action mappings for multimedia controls */
|
/** Key-to-action mappings for multimedia controls */
|
||||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||||
@@ -33,15 +33,15 @@ const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
|||||||
// bus approach — the audio hook only processes event-bus events, and
|
// bus approach — the audio hook only processes event-bus events, and
|
||||||
// Player.tsx calls audio methods directly. We therefore guard with
|
// Player.tsx calls audio methods directly. We therefore guard with
|
||||||
// a "playerFocused" flag passed via options.
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,48 +51,45 @@ export interface MultimediaKeysOptions {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/hooks/useScrollIntoView.ts
Normal file
115
src/hooks/useScrollIntoView.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* useScrollIntoView — keeps the ref'd row visible inside its enclosing
|
||||||
|
* `<scrollbox>` whenever the focus accessor is true.
|
||||||
|
*
|
||||||
|
* OpenTUI's `ScrollBoxRenderable` has built-in *keyboard* scrolling but does
|
||||||
|
* NOT auto-scroll to follow a programmatically-focused child (the app moves
|
||||||
|
* its own cursor via the yazi nav store, so the scrollbox never sees a key
|
||||||
|
* for row movement). Every scrollable panel therefore drifts out of view the
|
||||||
|
* moment the cursor crosses the viewport edge.
|
||||||
|
*
|
||||||
|
* Attach the returned `ref` callback to the element that represents the
|
||||||
|
* focused row of a scrollable list and call the hook with a `when()` that is
|
||||||
|
* true for exactly that row (e.g. `() => index() === focus()`). Whenever the
|
||||||
|
* accessor flips true, the nearest ScrollBoxRenderable is scrolled just enough
|
||||||
|
* to bring the element back into the viewport — a "nearest-edge" scroll:
|
||||||
|
* • scroll up only if the row's top is clipped above the viewport,
|
||||||
|
* • scroll down only if the row's bottom is clipped below the viewport,
|
||||||
|
* never snapping more than necessary (matches yazi list behaviour).
|
||||||
|
*
|
||||||
|
* Timing: for ordinary cursor movement (j/k) the list layout does not change
|
||||||
|
* — only background colour and the cursor glyph flip — so the focused row's
|
||||||
|
* Yoga-computed position is already valid when this effect fires, and the
|
||||||
|
* scroll is applied synchronously. On first mount / content population the
|
||||||
|
* layout for the new rows has not yet been computed, so the hook polls on a
|
||||||
|
* short timer until layout resolves (bounded so it can never loop forever).
|
||||||
|
*/
|
||||||
|
import { createEffect, onCleanup } from "solid-js";
|
||||||
|
|
||||||
|
/** Walk up the renderable parent chain to the nearest ScrollBoxRenderable,
|
||||||
|
* identified by its `viewport` + `content` + numeric `scrollTop`. */
|
||||||
|
function findScrollBox(node: any): any | null {
|
||||||
|
let p: any = node?.parent;
|
||||||
|
while (p) {
|
||||||
|
if (p.viewport && p.content && typeof p.scrollTop === "number") return p;
|
||||||
|
p = p.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of retries while waiting for Yoga layout to populate the
|
||||||
|
* row/viewport dimensions (handles the first-mount frame). */
|
||||||
|
const MAX_RETRIES = 12;
|
||||||
|
const RETRY_MS = 16;
|
||||||
|
|
||||||
|
export function useScrollIntoView(when: () => boolean) {
|
||||||
|
let el: any = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const ref = (node: any) => {
|
||||||
|
el = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compute the target scrollTop that brings `el` into the viewport of its
|
||||||
|
* enclosing scrollbox, or `null` if no scroll is possible / needed yet.
|
||||||
|
* Returns the decision so the caller knows whether to poll again. */
|
||||||
|
const compute = (): { scroll: number | null; ready: boolean } => {
|
||||||
|
const node = el;
|
||||||
|
if (!node) return { scroll: null, ready: false };
|
||||||
|
const sb = findScrollBox(node);
|
||||||
|
if (!sb) return { scroll: null, ready: false };
|
||||||
|
const vp = sb.viewport;
|
||||||
|
const top: number = sb.scrollTop ?? 0;
|
||||||
|
const vpH: number = vp?.height ?? 0;
|
||||||
|
// The scrollbar's onChange sets `content.translateY = -scrollTop`, so
|
||||||
|
// the child's cumulative `.y` already includes `-scrollTop`; subtracting
|
||||||
|
// the viewport's stable `.y` and re-adding `scrollTop` recovers the
|
||||||
|
// row's layout-space offset within the content (scroll-independent).
|
||||||
|
const childTop: number = node.y ?? 0;
|
||||||
|
const childH: number = node.height ?? 0;
|
||||||
|
if (!vpH || !childH) return { scroll: null, ready: false };
|
||||||
|
|
||||||
|
const offset = childTop - (vp.y ?? 0) + top;
|
||||||
|
let target = top;
|
||||||
|
if (offset < top) target = offset;
|
||||||
|
else if (offset + childH > top + vpH) target = offset + childH - vpH;
|
||||||
|
const max = Math.max(0, (sb.scrollHeight ?? 0) - vpH);
|
||||||
|
if (target > max) target = max;
|
||||||
|
if (target < 0) target = 0;
|
||||||
|
target = Math.round(target);
|
||||||
|
if (target === Math.round(top)) return { scroll: null, ready: true };
|
||||||
|
return { scroll: target, ready: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryScroll = (retriesLeft: number) => {
|
||||||
|
const { scroll, ready } = compute();
|
||||||
|
if (!ready) {
|
||||||
|
if (retriesLeft > 0)
|
||||||
|
timer = setTimeout(() => tryScroll(retriesLeft - 1), RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scroll != null) {
|
||||||
|
const sb = findScrollBox(el);
|
||||||
|
if (sb) sb.scrollTo(scroll);
|
||||||
|
}
|
||||||
|
clearTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!when()) return;
|
||||||
|
clearTimer();
|
||||||
|
tryScroll(MAX_RETRIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
clearTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const VERSION = "0.1.0";
|
const VERSION = "0.2.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
@@ -38,7 +38,8 @@ if (cliArgs.version) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
|
.then(async ({ loadFeedsFromFile }) => {
|
||||||
const feeds = await loadFeedsFromFile();
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
if (cliArgs.query !== null) {
|
if (cliArgs.query !== null) {
|
||||||
@@ -68,11 +69,19 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
const feed = matches[0];
|
const feed = matches[0];
|
||||||
console.log(`\n${feed.podcast.title}`);
|
console.log(`\n${feed.podcast.title}`);
|
||||||
if (feed.podcast.description) {
|
if (feed.podcast.description) {
|
||||||
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
|
console.log(
|
||||||
|
feed.podcast.description.substring(0, 200) +
|
||||||
|
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
|
console.log(
|
||||||
|
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
|
||||||
|
);
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
|
const date =
|
||||||
|
ep.pubDate instanceof Date
|
||||||
|
? ep.pubDate.toLocaleDateString()
|
||||||
|
: String(ep.pubDate);
|
||||||
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
||||||
});
|
});
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -89,18 +98,21 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
const playArg = cliArgs.play;
|
const playArg = cliArgs.play;
|
||||||
const normalizedArg = playArg.toLowerCase();
|
const normalizedArg = playArg.toLowerCase();
|
||||||
|
|
||||||
let feedResult: typeof feeds[0] | null = null;
|
let feedResult: (typeof feeds)[0] | null = null;
|
||||||
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
|
||||||
|
|
||||||
if (normalizedArg === "latest") {
|
if (normalizedArg === "latest") {
|
||||||
let latestFeed: typeof feeds[0] | null = null;
|
let latestFeed: (typeof feeds)[0] | null = null;
|
||||||
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
|
||||||
let latestDate = 0;
|
let latestDate = 0;
|
||||||
|
|
||||||
for (const feed of feeds) {
|
for (const feed of feeds) {
|
||||||
if (feed.episodes.length > 0) {
|
if (feed.episodes.length > 0) {
|
||||||
const ep = feed.episodes[0];
|
const ep = feed.episodes[0];
|
||||||
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
const epDate =
|
||||||
|
ep.pubDate instanceof Date
|
||||||
|
? ep.pubDate.getTime()
|
||||||
|
: Number(ep.pubDate);
|
||||||
if (epDate > latestDate) {
|
if (epDate > latestDate) {
|
||||||
latestDate = epDate;
|
latestDate = epDate;
|
||||||
latestFeed = feed;
|
latestFeed = feed;
|
||||||
@@ -117,7 +129,7 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
const episodeQuery = parts[1];
|
const episodeQuery = parts[1];
|
||||||
|
|
||||||
const matchingFeeds = feeds.filter((feed) =>
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
feed.podcast.title.toLowerCase().includes(showQuery)
|
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingFeeds.length === 0) {
|
if (matchingFeeds.length === 0) {
|
||||||
@@ -140,7 +152,7 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
episodeResult = feed.episodes[0];
|
episodeResult = feed.episodes[0];
|
||||||
} else {
|
} else {
|
||||||
const matchingEpisode = feed.episodes.find((ep) =>
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
ep.title.toLowerCase().includes(episodeQuery)
|
ep.title.toLowerCase().includes(episodeQuery),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingEpisode) {
|
if (matchingEpisode) {
|
||||||
@@ -180,7 +192,8 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
console.error("Error:", err);
|
console.error("Error:", err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@
|
|||||||
* 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;
|
||||||
@@ -154,13 +160,17 @@ function DiscoverPage() {
|
|||||||
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) => {
|
||||||
|
const lf = () => nav.depthFocus(0);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||||
@@ -169,7 +179,8 @@ function DiscoverPage() {
|
|||||||
{cat.name}
|
{cat.name}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
@@ -182,9 +193,10 @@ function DiscoverPage() {
|
|||||||
<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}
|
||||||
@@ -200,11 +212,6 @@ function DiscoverPage() {
|
|||||||
{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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -223,8 +230,10 @@ 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}
|
||||||
@@ -243,7 +252,9 @@ function DiscoverPage() {
|
|||||||
{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>
|
||||||
@@ -268,7 +279,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 +289,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 +369,7 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
||||||
* shows the full flat episodes list immediately.
|
* shows the full flat episodes list immediately.
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
* Renders entirely through `<PaneRow>` (the shared parent|current|preview
|
||||||
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
|
* 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
|
* root. j/k move only within the current column. The Shell router drives
|
||||||
* everything over `nav.action`; this page only handles list/preview data.
|
* everything over `nav.action`; this page only handles list/preview data.
|
||||||
@@ -35,8 +35,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 FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
@@ -187,8 +188,10 @@ function FeedPage() {
|
|||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(item, index) => {
|
{(item, index) => {
|
||||||
const fi = () => focusedEpIdx();
|
const fi = () => focusedEpIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -290,7 +293,7 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 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());
|
||||||
@@ -205,8 +216,10 @@ export function MyShowsPage() {
|
|||||||
<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}
|
||||||
@@ -243,8 +256,10 @@ 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}
|
||||||
@@ -283,8 +298,10 @@ 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}
|
||||||
@@ -361,7 +378,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 +425,7 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -1,47 +1,68 @@
|
|||||||
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",
|
|
||||||
afplay: "afplay",
|
|
||||||
system: "system",
|
|
||||||
none: "none",
|
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"
|
||||||
|
gap={1}
|
||||||
|
alignItems="center"
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onPrev}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<text fg={theme.primary}>[Prev]</text>
|
<text fg={theme.primary}>[Prev]</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onToggle}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||||
</box>
|
</box>
|
||||||
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
onMouseDown={props.onNext}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<text fg={theme.primary}>[Next]</text>
|
<text fg={theme.primary}>[Next]</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Vol</text>
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
|
<text fg={theme.textMuted}>↑↓</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
<text fg={theme.textMuted}>s</text>
|
||||||
</box>
|
</box>
|
||||||
{props.backendName && props.backendName !== "none" && (
|
{props.backendName && props.backendName !== "none" && (
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
@@ -60,5 +81,5 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</box>
|
</box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* depth 0 (parent) — tab list (muted, read-only).
|
* depth 0 (parent) — tab list (muted, read-only).
|
||||||
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
||||||
*
|
*
|
||||||
* No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause,
|
* No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
|
||||||
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
* 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
|
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
||||||
* tab root.
|
* tab root.
|
||||||
@@ -17,7 +17,7 @@ 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, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
export const PlayerPaneCount = 1;
|
export const PlayerPaneCount = 1;
|
||||||
@@ -82,8 +82,9 @@ export function PlayerPage() {
|
|||||||
<RealtimeWaveform
|
<RealtimeWaveform
|
||||||
visualizerConfig={(() => {
|
visualizerConfig={(() => {
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
|
// bars is width-derived in RealtimeWaveform; pass only the
|
||||||
|
// audio-processing params here.
|
||||||
return {
|
return {
|
||||||
bars: viz.bars,
|
|
||||||
noiseReduction: viz.noiseReduction,
|
noiseReduction: viz.noiseReduction,
|
||||||
lowCutOff: viz.lowCutOff,
|
lowCutOff: viz.lowCutOff,
|
||||||
highCutOff: viz.highCutOff,
|
highCutOff: viz.highCutOff,
|
||||||
@@ -109,13 +110,13 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{"P play/pause N next B prev </ seek · h back"}
|
{"P play/pause N next B prev ◀▶ seek h back"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
parentLabel="Up"
|
parentLabel="Up"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
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 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,14 +53,28 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
// Track whether cavacore is available
|
|
||||||
const [available, setAvailable] = createSignal(false);
|
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
let cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
let reader: AudioStreamReader | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let sampleBuffer: Float64Array | null = null;
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
|
||||||
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
|
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
|
||||||
|
// chrome (scrollbox border + box padding + waveform border + padding).
|
||||||
|
// Falls back to 64 before the renderer reports a real size.
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
const numBars = () => {
|
||||||
|
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
|
||||||
|
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
|
||||||
|
const width = dimensions().width;
|
||||||
|
if (!width) return 64;
|
||||||
|
return Math.max(
|
||||||
|
8,
|
||||||
|
Math.min(256, Math.floor((width * current) / total) - 8),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||||
|
|
||||||
const initCava = () => {
|
const initCava = () => {
|
||||||
@@ -66,11 +82,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
cava = loadCavaCore();
|
cava = loadCavaCore();
|
||||||
if (!cava) {
|
if (!cava) {
|
||||||
setAvailable(false);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailable(true);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,9 +95,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
if (!url || !initCava() || !cava) return;
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides
|
// Initialize cavacore with current resolution + any overrides.
|
||||||
|
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||||
|
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: 32,
|
bars: numBars(),
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
...props.visualizerConfig,
|
...props.visualizerConfig,
|
||||||
@@ -136,16 +152,16 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
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) {
|
||||||
@@ -204,11 +220,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
const numBars = 32;
|
const count = numBars();
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
// If no data yet, show empty placeholder
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(numBars);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="row" gap={0}>
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
<text fg="#3b4252">{placeholder}</text>
|
||||||
@@ -216,7 +232,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -239,8 +255,8 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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)),
|
||||||
@@ -249,7 +265,12 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
|
<box
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
padding={1}
|
||||||
|
onMouseDown={handleClick}
|
||||||
|
>
|
||||||
{renderLine()}
|
{renderLine()}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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 {
|
||||||
@@ -37,13 +38,15 @@ import {
|
|||||||
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 { 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 SearchPaneCount = 1;
|
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;
|
||||||
@@ -60,16 +63,22 @@ function SearchPage() {
|
|||||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
// `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)
|
// 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.
|
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||||||
// Depth transitions also drive it: typing is the default on the query depth.
|
//
|
||||||
let prevDepth = depth();
|
// Typing is the default only on the query depth (0); the results depth
|
||||||
onMount(() => nav.setInputFocused(true));
|
// (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));
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const d = depth();
|
nav.setInputFocused(depth() === 0);
|
||||||
if (d !== prevDepth) {
|
|
||||||
nav.setInputFocused(d === 0);
|
|
||||||
prevDepth = d;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||||
@@ -121,6 +130,8 @@ function SearchPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,8 +257,10 @@ function SearchPage() {
|
|||||||
<For each={recents()}>
|
<For each={recents()}>
|
||||||
{(query, index) => {
|
{(query, index) => {
|
||||||
const lf = () => focus(0);
|
const lf = () => focus(0);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -292,8 +305,10 @@ function SearchPage() {
|
|||||||
<For each={results()}>
|
<For each={results()}>
|
||||||
{(result, index) => {
|
{(result, index) => {
|
||||||
const fi = () => focusedResultIdx();
|
const fi = () => focusedResultIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -312,7 +327,9 @@ function SearchPage() {
|
|||||||
{result.podcast.title}
|
{result.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
<Show when={result.podcast.isSubscribed}>
|
<Show when={result.podcast.isSubscribed}>
|
||||||
<text fg={index() === fi() ? theme.surface : theme.success}>
|
<text
|
||||||
|
fg={index() === fi() ? theme.surface : theme.success}
|
||||||
|
>
|
||||||
[+]
|
[+]
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -407,7 +424,7 @@ function SearchPage() {
|
|||||||
: `Results · ${results().length}`;
|
: `Results · ${results().length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -14,13 +14,8 @@ const typeLabel = (sourceType?: SourceType) => {
|
|||||||
return "Source";
|
return "Source";
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
// No module-level typeColor here — it needs the theme from the component.
|
||||||
if (sourceType === SourceType.API) return theme.primary;
|
// The correct definition lives inside SourceBadge below.
|
||||||
if (sourceType === SourceType.RSS) return theme.success;
|
|
||||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
|
||||||
return theme.textMuted;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SourceBadge(props: SourceBadgeProps) {
|
export function SourceBadge(props: SourceBadgeProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const label = () => props.sourceName || props.sourceId;
|
const label = () => props.sourceName || props.sourceId;
|
||||||
|
|||||||
124
src/pages/Settings/DownloadManager.tsx
Normal file
124
src/pages/Settings/DownloadManager.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* 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/7 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
|
||||||
@@ -33,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;
|
||||||
|
|
||||||
@@ -61,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:
|
||||||
@@ -78,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 [];
|
||||||
}
|
}
|
||||||
@@ -377,7 +380,7 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
@@ -422,8 +425,10 @@ function Row(props: {
|
|||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const fg = () => (props.focused && props.active ? theme.surface : theme.text);
|
const fg = () => (props.focused && props.active ? theme.surface : 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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} 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,
|
||||||
@@ -55,7 +55,7 @@ export function createAppStore() {
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next).catch(() => {});
|
saveAppStateToFile(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateState = (next: AppState) => {
|
const updateState = (next: AppState) => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function createAudioNavStore() {
|
|||||||
|
|
||||||
/** 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 */
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* 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[] = [
|
||||||
@@ -24,168 +31,166 @@ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
|||||||
{ 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"],
|
|
||||||
|
/** Shape of a single entry in the remote JSON */
|
||||||
|
interface FeaturedEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
feedUrl: string;
|
||||||
|
author?: string;
|
||||||
|
categories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of the remote JSON manifest */
|
||||||
|
interface FeaturedManifest {
|
||||||
|
version: number;
|
||||||
|
podcasts: FeaturedEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
|
||||||
|
function entryToPodcast(entry: FeaturedEntry): Podcast {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
description: entry.description,
|
||||||
|
feedUrl: entry.feedUrl,
|
||||||
|
author: entry.author,
|
||||||
|
categories: entry.categories ?? [],
|
||||||
coverUrl: undefined,
|
coverUrl: undefined,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
isSubscribed: false,
|
isSubscribed: false,
|
||||||
},
|
};
|
||||||
{
|
}
|
||||||
id: "trend-2",
|
|
||||||
title: "The History Hour",
|
/** Reconcile isSubscribed state across the discover list against the feed store */
|
||||||
description: "Fascinating stories from history that shaped our world today.",
|
function syncSubscriptionState(
|
||||||
feedUrl: "https://example.com/historyhour.rss",
|
podcasts: Podcast[],
|
||||||
author: "History Channel",
|
subscribedUrls: Set<string>,
|
||||||
categories: ["Education", "History"],
|
subscribedIds: Set<string>,
|
||||||
lastUpdated: new Date(),
|
): Podcast[] {
|
||||||
isSubscribed: false,
|
return podcasts.map((p) => ({
|
||||||
},
|
...p,
|
||||||
{
|
isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
|
||||||
id: "trend-3",
|
}));
|
||||||
title: "Comedy Gold",
|
}
|
||||||
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
|
||||||
feedUrl: "https://example.com/comedygold.rss",
|
|
||||||
author: "Laugh Factory",
|
|
||||||
categories: ["Comedy", "Entertainment"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-4",
|
|
||||||
title: "Market Watch",
|
|
||||||
description: "Daily financial news, stock analysis, and investing tips.",
|
|
||||||
feedUrl: "https://example.com/marketwatch.rss",
|
|
||||||
author: "Finance Daily",
|
|
||||||
categories: ["Business", "News"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-5",
|
|
||||||
title: "Science Weekly",
|
|
||||||
description: "Breaking science news and in-depth analysis of the latest research.",
|
|
||||||
feedUrl: "https://example.com/scienceweekly.rss",
|
|
||||||
author: "Science Network",
|
|
||||||
categories: ["Science", "Education"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-6",
|
|
||||||
title: "True Crime Files",
|
|
||||||
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
|
||||||
feedUrl: "https://example.com/truecrime.rss",
|
|
||||||
author: "Crime Network",
|
|
||||||
categories: ["True Crime", "Documentary"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-7",
|
|
||||||
title: "Wellness Journey",
|
|
||||||
description: "Tips for mental and physical health, meditation, and mindful living.",
|
|
||||||
feedUrl: "https://example.com/wellness.rss",
|
|
||||||
author: "Health Media",
|
|
||||||
categories: ["Health", "Self-Help"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-8",
|
|
||||||
title: "Sports Talk Live",
|
|
||||||
description: "Live commentary, analysis, and interviews from the world of sports.",
|
|
||||||
feedUrl: "https://example.com/sportstalk.rss",
|
|
||||||
author: "Sports Network",
|
|
||||||
categories: ["Sports", "News"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-9",
|
|
||||||
title: "Creative Minds",
|
|
||||||
description: "Interviews with artists, designers, and creative professionals.",
|
|
||||||
feedUrl: "https://example.com/creativeminds.rss",
|
|
||||||
author: "Arts Weekly",
|
|
||||||
categories: ["Arts", "Culture"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trend-10",
|
|
||||||
title: "Dev Talk",
|
|
||||||
description: "Software development, programming tutorials, and tech career advice.",
|
|
||||||
feedUrl: "https://example.com/devtalk.rss",
|
|
||||||
author: "Code Academy",
|
|
||||||
categories: ["Technology", "Education"],
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 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[]>([]);
|
||||||
|
|
||||||
|
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
/** Reconcile local isSubscribed flags with the feed store */
|
||||||
|
const syncSubscriptions = () => {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feeds = feedStore.feeds();
|
||||||
|
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
|
||||||
|
const ids = new Set(feeds.map((f) => f.podcast.id));
|
||||||
|
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetch the featured-shows manifest from GitHub if stale */
|
||||||
|
const refresh = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Skip if cache is still fresh
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(FEATURED_JSON_URL, {
|
||||||
|
headers: { "User-Agent": "PodTUI/1.0" },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const manifest = (await resp.json()) as FeaturedManifest;
|
||||||
|
if (!manifest?.podcasts?.length) {
|
||||||
|
syncSubscriptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the podcast list from the manifest entries
|
||||||
|
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||||
|
cachedAt = now;
|
||||||
|
setPodcasts(fetched);
|
||||||
|
|
||||||
|
// Reflect current feed-store subscriptions
|
||||||
|
syncSubscriptions();
|
||||||
|
} catch {
|
||||||
|
// Network failure — keep whatever we have (stale or empty)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
/** Get filtered podcasts by category */
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
const category = selectedCategory()
|
const category = selectedCategory();
|
||||||
if (category === "all") {
|
if (category === "all") {
|
||||||
return podcasts()
|
return podcasts();
|
||||||
}
|
}
|
||||||
|
|
||||||
return podcasts().filter((p) => {
|
return podcasts().filter((p) => {
|
||||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
|
||||||
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
return cats.some((c) =>
|
||||||
})
|
c.includes(category.toLowerCase().replace("-", " ")),
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Subscribe to a podcast */
|
/** Subscribe to a podcast */
|
||||||
const subscribe = (podcastId: string) => {
|
const subscribe = (podcastId: string) => {
|
||||||
setPodcasts((prev) =>
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
prev.map((p) =>
|
if (podcast) {
|
||||||
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
// 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 */
|
/** Unsubscribe from a podcast */
|
||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
setPodcasts((prev) =>
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
prev.map((p) =>
|
if (podcast) {
|
||||||
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
// Remove the feed from the feed store
|
||||||
)
|
const feedStore = useFeedStore();
|
||||||
)
|
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||||
}
|
}
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Toggle subscription */
|
/** Toggle subscription */
|
||||||
const toggleSubscription = (podcastId: string) => {
|
const toggleSubscription = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId)
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast?.isSubscribed) {
|
if (podcast?.isSubscribed) {
|
||||||
unsubscribe(podcastId)
|
unsubscribe(podcastId);
|
||||||
} else {
|
} else {
|
||||||
subscribe(podcastId)
|
subscribe(podcastId);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh trending podcasts (mock) */
|
|
||||||
const refresh = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
|
||||||
// In real app, would fetch from API
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
@@ -201,15 +206,15 @@ export function createDiscoverStore() {
|
|||||||
unsubscribe,
|
unsubscribe,
|
||||||
toggleSubscription,
|
toggleSubscription,
|
||||||
refresh,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,95 +6,98 @@
|
|||||||
* 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() {
|
export 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
|
// 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:
|
||||||
|
rec.status === DownloadStatus.DOWNLOADING
|
||||||
|
? DownloadStatus.QUEUED
|
||||||
|
: rec.status,
|
||||||
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
||||||
filePath: rec.filePath,
|
filePath: rec.filePath,
|
||||||
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: rec.fileSize,
|
fileSize: rec.fileSize,
|
||||||
error: rec.error,
|
error: rec.error,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return map
|
return map;
|
||||||
} catch {
|
} catch {
|
||||||
return new Map()
|
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,
|
||||||
@@ -105,10 +108,10 @@ export function createDownloadStore() {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -116,7 +119,7 @@ export function createDownloadStore() {
|
|||||||
|
|
||||||
/** 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.
|
||||||
@@ -126,49 +129,52 @@ export function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
||||||
|
episodeId: string,
|
||||||
|
updates: Partial<DownloadedEpisode>,
|
||||||
|
): void {
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
const existing = next.get(episodeId)
|
const existing = next.get(episodeId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
next.set(episodeId, { ...existing, ...updates })
|
next.set(episodeId, { ...existing, ...updates });
|
||||||
}
|
}
|
||||||
return next
|
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
|
// 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,
|
||||||
@@ -179,13 +185,13 @@ export function createDownloadStore() {
|
|||||||
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, {
|
||||||
@@ -196,49 +202,52 @@ export function createDownloadStore() {
|
|||||||
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
|
// Create download entry
|
||||||
@@ -252,13 +261,13 @@ export function createDownloadStore() {
|
|||||||
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
|
// Add to queue
|
||||||
const queueItem: QueueItem = {
|
const queueItem: QueueItem = {
|
||||||
@@ -266,24 +275,24 @@ export function createDownloadStore() {
|
|||||||
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
|
// Remove from queue
|
||||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
updateDownload(episodeId, {
|
updateDownload(episodeId, {
|
||||||
@@ -291,46 +300,58 @@ export function createDownloadStore() {
|
|||||||
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(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||||
|
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
||||||
|
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 all downloads as an array */
|
/** Get all downloads as an array */
|
||||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
return Array.from(downloads().values())
|
return Array.from(downloads().values());
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get the current queue */
|
/** Get the current queue */
|
||||||
const getQueue = (): QueueItem[] => {
|
const getQueue = (): QueueItem[] => {
|
||||||
return queue()
|
return queue();
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get count of active downloads */
|
/** Get count of active downloads */
|
||||||
const getActiveCount = (): number => {
|
const getActiveCount = (): number => {
|
||||||
return activeCount()
|
return activeCount();
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Getters
|
// Getters
|
||||||
@@ -346,15 +367,16 @@ export function createDownloadStore() {
|
|||||||
startDownload,
|
startDownload,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
removeDownload,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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 {
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} 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,12 +34,12 @@ 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 */
|
||||||
@@ -62,14 +61,10 @@ export function createFeedStore() {
|
|||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// Filter by visibility
|
// 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
|
// Filter by source
|
||||||
@@ -184,12 +179,22 @@ export function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Check if a feed with this URL already exists */
|
||||||
|
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||||
|
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||||
|
};
|
||||||
|
|
||||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||||
const addFeed = async (
|
const addFeed = async (
|
||||||
podcast: Podcast,
|
podcast: Podcast,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||||
) => {
|
): Promise<Feed | null> => {
|
||||||
|
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||||
|
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||||
|
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const feedId = crypto.randomUUID();
|
const feedId = crypto.randomUUID();
|
||||||
const episodes = await fetchEpisodes(
|
const episodes = await fetchEpisodes(
|
||||||
podcast.feedUrl,
|
podcast.feedUrl,
|
||||||
@@ -300,6 +305,20 @@ export function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
|
||||||
|
const removeFeedByUrl = (feedUrl: string) => {
|
||||||
|
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
|
||||||
|
if (feed) {
|
||||||
|
fullEpisodeCache.delete(feed.id);
|
||||||
|
episodeLoadCount.delete(feed.id);
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||||
|
saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Update a feed */
|
/** Update a feed */
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
@@ -470,7 +489,9 @@ export function createFeedStore() {
|
|||||||
setFilter,
|
setFilter,
|
||||||
setSelectedFeedId,
|
setSelectedFeedId,
|
||||||
addFeed,
|
addFeed,
|
||||||
|
hasFeedByUrl,
|
||||||
removeFeed,
|
removeFeed,
|
||||||
|
removeFeedByUrl,
|
||||||
updateFeed,
|
updateFeed,
|
||||||
togglePinned,
|
togglePinned,
|
||||||
refreshFeed,
|
refreshFeed,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ 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 */
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -62,7 +62,7 @@ export type DesktopTheme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type VisualizerSettings = {
|
export type VisualizerSettings = {
|
||||||
/** Number of frequency bars (8–128, default: 32) */
|
/** Number of frequency bars (8–128, 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;
|
||||||
|
|||||||
@@ -180,12 +180,14 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
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 `:` in
|
||||||
|
// keybinds.jsonc). The old hardcoded "command_list" name was never a
|
||||||
|
// canonical action, so the palette was unreachable dead code.
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (value.suspended()) return;
|
if (value.suspended()) return;
|
||||||
if (dialog.isOpen) return;
|
if (dialog.isOpen) return;
|
||||||
if (evt.defaultPrevented) return;
|
if (evt.defaultPrevented) return;
|
||||||
if (keybind.match("command_list", evt)) {
|
if (keybind.match("command", evt)) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
value.show();
|
value.show();
|
||||||
return;
|
return;
|
||||||
@@ -279,7 +281,11 @@ function CommandDialog(props: {
|
|||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Command list */}
|
{/* Command list */}
|
||||||
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<For each={filteredOptions().slice(0, 10)}>
|
<For each={filteredOptions().slice(0, 10)}>
|
||||||
{(option, index) => (
|
{(option, index) => (
|
||||||
<SelectableBox
|
<SelectableBox
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 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,
|
||||||
@@ -14,10 +17,6 @@ import type {
|
|||||||
} 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 = {
|
||||||
@@ -47,41 +46,36 @@ const defaultState: AppState = {
|
|||||||
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;
|
|
||||||
|
|
||||||
const raw = await file.json();
|
|
||||||
if (!raw || typeof raw !== "object") return defaultState;
|
|
||||||
|
|
||||||
const parsed = raw as Partial<AppState>;
|
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings, ...parsed.settings },
|
settings: { ...defaultSettings, ...cfg.settings },
|
||||||
preferences: { ...defaultPreferences, ...parsed.preferences },
|
preferences: { ...defaultPreferences, ...cfg.preferences },
|
||||||
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaultState;
|
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;
|
||||||
@@ -107,32 +101,29 @@ export async function loadProgressFromFile(): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 backupConfigFile(PROGRESS_FILE);
|
await Bun.write(
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE);
|
getConfigFilePath(PROGRESS_FILE),
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
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();
|
||||||
@@ -144,15 +135,17 @@ export async function loadAudioNavFromFile<T>(): Promise<T | 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();
|
||||||
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
await Bun.write(
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
getConfigFilePath(AUDIO_NAV_FILE),
|
||||||
|
JSON.stringify(data, null, 2),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -381,467 +378,6 @@ export 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +30,32 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,72 +72,88 @@ export class AudioStreamReader {
|
|||||||
*/
|
*/
|
||||||
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.5–100.0; chain multiple for extremes.
|
// ffmpeg atempo only supports 0.5–100.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
|
// Capture generation for this run
|
||||||
const myGeneration = this.generation
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
// Start async reading loop
|
// Start async reading loop
|
||||||
this.readLoop(myGeneration)
|
this.readLoop(myGeneration);
|
||||||
|
|
||||||
// Detect process exit
|
// Detect process exit
|
||||||
this.proc.exited.then(() => {
|
this.proc.exited
|
||||||
|
.then(() => {
|
||||||
// Only clear _running if this is still the current generation
|
// Only clear _running if this is still the current generation
|
||||||
if (this.generation === myGeneration) {
|
if (this.generation === myGeneration) {
|
||||||
this._running = false
|
this._running = false;
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._running = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,21 +164,27 @@ export class AudioStreamReader {
|
|||||||
* @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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,59 +193,67 @@ export class AudioStreamReader {
|
|||||||
*/
|
*/
|
||||||
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 +264,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(",");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,27 +16,29 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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.0–1.0 (default: 0.77) */
|
/** Noise reduction factor 0.0–1.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> = {
|
||||||
@@ -47,20 +49,25 @@ const DEFAULTS: Required<CavaCoreConfig> = {
|
|||||||
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 =
|
||||||
|
platform === "darwin"
|
||||||
? "libcavacore.dylib"
|
? "libcavacore.dylib"
|
||||||
: platform === "win32"
|
: platform === "win32"
|
||||||
? "cavacore.dll"
|
? "cavacore.dll"
|
||||||
: "libcavacore.so"
|
: "libcavacore.so";
|
||||||
|
|
||||||
// Candidate paths, in priority order:
|
// Candidate paths, in priority order:
|
||||||
// 1. src/native/ (development)
|
// 1. src/native/ (development)
|
||||||
@@ -70,39 +77,39 @@ function findLibrary(): string | null {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,12 +119,12 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
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,
|
||||||
@@ -127,15 +134,16 @@ export class CavaCore {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,23 +156,23 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,12 +181,12 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +199,8 @@ export class CavaCore {
|
|||||||
*/
|
*/
|
||||||
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: {
|
||||||
@@ -204,6 +212,7 @@ export function loadCavaCore(): CavaCore | null {
|
|||||||
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,
|
||||||
},
|
},
|
||||||
@@ -220,11 +229,11 @@ export function loadCavaCore(): CavaCore | null {
|
|||||||
args: [FFIType.ptr], // plan
|
args: [FFIType.ptr], // plan
|
||||||
returns: FFIType.void,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
187
src/utils/config.ts
Normal file
187
src/utils/config.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Centralized PodTui configuration — a single `config.json` holding every
|
||||||
|
* user-facing bit needed to migrate to a new machine by copying one file.
|
||||||
|
*
|
||||||
|
* Contains: settings, preferences, custom theme, feeds (subscriptions), and
|
||||||
|
* sources (podcast search/RSS sources).
|
||||||
|
*
|
||||||
|
* Runtime state that changes on every playback action (progress, downloads,
|
||||||
|
* audio-nav) stays in separate files to avoid rewriting this file on every
|
||||||
|
* seek. Keybinds remain in `keybinds.jsonc` (user-editable JSONC).
|
||||||
|
*
|
||||||
|
* Writes are serialized to avoid concurrent read-modify-write races, and
|
||||||
|
* always overwrite — no backup files are created.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
|
import type {
|
||||||
|
AppSettings,
|
||||||
|
UserPreferences,
|
||||||
|
ThemeColors,
|
||||||
|
} from "../types/settings";
|
||||||
|
import type { Feed } from "../types/feed";
|
||||||
|
import type { PodcastSource } from "../types/source";
|
||||||
|
|
||||||
|
/** Everything a user needs to migrate, in one file. */
|
||||||
|
export interface PodTuiConfig {
|
||||||
|
settings?: AppSettings;
|
||||||
|
preferences?: UserPreferences;
|
||||||
|
customTheme?: ThemeColors;
|
||||||
|
feeds?: Feed[];
|
||||||
|
sources?: PodcastSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_FILE = "config.json";
|
||||||
|
|
||||||
|
/** Legacy per-section files, migrated into config.json on first load. */
|
||||||
|
const LEGACY_FILES = ["app-state.json", "feeds.json", "sources.json"] as const;
|
||||||
|
|
||||||
|
/** Load the full config from disk. Returns {} if missing or corrupt.
|
||||||
|
* Runs one-time legacy migration on first call. */
|
||||||
|
export async function loadConfig(): Promise<PodTuiConfig> {
|
||||||
|
await migrateOnce();
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath(CONFIG_FILE));
|
||||||
|
if (!(await file.exists())) return {};
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!raw || typeof raw !== "object") return {};
|
||||||
|
return raw as PodTuiConfig;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write serialization ────────────────────────────────────────────────────
|
||||||
|
// A simple promise chain ensures reads-modify-writes execute sequentially so
|
||||||
|
// two concurrent saves can't clobber each other's sections.
|
||||||
|
let writeChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||||
|
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||||
|
writeChain = writeChain.then(async () => {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const current = await loadConfig();
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(CONFIG_FILE),
|
||||||
|
JSON.stringify(next, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget persistence — silently ignore write errors.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Await all pending config writes (used by sync/export flows). */
|
||||||
|
export async function flushConfig(): Promise<void> {
|
||||||
|
await writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards so migration runs exactly once per process. */
|
||||||
|
let migrationDone = false;
|
||||||
|
let migrationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Run legacy migration + backup cleanup once, before the first config read. */
|
||||||
|
async function migrateOnce(): Promise<void> {
|
||||||
|
if (migrationDone) return;
|
||||||
|
if (!migrationPromise) migrationPromise = migrateLegacyConfig();
|
||||||
|
await migrationPromise;
|
||||||
|
migrationDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: if config.json doesn't exist but legacy per-section
|
||||||
|
* files do, merge them into a single config.json. Also cleans up any stale
|
||||||
|
* backup files (`.backup` suffix) left by the old config-backup module.
|
||||||
|
*
|
||||||
|
* Safe to call on every startup — no-op once config.json exists (except for
|
||||||
|
* backup cleanup, which runs unconditionally since those files are now dead).
|
||||||
|
*/
|
||||||
|
export async function migrateLegacyConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const dir = getConfigDir();
|
||||||
|
const configExists = await Bun.file(
|
||||||
|
getConfigFilePath(CONFIG_FILE),
|
||||||
|
).exists();
|
||||||
|
|
||||||
|
if (!configExists) {
|
||||||
|
const merged: PodTuiConfig = {};
|
||||||
|
|
||||||
|
// app-state.json → settings, preferences, customTheme
|
||||||
|
const appStateFile = Bun.file(getConfigFilePath("app-state.json"));
|
||||||
|
if (await appStateFile.exists()) {
|
||||||
|
try {
|
||||||
|
const raw = await appStateFile.json();
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
merged.settings = raw.settings;
|
||||||
|
merged.preferences = raw.preferences;
|
||||||
|
merged.customTheme = raw.customTheme;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupt legacy file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feeds.json → feeds
|
||||||
|
const feedsFile = Bun.file(getConfigFilePath("feeds.json"));
|
||||||
|
if (await feedsFile.exists()) {
|
||||||
|
try {
|
||||||
|
const raw = await feedsFile.json();
|
||||||
|
if (Array.isArray(raw)) merged.feeds = raw;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sources.json → sources
|
||||||
|
const sourcesFile = Bun.file(getConfigFilePath("sources.json"));
|
||||||
|
if (await sourcesFile.exists()) {
|
||||||
|
try {
|
||||||
|
const raw = await sourcesFile.json();
|
||||||
|
if (Array.isArray(raw)) merged.sources = raw;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(merged).length > 0) {
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(CONFIG_FILE),
|
||||||
|
JSON.stringify(merged, null, 2),
|
||||||
|
);
|
||||||
|
// Remove migrated legacy files
|
||||||
|
for (const name of LEGACY_FILES) {
|
||||||
|
await Bun.file(getConfigFilePath(name))
|
||||||
|
.exists()
|
||||||
|
.then(async (exists) => {
|
||||||
|
if (exists)
|
||||||
|
await import("fs/promises").then((fs) =>
|
||||||
|
fs.unlink(getConfigFilePath(name)).catch(() => {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale backup files (no longer created, remove old ones)
|
||||||
|
await cleanBackups(dir);
|
||||||
|
} catch {
|
||||||
|
// Migration is best-effort — never block startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all `.backup` files from the config directory. */
|
||||||
|
async function cleanBackups(dir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { readdir, unlink } = await import("fs/promises");
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
const backups = entries.filter((e) => e.endsWith(".backup"));
|
||||||
|
for (const name of backups) {
|
||||||
|
await unlink(`${dir}/${name}`).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
|||||||
"sort",
|
"sort",
|
||||||
"toggle-hidden",
|
"toggle-hidden",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"unsubscribe",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ export type AppEvents = {
|
|||||||
"player.play": { episodeId: string };
|
"player.play": { episodeId: string };
|
||||||
"player.pause": { episodeId: string };
|
"player.pause": { episodeId: string };
|
||||||
"player.stop": {};
|
"player.stop": {};
|
||||||
"auth.login": { userId: string };
|
|
||||||
"auth.logout": {};
|
|
||||||
"toast.show": {
|
"toast.show": {
|
||||||
message: string;
|
message: string;
|
||||||
variant: "info" | "success" | "warning" | "error";
|
variant: "info" | "success" | "warning" | "error";
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
* Feeds & sources persistence — stored in the centralized `config.json`
|
||||||
*
|
* (see utils/config.ts). No backups; writes always overwrite.
|
||||||
* Reads and writes feeds to a JSON file instead of localStorage.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import { backupConfigFile } from "./config-backup";
|
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
|
import type { PodcastSource } from "../types/source";
|
||||||
const FEEDS_FILE = "feeds.json";
|
|
||||||
const SOURCES_FILE = "sources.json";
|
|
||||||
|
|
||||||
/** Deserialize date strings back to Date objects in feed data */
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
function reviveDates(feed: Feed): Feed {
|
function reviveDates(feed: Feed): Feed {
|
||||||
@@ -27,56 +23,34 @@ function reviveDates(feed: Feed): Feed {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load feeds from JSON file */
|
/** Load feeds from config.json */
|
||||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!Array.isArray(cfg.feeds)) return [];
|
||||||
if (!(await file.exists())) return [];
|
return cfg.feeds.map(reviveDates);
|
||||||
|
|
||||||
const raw = await file.json();
|
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
return raw.map(reviveDates);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to JSON file */
|
/** Save feeds to config.json */
|
||||||
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
export function saveFeedsToFile(feeds: Feed[]): void {
|
||||||
try {
|
updateConfig({ feeds });
|
||||||
await ensureConfigDir();
|
|
||||||
await backupConfigFile(FEEDS_FILE);
|
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
|
||||||
await Bun.write(filePath, JSON.stringify(feeds, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Silently ignore write errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load sources from JSON file */
|
/** Load sources from config.json */
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!Array.isArray(cfg.sources)) return null;
|
||||||
if (!(await file.exists())) return null;
|
return cfg.sources as T[];
|
||||||
|
|
||||||
const raw = await file.json();
|
|
||||||
if (!Array.isArray(raw)) return null;
|
|
||||||
return raw as T[];
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to JSON file */
|
/** Save sources to config.json */
|
||||||
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||||
try {
|
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||||
await ensureConfigDir();
|
|
||||||
await backupConfigFile(SOURCES_FILE);
|
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
|
||||||
await Bun.write(filePath, JSON.stringify(sources, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Silently ignore write errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
|||||||
sort: [","],
|
sort: [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
refresh: ["r"],
|
refresh: ["r"],
|
||||||
|
unsubscribe: ["x"],
|
||||||
// audio transport (preserved; shifted single keys, no collisions)
|
// audio transport (preserved; shifted single keys, no collisions)
|
||||||
"audio-toggle": ["P"],
|
"audio-toggle": ["P"],
|
||||||
"audio-next": ["N"],
|
"audio-next": ["N"],
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { AppSettings, UserPreferences } from "../types/settings"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
settings: "podtui_settings",
|
|
||||||
preferences: "podtui_preferences",
|
|
||||||
feeds: "podtui_feeds",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const savePreference = (key: keyof UserPreferences, value: boolean) => {
|
|
||||||
const current = loadPreferences()
|
|
||||||
const next = { ...current, [key]: value }
|
|
||||||
savePreferences(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadPreference = (key: keyof UserPreferences) => {
|
|
||||||
return loadPreferences()[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveSettings = (settings: AppSettings) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadSettings = (): AppSettings | null => {
|
|
||||||
if (typeof localStorage === "undefined") return null
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.settings)
|
|
||||||
return raw ? (JSON.parse(raw) as AppSettings) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const savePreferences = (preferences: UserPreferences) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.preferences, JSON.stringify(preferences))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadPreferences = (): UserPreferences => {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return { showExplicit: false, autoDownload: false }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.preferences)
|
|
||||||
return raw ? (JSON.parse(raw) as UserPreferences) : { showExplicit: false, autoDownload: false }
|
|
||||||
} catch {
|
|
||||||
return { showExplicit: false, autoDownload: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveFeeds = (feeds: Feed[]) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadFeeds = (): Feed[] => {
|
|
||||||
if (typeof localStorage === "undefined") return []
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.feeds)
|
|
||||||
return raw ? (JSON.parse(raw) as Feed[]) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
||||||
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
||||||
* keep spawned players (mpv/ffplay/afplay) from surviving the host, useAudio
|
* keep spawned players (mpv) from surviving the host, useAudio
|
||||||
* registers a `process.on("exit")` handler that synchronously disposes the
|
* registers a `process.on("exit")` handler that synchronously disposes the
|
||||||
* backend. The exit handler's whole job is "kill the child process", so this
|
* backend. The exit handler's whole job is "kill the child process", so this
|
||||||
* test pins the contract directly: a backend holding a real spawned subprocess
|
* test pins the contract directly: a backend holding a real spawned subprocess
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*
|
*
|
||||||
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
||||||
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
||||||
* mpv/ffplay/afplay all share the identical kill-on-dispose pattern, so
|
* mpv is the only real backend, and it uses the kill-on-dispose
|
||||||
* exercising one is enough to guard the family.
|
* exercising one is enough to guard the family.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
|
|||||||
@@ -2,14 +2,29 @@
|
|||||||
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
|
||||||
* Run: bun tests/cavacore-smoke.ts
|
* Run: bun tests/cavacore-smoke.ts
|
||||||
*/
|
*/
|
||||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
||||||
import { join } from "path"
|
import { join } from "path";
|
||||||
|
|
||||||
const libPath = join(import.meta.dir, "..", "src", "native", "libcavacore.dylib")
|
const libPath = join(
|
||||||
|
import.meta.dir,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"native",
|
||||||
|
"libcavacore.dylib",
|
||||||
|
);
|
||||||
|
|
||||||
const lib = dlopen(libPath, {
|
const lib = dlopen(libPath, {
|
||||||
cava_init: {
|
cava_init: {
|
||||||
args: [FFIType.i32, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.double, FFIType.i32, FFIType.i32],
|
args: [
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.u32,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.double,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.i32,
|
||||||
|
FFIType.i32,
|
||||||
|
],
|
||||||
returns: FFIType.ptr,
|
returns: FFIType.ptr,
|
||||||
},
|
},
|
||||||
cava_execute: {
|
cava_execute: {
|
||||||
@@ -20,39 +35,52 @@ const lib = dlopen(libPath, {
|
|||||||
args: [FFIType.ptr],
|
args: [FFIType.ptr],
|
||||||
returns: FFIType.void,
|
returns: FFIType.void,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const bars = 10
|
const bars = 10;
|
||||||
const rate = 44100
|
const rate = 44100;
|
||||||
const channels = 1
|
const channels = 1;
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
const plan = lib.symbols.cava_init(bars, rate, channels, 1, 0.77, 50, 10000)
|
const plan = lib.symbols.cava_init(
|
||||||
|
bars,
|
||||||
|
rate,
|
||||||
|
channels,
|
||||||
|
1,
|
||||||
|
0.77,
|
||||||
|
50,
|
||||||
|
10000,
|
||||||
|
0 /* CAVA_SCALING_LINEAR */,
|
||||||
|
);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
console.error("FAIL: cava_init returned null")
|
console.error("FAIL: cava_init returned null");
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("cava_init OK, plan pointer:", plan)
|
console.log("cava_init OK, plan pointer:", plan);
|
||||||
|
|
||||||
// Generate a 200Hz sine wave test signal
|
// Generate a 200Hz sine wave test signal
|
||||||
const bufferSize = 512
|
const bufferSize = 512;
|
||||||
const cavaIn = new Float64Array(bufferSize)
|
const cavaIn = new Float64Array(bufferSize);
|
||||||
const cavaOut = new Float64Array(bars * channels)
|
const cavaOut = new Float64Array(bars * channels);
|
||||||
|
|
||||||
for (let k = 0; k < 100; k++) {
|
for (let k = 0; k < 100; k++) {
|
||||||
for (let n = 0; n < bufferSize; n++) {
|
for (let n = 0; n < bufferSize; n++) {
|
||||||
cavaIn[n] = Math.sin(2 * Math.PI * 200 / rate * (n + k * bufferSize)) * 20000
|
cavaIn[n] =
|
||||||
|
Math.sin(((2 * Math.PI * 200) / rate) * (n + k * bufferSize)) * 20000;
|
||||||
}
|
}
|
||||||
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan)
|
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("cava_execute OK, output:", Array.from(cavaOut).map(v => v.toFixed(3)))
|
console.log(
|
||||||
|
"cava_execute OK, output:",
|
||||||
|
Array.from(cavaOut).map((v) => v.toFixed(3)),
|
||||||
|
);
|
||||||
|
|
||||||
// Check that bar 2 (200Hz) has the peak
|
// Check that bar 2 (200Hz) has the peak
|
||||||
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut))
|
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut));
|
||||||
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`)
|
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`);
|
||||||
|
|
||||||
// Destroy
|
// Destroy
|
||||||
lib.symbols.cava_destroy(plan)
|
lib.symbols.cava_destroy(plan);
|
||||||
console.log("cava_destroy OK")
|
console.log("cava_destroy OK");
|
||||||
console.log("\nSMOKE TEST PASSED")
|
console.log("\nSMOKE TEST PASSED");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* YaziPaneRow tests — the 1:3:3 parent|current|preview layout primitive.
|
* PaneRow tests — the 1:3:3 parent|current|preview layout primitive.
|
||||||
*
|
*
|
||||||
* Verified through the opentui test renderer's captured frames (the same
|
* Verified through the opentui test renderer's captured frames (the same
|
||||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
import { describe, test, expect, afterAll } from "bun:test";
|
import { describe, test, expect, afterAll } from "bun:test";
|
||||||
import { testRender } from "@opentui/solid";
|
import { testRender } from "@opentui/solid";
|
||||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
import { YaziPaneRow } from "../src/components/YaziPaneRow";
|
import { PaneRow } from "../src/components/PaneRow";
|
||||||
|
|
||||||
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
||||||
type Frame = { lines: { spans: Span[] }[] };
|
type Frame = { lines: { spans: Span[] }[] };
|
||||||
@@ -88,7 +88,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
const setup = await testRender(
|
const setup = await testRender(
|
||||||
() => (
|
() => (
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={props.parent as any}
|
parent={props.parent as any}
|
||||||
current={props.current as any}
|
current={props.current as any}
|
||||||
preview={props.preview as any}
|
preview={props.preview as any}
|
||||||
@@ -106,7 +106,12 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
await new Promise((r) => setTimeout(r, 40));
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
}
|
}
|
||||||
const spans = setup.captureSpans() as unknown as Frame;
|
const spans = setup.captureSpans() as unknown as Frame;
|
||||||
return { spans, destroy: () => setup.renderer.destroy() };
|
return {
|
||||||
|
spans,
|
||||||
|
destroy: async () => {
|
||||||
|
setup.renderer.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanups: (() => void | Promise<void>)[] = [];
|
const cleanups: (() => void | Promise<void>)[] = [];
|
||||||
@@ -121,7 +126,7 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
|
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
|
||||||
describe("YaziPaneRow layout", () => {
|
describe("PaneRow layout", () => {
|
||||||
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
|
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
@@ -167,7 +172,7 @@ describe("YaziPaneRow layout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Integration: focused toggles the accent ring on the current column ─────
|
// ── Integration: focused toggles the accent ring on the current column ─────
|
||||||
describe("YaziPaneRow focus ring", () => {
|
describe("PaneRow focus ring", () => {
|
||||||
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "@opentui/solid",
|
"jsxImportSource": "@opentui/solid",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user