diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..718a544 --- /dev/null +++ b/.github/workflows/release.yml @@ -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--.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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1f0efa --- /dev/null +++ b/Makefile @@ -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--.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 \ No newline at end of file diff --git a/README.md b/README.md index 984eba3..bed7bff 100644 --- a/README.md +++ b/README.md @@ -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, `1–6` / `[` `]` to switch tabs. The tab list is the app root: + at launch it fills the current pane, and drilling into a tab's contents slides + it into the parent pane. +- **Three-pane view** — parent / current / preview (Up | Current | Preview), + mirroring yazi's pane model. +- **Podcast feeds** — add feeds, browse episodes, and manage your library + (My Shows, Discover, Feed tabs). +- **Search** across your subscribed shows. +- **Audio playback** through an external player with full transport control: + play/pause, next/previous, seek, speed, and per-episode resume progress. +- **Themeable** and **remappable keybindings**. +- Ships as a **standalone compiled binary** — no runtime or install step beyond + a system audio player. + +## Requirements + +- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm, + tmux, GNOME Terminal, etc.). +- An **audio player** on `PATH`. PodTui auto-detects in priority order: + + | Player | Platforms | Seek | Speed | Position tracking | + |----------|----------------|:----:|:-----:|:------------------| + | `mpv` | any | ✔ | ✔ | ✔ (recommended) | + | `ffplay` | any | ✔ | ✘ | ✘ | + | `afplay` | macOS built-in | ✔ | ✔ | ✘ | + | `open`/`xdg-open` | any | ✘ | ✘ | ✘ | + + Install `mpv` for the best experience (`brew install mpv`, + `sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend + with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`. + +## Installation + +PodTui distributes as a **self-contained binary** for macOS (arm64/x64) and +Linux (arm64/x64). Pick whichever fits your platform. + +### 1. Homebrew (macOS) + +```sh +brew install mikefreno/podtui/podtui # requires mpv: brew install mpv +``` + +> The formula installs the standalone binary plus its two native libraries +> side by side (see [Packaging model](#packaging-model)). It does **not** +> depend on Bun. + +### 2. Standalone tarball (all platforms) + +Grab `podtui--.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.` and +> `libcavacore.` **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 ` | Query feeds for a show title and print matching shows, without launching the TUI | +| `-p`, `--play ` | Play the matching show, without launching the TUI | + +### Keybindings + +All keys are remappable — edit `~/.config/podtui/keybinds.jsonc`. + +| Keys | Action | +|------|--------| +| `j` / `k` | Move cursor down / up | +| `J` / `K` | Jump 5 lines | +| `ctrl-d` / `ctrl-u` | Page down / up | +| `gg` / `G` | Go to top / bottom | +| `h` / `l` | Swipe to parent pane / preview pane | +| `Enter` | Open the item under the cursor (a tab, episode, show…) | +| `Space` | Select / toggle selection | +| `v` | Visual mode (multi-select) | +| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) | +| `[` / `]` | Previous / next tab | +| `P` (shift) | Play / pause | +| `N` / `B` | Next / previous episode | +| `shift-.` / `shift-,` | Seek forward / backward | +| `s` | Search (in a list) | +| `f` | Filter | +| `r` | Refresh | +| `:` | Command bar | +| `~`, `f1` | Help | +| `q`, `ctrl-c` | Quit | +| `Esc` | Escape / cancel | + +## Configuration + +Configuration lives under the XDG config directory — `~/.config/podtui` by +default (`$XDG_CONFIG_HOME/podtui` if set). + +| File | Purpose | +|------|---------| +| `feeds.json` | Your subscribed feeds (RSS/podcast sources) | +| `sources.json` | Custom feed sources | +| `downloads.json` | Downloaded episode metadata | +| `keybinds.jsonc` | Keybinding remaps (see above) | +| `themes/` | Optional custom theme files | + +Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads +the same OpenTUI environment variables. + +## Development ```bash -bun dev +bun install # install dependencies +bun run dev # run with hot reload +bun test # run the test suite +bun run build # bundle JS + copy native libs into dist/ +make native # rebuild cavacore from C source +make lint # type-check (tsc) ``` -This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI. +### Releasing + +Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs +to your GitHub Release automatically: + +```bash +make dist # build the standalone binary + tarball for THIS platform +make dist-mac # (run on macOS) → podtui-darwin-.tar.gz +make dist-linux # (run on Linux) → podtui-linux-.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. # OpenTUI native renderer FFI library +libcavacore. # 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 diff --git a/build.ts b/build.ts index 5c8d4a1..c3f6797 100644 --- a/build.ts +++ b/build.ts @@ -1,62 +1,130 @@ -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 = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-x64": "linux-x64", + "linux-arm64": "linux-arm64", + "win32-x64": "win32-x64", + "win32-arm64": "win32-arm64", +}; + +const libExt = + platform === "win32" ? "dll" : platform === "darwin" ? "dylib" : "so"; // Build the JavaScript bundle await Bun.build({ - entrypoints: ["./src/index.tsx"], - outdir: "./dist", - target: "bun", - minify: true, - sourcemap: "external", - plugins: [solidPlugin], -}) + entrypoints: ["./src/index.tsx"], + outdir: "./dist", + target: "bun", + minify: true, + sourcemap: "external", + plugins: [solidPlugin], +}); -// Copy the native library to dist for distribution -const platform = process.platform -const arch = process.arch - -// Map platform/arch to OpenTUI package names -const platformMap: Record = { - "darwin-arm64": "darwin-arm64", - "darwin-x64": "darwin-x64", - "linux-x64": "linux-x64", - "linux-arm64": "linux-arm64", - "win32-x64": "win32-x64", - "win32-arm64": "win32-arm64", -} - -const platformKey = `${platform}-${arch}` -const platformPkg = platformMap[platformKey] +// Copy the opentui native library to dist for distribution. +const platformKey = `${platform}-${arch}`; +const platformPkg = platformMap[platformKey]; if (platformPkg) { - const libName = platform === "win32" - ? "opentui.dll" - : platform === "darwin" - ? "libopentui.dylib" - : "libopentui.so" - const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName) - - if (existsSync(srcPath)) { - const destPath = join("dist", libName) - copyFileSync(srcPath, destPath) - console.log(`Copied native library: ${libName}`) - } + const libName = `libopentui.${libExt}`; + const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName); + + if (existsSync(srcPath)) { + const destPath = join("dist", libName); + copyFileSync(srcPath, destPath); + console.log(`Copied native library: ${libName}`); + } } // Copy cavacore native library to dist -const cavacoreLib = platform === "darwin" - ? "libcavacore.dylib" - : platform === "win32" - ? "cavacore.dll" - : "libcavacore.so" -const cavacoreSrc = join("src", "native", cavacoreLib) +const cavacoreLib = `libcavacore.${libExt}`; +const cavacoreSrc = join("src", "native", cavacoreLib); if (existsSync(cavacoreSrc)) { - copyFileSync(cavacoreSrc, join("dist", cavacoreLib)) - console.log(`Copied cavacore library: ${cavacoreLib}`) + copyFileSync(cavacoreSrc, join("dist", cavacoreLib)); + console.log(`Copied cavacore library: ${cavacoreLib}`); } else { - console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`) + console.warn( + `Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`, + ); } -console.log("Build complete") +// ── Standalone compiled binary (dist/podtui + libs beside it) ────────────── +// `bun run build.ts --compile` (or PODTUI_COMPILE=1). Embeds the Bun runtime +// so end users need nothing installed; the two FFI libs are shipped as +// SIBLING FILES next to the binary (both loaders already resolve them that +// way: cavacore checks dirname(process.execPath); opentui embeds via its +// bun-plugin and handles the embedded-file path itself). +if (COMPILE) { + const outfile = join("dist", "podtui"); + await Bun.build({ + entrypoints: ["./src/index.tsx"], + target: "bun", + minify: true, + sourcemap: "external", + plugins: [solidPlugin], + compile: { + outfile, + }, + }); + console.log(`Compiled standalone binary: ${outfile}`); + + // Ensure both native libs sit beside the binary. + const opentuiSrc = join( + "node_modules", + `@opentui/core-${platformPkg}`, + `libopentui.${libExt}`, + ); + if (existsSync(opentuiSrc)) { + copyFileSync(opentuiSrc, join("dist", `libopentui.${libExt}`)); + } + if (!existsSync(join("dist", cavacoreLib))) { + console.warn( + `Warning: ${cavacoreLib} missing beside the binary — run scripts/build-cavacore.sh`, + ); + } + + // Tarball: podtui + the two native libs (drop the JS bundle dir) + const tarRoot = join("dist", `podtui-${platform}-${arch}`); + rmSync(tarRoot, { recursive: true, force: true }); + mkdirSync(tarRoot, { recursive: true }); + copyFileSync(outfile, join(tarRoot, "podtui")); + for (const lib of [`libopentui.${libExt}`, cavacoreLib]) { + const s = join("dist", lib); + if (existsSync(s)) copyFileSync(s, join(tarRoot, lib)); + } + const tar = Bun.spawnSync([ + "tar", + "-czf", + `${tarRoot}.tar.gz`, + "-C", + "dist", + `podtui-${platform}-${arch}`, + ]); + if (tar.exitCode !== 0) { + console.error(tar.stderr.toString()); + process.exit(1); + } + console.log(`Tarball: ${tarRoot}.tar.gz`); + rmSync(tarRoot, { recursive: true, force: true }); +} + +console.log("Build complete"); diff --git a/bunfig.standalone.toml b/bunfig.standalone.toml new file mode 100644 index 0000000..f1be879 --- /dev/null +++ b/bunfig.standalone.toml @@ -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 diff --git a/notes.md b/notes.md index c4d699b..4963cc4 100644 --- a/notes.md +++ b/notes.md @@ -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 diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 71efeb9..170249b 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -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"; diff --git a/src/components/TabPanel.tsx b/src/components/TabPanel.tsx index 5de4246..6f96011 100644 --- a/src/components/TabPanel.tsx +++ b/src/components/TabPanel.tsx @@ -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 ( {(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)} > - - {isActive() ? "●" : " "} - - - {tab} - - + {/* ── selection marker (j/k cursor) ─────────────────────────── */} + {isCursor() ? "❯" : " "} + {tab} + {TAB_LABEL[tab]} diff --git a/src/components/YaziPaneRow.tsx b/src/components/YaziPaneRow.tsx index 9f7c752..596c08f 100644 --- a/src/components/YaziPaneRow.tsx +++ b/src/components/YaziPaneRow.tsx @@ -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 ( - + {/* ── slim header label row ─────────────────────────────────────────── */} {props.label()} @@ -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 ( {/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} @@ -169,22 +187,24 @@ export function YaziPaneRow(props: YaziPaneRowProps) { borderColor={() => theme.border} scrollFocused={() => false} /> - {/* ── current (3/7) — the focused list; accent ring when focused ───── */} + {/* ── current — the focused list; active-border ring when focused ──────────── */} (focused() ? theme.accent : theme.border)} + borderColor={() => (focused() ? theme.borderActive : theme.border)} scrollFocused={() => focused()} /> {/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} - theme.border} - scrollFocused={() => false} - /> + + theme.border} + scrollFocused={() => false} + /> + ); } diff --git a/src/context/navigation-store.ts b/src/context/navigation-store.ts index 64d370b..1e2a4b2 100644 --- a/src/context/navigation-store.ts +++ b/src/context/navigation-store.ts @@ -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.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(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>( {}, ); @@ -143,7 +132,7 @@ export function createNavigation() { const [commandBuffer, setCommandBuffer] = createSignal(""); const [commandError, setCommandError] = createSignal(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, diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index cb33b34..1718906 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -12,331 +12,368 @@ * ``` */ -import { createSignal, onCleanup } from "solid-js" +import { createSignal, onCleanup } from "solid-js"; import { - createAudioBackend, - detectPlayers, - type AudioBackend, - type BackendName, - type DetectedPlayer, -} from "../utils/audio-player" -import { emit, on } from "../utils/event-bus" -import { useAppStore } from "../stores/app" -import { useProgressStore } from "../stores/progress" -import { useMediaRegistry } from "../utils/media-registry" -import type { Episode } from "../types/episode" -import type { Feed } from "../types/feed" -import { useAudioNavStore, AudioSource } from "../stores/audio-nav" -import { useFeedStore } from "../stores/feed" + createAudioBackend, + detectPlayers, + type AudioBackend, + type BackendName, + type DetectedPlayer, +} from "../utils/audio-player"; +import { emit, on } from "../utils/event-bus"; +import { useAppStore } from "../stores/app"; +import { useProgressStore } from "../stores/progress"; +import { useMediaRegistry } from "../utils/media-registry"; +import type { Episode } from "../types/episode"; +import type { Feed } from "../types/feed"; +import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; +import { useFeedStore } from "../stores/feed"; export interface AudioControls { - // Signals (reactive getters) - isPlaying: () => boolean - position: () => number - duration: () => number - volume: () => number - speed: () => number - backendName: () => BackendName - error: () => string | null - currentEpisode: () => Episode | null - availablePlayers: () => DetectedPlayer[] + // Signals (reactive getters) + isPlaying: () => boolean; + position: () => number; + duration: () => number; + volume: () => number; + speed: () => number; + backendName: () => BackendName; + error: () => string | null; + currentEpisode: () => Episode | null; + availablePlayers: () => DetectedPlayer[]; - // Actions - play: (episode: Episode) => Promise - pause: () => Promise - resume: () => Promise - togglePlayback: () => Promise - stop: () => Promise - seek: (seconds: number) => Promise - seekRelative: (delta: number) => Promise - setVolume: (volume: number) => Promise - setSpeed: (speed: number) => Promise - switchBackend: (name: BackendName) => Promise - prev: () => Promise - next: () => Promise + // Actions + play: (episode: Episode) => Promise; + pause: () => Promise; + resume: () => Promise; + togglePlayback: () => Promise; + stop: () => Promise; + seek: (seconds: number) => Promise; + seekRelative: (delta: number) => Promise; + setVolume: (volume: number) => Promise; + setSpeed: (speed: number) => Promise; + switchBackend: (name: BackendName) => Promise; + prev: () => Promise; + next: () => Promise; } // Singleton state — shared across all components that call useAudio() -let backend: AudioBackend | null = null -let pollTimer: ReturnType | null = null -let refCount = 0 -let pollCount = 0 // Counts poll ticks for throttling progress saves +let backend: AudioBackend | null = null; +let pollTimer: ReturnType | 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("none") -const [error, setError] = createSignal(null) -const [currentEpisode, setCurrentEpisode] = createSignal(null) -const [availablePlayers, setAvailablePlayers] = createSignal([]) +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("none"); +const [error, setError] = createSignal(null); +const [currentEpisode, setCurrentEpisode] = createSignal(null); +const [availablePlayers, setAvailablePlayers] = createSignal( + [], +); function ensureBackend(): AudioBackend { - if (!backend) { - const detected = detectPlayers() - setAvailablePlayers(detected) - backend = createAudioBackend() - setBackendName(backend.name) - } - return backend + if (!backend) { + const detected = detectPlayers(); + setAvailablePlayers(detected); + backend = createAudioBackend(); + setBackendName(backend.name); + registerExitTeardown(); + } + return backend; +} + +// ── Process-exit teardown ───────────────────────────────────────────── +// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's +// onCleanup — where `backend.dispose()` would otherwise kill the spawned +// player (mpv/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); + }); + } } function startPolling(): void { - stopPolling() - pollCount = 0 - pollTimer = setInterval(async () => { - if (!backend || !isPlaying()) return - try { - const pos = await backend.getPosition() - const dur = await backend.getDuration() - setPosition(pos) - if (dur > 0) setDuration(dur) + stopPolling(); + pollCount = 0; + pollTimer = setInterval(async () => { + if (!backend || !isPlaying()) return; + try { + const pos = await backend.getPosition(); + const dur = await backend.getDuration(); + setPosition(pos); + if (dur > 0) setDuration(dur); - // Save progress every ~5 seconds (10 ticks * 500ms) - pollCount++ - if (pollCount % 10 === 0) { - const ep = currentEpisode() - if (ep) { - const progressStore = useProgressStore() - progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()) + // Save progress every ~5 seconds (10 ticks * 500ms) + pollCount++; + if (pollCount % 10 === 0) { + const ep = currentEpisode(); + if (ep) { + const progressStore = useProgressStore(); + progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); - // Update platform media position - const media = useMediaRegistry() - media.setPosition(pos) - } - } + // Update platform media position + const media = useMediaRegistry(); + media.setPosition(pos); + } + } - // Check if backend stopped playing (track ended) - if (!backend.isPlaying() && isPlaying()) { - setIsPlaying(false) - stopPolling() - // Save final position on track end - const ep = currentEpisode() - if (ep) { - const progressStore = useProgressStore() - progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()) - } - } - } catch { - // Backend may have been disposed - } - }, 500) + // Check if backend stopped playing (track ended) + if (!backend.isPlaying() && isPlaying()) { + setIsPlaying(false); + stopPolling(); + // Save final position on track end + const ep = currentEpisode(); + if (ep) { + const progressStore = useProgressStore(); + progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); + } + } + } catch { + // Backend may have been disposed + } + }, 500); } function stopPolling(): void { - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = null - } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } } async function play(episode: Episode): Promise { - const b = ensureBackend() - setError(null) + const b = ensureBackend(); + setError(null); - if (!episode.audioUrl) { - setError("No audio URL for this episode") - return - } + if (!episode.audioUrl) { + setError("No audio URL for this episode"); + return; + } - try { - const appStore = useAppStore() - const progressStore = useProgressStore() - const storeSpeed = appStore.state().settings.playbackSpeed - const vol = volume() - const spd = storeSpeed || speed() + try { + const appStore = useAppStore(); + const progressStore = useProgressStore(); + const storeSpeed = appStore.state().settings.playbackSpeed; + const vol = volume(); + const spd = storeSpeed || speed(); - // Resume from saved progress if available and not completed - const savedProgress = progressStore.get(episode.id) - let startPos = 0 - if (savedProgress && !progressStore.isCompleted(episode.id)) { - startPos = savedProgress.position - } + // Resume from saved progress if available and not completed + const savedProgress = progressStore.get(episode.id); + let startPos = 0; + if (savedProgress && !progressStore.isCompleted(episode.id)) { + startPos = savedProgress.position; + } - await b.play(episode.audioUrl, { - volume: vol, - speed: spd, - startPosition: startPos > 0 ? startPos : undefined, - }) + await b.play(episode.audioUrl, { + volume: vol, + speed: spd, + startPosition: startPos > 0 ? startPos : undefined, + }); - setCurrentEpisode(episode) - setIsPlaying(true) - setPosition(startPos) - setSpeed(spd) - if (episode.duration) setDuration(episode.duration) + setCurrentEpisode(episode); + setIsPlaying(true); + setPosition(startPos); + setSpeed(spd); + if (episode.duration) setDuration(episode.duration); - // Register with platform media controls - const media = useMediaRegistry() - media.setNowPlaying({ - title: episode.title, - artist: episode.podcastId, - duration: episode.duration, - }) - media.setPlaybackState(true) - if (startPos > 0) media.setPosition(startPos) + // Register with platform media controls + const media = useMediaRegistry(); + media.setNowPlaying({ + title: episode.title, + artist: episode.podcastId, + duration: episode.duration, + }); + media.setPlaybackState(true); + if (startPos > 0) media.setPosition(startPos); - startPolling() - emit("player.play", { episodeId: episode.id }) - } catch (err) { - setError(err instanceof Error ? err.message : "Playback failed") - setIsPlaying(false) - } + startPolling(); + emit("player.play", { episodeId: episode.id }); + } catch (err) { + setError(err instanceof Error ? err.message : "Playback failed"); + setIsPlaying(false); + } } async function pause(): Promise { - if (!backend) return - try { - await backend.pause() - setIsPlaying(false) - stopPolling() - const ep = currentEpisode() - if (ep) { - // Save progress on pause - const progressStore = useProgressStore() - progressStore.update(ep.id, position(), duration(), speed()) - emit("player.pause", { episodeId: ep.id }) + if (!backend) return; + try { + await backend.pause(); + setIsPlaying(false); + stopPolling(); + const ep = currentEpisode(); + if (ep) { + // Save progress on pause + const progressStore = useProgressStore(); + progressStore.update(ep.id, position(), duration(), speed()); + emit("player.pause", { episodeId: ep.id }); - // Update platform media controls - const media = useMediaRegistry() - media.setPlaybackState(false) - media.setPosition(position()) - } - } catch (err) { - setError(err instanceof Error ? err.message : "Pause failed") - } + // Update platform media controls + const media = useMediaRegistry(); + media.setPlaybackState(false); + media.setPosition(position()); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Pause failed"); + } } async function resume(): Promise { - if (!backend) return - try { - await backend.resume() - setIsPlaying(true) - startPolling() - const ep = currentEpisode() - if (ep) { - emit("player.play", { episodeId: ep.id }) - const media = useMediaRegistry() - media.setPlaybackState(true) - } - } catch (err) { - setError(err instanceof Error ? err.message : "Resume failed") - } + if (!backend) return; + try { + await backend.resume(); + setIsPlaying(true); + startPolling(); + const ep = currentEpisode(); + if (ep) { + emit("player.play", { episodeId: ep.id }); + const media = useMediaRegistry(); + media.setPlaybackState(true); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Resume failed"); + } } async function togglePlayback(): Promise { - if (isPlaying()) { - await pause() - } else if (currentEpisode()) { - await resume() - } + if (isPlaying()) { + await pause(); + } else if (currentEpisode()) { + await resume(); + } } async function stop(): Promise { - if (!backend) return - try { - // Save progress before stopping - const ep = currentEpisode() - if (ep) { - const progressStore = useProgressStore() - progressStore.update(ep.id, position(), duration(), speed()) - } - await backend.stop() - setIsPlaying(false) - setPosition(0) - setCurrentEpisode(null) - stopPolling() - emit("player.stop", {}) + if (!backend) return; + try { + // Save progress before stopping + const ep = currentEpisode(); + if (ep) { + const progressStore = useProgressStore(); + progressStore.update(ep.id, position(), duration(), speed()); + } + await backend.stop(); + setIsPlaying(false); + setPosition(0); + setCurrentEpisode(null); + stopPolling(); + emit("player.stop", {}); - // Clear platform media controls - const media = useMediaRegistry() - media.clearNowPlaying() - } catch (err) { - setError(err instanceof Error ? err.message : "Stop failed") - } + // Clear platform media controls + const media = useMediaRegistry(); + media.clearNowPlaying(); + } catch (err) { + setError(err instanceof Error ? err.message : "Stop failed"); + } } async function seek(seconds: number): Promise { - if (!backend) return - const clamped = Math.max(0, Math.min(seconds, duration())) - try { - await backend.seek(clamped) - setPosition(clamped) - } catch (err) { - setError(err instanceof Error ? err.message : "Seek failed") - } + if (!backend) return; + const clamped = Math.max(0, Math.min(seconds, duration())); + try { + await backend.seek(clamped); + setPosition(clamped); + } catch (err) { + setError(err instanceof Error ? err.message : "Seek failed"); + } } async function seekRelative(delta: number): Promise { - await seek(position() + delta) + await seek(position() + delta); } async function doSetVolume(vol: number): Promise { - const clamped = Math.max(0, Math.min(1, vol)) - if (backend) { - try { - await backend.setVolume(clamped) - } catch { - // Some backends can't change volume at runtime - } - } - setVolume(clamped) + const clamped = Math.max(0, Math.min(1, vol)); + if (backend) { + try { + await backend.setVolume(clamped); + } catch { + // Some backends can't change volume at runtime + } + } + setVolume(clamped); } async function doSetSpeed(spd: number): Promise { - const clamped = Math.max(0.25, Math.min(3, spd)) - if (backend) { - try { - await backend.setSpeed(clamped) - } catch { - // Some backends can't change speed at runtime - } - } - setSpeed(clamped) + const clamped = Math.max(0.25, Math.min(3, spd)); + if (backend) { + try { + await backend.setSpeed(clamped); + } catch { + // Some backends can't change speed at runtime + } + } + setSpeed(clamped); - // Sync back to app store - try { - const appStore = useAppStore() - appStore.updateSettings({ playbackSpeed: clamped }) - } catch { - // Store may not be available - } + // Sync back to app store + try { + const appStore = useAppStore(); + appStore.updateSettings({ playbackSpeed: clamped }); + } catch { + // Store may not be available + } } async function switchBackend(name: BackendName): Promise { - const wasPlaying = isPlaying() - const ep = currentEpisode() - const pos = position() - const vol = volume() - const spd = speed() + const wasPlaying = isPlaying(); + const ep = currentEpisode(); + const pos = position(); + const vol = volume(); + const spd = speed(); - // Stop current backend - if (backend) { - stopPolling() - backend.dispose() - backend = null - } + // Stop current backend + if (backend) { + stopPolling(); + backend.dispose(); + backend = null; + } - // Create new backend - backend = createAudioBackend(name) - setBackendName(backend.name) - setAvailablePlayers(detectPlayers()) + // Create new backend + backend = createAudioBackend(name); + setBackendName(backend.name); + setAvailablePlayers(detectPlayers()); - // Resume playback if we were playing - if (wasPlaying && ep && ep.audioUrl) { - try { - await backend.play(ep.audioUrl, { - startPosition: pos, - volume: vol, - speed: spd, - }) - setIsPlaying(true) - startPolling() - } catch (err) { - setError(err instanceof Error ? err.message : "Backend switch failed") - setIsPlaying(false) - } - } + // Resume playback if we were playing + if (wasPlaying && ep && ep.audioUrl) { + try { + await backend.play(ep.audioUrl, { + startPosition: pos, + volume: vol, + speed: spd, + }); + setIsPlaying(true); + startPolling(); + } catch (err) { + setError(err instanceof Error ? err.message : "Backend switch failed"); + setIsPlaying(false); + } + } } /** @@ -346,183 +383,187 @@ async function switchBackend(name: BackendName): Promise { * Registers event bus listeners and cleans them up with onCleanup. */ export function useAudio(): AudioControls { - // Initialize backend on first use - ensureBackend() + // Initialize backend on first use + ensureBackend(); - // Sync initial speed from app store - if (refCount === 0) { - try { - const appStore = useAppStore() - const storeSpeed = appStore.state().settings.playbackSpeed - if (storeSpeed && storeSpeed !== speed()) { - setSpeed(storeSpeed) - } - } catch { - // Store may not be available yet - } - } + // Sync initial speed from app store + if (refCount === 0) { + try { + const appStore = useAppStore(); + const storeSpeed = appStore.state().settings.playbackSpeed; + if (storeSpeed && storeSpeed !== speed()) { + setSpeed(storeSpeed); + } + } catch { + // Store may not be available yet + } + } - refCount++ + refCount++; - // Listen for event bus commands (e.g. from other components) - const unsubPlay = on("player.play", async (data) => { - // External play requests — currently just tracks episodeId. - // Episode lookup would require feed store integration. - }) + // Listen for event bus commands (e.g. from other components) + const unsubPlay = on("player.play", async (data) => { + // External play requests — currently just tracks episodeId. + // Episode lookup would require feed store integration. + }); - const unsubStop = on("player.stop", async () => { - if (backend && isPlaying()) { - await backend.stop() - setIsPlaying(false) - setPosition(0) - setCurrentEpisode(null) - stopPolling() - } - }) + const unsubStop = on("player.stop", async () => { + if (backend && isPlaying()) { + await backend.stop(); + setIsPlaying(false); + setPosition(0); + setCurrentEpisode(null); + stopPolling(); + } + }); - // Listen for global multimedia key events (from useMultimediaKeys) - const unsubMediaToggle = on("media.toggle", async () => { - await togglePlayback() - }) + // Listen for global multimedia key events (from useMultimediaKeys) + const unsubMediaToggle = on("media.toggle", async () => { + await togglePlayback(); + }); - const unsubMediaVolUp = on("media.volumeUp", async () => { - await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2)))) - }) + const unsubMediaVolUp = on("media.volumeUp", async () => { + await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2)))); + }); - const unsubMediaVolDown = on("media.volumeDown", async () => { - await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2)))) - }) + const unsubMediaVolDown = on("media.volumeDown", async () => { + await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2)))); + }); - const unsubMediaSeekFwd = on("media.seekForward", async () => { - await seekRelative(10) - }) + const unsubMediaSeekFwd = on("media.seekForward", async () => { + await seekRelative(10); + }); - const unsubMediaSeekBack = on("media.seekBackward", async () => { - await seekRelative(-10) - }) + const unsubMediaSeekBack = on("media.seekBackward", async () => { + await seekRelative(-10); + }); - const unsubMediaSpeed = on("media.speedCycle", async () => { - const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2)) - await doSetSpeed(next) - }) + const unsubMediaSpeed = on("media.speedCycle", async () => { + const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2)); + await doSetSpeed(next); + }); - const audioNav = useAudioNavStore(); - const feedStore = useFeedStore(); + const audioNav = useAudioNavStore(); + const feedStore = useFeedStore(); - async function prev(): Promise { - const current = currentEpisode(); - if (!current) return; + async function prev(): Promise { + const current = currentEpisode(); + if (!current) return; - const currentPos = position(); - const currentDur = duration(); + const currentPos = position(); + const currentDur = duration(); - const NAV_START_THRESHOLD = 30; + const NAV_START_THRESHOLD = 30; - if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { - await seek(NAV_START_THRESHOLD); - } else { - const source = audioNav.getSource(); - let episodes: Array<{ episode: Episode; feed: Feed }> = []; + if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { + await seek(NAV_START_THRESHOLD); + } else { + const source = audioNav.getSource(); + let episodes: Array<{ episode: Episode; feed: Feed }> = []; - if (source === AudioSource.FEED) { - episodes = feedStore.getAllEpisodesChronological(); - } else if (source === AudioSource.MY_SHOWS) { - const podcastId = audioNav.getPodcastId(); - if (!podcastId) return; + if (source === AudioSource.FEED) { + episodes = feedStore.getAllEpisodesChronological(); + } else if (source === AudioSource.MY_SHOWS) { + const podcastId = audioNav.getPodcastId(); + if (!podcastId) return; - const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId); - if (!feed) return; + const feed = feedStore + .getFilteredFeeds() + .find((f) => f.podcast.id === podcastId); + if (!feed) return; - episodes = feed.episodes.map(ep => ({ episode: ep, feed })); - } + episodes = feed.episodes.map((ep) => ({ episode: ep, feed })); + } - const currentIndex = audioNav.getCurrentIndex(); - const newIndex = Math.max(0, currentIndex - 1); + const currentIndex = audioNav.getCurrentIndex(); + const newIndex = Math.max(0, currentIndex - 1); - if (newIndex < episodes.length && episodes[newIndex]) { - const { episode } = episodes[newIndex]; - await play(episode); - audioNav.prev(newIndex); - } - } - } + if (newIndex < episodes.length && episodes[newIndex]) { + const { episode } = episodes[newIndex]; + await play(episode); + audioNav.prev(newIndex); + } + } + } - async function next(): Promise { - const current = currentEpisode(); - if (!current) return; + async function next(): Promise { + const current = currentEpisode(); + if (!current) return; - const source = audioNav.getSource(); - let episodes: Array<{ episode: Episode; feed: Feed }> = []; + const source = audioNav.getSource(); + let episodes: Array<{ episode: Episode; feed: Feed }> = []; - if (source === AudioSource.FEED) { - episodes = feedStore.getAllEpisodesChronological(); - } else if (source === AudioSource.MY_SHOWS) { - const podcastId = audioNav.getPodcastId(); - if (!podcastId) return; + if (source === AudioSource.FEED) { + episodes = feedStore.getAllEpisodesChronological(); + } else if (source === AudioSource.MY_SHOWS) { + const podcastId = audioNav.getPodcastId(); + if (!podcastId) return; - const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId); - if (!feed) return; + const feed = feedStore + .getFilteredFeeds() + .find((f) => f.podcast.id === podcastId); + if (!feed) return; - episodes = feed.episodes.map(ep => ({ episode: ep, feed })); - } + episodes = feed.episodes.map((ep) => ({ episode: ep, feed })); + } - const currentIndex = audioNav.getCurrentIndex(); - const newIndex = Math.min(episodes.length - 1, currentIndex + 1); + const currentIndex = audioNav.getCurrentIndex(); + const newIndex = Math.min(episodes.length - 1, currentIndex + 1); - if (newIndex >= 0 && episodes[newIndex]) { - const { episode } = episodes[newIndex]; - await play(episode); - audioNav.next(newIndex); - } - } + if (newIndex >= 0 && episodes[newIndex]) { + const { episode } = episodes[newIndex]; + await play(episode); + audioNav.next(newIndex); + } + } - onCleanup(() => { - refCount-- - unsubPlay() - unsubStop() - unsubMediaToggle() - unsubMediaVolUp() - unsubMediaVolDown() - unsubMediaSeekFwd() - unsubMediaSeekBack() - unsubMediaSpeed() + onCleanup(() => { + refCount--; + unsubPlay(); + unsubStop(); + unsubMediaToggle(); + unsubMediaVolUp(); + unsubMediaVolDown(); + unsubMediaSeekFwd(); + unsubMediaSeekBack(); + unsubMediaSpeed(); - if (refCount <= 0) { - stopPolling() - if (backend) { - backend.dispose() - backend = null - } - // Clear media registry on full teardown - const media = useMediaRegistry() - media.clearNowPlaying() + if (refCount <= 0) { + stopPolling(); + if (backend) { + backend.dispose(); + backend = null; + } + // Clear media registry on full teardown + const media = useMediaRegistry(); + media.clearNowPlaying(); - refCount = 0 - } - }) + refCount = 0; + } + }); - return { - isPlaying, - position, - duration, - volume, - speed, - backendName, - error, - currentEpisode, - availablePlayers, + return { + isPlaying, + position, + duration, + volume, + speed, + backendName, + error, + currentEpisode, + availablePlayers, - play, - pause, - resume, - togglePlayback, - stop, - seek, - seekRelative, - setVolume: doSetVolume, - setSpeed: doSetSpeed, - switchBackend, - prev, - next, - } + play, + pause, + resume, + togglePlayback, + stop, + seek, + seekRelative, + setVolume: doSetVolume, + setSpeed: doSetSpeed, + switchBackend, + prev, + next, + }; } diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index 7fe9a96..0bb2c91 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -181,7 +181,7 @@ function DiscoverPage() { {(cat, index) => { - const lf = focusedCatIdx(); + const lf = () => focusedCatIdx(); const selected = () => cat.id === discoverStore.selectedCategory(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); discoverStore.setSelectedCategory(cat.id); }} > - - {index() === lf ? "❯" : " "} + + {index() === lf() ? "❯" : " "} - {cat.name} + {cat.name} - + * @@ -222,35 +222,35 @@ function DiscoverPage() { > {(podcast, index) => { - const lf = focusedPodIdx(); + const lf = () => focusedPodIdx(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} > - - {index() === lf ? "❯" : " "} + + {index() === lf() ? "❯" : " "} - + {podcast.title} - + [+] by {podcast.author} diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index cf872d4..5dc0953 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -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 `` (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(() => { - 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(() => { - 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( + () => feedStore.getAllEpisodesChronological() as EpItem[], + ); + const focus = () => nav.depthFocus(0); const focusedEpIdx = () => - episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1); + episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1); const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()]; - - const curLen = () => (depth() === 0 ? feedList().length : episodes().length); + const curLen = () => episodes().length; const ensureFocus = () => { - if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length) - nav.setDepthFocus(feedList().length - 1, 0); - if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length) - nav.setDepthFocus(episodes().length - 1, 1); + if (episodes().length > 0 && focus() >= episodes().length) + nav.setDepthFocus(episodes().length - 1, 0); }; onMount(ensureFocus); onMount(() => { - nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { - if (depth() === 0) { - const it = feedList()[i]; - return it?.kind === "feed" ? it.feed.podcast.id : "all"; - } - return episodes()[i]?.episode.id; - }); + nav.registerResolver( + `${nav.activeTab()}:${DEPTH_CENTER_PANE}`, + (i) => episodes()[i]?.episode.id, + ); }); // ── helpers ──────────────────────────────────────────────────────────────── @@ -146,19 +115,9 @@ function FeedPage() { audioNav.setSource(AudioSource.FEED); }; - // ── drill / open ─────────────────────────────────────────────────────────── + // ── open ─────────────────────────────────────────────────────────────────── function open() { - if (depth() === 0) { - const item = focusedFeedItem(); - if (!item) return; - const ctx = item.kind === "all" ? "all" : item.feed.podcast.id; - nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame); - nav.setActivePane(DEPTH_CENTER_PANE); - return; - } - if (depth() >= 1) { - playEpisode(focusedItem()); - } + playEpisode(focusedItem()); } // ── nav.action handler ──────────────────────────────────────────────────── @@ -173,16 +132,11 @@ function FeedPage() { "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), open: () => open(), "toggle-select": () => { - if (depth() >= 1) { - const item = focusedItem(); - if (item) nav.toggleSelected(item.episode.id); - } + const item = focusedItem(); + if (item) nav.toggleSelected(item.episode.id); }, refresh: () => { - const item = focusedFeedItem(); - if (item?.kind === "feed") - feedStore.refreshFeed(item.feed.id).catch(() => {}); - else feedStore.refreshAllFeeds().catch(() => {}); + feedStore.refreshAllFeeds().catch(() => {}); }, }; function step(delta: number) { @@ -205,7 +159,7 @@ function FeedPage() { // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; - // Row highlight within a list. `active=true` only for the current pane. + // Row highlight within the list. `active=true` only for the current pane. const focusBg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.primary @@ -215,265 +169,132 @@ function FeedPage() { const focusFg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.surface : theme.text; - const feedLabel = (item: FeedListItem) => - item.kind === "all" - ? "All Feeds" - : item.feed.customName || item.feed.podcast.title; - const feedCount = (item: FeedListItem) => - item.kind === "all" - ? feedStore.getAllEpisodesChronological().length - : item.feed.episodes.length; + const currentLabel = () => `Feed · ${episodes().length}`; - const currentLabel = () => - depth() === 0 - ? `Feeds · ${feedList().length - 1}` - : `${(() => { - const fi = focusedFeedItem(); - return fi?.kind === "feed" - ? fi.feed.customName || fi.feed.podcast.title - : "All Episodes"; - })()} · ${episodes().length}`; + // ── parent pane: muted tab list (no parent list — Feed is one depth) ────── + const parentContent = () => ; - // ── parent pane: previous-depth list (muted/blank at depth 0) ────────── - // Wrap in a stable (the sibling-Show pattern) so the parent list - // mounts/unmounts cleanly on depth change instead of swapping roots. - const parentContent = () => ( - = 1} fallback={}> - + // ── current pane: the flat episodes list (the only focusable column) ────── + const currentContent = () => ( + 0} + fallback={ + + No feeds. Subscribe from Discover/Search. + + } + > + {(item, index) => { - const lf = nav.depthFocus(0); + const fi = () => focusedEpIdx(); return ( { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} > - - {index() === lf ? "❯" : " "} - - {feedLabel(item)} - ({feedCount(item)}) + + + {index() === fi() ? "❯" : " "} + + + {item.episode.episodeNumber + ? `#${item.episode.episodeNumber} ` + : ""} + {item.episode.title} + + + + + {formatDate(item.episode.pubDate)} + + + {formatDuration(item.episode.duration)} + + + {item.feed.customName || item.feed.podcast.title} + + + + + + + {downloadLabel(item.episode.id)} + + + ); }} + + + + + ); - // ── current pane: the current-depth list (the only focusable column) ────── - const currentContent = () => ( - <> - {/* depth 0: feeds — stable sibling so the swap disposes cleanly */} - - 1} - fallback={ - - - No feeds. Subscribe from Discover/Search. + // ── preview pane: hovered-episode detail ─────────────────────────────────── + const previewContent = () => ( + + No episode focused + + } + > + {(item) => ( + + + + {item().episode.episodeNumber + ? `#${item().episode.episodeNumber} ` + : ""} + {item().episode.title} + + + + {formatDate(item().episode.pubDate)} + {formatDuration(item().episode.duration)} + + + {downloadLabel(item().episode.id)} - - } - > - - {(item, index) => { - const fi = focusedFeedIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - {index() === fi ? "❯" : " "} - - - {feedLabel(item)} - - - ({feedCount(item)}) - - - ); - }} - - - - = 1}> - {/* depth ≥1: episodes */} - 0} - fallback={ - - No episodes. :refresh - - } - > - - {(item, index) => { - const fi = focusedEpIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); - }} - > - - - {index() === fi ? "❯" : " "} - - - {item.episode.episodeNumber - ? `#${item.episode.episodeNumber} ` - : ""} - {item.episode.title} - - - - - {formatDate(item.episode.pubDate)} - - - {formatDuration(item.episode.duration)} - - - {item.feed.customName || item.feed.podcast.title} - - - - - - - {downloadLabel(item.episode.id)} - - - - - ); - }} - - - - - + + + + {item().feed.customName || item().feed.podcast.title} + + + by {item().feed.podcast.author} - - - + + + {item().episode.description?.slice(0, 400) ?? + "No description available."} + {(item().episode.description?.length ?? 0) > 400 ? "…" : ""} + + + enter: play · space: select · h back + + )} + ); - // ── preview pane: hovered-item detail ────────────────────────────────────── - const previewContent = () => - depth() === 0 ? ( - // depth 0 preview: hovered feed - - No feed focused - - } - > - {(item) => { - const it = item(); - return ( - - - {feedLabel(it)} - - - {it.kind === "feed" - ? `by ${it.feed.podcast.author ?? "unknown"}` - : ""} - - - {it.kind === "all" - ? `${feedCount(it)} episodes across all feeds` - : `${feedCount(it)} episodes`} - - - {it.kind === "feed" - ? (it.feed.podcast.description?.slice(0, 400) ?? - "No description.") - : "Drill in to see episodes across every feed."} - - - enter/l: open · h: back - - ); - }} - - ) : ( - // depth ≥1 preview: hovered episode - - No episode focused - - } - > - {(item) => { - const it = item(); - return ( - - - - {it.episode.episodeNumber - ? `#${it.episode.episodeNumber} ` - : ""} - {it.episode.title} - - - - {formatDate(it.episode.pubDate)} - {formatDuration(it.episode.duration)} - - - {downloadLabel(it.episode.id)} - - - - - {it.feed.customName || it.feed.podcast.title} - - - by {it.feed.podcast.author} - - - - {it.episode.description?.slice(0, 400) ?? - "No description available."} - {(it.episode.description?.length ?? 0) > 400 ? "…" : ""} - - - enter: play · space: select · h: back - - ); - }} - - ); - return ( (depth() >= 1 ? "Feeds" : "Up")} + parentLabel="Up" currentLabel={currentLabel} previewLabel="Detail" focused={isActive} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index b46d5c7..537cc54 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -204,19 +204,19 @@ export function MyShowsPage() { = 1} fallback={}> {(feed, index) => { - const lf = nav.depthFocus(0); + const lf = () => nav.depthFocus(0); return ( - - {index() === lf ? "❯" : " "} + + {index() === lf() ? "❯" : " "} - {showTitle(feed)} + {showTitle(feed)} ({feed.episodes.length}) ); @@ -242,26 +242,26 @@ export function MyShowsPage() { > {(feed, index) => { - const lf = focusedShowIdx(); + const lf = () => focusedShowIdx(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); }} > - - {index() === lf ? "❯" : " "} + + {index() === lf() ? "❯" : " "} - + {showTitle(feed)} - + ({feed.episodes.length}) @@ -282,33 +282,33 @@ export function MyShowsPage() { > {(ep, index) => { - const lf = focusedEpIdx(); + const lf = () => focusedEpIdx(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} > - - {index() === lf ? "❯" : " "} + + {index() === lf() ? "❯" : " "} - + {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} {ep.title} - + {formatDate(ep.pubDate)} - + {formatDuration(ep.duration)} diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index 0b703ff..8fc42a3 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -1,10 +1,13 @@ /** - * PlayerPage — single-pane audio now-playing view. + * PlayerPage — 2-pane yazi depth view of the now-playing episode. * - * Audio transport (play/pause, next/prev, seek) is handled globally by the - * Shell router (P/N/B/). This page renders a single rich pane showing the - * current episode, waveform, and playback controls. Panes/swipe do nothing - * (PaneCount=1). + * depth 0 (parent) — tab list (muted, read-only). + * depth 0 (current) — the single now-playing pane (rich view + controls). + * + * No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause, + * next/prev, seek) is handled globally by the Shell router (P/N/B/); this + * page only renders the now-playing surface. `h` at depth 0 returns to the + * tab root. */ import { Show } from "solid-js"; @@ -13,7 +16,9 @@ import { RealtimeWaveform } from "./RealtimeWaveform"; import { useAudio } from "@/hooks/useAudio"; import { useAppStore } from "@/stores/app"; import { useTheme } from "@/context/ThemeContext"; -import { useNavigation } from "@/context/NavigationContext"; +import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext"; +import { YaziPaneRow } from "@/components/YaziPaneRow"; +import { TabListPane } from "@/components/TabPanel"; export const PlayerPaneCount = 1; @@ -23,9 +28,7 @@ export function PlayerPage() { const nav = useNavigation(); const muted = () => theme.muted || theme.text; - // Single pane — always active. - const isActive = () => true; - const border = () => theme.accent; + const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const progressPercent = () => { const d = audio.duration(); @@ -39,84 +42,86 @@ export function PlayerPage() { return `${m}:${String(s).padStart(2, "0")}`; }; - return ( - - {/* ── pane 0: now playing ─────────────────────────────────────────── */} - - Player + // ── parent pane: the tab list (muted) ────────────────────────────────────── + const parentContent = () => ; + + // ── current pane: now playing ─────────────────────────────────────────────── + const currentContent = () => ( + + + + Now Playing + + + {formatTime(audio.position())} / {formatTime(audio.duration())} ( + {progressPercent()}%) + - + {(err) => {err()}} + + + + No episode loaded. + + } > - - + {(ep) => ( + - Now Playing + {ep().title} - {formatTime(audio.position())} / {formatTime(audio.duration())} ( - {progressPercent()}%) + {ep().description?.slice(0, 500) ?? "No description available."} + + { + const viz = useAppStore().state().settings.visualizer; + return { + bars: viz.bars, + noiseReduction: viz.noiseReduction, + lowCutOff: viz.lowCutOff, + highCutOff: viz.highCutOff, + }; + })()} + /> + )} + - - {(err) => {err()}} - + audio.seek(0)} + onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} + onSpeedChange={(s: number) => audio.setSpeed(s)} + onVolumeChange={(v: number) => audio.setVolume(v)} + /> - - No episode loaded. - - } - > - {(ep) => ( - - - {ep().title} - - - {ep().description?.slice(0, 500) ?? - "No description available."} - - - { - const viz = useAppStore().state().settings.visualizer; - return { - bars: viz.bars, - noiseReduction: viz.noiseReduction, - lowCutOff: viz.lowCutOff, - highCutOff: viz.highCutOff, - }; - })()} - /> - - )} - - - audio.seek(0)} - onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} - onSpeedChange={(s: number) => audio.setSpeed(s)} - onVolumeChange={(v: number) => audio.setVolume(v)} - /> - - - {"P play/pause N next B prev - - + + + {"P play/pause N next B prev ); + + return ( + + ); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 044c3f2..6571620 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -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 `` - * 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 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 ───── - 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 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 void>> = { + "move-down": () => step(1), + "move-up": () => step(-1), + "jump-down": () => step(5), + "jump-up": () => step(-5), + "page-down": () => step(10), + "page-up": () => step(-10), + "goto-top": () => nav.gotoIndex(0, curLen()), + "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), + open: () => open(), + "toggle-select": () => { + if (depth() === 1) { + const r = focusedResult(); + if (r) nav.toggleSelected(r.podcast.id); } }, search: () => { - nav.setActivePane(INPUT); + // `s` refocuses the query input (typing mode) when on the query depth. + if (depth() === 0) nav.setInputFocused(true); }, refresh: () => { - if (inputValue().trim()) { - searchStore.search(inputValue().trim()).catch(() => {}); - } + const q = submittedQuery() || inputValue().trim(); + if (q) searchStore.search(q).catch(() => {}); }, }; - function len(pane: PaneId): number { - if (pane === RESULTS) return results().length; - return 0; + function step(delta: number) { + nav.move(delta, curLen()); } - function step(pane: PaneId, delta: number) { - nav.move(delta, len(pane)); + function open() { + if (depth() === 0) { + // Enter/l on a focused recent search → submit it and drill to results. + const list = recents(); + const idx = Math.min(focus(0), list.length - 1); + const q = list[idx]; + if (q) selectRecent(q); + return; + } + if (depth() === 1) { + const r = focusedResult(); + if (r) handleSubscribe(r); + } } const onAction = (data: { @@ -161,235 +174,248 @@ 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 ( - - {/* ── pane 0: query input ──────────────────────────────────────────────── */} - - - Search - - - - - Query: - handleSubmit()} - placeholder="Enter podcast name..." - focused={isActive(INPUT)} - width={28} - /> - - Enter to search · h/l: panes - - - Searching... - - - {searchStore.error()} - - - - Recent - 0} - fallback={No recent searches} - > - - {(query) => ( - handleHistorySelect(query)} - > - - {">"} {query} - - - )} - - - - + // ── parent pane: previous-depth content (tab list at depth 0) ────────────── + const parentContent = () => ( + = 1} fallback={}> + + Query + {submittedQuery() || "(empty)"} + + h: back to query + + ); - {/* ── pane 1: results ──────────────────────────────────────────────────── */} - - - Results · {results().length} - - + // ── current pane ──────────────────────────────────────────────────────────── + const currentContent = () => ( + <> + + {/* query input row + recent searches */} + + + Query: + handleSubmit()} + placeholder="Enter podcast name..." + focused={inputActive()} + width={28} + /> + + + Searching... + + + {searchStore.error()} + + + Recent 0} + when={recents().length > 0} fallback={ - - - {searchStore.query() - ? "No results found" - : "Enter a search term to find podcasts"} - - + + {inputActive() + ? "Enter to search" + : "s to type · Enter to search"} + } > - - {(result, index) => ( + + {(query, index) => { + const lf = () => focus(0); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + > + + {index() === lf() ? "❯" : " "} + + {query} + + ); + }} + + + + + {inputActive() + ? "Enter to search · Esc to defocus" + : "j/k recents · s to type · h back"} + + + + = 1}> + {/* results list */} + 0} + fallback={ + + + {searchStore.query() + ? "No results found" + : "Enter a search term to find podcasts"} + + + } + > + + {(result, index) => { + const fi = () => focusedResultIdx(); + return ( { - nav.setActivePane(RESULTS); - nav.setFocusedIndex(RESULTS, index()); + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); }} > - - {index() === nav.focusedIndex(RESULTS) ? "❯" : " "} + + {index() === fi() ? "❯" : " "} - + {result.podcast.title} - + [+] by {result.podcast.author} - )} - - - + ); + }} + + + + + ); + + // ── preview pane ──────────────────────────────────────────────────────────── + const previewContent = () => + depth() === 0 ? ( + + + Search + + Type a query, press Enter to search. + Esc defocuses the input; h goes back. + + Recent · {recents().length} + + {(q) => ‣ {q}} + - - {/* ── pane 2: detail ───────────────────────────────────────────────────── */} - - - Detail - - - - No result focused + ) : ( + + No result focused + + } + > + {(result) => ( + + + {result().podcast.title} + + + by {result().podcast.author} + + + + {result().podcast.description!.slice(0, 400) ?? + "No description available."} + {(result().podcast.description?.length ?? 0) > 400 ? "…" : ""} + + + 0}> + + + {(cat) => [{cat}]} + - } - > - {(result) => ( - - - {result().podcast.title} - + + Feed: {result().podcast.feedUrl} + + Updated: {formatDate(result().podcast.lastUpdated)} + + + Source: {result().sourceName} + + + + [+] Subscribe (enter) + + + Already subscribed + + + enter: subscribe · h: back to query + + )} + + ); - - by {result().podcast.author} - + const currentLabel = () => + depth() === 0 + ? `Search · ${recents().length} recent` + : `Results · ${results().length}`; - - - {result().podcast.description!.slice(0, 400) ?? - "No description available."} - {(result().podcast.description?.length ?? 0) > 400 - ? "…" - : ""} - - - - 0}> - - - {(cat) => [{cat}]} - - - - - Feed: {result().podcast.feedUrl} - - Updated: {formatDate(result().podcast.lastUpdated)} - - - - Source: {result().sourceName} - - - - - [+] Subscribe (enter) - - - Already subscribed - - - enter: subscribe h/l: panes - - )} - - - - + return ( + (depth() >= 1 ? "Query" : "Up")} + currentLabel={currentLabel} + previewLabel="Detail" + focused={isActive} + /> ); } diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 21ec7d7..bdf2700 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -18,6 +18,7 @@ */ import { For, Show, onMount, onCleanup, createMemo } from "solid-js"; +import { rgbToHex, type RGBA } from "@opentui/core"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation, @@ -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(() => { const d = depth(); @@ -278,7 +288,7 @@ export function SettingsPage() { {(section, index) => ( @@ -307,7 +317,7 @@ export function SettingsPage() { {(section, index) => ( { @@ -356,8 +366,13 @@ export function SettingsPage() { // ── preview pane ────────────────────────────────────────────────────────── const previewContent = () => ( - - + + {/* Keep everything on a stable root so Solid re-resolves the swap + between plain help text and the theme breakdown on focus move. */} + }> + + + ); @@ -458,6 +473,47 @@ 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›