Compare commits

..

2 Commits

Author SHA1 Message Date
25fe7f6ac9 theme rendering 2026-08-07 13:42:03 -04:00
85cb9fba26 ui cleanup 2026-08-07 13:38:32 -04:00
24 changed files with 1842 additions and 1376 deletions

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

@@ -0,0 +1,83 @@
name: release
# Builds standalone PodTui binaries for each supported OS/arch and attaches
# them to a GitHub Release. One runner per platform because Bun cannot
# cross-compile — each runner runs `make dist`, which emits a
# podtui-<platform>-<arch>.tar.gz (binary + native libs side by side).
#
# Trigger: push a tag like v0.1.0. Bump VERSION in src/index.tsx in the same
# commit as the tag so the released binary reports the tagged version.
on: # intentional: YAML `on` key
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
name: build (${{ matrix.os }} / ${{ matrix.arch }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
arch: x64
plat: linux
- os: ubuntu-24.04-arm
arch: arm64
plat: linux
- os: macos-latest
arch: x64
plat: darwin
- os: macos-14
arch: arm64
plat: darwin
steps:
- name: Check out repo
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build native cavacore library
run: scripts/build-cavacore.sh
- name: Build standalone binary + tarball
run: make dist
- name: Smoke-test binary boot
env:
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
run: |
tar -xzf dist/$DIST_TAR -C dist
./dist/podtui --version
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
path: dist/podtui-*.tar.gz
upload:
name: Attach to GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all binaries
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Publish release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: artifacts/**/*.tar.gz

65
Makefile Normal file
View File

@@ -0,0 +1,65 @@
# PodTui — Makefile
#
# Development targets:
# make install install dependencies (bun install) + build native lib
# make dev run with hot reload
# make test run the test suite
# make build produce the JS bundle + native libs in dist/
# make native build the cavacore FFI library from C source
# make lint run typecheck-style checks (lsp), not eslint
#
# Packaging / release targets:
# make dist build a standalone compiled binary + tarball for the
# CURRENT platform (see dist/ for podtui + libs + tarball)
# make dist-mac alias for `dist` targeting macOS (run on macOS)
# make dist-linux alias for `dist` targeting Linux (run on Linux)
# make clean remove dist/ output
#
# Cross-platform binaries are produced by CI (GitHub Actions) with one runner
# per OS/arch — Bun cannot cross-compile, so dist:mac / dist:linux only produce
# the binary for the OS they run on. Each runner runs `make dist` and uploads
# its podtui-<platform>-<arch>.tar.gz artifact.
SHELL := /bin/bash
.PHONY: install dev build native dist dist-mac dist-linux test clean
## Install dependencies and build the native runtime library.
install:
bun install
make native
## Run the dev server with hot reload.
dev:
bun run dev
## Type-check the whole project. (See AGENTS.md: `bun run lint` points at a
## nonexistent lint.ts; LSP diagnostics are the maintained clean bar.)
lint:
bun tsc --noEmit
## Build the JS bundle + native libs into dist/ (the `podtui` npm bin target).
build:
bun run build
## Build the cavacore FFI library from src/native/cavacore.c.
native:
scripts/build-cavacore.sh
## Standalone binary + native-libs tarball for the current platform.
## Compiles against an empty bunfig so the binary does not bake the
## @opentui/solid/preload entry (which would break the compiled executable).
dist:
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
## macOS build (run on a macOS runner / host).
dist-mac:
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
## Linux build (run on a Linux runner / host).
dist-linux:
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile
## Remove build artifacts.
clean:
rm -rf dist

210
README.md
View File

@@ -1,15 +1,215 @@
# solid
# PodTui
To install dependencies:
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
browse episodes in a three-pane file-manager layout, and play audio through an
external player with full transport control — all from your terminal.
## Features
- **Vim/yazi-style navigation** — `j/k` to move, `h/l` to swipe between panes,
`Enter` to open, `16` / `[` `]` to switch tabs. The tab list is the app root:
at launch it fills the current pane, and drilling into a tab's contents slides
it into the parent pane.
- **Three-pane view** — parent / current / preview (Up | Current | Preview),
mirroring yazi's pane model.
- **Podcast feeds** — add feeds, browse episodes, and manage your library
(My Shows, Discover, Feed tabs).
- **Search** across your subscribed shows.
- **Audio playback** through an external player with full transport control:
play/pause, next/previous, seek, speed, and per-episode resume progress.
- **Themeable** and **remappable keybindings**.
- Ships as a **standalone compiled binary** — no runtime or install step beyond
a system audio player.
## Requirements
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
tmux, GNOME Terminal, etc.).
- An **audio player** on `PATH`. PodTui auto-detects in priority order:
| Player | Platforms | Seek | Speed | Position tracking |
|----------|----------------|:----:|:-----:|:------------------|
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
| `ffplay` | any | ✔ | ✘ | ✘ |
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
Install `mpv` for the best experience (`brew install mpv`,
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
## Installation
PodTui distributes as a **self-contained binary** for macOS (arm64/x64) and
Linux (arm64/x64). Pick whichever fits your platform.
### 1. Homebrew (macOS)
```sh
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv
```
> The formula installs the standalone binary plus its two native libraries
> side by side (see [Packaging model](#packaging-model)). It does **not**
> depend on Bun.
### 2. Standalone tarball (all platforms)
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
[GitHub Release](https://github.com/mikefreno/podtui/releases), unpack it, and
put `podtui` on your `PATH`:
```bash
curl -sSL -o podtui.tar.gz \
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
tar -xzf podtui.tar.gz
sudo install -m755 podtui /usr/local/bin/podtui
```
> The tarball contains `podtui` plus `libopentui.<ext>` and
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
> binary alone), or the native FFI libraries won't load.
### 3. Arch Linux (AUR)
```bash
yay -S podtui
```
or build from the PKGBUILD (`podtui-bin`). The package installs the released
binary and its sibling libraries.
### 4. From source
Requires [Bun](https://bun.sh) ≥ 1.2.
```bash
git clone https://github.com/mikefreno/podtui.git
cd podtui
bun install
bun run build:native # build the cavacore FFI lib from C source
bun run dev # run with hot reload, or: bun start
```
To run:
## Linux distribution notes
PodTUI deliberately does **not** ship `.deb`, `.rpm`, Flatpak, or Snap
packages. For a terminal application that's overwhelmingly installed through
repositories or archives, those formats add desktop-sandboxing overhead and a
packaging tax with little benefit. Instead:
- **GitHub Release tarballs** are the universal path — one upload, works on
any distro with `curl` + `tar`.
- **AUR (`podtui-bin`)** covers Arch. Anyone on Arch/Manjaro gets the same
binary through their native package manager.
- **Nix / cross-distro** users can build from source (or a Nix flake can be
added later).
This keeps maintenance to a single build per OS/arch and still reaches the
vast majority of desktop Linux users through their preferred path.
## Usage
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~`
for the in-app help.
### Command-line flags
| Flag | Description |
|------|-------------|
| `-v`, `--version` | Print the version and exit |
| `-q`, `--query <term>` | Query feeds for a show title and print matching shows, without launching the TUI |
| `-p`, `--play <term>` | Play the matching show, without launching the TUI |
### Keybindings
All keys are remappable — edit `~/.config/podtui/keybinds.jsonc`.
| Keys | Action |
|------|--------|
| `j` / `k` | Move cursor down / up |
| `J` / `K` | Jump 5 lines |
| `ctrl-d` / `ctrl-u` | Page down / up |
| `gg` / `G` | Go to top / bottom |
| `h` / `l` | Swipe to parent pane / preview pane |
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
| `Space` | Select / toggle selection |
| `v` | Visual mode (multi-select) |
| `1``6` | Jump to tab 16 (Feed, My Shows, Discover, Search, Player, Settings) |
| `[` / `]` | Previous / next tab |
| `P` (shift) | Play / pause |
| `N` / `B` | Next / previous episode |
| `shift-.` / `shift-,` | Seek forward / backward |
| `s` | Search (in a list) |
| `f` | Filter |
| `r` | Refresh |
| `:` | Command bar |
| `~`, `f1` | Help |
| `q`, `ctrl-c` | Quit |
| `Esc` | Escape / cancel |
## Configuration
Configuration lives under the XDG config directory — `~/.config/podtui` by
default (`$XDG_CONFIG_HOME/podtui` if set).
| File | Purpose |
|------|---------|
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) |
| `sources.json` | Custom feed sources |
| `downloads.json` | Downloaded episode metadata |
| `keybinds.jsonc` | Keybinding remaps (see above) |
| `themes/` | Optional custom theme files |
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
the same OpenTUI environment variables.
## Development
```bash
bun dev
bun install # install dependencies
bun run dev # run with hot reload
bun test # run the test suite
bun run build # bundle JS + copy native libs into dist/
make native # rebuild cavacore from C source
make lint # type-check (tsc)
```
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.
### Releasing
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs
to your GitHub Release automatically:
```bash
make dist # build the standalone binary + tarball for THIS platform
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
```
`make dist` compiles against `bunfig.standalone.toml` (a preload-free Bun
config) so the emitted binary doesn't bake in the dev-only `@opentui/solid`
preload. The solid JSX transform is registered in `build.ts` itself.
## Packaging model
A release tarball is three files sitting side by side:
```
podtui # standalone compiled binary (embeds the Bun runtime)
libopentui.<dylib|so> # OpenTUI native renderer FFI library
libcavacore.<dylib|so> # cavacore spectrum FFI library (built from C)
```
PodTui loads its native libraries relative to the binary, so **keep them in
the same directory**. The compiled binary embeds the Bun runtime, so it runs
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
is no cross-compilation.
## License
TBD — choose and document a license before first release.
## Related
- [OpenTUI](https://github.com/opentui/opentui) — the TUI framework driving the interface

146
build.ts
View File

@@ -1,6 +1,33 @@
import solidPlugin from "@opentui/solid/bun-plugin"
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
import { join, dirname } from "node:path"
import solidPlugin from "@opentui/solid/bun-plugin";
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { plugin } from "bun";
// Register the solid transform globally (dedup'd by name). This is what makes
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins
// that are registered via `plugin()`, not the `plugins:` array. The compiled
// binary is then built against an empty bunfig (PODTUI_COMPILE config) so the
// runtime bakes NO preload — the solid transform is already in the binary.
plugin(solidPlugin);
const COMPILE =
process.argv.includes("--compile") || process.env.PODTUI_COMPILE === "1";
const platform = process.platform;
const arch = process.arch;
// Platform/arch → OpenTUI package name
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
};
const libExt =
platform === "win32" ? "dll" : platform === "darwin" ? "dylib" : "so";
// Build the JavaScript bundle
await Bun.build({
@@ -10,53 +37,94 @@ await Bun.build({
minify: true,
sourcemap: "external",
plugins: [solidPlugin],
})
});
// Copy the native library to dist for distribution
const platform = process.platform
const arch = process.arch
// Map platform/arch to OpenTUI package names
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
}
const platformKey = `${platform}-${arch}`
const platformPkg = platformMap[platformKey]
// Copy the opentui native library to dist for distribution.
const platformKey = `${platform}-${arch}`;
const platformPkg = platformMap[platformKey];
if (platformPkg) {
const libName = platform === "win32"
? "opentui.dll"
: platform === "darwin"
? "libopentui.dylib"
: "libopentui.so"
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName)
const libName = `libopentui.${libExt}`;
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName);
if (existsSync(srcPath)) {
const destPath = join("dist", libName)
copyFileSync(srcPath, destPath)
console.log(`Copied native library: ${libName}`)
const destPath = join("dist", libName);
copyFileSync(srcPath, destPath);
console.log(`Copied native library: ${libName}`);
}
}
// Copy cavacore native library to dist
const cavacoreLib = platform === "darwin"
? "libcavacore.dylib"
: platform === "win32"
? "cavacore.dll"
: "libcavacore.so"
const cavacoreSrc = join("src", "native", cavacoreLib)
const cavacoreLib = `libcavacore.${libExt}`;
const cavacoreSrc = join("src", "native", cavacoreLib);
if (existsSync(cavacoreSrc)) {
copyFileSync(cavacoreSrc, join("dist", cavacoreLib))
console.log(`Copied cavacore library: ${cavacoreLib}`)
copyFileSync(cavacoreSrc, join("dist", cavacoreLib));
console.log(`Copied cavacore library: ${cavacoreLib}`);
} else {
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`)
console.warn(
`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`,
);
}
console.log("Build complete")
// ── Standalone compiled binary (dist/podtui + libs beside it) ──────────────
// `bun run build.ts --compile` (or PODTUI_COMPILE=1). Embeds the Bun runtime
// so end users need nothing installed; the two FFI libs are shipped as
// SIBLING FILES next to the binary (both loaders already resolve them that
// way: cavacore checks dirname(process.execPath); opentui embeds via its
// bun-plugin and handles the embedded-file path itself).
if (COMPILE) {
const outfile = join("dist", "podtui");
await Bun.build({
entrypoints: ["./src/index.tsx"],
target: "bun",
minify: true,
sourcemap: "external",
plugins: [solidPlugin],
compile: {
outfile,
},
});
console.log(`Compiled standalone binary: ${outfile}`);
// Ensure both native libs sit beside the binary.
const opentuiSrc = join(
"node_modules",
`@opentui/core-${platformPkg}`,
`libopentui.${libExt}`,
);
if (existsSync(opentuiSrc)) {
copyFileSync(opentuiSrc, join("dist", `libopentui.${libExt}`));
}
if (!existsSync(join("dist", cavacoreLib))) {
console.warn(
`Warning: ${cavacoreLib} missing beside the binary — run scripts/build-cavacore.sh`,
);
}
// Tarball: podtui + the two native libs (drop the JS bundle dir)
const tarRoot = join("dist", `podtui-${platform}-${arch}`);
rmSync(tarRoot, { recursive: true, force: true });
mkdirSync(tarRoot, { recursive: true });
copyFileSync(outfile, join(tarRoot, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
}
const tar = Bun.spawnSync([
"tar",
"-czf",
`${tarRoot}.tar.gz`,
"-C",
"dist",
`podtui-${platform}-${arch}`,
]);
if (tar.exitCode !== 0) {
console.error(tar.stderr.toString());
process.exit(1);
}
console.log(`Tarball: ${tarRoot}.tar.gz`);
rmSync(tarRoot, { recursive: true, force: true });
}
console.log("Build complete");

11
bunfig.standalone.toml Normal file
View File

@@ -0,0 +1,11 @@
# 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

View File

@@ -1 +1,7 @@
- [ ] Audio play can survive quit out
- [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

View File

@@ -19,7 +19,6 @@ import { useNavigation, NavMode } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed";
import type { Episode } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph";
@@ -200,8 +199,16 @@ export function Shell() {
useKeyboard(
(evt: any) => {
// Input fields (search boxes, dialogs) own their keys.
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return;
// Input fields (search boxes, dialogs) own their keys — except Escape,
// which defocuses the input so j/k/h navigation resumes (search: h back
// to the tab root, j/k to move the recent-searches list).
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
if (evt.name === "escape") {
evt.preventDefault();
nav.setInputFocused(false);
}
return;
}
if (nav.mode() === NavMode.COMMAND) {
handleCommandKey(evt);
return;
@@ -460,8 +467,9 @@ export function playEpisodeAndSwitch(
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees.
export type { Episode };
export type { Episode } from "@/types/episode";

View File

@@ -2,12 +2,17 @@
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
* PREVIEW flow (replaces the old fixed chrome tab column).
*
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
* `focused` only matters to the surrounding frame (the CURRENT column draws
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
* column (`muted`), the cursor highlight is suppressed and only the active ●
* shows, so it reads as the read-only parent listing.
* Renders one row per tab (digit + label) using the same selection UI every
* other yazi pane uses: the CURSOR row (the one j/k hovers) gets a `` marker
* and the focus background (`theme.primary` when this pane is the CURRENT
* column, `theme.border` when it is the muted UP/parent column). The ACTIVE
* tab (the one whose content is open) always carries a `●` marker in accent so
* it stays readable in both positions.
*
* `muted` marks the parent-column rendering: the highlight is dimmed (border
* bg, text fg) rather than suppressed, so the Up pane still shows the cursor
* and active tab — matching how every other pane's parent column renders its
* focused row.
*/
import { For } from "solid-js";
@@ -34,18 +39,32 @@ export function TabListPane(props: { muted?: boolean }) {
const nav = useNavigation();
const cursor = () => nav.tabCursor();
const active = () => nav.activeTab();
const muted = () => props.muted ?? false;
const activeTab = () => nav.activeTab();
/** `active=true` when this pane is the CURRENT column (Shell root);
* `false` when it is the muted UP/parent column (pages' parent pane). */
const active = () => !props.muted;
// Same focus-bg / focus-fg contract every other pane uses.
const focusBg = (t: TABS) =>
t === cursor() && active()
? theme.primary
: t === cursor()
? theme.border
: undefined;
const focusFg = (t: TABS) =>
t === cursor() && active() ? theme.surface : theme.text;
return (
<For each={TAB_ORDER}>
{(tab) => {
const isCursor = () => cursor() === tab && !muted();
const isActive = () => active() === tab;
const fg = () =>
const isCursor = () => cursor() === tab;
const isActive = () => activeTab() === tab;
// The active tab is only accented in the Up/parent position — when this
// pane is CURRENT, the cursor highlight is the only highlight.
const labelFg = () =>
isCursor()
? theme.textSelectedPrimary
: isActive()
? focusFg(tab)
: isActive() && !active()
? theme.accent
: theme.text;
return (
@@ -53,21 +72,13 @@ export function TabListPane(props: { muted?: boolean }) {
width="100%"
height={1}
flexDirection="row"
backgroundColor={isCursor() ? theme.primary : "transparent"}
paddingRight={1}
backgroundColor={focusBg(tab)}
>
<text
width={2}
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
>
{isActive() ? "●" : " "}
</text>
<text
width={2}
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
>
{tab}
</text>
<text fg={fg()} paddingLeft={1}>
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text>
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
<text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]}
</text>
</box>

View File

@@ -11,7 +11,7 @@
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 1/7 slot when blank (never collapses to width 0).
* current — the current-depth list. The only focusable content column; it
* carries the accent focus ring when `focused` is truthy.
* carries the active-border focus ring when `focused` is truthy.
* preview — detail of the hovered item in `current`; always muted border.
*
* The primitive is purely structural: callers pass their own JSX per column
@@ -31,7 +31,7 @@
* />
*/
import { createMemo } from "solid-js";
import { createMemo, Show } from "solid-js";
import type { JSX } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
@@ -47,15 +47,19 @@ export type YaziPaneRowProps = {
parent?: PaneContent;
/** Current column content (the focused list). */
current?: PaneContent;
/** Preview column content (detail of the hovered item). */
/** Preview column content (detail of the hovered item). Omit/undefined
* together with `panes={2}` to render a 2-pane parent|current row. */
preview?: PaneContent;
parentLabel?: PaneLabel;
currentLabel?: PaneLabel;
previewLabel?: PaneLabel;
/** Whether the current column carries the accent focus ring. Defaults to
/** Whether the current column carries the active-border focus ring. Defaults to
* true; pass `false` (or a signal) when the row is inactive. Parent and
* preview columns always render muted borders. */
focused?: boolean | (() => boolean);
/** Number of visible columns. `3` (default) = parent|current|preview;
* `2` = parent|current (preview omitted, current grows to fill). */
panes?: 2 | 3;
};
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -107,7 +111,12 @@ function YaziPane(props: {
const scrollFocused = createMemo(() => props.scrollFocused());
return (
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
<box
flexDirection="column"
flexGrow={props.grow}
flexBasis={0}
height="100%"
>
{/* ── slim header label row ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>{props.label()}</text>
@@ -143,10 +152,10 @@ function YaziPane(props: {
export function YaziPaneRow(props: YaziPaneRowProps) {
const { theme } = useTheme();
/** true → the current column gets the accent focus ring. */
/** true → the current column gets the active-border focus ring. */
const focused = createMemo(() => {
const f = props.focused;
return typeof f === "function" ? f() : f ?? true;
return typeof f === "function" ? f() : (f ?? true);
});
// Normalize static JSX and accessor children into reactive accessors
@@ -159,6 +168,15 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
// 2-pane mode (parent|current) grows the current column to fill the
// preview slot. Defaults to 3 (parent|current|preview).
const panes = createMemo(() => props.panes ?? 3);
const currentGrow = createMemo(() =>
panes() === 2
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
@@ -169,15 +187,16 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
borderColor={() => theme.border}
scrollFocused={() => false}
/>
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
{/* ── current — the focused list; active-border ring when focused ──────────── */}
<YaziPane
grow={PANE_RATIO.current}
grow={currentGrow()}
label={currentLabel}
content={currentContent}
borderColor={() => (focused() ? theme.accent : theme.border)}
borderColor={() => (focused() ? theme.borderActive : theme.border)}
scrollFocused={() => focused()}
/>
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
<Show when={panes() === 3}>
<YaziPane
grow={PANE_RATIO.preview}
label={previewLabel}
@@ -185,6 +204,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
borderColor={() => theme.border}
scrollFocused={() => false}
/>
</Show>
</box>
);
}

View File

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

View File

@@ -12,315 +12,352 @@
* ```
*/
import { createSignal, onCleanup } from "solid-js"
import { createSignal, onCleanup } from "solid-js";
import {
createAudioBackend,
detectPlayers,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player"
import { emit, on } from "../utils/event-bus"
import { useAppStore } from "../stores/app"
import { useProgressStore } from "../stores/progress"
import { useMediaRegistry } from "../utils/media-registry"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"
import { useFeedStore } from "../stores/feed"
} from "../utils/audio-player";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry";
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useFeedStore } from "../stores/feed";
export interface AudioControls {
// Signals (reactive getters)
isPlaying: () => boolean
position: () => number
duration: () => number
volume: () => number
speed: () => number
backendName: () => BackendName
error: () => string | null
currentEpisode: () => Episode | null
availablePlayers: () => DetectedPlayer[]
isPlaying: () => boolean;
position: () => number;
duration: () => number;
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
availablePlayers: () => DetectedPlayer[];
// Actions
play: (episode: Episode) => Promise<void>
pause: () => Promise<void>
resume: () => Promise<void>
togglePlayback: () => Promise<void>
stop: () => Promise<void>
seek: (seconds: number) => Promise<void>
seekRelative: (delta: number) => Promise<void>
setVolume: (volume: number) => Promise<void>
setSpeed: (speed: number) => Promise<void>
switchBackend: (name: BackendName) => Promise<void>
prev: () => Promise<void>
next: () => Promise<void>
play: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
switchBackend: (name: BackendName) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
}
// Singleton state — shared across all components that call useAudio()
let backend: AudioBackend | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let refCount = 0
let pollCount = 0 // Counts poll ticks for throttling progress saves
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false)
const [position, setPosition] = createSignal(0)
const [duration, setDuration] = createSignal(0)
const [volume, setVolume] = createSignal(0.7)
const [speed, setSpeed] = createSignal(1)
const [backendName, setBackendName] = createSignal<BackendName>("none")
const [error, setError] = createSignal<string | null>(null)
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(0.7);
const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null);
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers()
setAvailablePlayers(detected)
backend = createAudioBackend()
setBackendName(backend.name)
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
registerExitTeardown();
}
return backend;
}
// ── Process-exit teardown ─────────────────────────────────────────────
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
// player (mpv/ffplay/afplay). Without this hook those child processes
// survive the host and keep playing audio after the TUI has quit. The
// `exit` event fires synchronously on `process.exit(N)`; the signal
// handlers cover Ctrl-C / kill, which otherwise terminate without running
// `exit` listeners.
let exitTeardownRegistered = false;
function registerExitTeardown(): void {
if (exitTeardownRegistered) return;
exitTeardownRegistered = true;
const teardown = (): void => {
stopPolling();
try {
backend?.dispose();
} catch {
/* best-effort at exit */
}
try {
useMediaRegistry().clearNowPlaying();
} catch {
/* best-effort at exit */
}
};
process.on("exit", teardown);
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(sig, () => {
teardown();
process.exit(0);
});
}
return backend
}
function startPolling(): void {
stopPolling()
pollCount = 0
stopPolling();
pollCount = 0;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return
if (!backend || !isPlaying()) return;
try {
const pos = await backend.getPosition()
const dur = await backend.getDuration()
setPosition(pos)
if (dur > 0) setDuration(dur)
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms)
pollCount++
pollCount++;
if (pollCount % 10 === 0) {
const ep = currentEpisode()
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
// Update platform media position
const media = useMediaRegistry()
media.setPosition(pos)
const media = useMediaRegistry();
media.setPosition(pos);
}
}
// Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false)
stopPolling()
setIsPlaying(false);
stopPolling();
// Save final position on track end
const ep = currentEpisode()
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
}
}
} catch {
// Backend may have been disposed
}
}, 500)
}, 500);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
clearInterval(pollTimer);
pollTimer = null;
}
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend()
setError(null)
const b = ensureBackend();
setError(null);
if (!episode.audioUrl) {
setError("No audio URL for this episode")
return
setError("No audio URL for this episode");
return;
}
try {
const appStore = useAppStore()
const progressStore = useProgressStore()
const storeSpeed = appStore.state().settings.playbackSpeed
const vol = volume()
const spd = storeSpeed || speed()
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id)
let startPos = 0
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position
startPos = savedProgress.position;
}
await b.play(episode.audioUrl, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
})
});
setCurrentEpisode(episode)
setIsPlaying(true)
setPosition(startPos)
setSpeed(spd)
if (episode.duration) setDuration(episode.duration)
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
// Register with platform media controls
const media = useMediaRegistry()
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: episode.podcastId,
duration: episode.duration,
})
media.setPlaybackState(true)
if (startPos > 0) media.setPosition(startPos)
});
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
startPolling()
emit("player.play", { episodeId: episode.id })
startPolling();
emit("player.play", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed")
setIsPlaying(false)
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
async function pause(): Promise<void> {
if (!backend) return
if (!backend) return;
try {
await backend.pause()
setIsPlaying(false)
stopPolling()
const ep = currentEpisode()
await backend.pause();
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore()
progressStore.update(ep.id, position(), duration(), speed())
emit("player.pause", { episodeId: ep.id })
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry()
media.setPlaybackState(false)
media.setPosition(position())
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed")
setError(err instanceof Error ? err.message : "Pause failed");
}
}
async function resume(): Promise<void> {
if (!backend) return
if (!backend) return;
try {
await backend.resume()
setIsPlaying(true)
startPolling()
const ep = currentEpisode()
await backend.resume();
setIsPlaying(true);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id })
const media = useMediaRegistry()
media.setPlaybackState(true)
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Resume failed")
setError(err instanceof Error ? err.message : "Resume failed");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause()
await pause();
} else if (currentEpisode()) {
await resume()
await resume();
}
}
async function stop(): Promise<void> {
if (!backend) return
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode()
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, position(), duration(), speed())
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop()
setIsPlaying(false)
setPosition(0)
setCurrentEpisode(null)
stopPolling()
emit("player.stop", {})
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
emit("player.stop", {});
// Clear platform media controls
const media = useMediaRegistry()
media.clearNowPlaying()
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed")
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return
const clamped = Math.max(0, Math.min(seconds, duration()))
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped)
setPosition(clamped)
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed")
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta)
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol))
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped)
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped)
setVolume(clamped);
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd))
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped)
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped)
setSpeed(clamped);
// Sync back to app store
try {
const appStore = useAppStore()
appStore.updateSettings({ playbackSpeed: clamped })
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
} catch {
// Store may not be available
}
}
async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying()
const ep = currentEpisode()
const pos = position()
const vol = volume()
const spd = speed()
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
// Stop current backend
if (backend) {
stopPolling()
backend.dispose()
backend = null
stopPolling();
backend.dispose();
backend = null;
}
// Create new backend
backend = createAudioBackend(name)
setBackendName(backend.name)
setAvailablePlayers(detectPlayers())
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
@@ -329,12 +366,12 @@ async function switchBackend(name: BackendName): Promise<void> {
startPosition: pos,
volume: vol,
speed: spd,
})
setIsPlaying(true)
startPolling()
});
setIsPlaying(true);
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed")
setIsPlaying(false)
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
@@ -347,64 +384,64 @@ async function switchBackend(name: BackendName): Promise<void> {
*/
export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend()
ensureBackend();
// Sync initial speed from app store
if (refCount === 0) {
try {
const appStore = useAppStore()
const storeSpeed = appStore.state().settings.playbackSpeed
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed)
setSpeed(storeSpeed);
}
} catch {
// Store may not be available yet
}
}
refCount++
refCount++;
// Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
})
});
const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop()
setIsPlaying(false)
setPosition(0)
setCurrentEpisode(null)
stopPolling()
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
})
});
// Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback()
})
await togglePlayback();
});
const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
})
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
})
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
const unsubMediaSeekFwd = on("media.seekForward", async () => {
await seekRelative(10)
})
await seekRelative(10);
});
const unsubMediaSeekBack = on("media.seekBackward", async () => {
await seekRelative(-10)
})
await seekRelative(-10);
});
const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
await doSetSpeed(next)
})
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
});
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
@@ -430,10 +467,12 @@ export function useAudio(): AudioControls {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
@@ -460,10 +499,12 @@ export function useAudio(): AudioControls {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
@@ -477,29 +518,29 @@ export function useAudio(): AudioControls {
}
onCleanup(() => {
refCount--
unsubPlay()
unsubStop()
unsubMediaToggle()
unsubMediaVolUp()
unsubMediaVolDown()
unsubMediaSeekFwd()
unsubMediaSeekBack()
unsubMediaSpeed()
refCount--;
unsubPlay();
unsubStop();
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed();
if (refCount <= 0) {
stopPolling()
stopPolling();
if (backend) {
backend.dispose()
backend = null
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
const media = useMediaRegistry()
media.clearNowPlaying()
const media = useMediaRegistry();
media.clearNowPlaying();
refCount = 0
refCount = 0;
}
})
});
return {
isPlaying,
@@ -524,5 +565,5 @@ export function useAudio(): AudioControls {
switchBackend,
prev,
next,
}
};
}

View File

@@ -181,7 +181,7 @@ function DiscoverPage() {
<Show when={depth() === 0}>
<For each={categories()}>
{(cat, index) => {
const lf = focusedCatIdx();
const lf = () => focusedCatIdx();
const selected = () => cat.id === discoverStore.selectedCategory();
return (
<box
@@ -189,19 +189,19 @@ function DiscoverPage() {
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
<text fg={index() === lf() ? theme.surface : theme.accent}>
*
</text>
</Show>
@@ -222,35 +222,35 @@ function DiscoverPage() {
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = focusedPodIdx();
const lf = () => focusedPodIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
<text fg={focusFg(index(), lf(), isActive())}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text fg={index() === lf ? theme.surface : theme.success}>
<text fg={index() === lf() ? theme.surface : theme.success}>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf ? theme.surface : muted()}
fg={index() === lf() ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}

View File

@@ -1,18 +1,19 @@
/**
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
* FeedPage — flat chronological list of episodes across all subscribed feeds.
*
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
* virtual "All Feeds". Parent pane shows the muted
* placeholder (1/7 slot kept).
* depth 1 (current) — flat episodes list for the drilled feed (reverse
* chronological). Parent pane = the feeds list (prev).
* preview — detail of the hovered item in the current column.
* depth 0 (current) — every episode from every feed, newest-first (the
* combined view the old "All Feeds" virtual row used to
* drill into). Parent pane shows the muted tab list.
* preview — detail of the hovered episode.
*
* This page does NOT drill: the previous depth-1 "episodes of one feed" panel
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
* shows the full flat episodes list immediately.
*
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
* (push); `h` pops a depth (noop at 0). j/k move only within the current
* column. The Shell router drives everything over `nav.action`; this page
* only handles list/preview data.
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
* root. j/k move only within the current column. The Shell router drives
* everything over `nav.action`; this page only handles list/preview data.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -27,7 +28,6 @@ import {
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
@@ -40,7 +40,6 @@ import { TabListPane } from "@/components/TabPanel";
export const FeedPaneCount = 1;
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
type EpItem = { episode: Episode; feed: Feed };
function FeedPage() {
@@ -52,57 +51,27 @@ function FeedPage() {
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
// ── feeds list (depth 0) ─────────────────────────────────────────────────
const feedList = createMemo<FeedListItem[]>(() => {
const all: FeedListItem[] = [{ kind: "all" }];
for (const f of feedStore.getFilteredFeeds())
all.push({ kind: "feed", feed: f });
return all;
});
const focusedFeedIdx = () =>
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
const focusedFeedItem = (): FeedListItem | undefined =>
feedList()[focusedFeedIdx()];
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
const episodes = createMemo<EpItem[]>(() => {
if (depth() < 1) return [];
const id = drilledFeedId();
if (id === "all")
return feedStore.getAllEpisodesChronological() as EpItem[];
const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id);
if (!f) return [];
return [...f.episodes]
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
.map((episode) => ({ episode, feed: f }));
});
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
const episodes = createMemo<EpItem[]>(
() => feedStore.getAllEpisodesChronological() as EpItem[],
);
const focus = () => nav.depthFocus(0);
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
const curLen = () => episodes().length;
const ensureFocus = () => {
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
nav.setDepthFocus(feedList().length - 1, 0);
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 1);
if (episodes().length > 0 && focus() >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 0);
};
onMount(ensureFocus);
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) {
const it = feedList()[i];
return it?.kind === "feed" ? it.feed.podcast.id : "all";
}
return episodes()[i]?.episode.id;
});
nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
(i) => episodes()[i]?.episode.id,
);
});
// ── helpers ────────────────────────────────────────────────────────────────
@@ -146,20 +115,10 @@ function FeedPage() {
audioNav.setSource(AudioSource.FEED);
};
// ── drill / open ───────────────────────────────────────────────────────────
// ── open ───────────────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
const item = focusedFeedItem();
if (!item) return;
const ctx = item.kind === "all" ? "all" : item.feed.podcast.id;
nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 1) {
playEpisode(focusedItem());
}
}
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
@@ -173,16 +132,11 @@ function FeedPage() {
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id);
}
},
refresh: () => {
const item = focusedFeedItem();
if (item?.kind === "feed")
feedStore.refreshFeed(item.feed.id).catch(() => {});
else feedStore.refreshAllFeeds().catch(() => {});
feedStore.refreshAllFeeds().catch(() => {});
},
};
function step(delta: number) {
@@ -205,7 +159,7 @@ function FeedPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within a list. `active=true` only for the current pane.
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
@@ -215,128 +169,41 @@ function FeedPage() {
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
const feedLabel = (item: FeedListItem) =>
item.kind === "all"
? "All Feeds"
: item.feed.customName || item.feed.podcast.title;
const feedCount = (item: FeedListItem) =>
item.kind === "all"
? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length;
const currentLabel = () => `Feed · ${episodes().length}`;
const currentLabel = () =>
depth() === 0
? `Feeds · ${feedList().length - 1}`
: `${(() => {
const fi = focusedFeedItem();
return fi?.kind === "feed"
? fi.feed.customName || fi.feed.podcast.title
: "All Episodes";
})()} · ${episodes().length}`;
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
const parentContent = () => <TabListPane muted />;
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
// mounts/unmounts cleanly on depth change instead of swapping roots.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={feedList()}>
{(item, index) => {
const lf = nav.depthFocus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
>
<text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
<text fg={muted()}>({feedCount(item)})</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane: the current-depth list (the only focusable column) ──────
// ── current pane: the flat episodes list (the only focusable column) ──────
const currentContent = () => (
<>
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={feedList().length > 1}
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const fi = focusedFeedIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
</Show>
<Show when={depth() >= 1}>
{/* depth ≥1: episodes */}
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
const fi = () => focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
<text fg={focusFg(index(), fi(), isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
@@ -344,13 +211,13 @@ function FeedPage() {
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi ? theme.surface : theme.info}>
<text fg={index() === fi() ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
<text fg={index() === fi() ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
<text fg={index() === fi() ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
@@ -372,53 +239,10 @@ function FeedPage() {
</box>
</Show>
</Show>
</Show>
</>
);
// ── preview pane: hovered-item detail ──────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered feed
<Show
when={focusedFeedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No feed focused</text>
</box>
}
>
{(item) => {
const it = item();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{feedLabel(it)}</strong>
</text>
<text fg={muted()}>
{it.kind === "feed"
? `by ${it.feed.podcast.author ?? "unknown"}`
: ""}
</text>
<text fg={theme.textSecondary}>
{it.kind === "all"
? `${feedCount(it)} episodes across all feeds`
: `${feedCount(it)} episodes`}
</text>
<text fg={muted()}>
{it.kind === "feed"
? (it.feed.podcast.description?.slice(0, 400) ??
"No description.")
: "Drill in to see episodes across every feed."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
</box>
);
}}
</Show>
) : (
// depth ≥1 preview: hovered episode
// ── preview pane: hovered-episode detail ───────────────────────────────────
const previewContent = () => (
<Show
when={focusedItem()}
fallback={
@@ -427,44 +251,41 @@ function FeedPage() {
</box>
}
>
{(item) => {
const it = item();
return (
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{it.episode.episodeNumber
? `#${it.episode.episodeNumber} `
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{it.episode.title}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(it.episode.duration)}</text>
<Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(it.episode.id)}>
{downloadLabel(it.episode.id)}
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{it.feed.customName || it.feed.podcast.title}
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={it.feed.podcast.author}>
<text fg={muted()}>by {it.feed.podcast.author}</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{it.episode.description?.slice(0, 400) ??
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
<text fg={muted()}>enter: play · space: select · h back</text>
</box>
);
}}
)}
</Show>
);
@@ -473,7 +294,7 @@ function FeedPage() {
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
parentLabel="Up"
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}

View File

@@ -204,19 +204,19 @@ export function MyShowsPage() {
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={shows()}>
{(feed, index) => {
const lf = nav.depthFocus(0);
const lf = () => nav.depthFocus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "}
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
@@ -242,26 +242,26 @@ export function MyShowsPage() {
>
<For each={shows()}>
{(feed, index) => {
const lf = focusedShowIdx();
const lf = () => focusedShowIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
@@ -282,33 +282,33 @@ export function MyShowsPage() {
>
<For each={episodes()}>
{(ep, index) => {
const lf = focusedEpIdx();
const lf = () => focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
<text fg={focusFg(index(), lf(), isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf ? theme.surface : theme.info}>
<text fg={index() === lf() ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
<text fg={index() === lf() ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>

View File

@@ -1,10 +1,13 @@
/**
* PlayerPage — single-pane audio now-playing view.
* PlayerPage — 2-pane yazi depth view of the now-playing episode.
*
* Audio transport (play/pause, next/prev, seek) is handled globally by the
* Shell router (P/N/B/</>). This page renders a single rich pane showing the
* current episode, waveform, and playback controls. Panes/swipe do nothing
* (PaneCount=1).
* depth 0 (parent) — tab list (muted, read-only).
* depth 0 (current) — the single now-playing pane (rich view + controls).
*
* No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause,
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
* page only renders the now-playing surface. `h` at depth 0 returns to the
* tab root.
*/
import { Show } from "solid-js";
@@ -13,7 +16,9 @@ import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const PlayerPaneCount = 1;
@@ -23,9 +28,7 @@ export function PlayerPage() {
const nav = useNavigation();
const muted = () => theme.muted || theme.text;
// Single pane — always active.
const isActive = () => true;
const border = () => theme.accent;
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const progressPercent = () => {
const d = audio.duration();
@@ -39,19 +42,11 @@ export function PlayerPage() {
return `${m}:${String(s).padStart(2, "0")}`;
};
return (
<box flexDirection="column" width="100%" height="100%">
{/* ── pane 0: now playing ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Player</text>
</box>
<scrollbox
height="100%"
focused={isActive()}
border
borderColor={border()}
backgroundColor={theme.background}
>
// ── parent pane: the tab list (muted) ──────────────────────────────────────
const parentContent = () => <TabListPane muted />;
// ── current pane: now playing ───────────────────────────────────────────────
const currentContent = () => (
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
@@ -81,8 +76,7 @@ export function PlayerPage() {
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ??
"No description available."}
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<RealtimeWaveform
@@ -114,9 +108,20 @@ export function PlayerPage() {
/>
<box height={1} />
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
</box>
</scrollbox>
<text fg={muted()}>
{"P play/pause N next B prev </ seek · h back"}
</text>
</box>
);
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
parentLabel="Up"
currentLabel="Player"
panes={2}
focused={isActive}
/>
);
}

View File

@@ -1,16 +1,18 @@
/**
* SearchPage — yazi-style 3-pane view.
* SearchPage — yazi depth-stack view of podcast search.
*
* pane 1 (parent) — query input with recent-search history (clickable)
* pane 2 (current) — search results list (navigate j/k)
* pane 3 (preview) — detail of the focused search result
* depth 0 (current) — query input row + recent-searches list (navigable
* with j/k when the input is defocused). Parent pane
* shows the tab list (muted); preview shows a hint.
* depth 1 (current) — search results list. Parent pane shows the submitted
* query (muted, read-only); preview shows the detail of
* the focused result.
*
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
* on tab enter so the user lands on the results pane. Swipe left (h) to pane
* 1 to type a query — the Shell
* router skips keys while `nav.inputFocused()` is true so the `<input>`
* element captures typing natively. Press Enter (onSubmit) to search and
* auto-swipe to the results pane.
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
* input (or on a focused recent at depth 0) submits the query and pushes to
* depth 1 (results). `h` pops: results→query, query→tab root.
*/
import {
@@ -28,15 +30,17 @@ import { useTheme } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
PaneSlot,
DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source";
import { PANE_RATIO } from "@/utils/navigation";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const SearchPaneCount = 3;
export const SearchPaneCount = 1;
function SearchPage() {
const searchStore = useSearchStore();
@@ -45,69 +49,75 @@ function SearchPage() {
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const INPUT = PaneSlot.PARENT; // 1 (input row)
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
// depth 1's ctx carries the submitted query string.
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
// Depth transitions also drive it: typing is the default on the query depth.
let prevDepth = depth();
onMount(() => nav.setInputFocused(true));
onCleanup(() => nav.setInputFocused(false));
createEffect(() => {
const d = depth();
if (d !== prevDepth) {
nav.setInputFocused(d === 0);
prevDepth = d;
}
});
// ── results (depth 1) ─────────────────────────────────────────────────────
const results = () => searchStore.results();
// The focused result tracks pane 1's focused row.
const focusedResultIdx = () =>
results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
const focusedResult = createMemo(() => {
const list = results();
if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1);
return list[idx];
return list[focusedResultIdx()];
});
// Register a resolver so visual-mode range selection grows by result id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => results()[i]?.podcast.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => results()[i]?.podcast.id,
);
});
onCleanup(() => unsub());
});
// ── recents (depth 0) ────────────────────────────────────────────────────
const recents = () => searchStore.history();
const curLen = () => (depth() === 0 ? recents().length : results().length);
// Keep results focus in range after searches complete.
const ensureFocus = () => {
const list = results();
if (list.length === 0) return;
const cur = nav.focusedIndex(RESULTS);
if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
nav.setDepthFocus(results().length - 1, 1);
};
onMount(ensureFocus);
// ── input pane: set inputFocused so Shell router yields keys to <input> ─────
createEffect(() => {
const isInputPane = nav.activePane() === INPUT;
nav.setInputFocused(isInputPane);
});
// Register a visual-mode resolver for the results list (depth 1).
onMount(() => {
onCleanup(() => nav.setInputFocused(false));
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubmit = () => {
const query = inputValue().trim();
if (!query) return;
searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
const runSearch = (query: string) => {
const q = query.trim();
if (!q) return;
searchStore.search(q).catch(() => {});
nav.pushDepth({
kind: "search:results",
ctx: q,
focus: 0,
} as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
};
const handleHistorySelect = (query: string) => {
const handleSubmit = () => runSearch(inputValue());
const selectRecent = (query: string) => {
setInputValue(query);
searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
runSearch(query);
};
const handleSubscribe = (result: SearchResult) => {
@@ -115,45 +125,48 @@ function SearchPage() {
};
// ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === RESULTS || p === DETAIL) {
const result = focusedResult();
if (result) handleSubscribe(result);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const result = focusedResult();
if (result) nav.toggleSelected(result.podcast.id);
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() === 1) {
const r = focusedResult();
if (r) nav.toggleSelected(r.podcast.id);
}
},
search: () => {
nav.setActivePane(INPUT);
// `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true);
},
refresh: () => {
if (inputValue().trim()) {
searchStore.search(inputValue().trim()).catch(() => {});
}
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
},
};
function len(pane: PaneId): number {
if (pane === RESULTS) return results().length;
return 0;
function step(delta: number) {
nav.move(delta, curLen());
}
function open() {
if (depth() === 0) {
// Enter/l on a focused recent search → submit it and drill to results.
const list = recents();
const idx = Math.min(focus(0), list.length - 1);
const q = list[idx];
if (q) selectRecent(q);
return;
}
if (depth() === 1) {
const r = focusedResult();
if (r) handleSubscribe(r);
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
const onAction = (data: {
@@ -161,43 +174,45 @@ function SearchPage() {
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const inputActive = () => nav.inputFocused() && depth() === 0;
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === nav.focusedIndex(pane)
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: query input ──────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Search</text>
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={muted()}>h: back to query</text>
</box>
<scrollbox
height="100%"
focused={false}
border
borderColor={border(INPUT)}
backgroundColor={theme.background}
>
</Show>
);
// ── current pane ────────────────────────────────────────────────────────────
const currentContent = () => (
<>
<Show when={depth() === 0}>
{/* query input row + recent searches */}
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text>
@@ -206,55 +221,62 @@ function SearchPage() {
onInput={setInputValue}
onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..."
focused={isActive(INPUT)}
focused={inputActive()}
width={28}
/>
</box>
<text fg={muted()}>Enter to search · h/l: panes</text>
<Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>Recent</text>
<Show
when={searchStore.history().length > 0}
fallback={<text fg={muted()}>No recent searches</text>}
when={recents().length > 0}
fallback={
<text fg={muted()}>
{inputActive()
? "Enter to search"
: "s to type · Enter to search"}
</text>
}
>
<For each={searchStore.history().slice(0, 12)}>
{(query) => (
<For each={recents()}>
{(query, index) => {
const lf = () => focus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
onMouseDown={() => handleHistorySelect(query)}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={muted()}>
{">"} {query}
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
</box>
)}
);
}}
</For>
</Show>
<box height={1} />
<text fg={muted()}>
{inputActive()
? "Enter to search · Esc to defocus"
: "j/k recents · s to type · h back"}
</text>
</box>
</scrollbox>
</box>
{/* ── pane 1: results ──────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Results · {results().length}</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
</Show>
<Show when={depth() >= 1}>
{/* results list */}
<Show
when={results().length > 0}
fallback={
@@ -268,68 +290,66 @@ function SearchPage() {
}
>
<For each={results()}>
{(result, index) => (
{(result, index) => {
const fi = () => focusedResultIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>
<text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title}
</text>
<Show when={result.podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
<text fg={index() === fi() ? theme.surface : theme.success}>
[+]
</text>
</Show>
</box>
<Show when={result.podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
</box>
)}
);
}}
</For>
</Show>
</scrollbox>
</box>
</Show>
</>
);
{/* ── pane 2: detail ───────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Detail</text>
// ── preview pane ────────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>Search</strong>
</text>
<text fg={muted()}>Type a query, press Enter to search.</text>
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
<box height={1} />
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
<For each={recents().slice(0, 6)}>
{(q) => <text fg={muted()}> {q}</text>}
</For>
</box>
<scrollbox
height="100%"
focused={isActive(DETAIL)}
border
borderColor={border(DETAIL)}
backgroundColor={theme.background}
>
) : (
<Show
when={focusedResult()}
fallback={
@@ -343,21 +363,16 @@ function SearchPage() {
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<Show when={result().podcast.author}>
<text fg={muted()}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400) ??
"No description available."}
{(result().podcast.description?.length ?? 0) > 400
? "…"
: ""}
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
@@ -365,16 +380,13 @@ function SearchPage() {
</For>
</box>
</Show>
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
<text fg={muted()}>
Updated: {formatDate(result().podcast.lastUpdated)}
</text>
<Show when={result().sourceName}>
<text fg={muted()}>Source: {result().sourceName}</text>
</Show>
<box height={1} />
<Show when={!result().podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
@@ -383,13 +395,27 @@ function SearchPage() {
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes</text>
<text fg={muted()}>enter: subscribe · h: back to query</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
const currentLabel = () =>
depth() === 0
? `Search · ${recents().length} recent`
: `Results · ${results().length}`;
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -18,7 +18,8 @@
*/
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
import { rgbToHex, type RGBA } from "@opentui/core";
import { useTheme, type ThemeResolved } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
@@ -230,6 +231,15 @@ export function SettingsPage() {
// ── render helpers ───────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Whether the currently-focused settings row is the Theme select — the
// only item whose Detail pane carries a color breakdown below the help text.
const isThemeItem = () => {
const d = depth();
if (d === 1) return focusedItem()?.id === "theme";
if (d === 2) return editorItem()?.id === "theme";
return false;
};
// preview text for the right column
const previewText = createMemo<string>(() => {
const d = depth();
@@ -278,7 +288,7 @@ export function SettingsPage() {
<For each={SECTIONS}>
{(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
label={section.label}
focused={index() === focusedSectionIdx()}
active={false}
/>
@@ -307,7 +317,7 @@ export function SettingsPage() {
<For each={SECTIONS}>
{(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
label={section.label}
focused={index() === focusedSectionIdx()}
active={isActive()}
onMouseDown={() => {
@@ -356,8 +366,13 @@ export function SettingsPage() {
// ── preview pane ──────────────────────────────────────────────────────────
const previewContent = () => (
<box padding={1}>
<box padding={1} flexDirection="column">
{/* Keep everything on a stable root so Solid re-resolves the swap
between plain help text and the theme breakdown on focus move. */}
<Show when={isThemeItem()} fallback={<MultiLine text={previewText()} />}>
<MultiLine text={previewText()} />
<ThemeBreakdown />
</Show>
</box>
);
@@ -458,6 +473,49 @@ function GenericEditor(props: { item: SettingItem }) {
);
}
/** Curated theme color roles shown in the Theme breakdown. */
const THEME_ROLES: Array<{ key: keyof ThemeResolved; label: string }> = [
{ key: "primary", label: "Primary" },
{ key: "secondary", label: "Secondary" },
{ key: "accent", label: "Accent" },
{ key: "text", label: "Text" },
{ key: "textMuted", label: "Muted" },
{ key: "background", label: "Background" },
{ key: "surface", label: "Surface" },
{ key: "border", label: "Border" },
{ key: "error", label: "Error" },
{ key: "warning", label: "Warning" },
{ key: "success", label: "Success" },
{ key: "info", label: "Info" },
];
/** Color swatch breakdown (block <Label> (<HEX>)) of the resolved theme. */
function ThemeBreakdown() {
const { theme, selected } = useTheme();
return (
<box flexDirection="column" paddingTop={1} gap={1}>
<text fg={theme.accent}>Theme · {selected}</text>
<For each={THEME_ROLES}>
{(role) => {
const color = theme[role.key] as RGBA | undefined;
return (
<box flexDirection="row" gap={1} alignItems="center">
<box backgroundColor={color}>
<text>{" "}</text>
</box>
<text fg={theme.text}>{role.label}</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>
{color ? rgbToHex(color).toUpperCase() : "n/a"}
</text>
</box>
);
}}
</For>
</box>
);
}
/** Renders a string with `\n` newlines as stacked <text> lines. */
function MultiLine(props: { text: string }) {
const lines = () => props.text.split("\n");

View File

@@ -78,7 +78,7 @@ function mpvSocketPath(): string {
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
class MpvBackend implements AudioBackend {
export class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv";
private proc: ReturnType<typeof Bun.spawn> | null = null;
private socketPath = mpvSocketPath();

View File

@@ -22,12 +22,10 @@
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
* switch tabs; focus keeps its context (root iff already at the root,
* otherwise the content `DEPTH_CENTER_PANE`).
* • `h`/`l` are `swipe-prev`/`swipe-next` in content:
* - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth
* when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
* where the tab becomes CURRENT again.
* - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to
* [1, paneCount]; `h` on the first content pane stays (no tab overflow).
* • `h`/`l` are `swipe-prev`/`swipe-next` in content (every tab is a
* depth-tab): `l` at the current pane drills in (emits `open`); `h` pops
* a depth when depth > 0; at depth 0 `h` returns to the tab root
* (`backToTabRoot`), where the tab becomes CURRENT again.
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
@@ -36,7 +34,7 @@
import type { KeybindActionName } from "@/context/KeybindContext";
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
import { TABS, TabsCount } from "@/utils/navigation";
import { emit } from "@/utils/event-bus";
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
@@ -182,33 +180,23 @@ export function createDispatcher(deps: DispatcherDeps) {
if (action === "swipe-prev") break;
}
// ── pane swipe / depth nav ──
// Depth-tabs: l at the center drills in (emits `open`); h at the
// center pops a depth; at depth 0 h returns to the tab root (the tab
// becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
// h/l swipe across [1, paneCount]; h on the first pane stays.
// Every tab is a depth-tab: `l` at the center drills in (emits `open`);
// `h` at the center pops a depth when depth > 0, and at depth 0 returns
// to the tab root (the tab becomes CURRENT again).
if (action === "swipe-prev") {
evt.preventDefault();
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
if (nav.currentDepth() > 0) nav.popDepth();
else nav.backToTabRoot(); // depth 0 → tab root
} else if (nav.activePane() > DEPTH_CENTER_PANE) {
nav.swipe(-1, TabPaneCount[tab]); // content 1..n
}
// fixed first content pane (1): no-op, stays (special tabs)
break;
}
if (action === "swipe-next") {
evt.preventDefault();
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
emit("nav.action", {
action: "open",
tab,
pane: DEPTH_CENTER_PANE,
mode: nav.mode(),
});
} else {
nav.swipe(1, TabPaneCount[tab]);
}
break;
}
// ── audio transport (global) ──

View File

@@ -14,12 +14,15 @@ export enum TABS {
export const TabsCount = 6;
/** Tabs that use the yazi depth-stack model (prev | current | preview
* columns, infinite drill via push/pop). Search and Player keep the legacy
* fixed-pane model. */
* columns, infinite drill via push/pop). Search drills query→results, and
* Player drills into its single now-playing pane under the tab list (the
* parent=/tabs, current=player, preview hidden). */
export const DEPTH_TABS: ReadonlySet<TABS> = new Set([
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SEARCH,
TABS.PLAYER,
TABS.SETTINGS,
]);
@@ -35,6 +38,10 @@ export function rootFrameFor(
return { kind: "shows", focus: 0 };
case TABS.DISCOVER:
return { kind: "discover:categories", focus: 0 };
case TABS.SEARCH:
return { kind: "search:query", focus: 0 };
case TABS.PLAYER:
return { kind: "player:nowplaying", focus: 0 };
case TABS.SETTINGS:
return { kind: "settings:sections", focus: 0 };
default:
@@ -62,16 +69,15 @@ export const PANE_RATIO = {
// Number of *focusable* content panes per tab. The three visible columns
// (parent | current | preview) are a *render* concern, NOT three panes — for
// depth-tabs only the current column (index 0) is focusable, so this is 1.
// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
// here (after TABS) to avoid re-introducing the old NavigationContext
// top-level-init circular deadlock.
// Every tab is now a depth-tab: each drills with `l` (push) and pops with `h`
// (returns to the tab root at depth 0) via the Shell dispatch. Defined here
// (after TABS) to avoid re-introducing the old NavigationContext top-level-
// init circular deadlock.
export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 1, // depth: feeds → episodes → preview
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
[TABS.DISCOVER]: 1, // depth: categories → results → preview
[TABS.SEARCH]: 3, // fixed: query | results | detail
[TABS.PLAYER]: 1, // single pane
[TABS.SEARCH]: 1, // depth: query results, preview=detail
[TABS.PLAYER]: 1, // depth: now-playing (2-pane, no preview)
[TABS.SETTINGS]: 1, // depth: sections → items → editor
};

View File

@@ -0,0 +1,38 @@
/**
* Audio backend dispose regression test.
*
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
* keep spawned players (mpv/ffplay/afplay) from surviving the host, useAudio
* registers a `process.on("exit")` handler that synchronously disposes the
* backend. The exit handler's whole job is "kill the child process", so this
* test pins the contract directly: a backend holding a real spawned subprocess
* must have killed it once `dispose()` returns.
*
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
* player process, injected into the (private) `proc` slot of an MpvBackend —
* mpv/ffplay/afplay all share the identical kill-on-dispose pattern, so
* exercising one is enough to guard the family.
*/
import { test, expect } from "bun:test";
import { MpvBackend } from "../src/utils/audio-player";
test("MpvBackend.dispose() kills the spawned child process", async () => {
const backend = new MpvBackend();
// Inject a real long-lived subprocess as if mpv had been spawned.
const child = Bun.spawn(["sleep", "60"], {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
(backend as unknown as { proc: typeof child }).proc = child;
// Sanity: the child is alive.
expect(child.killed).toBe(false);
backend.dispose();
// dispose() sent SIGTERM (proc.kill()); wait for the child to exit.
await child.exited;
expect(child.killed).toBe(true);
});

View File

@@ -11,8 +11,9 @@
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
* • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
* `h` on the first pane stays — never overflows to the tab root.
* • Every tab is a depth-tab: `swipe-next` (l) at depth 0 emits `open`
* (drill); `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the
* tab root. Player has no deeper drill (single now-playing pane).
* • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
* tabs and preserve focus context (root stays root for depth-tabs, content
* stays content).
@@ -218,33 +219,50 @@ test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
});
});
test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
test("dispatch('swipe-prev') on Search at depth 0 returns to the tab root", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane
nav.setActiveTab(TABS.SEARCH); // depth-tab, query root
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
dispatch("swipe-prev");
// special tab: h on the first content pane does not return to the root.
expect(nav.atRootTab()).toBe(false);
// h at depth 0 returns to the tab root — so Search isn't a dead end.
expect(nav.atRootTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// a second h at the root is inert (out of the panes).
dispatch("swipe-prev");
expect(nav.atRootTab()).toBe(true);
});
});
test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => {
test("dispatch('swipe-prev') on the single-pane Player tab returns to the tab root", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // 3 panes
nav.setActiveTab(TABS.PLAYER); // depth-tab, single now-playing pane
nav.enterTabContent();
nav.swipe(1, 3);
nav.swipe(1, 3);
expect(nav.activePane()).toBe(3);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
dispatch("swipe-prev");
expect(nav.activePane()).toBe(2);
expect(nav.atRootTab()).toBe(true);
});
});
test("dispatch('swipe-prev') at depth 1 (results) pops to depth 0 (query)", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // depth-tab: query(0) → results(1)
nav.enterTabContent();
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
expect(nav.currentDepth()).toBe(1);
dispatch("swipe-prev");
expect(nav.currentDepth()).toBe(0);
expect(nav.atRootTab()).toBe(false); // h at depth>0 stays in content
});
});
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => {
test("tab-goto-N from the root keeps depth-tabs at the root", () => {
withHarness(({ nav, dispatch }) => {
// focus starts on the tab root.
expect(nav.atRootTab()).toBe(true);
@@ -259,11 +277,12 @@ test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open",
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(true);
// fixed-pane tab is special: switching from the root opens its content.
// every tab is a depth-tab now: switching to Search from the root
// keeps the root too (Enter/l opens content).
dispatch("tab-goto-4"); // → Search
expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.tabCursor()).toBe(TABS.SEARCH);
expect(nav.atRootTab()).toBe(false);
expect(nav.atRootTab()).toBe(true);
});
});
@@ -276,7 +295,7 @@ test("tab-goto-N from content keeps focus in the active tab's content", () => {
expect(nav.activeTab()).toBe(TABS.DISCOVER);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
dispatch("tab-goto-4"); // → Search (depth-tab) lands its current pane
expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);

View File

@@ -8,7 +8,8 @@
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
* for the fixed-pane Search/Player tabs).
* for the fixed-pane Search/Player tabs (they clear `atRootTab` on switch
* and regain it via `backToTabRoot`, the `h`-back-up path).
* • the root tab list is a normal list: `tabCursor` is independent of
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
* the hovered tab + enters content, and direct tab switches re-sync it.
@@ -23,7 +24,7 @@ import {
DEPTH_CENTER_PANE,
NavMode,
} from "../src/context/navigation-store";
import { TABS, TabPaneCount } from "../src/utils/navigation";
import { TABS } from "../src/utils/navigation";
/** Build a fresh nav graph inside a reactive root and run `fn` against it.
* Disposes the root afterwards so effects/signals don't leak between tests. */
@@ -115,15 +116,22 @@ test("tab switch keeps focus context: in content it stays in content", () => {
});
});
test("switching to a Search/Player tab leaves the root (special content)", () => {
test("switching to a Search/Player tab keeps the root (depth-tab)", () => {
withNav((nav) => {
// at root, opening Search is special: atRootTab() reports false because
// Search has its own content and never renders the tab-list root view.
// at root, opening Search keeps the root: every tab is a depth-tab now,
// so Enter/l is required to drop into content. `h`-back-up still works.
nav.setActiveTab(TABS.SEARCH);
expect(nav.atRootTab()).toBe(false);
expect(nav.atRootTab()).toBe(true);
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
nav.backToTabRoot();
expect(nav.atRootTab()).toBe(true);
// Player is also a depth-tab now.
nav.setActiveTab(TABS.PLAYER);
expect(nav.atRootTab()).toBe(true);
nav.enterTabContent();
expect(nav.atRootTab()).toBe(false);
});
});
@@ -217,43 +225,30 @@ test("tab-switch resets mode/visual/command state", () => {
});
// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
test("Search is a depth-tab: query root drills to results and back", () => {
withNav((nav) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
expect(TabPaneCount[TABS.SEARCH]).toBe(3);
nav.setActiveTab(TABS.SEARCH);
expect(nav.isDepthTab()).toBe(true);
expect(nav.topFrame()?.kind).toBe("search:query");
nav.enterTabContent();
expect(nav.activePane()).toBe(1);
// swipe left stays at 1 (no pane 0).
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
// swipe right up through the columns, then hold the upper bound.
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2);
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(3);
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(3); // never exceeds paneCount
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
expect(nav.currentDepth()).toBe(0);
// Enter on the query submits → push a results frame.
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
expect(nav.currentDepth()).toBe(1);
// h at depth 1 pops back to the query.
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(0);
});
});
test("swipe on a single-pane fixed tab stays at its one content pane", () => {
test("Player is a single-depth depth-tab (now-playing only)", () => {
withNav((nav) => {
nav.setActiveTab(TABS.PLAYER); // single-pane
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
nav.enterTabContent(); // lands on its one content pane (1)
expect(nav.activePane()).toBe(1);
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(1); // upper bound
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0
nav.setActiveTab(TABS.PLAYER);
expect(nav.isDepthTab()).toBe(true);
expect(nav.topFrame()?.kind).toBe("player:nowplaying");
nav.enterTabContent();
expect(nav.currentDepth()).toBe(0); // no deeper drill
expect(nav.popDepth()).toBe(false); // noop at depth 0
});
});

View File

@@ -1,8 +1,8 @@
/**
* yazi-pages-depth.test.ts — task 03 page contract tests.
*
* The four depth-stack list tabs (Feed / MyShows / Discover / Settings) all
* render through `<YaziPaneRow>` with the parent pane reading the
* Every depth-stack tab (Feed / MyShows / Discover / Search / Player /
* Settings) renders through `<YaziPaneRow>` with the parent pane reading the
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
@@ -37,8 +37,15 @@ function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
});
}
/** The depth-tabs that must render via <YaziPaneRow> (task 03 conversion). */
const CONVERTED_TABS = [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS];
/** The depth-tabs that render via <YaziPaneRow> (task 03 conversion). */
const CONVERTED_TABS = [
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SEARCH,
TABS.PLAYER,
TABS.SETTINGS,
];
for (const tab of CONVERTED_TABS) {
const name = TABS[tab];
@@ -54,7 +61,11 @@ for (const tab of CONVERTED_TABS) {
// drill (l): page open() pushes a child frame — parent becomes
// the previous-depth list.
const child: DepthFrame = { kind: `${name.toLowerCase()}:child`, ctx: "c1", focus: 0 };
const child: DepthFrame = {
kind: `${name.toLowerCase()}:child`,
ctx: "c1",
focus: 0,
};
nav.pushDepth(child);
nav.setActivePane(DEPTH_CENTER_PANE);
expect(nav.currentDepth()).toBe(1);
@@ -65,7 +76,11 @@ for (const tab of CONVERTED_TABS) {
// drill again (l): push a second child — parent shows the first
// child's list (the chain Settings exercises: sections→items→editor).
const grandchild: DepthFrame = { kind: `${name.toLowerCase()}:grand`, ctx: "g1", focus: 0 };
const grandchild: DepthFrame = {
kind: `${name.toLowerCase()}:grand`,
ctx: "g1",
focus: 0,
};
nav.pushDepth(grandchild);
expect(nav.currentDepth()).toBe(2);
expect(nav.depthStack()).toHaveLength(3);
@@ -97,9 +112,16 @@ for (const tab of CONVERTED_TABS) {
});
}
// ── DEPTH_TABS covers exactly the four converted pages ───────────────────────
test("DEPTH_TABS is exactly the four converted list tabs", () => {
// ── DEPTH_TABS covers exactly the depth-stack pages ───────────────────────
test("DEPTH_TABS is exactly the depth-stack tabs", () => {
expect([...DEPTH_TABS].sort()).toEqual(
[TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS].sort(),
[
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SEARCH,
TABS.PLAYER,
TABS.SETTINGS,
].sort(),
);
});