ui cleanup
This commit is contained in:
@@ -19,7 +19,6 @@ import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { emit } from "@/utils/event-bus";
|
||||
import { LayerGraph } from "@/utils/layer-graph";
|
||||
@@ -200,8 +199,16 @@ export function Shell() {
|
||||
|
||||
useKeyboard(
|
||||
(evt: any) => {
|
||||
// Input fields (search boxes, dialogs) own their keys.
|
||||
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return;
|
||||
// Input fields (search boxes, dialogs) own their keys — except Escape,
|
||||
// which defocuses the input so j/k/h navigation resumes (search: h back
|
||||
// to the tab root, j/k to move the recent-searches list).
|
||||
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
|
||||
if (evt.name === "escape") {
|
||||
evt.preventDefault();
|
||||
nav.setInputFocused(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nav.mode() === NavMode.COMMAND) {
|
||||
handleCommandKey(evt);
|
||||
return;
|
||||
@@ -460,8 +467,9 @@ export function playEpisodeAndSwitch(
|
||||
) {
|
||||
audio.play(episode);
|
||||
nav.setActiveTab(TABS.PLAYER);
|
||||
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
|
||||
useAudioNavStore().setSource(AudioSource.FEED);
|
||||
}
|
||||
|
||||
// Re-export Episode type for callers building pane trees.
|
||||
export type { Episode };
|
||||
export type { Episode } from "@/types/episode";
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
|
||||
* PREVIEW flow (replaces the old fixed chrome tab column).
|
||||
*
|
||||
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
|
||||
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
|
||||
* `focused` only matters to the surrounding frame (the CURRENT column draws
|
||||
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
|
||||
* column (`muted`), the cursor highlight is suppressed and only the active ●
|
||||
* shows, so it reads as the read-only parent listing.
|
||||
* Renders one row per tab (digit + label) using the same selection UI every
|
||||
* other yazi pane uses: the CURSOR row (the one j/k hovers) gets a `❯` marker
|
||||
* and the focus background (`theme.primary` when this pane is the CURRENT
|
||||
* column, `theme.border` when it is the muted UP/parent column). The ACTIVE
|
||||
* tab (the one whose content is open) always carries a `●` marker in accent so
|
||||
* it stays readable in both positions.
|
||||
*
|
||||
* `muted` marks the parent-column rendering: the highlight is dimmed (border
|
||||
* bg, text fg) rather than suppressed, so the Up pane still shows the cursor
|
||||
* and active tab — matching how every other pane's parent column renders its
|
||||
* focused row.
|
||||
*/
|
||||
|
||||
import { For } from "solid-js";
|
||||
@@ -34,18 +39,32 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
const nav = useNavigation();
|
||||
|
||||
const cursor = () => nav.tabCursor();
|
||||
const active = () => nav.activeTab();
|
||||
const muted = () => props.muted ?? false;
|
||||
const activeTab = () => nav.activeTab();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
|
||||
// Same focus-bg / focus-fg contract every other pane uses.
|
||||
const focusBg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.primary
|
||||
: t === cursor()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active() ? theme.surface : theme.text;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab && !muted();
|
||||
const isActive = () => active() === tab;
|
||||
const fg = () =>
|
||||
const isCursor = () => cursor() === tab;
|
||||
const isActive = () => activeTab() === tab;
|
||||
// The active tab is only accented in the Up/parent position — when this
|
||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||
const labelFg = () =>
|
||||
isCursor()
|
||||
? theme.textSelectedPrimary
|
||||
: isActive()
|
||||
? focusFg(tab)
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
return (
|
||||
@@ -53,21 +72,13 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
backgroundColor={isCursor() ? theme.primary : "transparent"}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
|
||||
>
|
||||
{isActive() ? "●" : " "}
|
||||
</text>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
|
||||
>
|
||||
{tab}
|
||||
</text>
|
||||
<text fg={fg()} paddingLeft={1}>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
||||
* current — the current-depth list. The only focusable content column; it
|
||||
* carries the accent focus ring when `focused` is truthy.
|
||||
* carries the active-border focus ring when `focused` is truthy.
|
||||
* preview — detail of the hovered item in `current`; always muted border.
|
||||
*
|
||||
* The primitive is purely structural: callers pass their own JSX per column
|
||||
@@ -31,7 +31,7 @@
|
||||
* />
|
||||
*/
|
||||
|
||||
import { createMemo } from "solid-js";
|
||||
import { createMemo, Show } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
@@ -47,15 +47,19 @@ export type YaziPaneRowProps = {
|
||||
parent?: PaneContent;
|
||||
/** Current column content (the focused list). */
|
||||
current?: PaneContent;
|
||||
/** Preview column content (detail of the hovered item). */
|
||||
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||
preview?: PaneContent;
|
||||
parentLabel?: PaneLabel;
|
||||
currentLabel?: PaneLabel;
|
||||
previewLabel?: PaneLabel;
|
||||
/** Whether the current column carries the accent focus ring. Defaults to
|
||||
/** Whether the current column carries the active-border focus ring. Defaults to
|
||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
||||
* preview columns always render muted borders. */
|
||||
focused?: boolean | (() => boolean);
|
||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||
panes?: 2 | 3;
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -107,7 +111,12 @@ function YaziPane(props: {
|
||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||
|
||||
return (
|
||||
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={props.grow}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||
@@ -143,10 +152,10 @@ function YaziPane(props: {
|
||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
/** true → the current column gets the accent focus ring. */
|
||||
/** true → the current column gets the active-border focus ring. */
|
||||
const focused = createMemo(() => {
|
||||
const f = props.focused;
|
||||
return typeof f === "function" ? f() : f ?? true;
|
||||
return typeof f === "function" ? f() : (f ?? true);
|
||||
});
|
||||
|
||||
// Normalize static JSX and accessor children into reactive accessors
|
||||
@@ -159,6 +168,15 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
||||
|
||||
// 2-pane mode (parent|current) grows the current column to fill the
|
||||
// preview slot. Defaults to 3 (parent|current|preview).
|
||||
const panes = createMemo(() => props.panes ?? 3);
|
||||
const currentGrow = createMemo(() =>
|
||||
panes() === 2
|
||||
? PANE_RATIO.current + PANE_RATIO.preview
|
||||
: PANE_RATIO.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||
@@ -169,22 +187,24 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
|
||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.current}
|
||||
grow={currentGrow()}
|
||||
label={currentLabel}
|
||||
content={currentContent}
|
||||
borderColor={() => (focused() ? theme.accent : theme.border)}
|
||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
content={previewContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
<Show when={panes() === 3}>
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
content={previewContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,31 +18,28 @@
|
||||
* nav model — which column is focused and where its list cursor lives. The
|
||||
* parent/preview columns are always derived, never focused.
|
||||
*
|
||||
* Two pane models coexist under a single TAB list:
|
||||
* The tab list is the app's ROOT and participates in the same pane flow as
|
||||
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:
|
||||
*
|
||||
* • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal,
|
||||
* focusable pane at the left of every tab's content, just like in yazi.
|
||||
* Starting focus lives here; tab switches made from here keep focus here.
|
||||
* When it is focused, j/k moves the tab cursor (`tabCursor`) and
|
||||
* `l`/Enter opens the hovered tab into its content. Swiping left past
|
||||
* it goes out of the panes (inert — there is no pane beyond it).
|
||||
* • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
|
||||
* • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
|
||||
* that tab's content becomes CURRENT and its hovered item PREVIEW
|
||||
* (`enterTabContent`).
|
||||
* • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
|
||||
* own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
|
||||
* OUT of the 3-pane view.
|
||||
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
|
||||
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
|
||||
* stays (out of the panes — no-op).
|
||||
*
|
||||
* • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
|
||||
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The
|
||||
* parent column renders the previous depth's list (blank at depth 0); the
|
||||
* preview column renders the hovered item. `l`/Enter drills in (push a
|
||||
* frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus
|
||||
* to the tab list (TAB_PANE).
|
||||
* Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
|
||||
* ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
|
||||
* the parent/preview are derived. Search drills query→results; Player is a
|
||||
* single now-playing pane under the tab list (2-pane, no preview). Every
|
||||
* tab returns to the root via `h` at depth 0 (`backToTabRoot`).
|
||||
*
|
||||
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
|
||||
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the
|
||||
* parent/current/preview columns with `h`/`l`, clamped to
|
||||
* [1, paneCount]; `h` on the first content pane (1) moves focus to the tab
|
||||
* list; `h` on the tab list stays out-of-panear (inert).
|
||||
*
|
||||
* Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
|
||||
* Focus on the tab list persists across a tab switch; from there `l`/Enter
|
||||
* drops into the active tab's content (panes 1..N).
|
||||
* Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
|
||||
* `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
|
||||
*/
|
||||
import { createSignal, batch } from "solid-js";
|
||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||
@@ -55,10 +52,9 @@ export enum NavMode {
|
||||
}
|
||||
|
||||
/** The current content pane of the active tab, i.e. the focusable column
|
||||
* (index 1) for depth-tabs, and the default landing pane for fixed-pane
|
||||
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
|
||||
* while focused on content resets `activePane` to this pane (unless already
|
||||
* on the tab list). */
|
||||
* (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
|
||||
* pane 0. A tab switch made while focused on content resets `activePane` to
|
||||
* this pane (unless already on the tab list). */
|
||||
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||
|
||||
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
||||
@@ -67,13 +63,6 @@ export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||
* swiping left past the first content pane returns to it. Swiping left again
|
||||
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
||||
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
||||
/** Content pane slots for fixed-pane tabs (Search). Values are the global
|
||||
* pane indices (content starts at 1). */
|
||||
export enum PaneSlot {
|
||||
PARENT = 1, // Search: input
|
||||
CURRENT = 2, // Search: results
|
||||
PREVIEW = 3, // Search: detail
|
||||
}
|
||||
|
||||
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
||||
|
||||
@@ -110,11 +99,11 @@ export function createNavigation() {
|
||||
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
||||
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
||||
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
||||
// App focus starts on the tab list (the app root). `activePane` drives the
|
||||
// fixed-pane pages (Search/Player) and each page's content focus ring;
|
||||
// depth-tab focus is instead described by the per-tab depth stack plus the
|
||||
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and
|
||||
// slides into the UP/parent pane once content is opened).
|
||||
// App focus starts on the tab list (the app root). `activePane` is always
|
||||
// DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
|
||||
// the `atRootTab` flag describe where focus sits (the tab is the CURRENT
|
||||
// pane when at the root, and slides into the UP/parent pane once content is
|
||||
// opened).
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
||||
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
||||
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
||||
@@ -128,9 +117,9 @@ export function createNavigation() {
|
||||
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
||||
);
|
||||
|
||||
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
|
||||
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
|
||||
// for pane 0 (DEPTH_CENTER_PANE) instead.
|
||||
// per-pane focused index map (unused by depth-tabs, which read/write the
|
||||
// top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
|
||||
// pages). Keyed by `${tab}:${pane}`.
|
||||
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
@@ -143,7 +132,7 @@ export function createNavigation() {
|
||||
const [commandBuffer, setCommandBuffer] = createSignal("");
|
||||
const [commandError, setCommandError] = createSignal<string | null>(null);
|
||||
|
||||
/** Depth stack for a tab (empty for fixed-pane tabs). */
|
||||
/** Depth stack for a tab (always non-empty — every tab is a depth-tab). */
|
||||
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
|
||||
|
||||
const ensureStack = (tab: TABS) => {
|
||||
@@ -159,21 +148,17 @@ export function createNavigation() {
|
||||
* no-op (server build). Routing every tab change through this helper
|
||||
* keeps the behavior identical under both runtimes.
|
||||
*
|
||||
* - when switching to a special (fixed-pane) tab from the tab root, leave the
|
||||
* root — those tabs render only their content, never the tab-list view.
|
||||
* - keep focus on the tab root if it is focused (depth-tab switch),
|
||||
* otherwise recenter on the active tab's current/center pane
|
||||
* - a depth-tab switch from the root keeps the root (the tab list stays
|
||||
* CURRENT); switches made from inside content drop into the new tab's
|
||||
* current/center pane.
|
||||
* - clear mode/command/visual/count state */
|
||||
const applyTabSwitch = (tab: TABS) => {
|
||||
ensureStack(tab);
|
||||
batch(() => {
|
||||
// A depth-tab switch from the root keeps the root; switching to a
|
||||
// special (fixed-pane) tab always leaves it. Switches made from
|
||||
// inside content drop into the new tab's content pane.
|
||||
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) {
|
||||
setAtTabRoot(false);
|
||||
}
|
||||
if (!atRootTabSignal()) {
|
||||
if (atRootTabSignal()) {
|
||||
// a depth-tab switch from the root keeps the root (focus stays on
|
||||
// the tab list); only entering content (enterTabContent) leaves it.
|
||||
} else {
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
}
|
||||
setMode(NavMode.NORMAL);
|
||||
@@ -256,23 +241,14 @@ export function createNavigation() {
|
||||
// ── pane focus ──────────────────────────────────────────────────────────
|
||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||
|
||||
/** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` =
|
||||
* -1 (left, toward parent) or +1 (right, toward preview). Clamped to
|
||||
* [0, paneCount-1]. The root panel transition (from content pane 0 to
|
||||
* (1..TabPaneCount) is handled by the dispatcher, not here. */
|
||||
const swipe = (dir: -1 | 1, paneCount: number) => {
|
||||
setActivePane((p) => {
|
||||
const n = Math.max(1, Math.min(paneCount, p + dir));
|
||||
return n;
|
||||
});
|
||||
};
|
||||
// (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
|
||||
|
||||
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
||||
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
||||
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings)
|
||||
* participate; Search & Player are special and always show their content. */
|
||||
const atRootTab = (): boolean =>
|
||||
atRootTabSignal() && DEPTH_TABS.has(activeTab());
|
||||
* with nothing above it. Applies to every tab: a depth-tab switch from
|
||||
* the root keeps it; entering content (`enterTabContent`) clears it; `h`
|
||||
* at content depth 0 regains it via `backToTabRoot`. */
|
||||
const atRootTab = (): boolean => atRootTabSignal();
|
||||
|
||||
/** Open the active tab's content: the tab slides from CURRENT into the
|
||||
* UP/parent pane and focus lands on the content's current pane. */
|
||||
@@ -306,9 +282,9 @@ export function createNavigation() {
|
||||
// ── per-pane focus index ────────────────────────────────────────────────
|
||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
||||
|
||||
/** For depth-tabs, pane 0 (the center/current pane) reads/writes
|
||||
* the top frame's focus. Other panes and fixed-pane tabs use the
|
||||
* per-pane index map. */
|
||||
/** For depth-tabs (every tab), pane 1 (DEPTH_CENTER_PANE) reads/writes
|
||||
* the top frame's focus. Other panes fall back to the per-pane index
|
||||
* map (unused by current pages). */
|
||||
const focusedIndex = (pane: PaneId = activePane()): number => {
|
||||
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
||||
return topFrame()?.focus ?? 0;
|
||||
@@ -497,7 +473,6 @@ export function createNavigation() {
|
||||
activateTabCursor,
|
||||
// pane focus
|
||||
setActivePane: setPane,
|
||||
swipe,
|
||||
// focus index
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
|
||||
@@ -12,331 +12,368 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import {
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "../utils/audio-player"
|
||||
import { emit, on } from "../utils/event-bus"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useProgressStore } from "../stores/progress"
|
||||
import { useMediaRegistry } from "../utils/media-registry"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Feed } from "../types/feed"
|
||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "../utils/audio-player";
|
||||
import { emit, on } from "../utils/event-bus";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useProgressStore } from "../stores/progress";
|
||||
import { useMediaRegistry } from "../utils/media-registry";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
|
||||
export interface AudioControls {
|
||||
// Signals (reactive getters)
|
||||
isPlaying: () => boolean
|
||||
position: () => number
|
||||
duration: () => number
|
||||
volume: () => number
|
||||
speed: () => number
|
||||
backendName: () => BackendName
|
||||
error: () => string | null
|
||||
currentEpisode: () => Episode | null
|
||||
availablePlayers: () => DetectedPlayer[]
|
||||
// Signals (reactive getters)
|
||||
isPlaying: () => boolean;
|
||||
position: () => number;
|
||||
duration: () => number;
|
||||
volume: () => number;
|
||||
speed: () => number;
|
||||
backendName: () => BackendName;
|
||||
error: () => string | null;
|
||||
currentEpisode: () => Episode | null;
|
||||
availablePlayers: () => DetectedPlayer[];
|
||||
|
||||
// Actions
|
||||
play: (episode: Episode) => Promise<void>
|
||||
pause: () => Promise<void>
|
||||
resume: () => Promise<void>
|
||||
togglePlayback: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
seek: (seconds: number) => Promise<void>
|
||||
seekRelative: (delta: number) => Promise<void>
|
||||
setVolume: (volume: number) => Promise<void>
|
||||
setSpeed: (speed: number) => Promise<void>
|
||||
switchBackend: (name: BackendName) => Promise<void>
|
||||
prev: () => Promise<void>
|
||||
next: () => Promise<void>
|
||||
// Actions
|
||||
play: (episode: Episode) => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
resume: () => Promise<void>;
|
||||
togglePlayback: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seek: (seconds: number) => Promise<void>;
|
||||
seekRelative: (delta: number) => Promise<void>;
|
||||
setVolume: (volume: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
switchBackend: (name: BackendName) => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
next: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Singleton state — shared across all components that call useAudio()
|
||||
let backend: AudioBackend | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let refCount = 0
|
||||
let pollCount = 0 // Counts poll ticks for throttling progress saves
|
||||
let backend: AudioBackend | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let refCount = 0;
|
||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||
|
||||
const [isPlaying, setIsPlaying] = createSignal(false)
|
||||
const [position, setPosition] = createSignal(0)
|
||||
const [duration, setDuration] = createSignal(0)
|
||||
const [volume, setVolume] = createSignal(0.7)
|
||||
const [speed, setSpeed] = createSignal(1)
|
||||
const [backendName, setBackendName] = createSignal<BackendName>("none")
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
|
||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
|
||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
||||
const [position, setPosition] = createSignal(0);
|
||||
const [duration, setDuration] = createSignal(0);
|
||||
const [volume, setVolume] = createSignal(0.7);
|
||||
const [speed, setSpeed] = createSignal(1);
|
||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
function ensureBackend(): AudioBackend {
|
||||
if (!backend) {
|
||||
const detected = detectPlayers()
|
||||
setAvailablePlayers(detected)
|
||||
backend = createAudioBackend()
|
||||
setBackendName(backend.name)
|
||||
}
|
||||
return backend
|
||||
if (!backend) {
|
||||
const detected = detectPlayers();
|
||||
setAvailablePlayers(detected);
|
||||
backend = createAudioBackend();
|
||||
setBackendName(backend.name);
|
||||
registerExitTeardown();
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
||||
// player (mpv/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<void> {
|
||||
const b = ensureBackend()
|
||||
setError(null)
|
||||
const b = ensureBackend();
|
||||
setError(null);
|
||||
|
||||
if (!episode.audioUrl) {
|
||||
setError("No audio URL for this episode")
|
||||
return
|
||||
}
|
||||
if (!episode.audioUrl) {
|
||||
setError("No audio URL for this episode");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
const progressStore = useProgressStore()
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed
|
||||
const vol = volume()
|
||||
const spd = storeSpeed || speed()
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const progressStore = useProgressStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id)
|
||||
let startPos = 0
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position
|
||||
}
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
let startPos = 0;
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position;
|
||||
}
|
||||
|
||||
await b.play(episode.audioUrl, {
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
startPosition: startPos > 0 ? startPos : undefined,
|
||||
})
|
||||
await b.play(episode.audioUrl, {
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
startPosition: startPos > 0 ? startPos : undefined,
|
||||
});
|
||||
|
||||
setCurrentEpisode(episode)
|
||||
setIsPlaying(true)
|
||||
setPosition(startPos)
|
||||
setSpeed(spd)
|
||||
if (episode.duration) setDuration(episode.duration)
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(true);
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry()
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: episode.podcastId,
|
||||
duration: episode.duration,
|
||||
})
|
||||
media.setPlaybackState(true)
|
||||
if (startPos > 0) media.setPosition(startPos)
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(true);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
startPolling()
|
||||
emit("player.play", { episodeId: episode.id })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed")
|
||||
setIsPlaying(false)
|
||||
}
|
||||
startPolling();
|
||||
emit("player.play", { episodeId: episode.id });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
if (!backend) return
|
||||
try {
|
||||
await backend.pause()
|
||||
setIsPlaying(false)
|
||||
stopPolling()
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
// Save progress on pause
|
||||
const progressStore = useProgressStore()
|
||||
progressStore.update(ep.id, position(), duration(), speed())
|
||||
emit("player.pause", { episodeId: ep.id })
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.pause();
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
// Save progress on pause
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
|
||||
// Update platform media controls
|
||||
const media = useMediaRegistry()
|
||||
media.setPlaybackState(false)
|
||||
media.setPosition(position())
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pause failed")
|
||||
}
|
||||
// Update platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pause failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
if (!backend) return
|
||||
try {
|
||||
await backend.resume()
|
||||
setIsPlaying(true)
|
||||
startPolling()
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id })
|
||||
const media = useMediaRegistry()
|
||||
media.setPlaybackState(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Resume failed")
|
||||
}
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.resume();
|
||||
setIsPlaying(true);
|
||||
startPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Resume failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (isPlaying()) {
|
||||
await pause()
|
||||
} else if (currentEpisode()) {
|
||||
await resume()
|
||||
}
|
||||
if (isPlaying()) {
|
||||
await pause();
|
||||
} else if (currentEpisode()) {
|
||||
await resume();
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
if (!backend) return
|
||||
try {
|
||||
// Save progress before stopping
|
||||
const ep = currentEpisode()
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore()
|
||||
progressStore.update(ep.id, position(), duration(), speed())
|
||||
}
|
||||
await backend.stop()
|
||||
setIsPlaying(false)
|
||||
setPosition(0)
|
||||
setCurrentEpisode(null)
|
||||
stopPolling()
|
||||
emit("player.stop", {})
|
||||
if (!backend) return;
|
||||
try {
|
||||
// Save progress before stopping
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
stopPolling();
|
||||
emit("player.stop", {});
|
||||
|
||||
// Clear platform media controls
|
||||
const media = useMediaRegistry()
|
||||
media.clearNowPlaying()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Stop failed")
|
||||
}
|
||||
// Clear platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Stop failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seek(seconds: number): Promise<void> {
|
||||
if (!backend) return
|
||||
const clamped = Math.max(0, Math.min(seconds, duration()))
|
||||
try {
|
||||
await backend.seek(clamped)
|
||||
setPosition(clamped)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Seek failed")
|
||||
}
|
||||
if (!backend) return;
|
||||
const clamped = Math.max(0, Math.min(seconds, duration()));
|
||||
try {
|
||||
await backend.seek(clamped);
|
||||
setPosition(clamped);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Seek failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seekRelative(delta: number): Promise<void> {
|
||||
await seek(position() + delta)
|
||||
await seek(position() + delta);
|
||||
}
|
||||
|
||||
async function doSetVolume(vol: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(1, vol))
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setVolume(clamped)
|
||||
} catch {
|
||||
// Some backends can't change volume at runtime
|
||||
}
|
||||
}
|
||||
setVolume(clamped)
|
||||
const clamped = Math.max(0, Math.min(1, vol));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setVolume(clamped);
|
||||
} catch {
|
||||
// Some backends can't change volume at runtime
|
||||
}
|
||||
}
|
||||
setVolume(clamped);
|
||||
}
|
||||
|
||||
async function doSetSpeed(spd: number): Promise<void> {
|
||||
const clamped = Math.max(0.25, Math.min(3, spd))
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setSpeed(clamped)
|
||||
} catch {
|
||||
// Some backends can't change speed at runtime
|
||||
}
|
||||
}
|
||||
setSpeed(clamped)
|
||||
const clamped = Math.max(0.25, Math.min(3, spd));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setSpeed(clamped);
|
||||
} catch {
|
||||
// Some backends can't change speed at runtime
|
||||
}
|
||||
}
|
||||
setSpeed(clamped);
|
||||
|
||||
// Sync back to app store
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
appStore.updateSettings({ playbackSpeed: clamped })
|
||||
} catch {
|
||||
// Store may not be available
|
||||
}
|
||||
// Sync back to app store
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ playbackSpeed: clamped });
|
||||
} catch {
|
||||
// Store may not be available
|
||||
}
|
||||
}
|
||||
|
||||
async function switchBackend(name: BackendName): Promise<void> {
|
||||
const wasPlaying = isPlaying()
|
||||
const ep = currentEpisode()
|
||||
const pos = position()
|
||||
const vol = volume()
|
||||
const spd = speed()
|
||||
const wasPlaying = isPlaying();
|
||||
const ep = currentEpisode();
|
||||
const pos = position();
|
||||
const vol = volume();
|
||||
const spd = speed();
|
||||
|
||||
// Stop current backend
|
||||
if (backend) {
|
||||
stopPolling()
|
||||
backend.dispose()
|
||||
backend = null
|
||||
}
|
||||
// Stop current backend
|
||||
if (backend) {
|
||||
stopPolling();
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
backend = createAudioBackend(name)
|
||||
setBackendName(backend.name)
|
||||
setAvailablePlayers(detectPlayers())
|
||||
// Create new backend
|
||||
backend = createAudioBackend(name);
|
||||
setBackendName(backend.name);
|
||||
setAvailablePlayers(detectPlayers());
|
||||
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
await backend.play(ep.audioUrl, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
})
|
||||
setIsPlaying(true)
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed")
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
await backend.play(ep.audioUrl, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
});
|
||||
setIsPlaying(true);
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,183 +383,187 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
* Registers event bus listeners and cleans them up with onCleanup.
|
||||
*/
|
||||
export function useAudio(): AudioControls {
|
||||
// Initialize backend on first use
|
||||
ensureBackend()
|
||||
// Initialize backend on first use
|
||||
ensureBackend();
|
||||
|
||||
// Sync initial speed from app store
|
||||
if (refCount === 0) {
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed)
|
||||
}
|
||||
} catch {
|
||||
// Store may not be available yet
|
||||
}
|
||||
}
|
||||
// Sync initial speed from app store
|
||||
if (refCount === 0) {
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
} catch {
|
||||
// Store may not be available yet
|
||||
}
|
||||
}
|
||||
|
||||
refCount++
|
||||
refCount++;
|
||||
|
||||
// Listen for event bus commands (e.g. from other components)
|
||||
const unsubPlay = on("player.play", async (data) => {
|
||||
// External play requests — currently just tracks episodeId.
|
||||
// Episode lookup would require feed store integration.
|
||||
})
|
||||
// Listen for event bus commands (e.g. from other components)
|
||||
const unsubPlay = on("player.play", async (data) => {
|
||||
// External play requests — currently just tracks episodeId.
|
||||
// Episode lookup would require feed store integration.
|
||||
});
|
||||
|
||||
const unsubStop = on("player.stop", async () => {
|
||||
if (backend && isPlaying()) {
|
||||
await backend.stop()
|
||||
setIsPlaying(false)
|
||||
setPosition(0)
|
||||
setCurrentEpisode(null)
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
const unsubStop = on("player.stop", async () => {
|
||||
if (backend && isPlaying()) {
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
stopPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||
const unsubMediaToggle = on("media.toggle", async () => {
|
||||
await togglePlayback()
|
||||
})
|
||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||
const unsubMediaToggle = on("media.toggle", async () => {
|
||||
await togglePlayback();
|
||||
});
|
||||
|
||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
|
||||
})
|
||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
|
||||
})
|
||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||
await seekRelative(10)
|
||||
})
|
||||
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||
await seekRelative(10);
|
||||
});
|
||||
|
||||
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||
await seekRelative(-10)
|
||||
})
|
||||
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||
await seekRelative(-10);
|
||||
});
|
||||
|
||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
|
||||
await doSetSpeed(next)
|
||||
})
|
||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||
await doSetSpeed(next);
|
||||
});
|
||||
|
||||
const audioNav = useAudioNavStore();
|
||||
const feedStore = useFeedStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
} else {
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
} else {
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
||||
}
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.max(0, currentIndex - 1);
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.max(0, currentIndex - 1);
|
||||
|
||||
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.prev(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.prev(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
|
||||
}
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||
|
||||
if (newIndex >= 0 && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.next(newIndex);
|
||||
}
|
||||
}
|
||||
if (newIndex >= 0 && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.next(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
refCount--
|
||||
unsubPlay()
|
||||
unsubStop()
|
||||
unsubMediaToggle()
|
||||
unsubMediaVolUp()
|
||||
unsubMediaVolDown()
|
||||
unsubMediaSeekFwd()
|
||||
unsubMediaSeekBack()
|
||||
unsubMediaSpeed()
|
||||
onCleanup(() => {
|
||||
refCount--;
|
||||
unsubPlay();
|
||||
unsubStop();
|
||||
unsubMediaToggle();
|
||||
unsubMediaVolUp();
|
||||
unsubMediaVolDown();
|
||||
unsubMediaSeekFwd();
|
||||
unsubMediaSeekBack();
|
||||
unsubMediaSpeed();
|
||||
|
||||
if (refCount <= 0) {
|
||||
stopPolling()
|
||||
if (backend) {
|
||||
backend.dispose()
|
||||
backend = null
|
||||
}
|
||||
// Clear media registry on full teardown
|
||||
const media = useMediaRegistry()
|
||||
media.clearNowPlaying()
|
||||
if (refCount <= 0) {
|
||||
stopPolling();
|
||||
if (backend) {
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
// Clear media registry on full teardown
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
|
||||
refCount = 0
|
||||
}
|
||||
})
|
||||
refCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
speed,
|
||||
backendName,
|
||||
error,
|
||||
currentEpisode,
|
||||
availablePlayers,
|
||||
return {
|
||||
isPlaying,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
speed,
|
||||
backendName,
|
||||
error,
|
||||
currentEpisode,
|
||||
availablePlayers,
|
||||
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
stop,
|
||||
seek,
|
||||
seekRelative,
|
||||
setVolume: doSetVolume,
|
||||
setSpeed: doSetSpeed,
|
||||
switchBackend,
|
||||
prev,
|
||||
next,
|
||||
}
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
stop,
|
||||
seek,
|
||||
seekRelative,
|
||||
setVolume: doSetVolume,
|
||||
setSpeed: doSetSpeed,
|
||||
switchBackend,
|
||||
prev,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ function DiscoverPage() {
|
||||
<Show when={depth() === 0}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const lf = () => focusedCatIdx();
|
||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
<box
|
||||
@@ -189,19 +189,19 @@ function DiscoverPage() {
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
@@ -222,35 +222,35 @@ function DiscoverPage() {
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = focusedPodIdx();
|
||||
const lf = () => focusedPodIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text fg={index() === lf ? theme.surface : theme.success}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.success}>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : muted()}
|
||||
fg={index() === lf() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
||||
* FeedPage — flat chronological list of episodes across all subscribed feeds.
|
||||
*
|
||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
||||
* virtual "All Feeds". Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||
* chronological). Parent pane = the feeds list (prev).
|
||||
* preview — detail of the hovered item in the current column.
|
||||
* depth 0 (current) — every episode from every feed, newest-first (the
|
||||
* combined view the old "All Feeds" virtual row used to
|
||||
* drill into). Parent pane shows the muted tab list.
|
||||
* preview — detail of the hovered episode.
|
||||
*
|
||||
* This page does NOT drill: the previous depth-1 "episodes of one feed" panel
|
||||
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
||||
* shows the full flat episodes list immediately.
|
||||
*
|
||||
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
||||
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
|
||||
* (push); `h` pops a depth (noop at 0). j/k move only within the current
|
||||
* column. The Shell router drives everything over `nav.action`; this page
|
||||
* only handles list/preview data.
|
||||
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
|
||||
* root. j/k move only within the current column. The Shell router drives
|
||||
* everything over `nav.action`; this page only handles list/preview data.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
NavMode,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
@@ -40,7 +40,6 @@ import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
@@ -52,57 +51,27 @@ function FeedPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// ── feeds list (depth 0) ─────────────────────────────────────────────────
|
||||
const feedList = createMemo<FeedListItem[]>(() => {
|
||||
const all: FeedListItem[] = [{ kind: "all" }];
|
||||
for (const f of feedStore.getFilteredFeeds())
|
||||
all.push({ kind: "feed", feed: f });
|
||||
return all;
|
||||
});
|
||||
const focusedFeedIdx = () =>
|
||||
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
|
||||
const focusedFeedItem = (): FeedListItem | undefined =>
|
||||
feedList()[focusedFeedIdx()];
|
||||
|
||||
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
|
||||
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
|
||||
const episodes = createMemo<EpItem[]>(() => {
|
||||
if (depth() < 1) return [];
|
||||
const id = drilledFeedId();
|
||||
if (id === "all")
|
||||
return feedStore.getAllEpisodesChronological() as EpItem[];
|
||||
const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id);
|
||||
if (!f) return [];
|
||||
return [...f.episodes]
|
||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
|
||||
.map((episode) => ({ episode, feed: f }));
|
||||
});
|
||||
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||
const episodes = createMemo<EpItem[]>(
|
||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||
);
|
||||
const focus = () => nav.depthFocus(0);
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
|
||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||
|
||||
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
|
||||
const curLen = () => episodes().length;
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
|
||||
nav.setDepthFocus(feedList().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
if (episodes().length > 0 && focus() >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 0);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) {
|
||||
const it = feedList()[i];
|
||||
return it?.kind === "feed" ? it.feed.podcast.id : "all";
|
||||
}
|
||||
return episodes()[i]?.episode.id;
|
||||
});
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -146,19 +115,9 @@ function FeedPage() {
|
||||
audioNav.setSource(AudioSource.FEED);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
// ── open ───────────────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const item = focusedFeedItem();
|
||||
if (!item) return;
|
||||
const ctx = item.kind === "all" ? "all" : item.feed.podcast.id;
|
||||
nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
|
||||
// ── nav.action handler ────────────────────────────────────────────────────
|
||||
@@ -173,16 +132,11 @@ function FeedPage() {
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
const item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
}
|
||||
const item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
},
|
||||
refresh: () => {
|
||||
const item = focusedFeedItem();
|
||||
if (item?.kind === "feed")
|
||||
feedStore.refreshFeed(item.feed.id).catch(() => {});
|
||||
else feedStore.refreshAllFeeds().catch(() => {});
|
||||
feedStore.refreshAllFeeds().catch(() => {});
|
||||
},
|
||||
};
|
||||
function step(delta: number) {
|
||||
@@ -205,7 +159,7 @@ function FeedPage() {
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
// Row highlight within a list. `active=true` only for the current pane.
|
||||
// Row highlight within the list. `active=true` only for the current pane.
|
||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
@@ -215,265 +169,132 @@ function FeedPage() {
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
|
||||
const feedLabel = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const feedCount = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Feeds · ${feedList().length - 1}`
|
||||
: `${(() => {
|
||||
const fi = focusedFeedItem();
|
||||
return fi?.kind === "feed"
|
||||
? fi.feed.customName || fi.feed.podcast.title
|
||||
: "All Episodes";
|
||||
})()} · ${episodes().length}`;
|
||||
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
|
||||
const parentContent = () => <TabListPane muted />;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
|
||||
// mounts/unmounts cleanly on depth change instead of swapping roots.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={feedList()}>
|
||||
// ── current pane: the flat episodes list (the only focusable column) ──────
|
||||
const currentContent = () => (
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
const fi = () => focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, false)}
|
||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
|
||||
<text fg={muted()}>({feedCount(item)})</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{index() === fi() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi() ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── current pane: the current-depth list (the only focusable column) ──────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
||||
const previewContent = () => (
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedFeedIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
({feedCount(item)})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={depth() >= 1}>
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── preview pane: hovered-item detail ──────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered feed
|
||||
<Show
|
||||
when={focusedFeedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No feed focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{feedLabel(it)}</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{it.kind === "feed"
|
||||
? `by ${it.feed.podcast.author ?? "unknown"}`
|
||||
: ""}
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{it.kind === "all"
|
||||
? `${feedCount(it)} episodes across all feeds`
|
||||
: `${feedCount(it)} episodes`}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{it.kind === "feed"
|
||||
? (it.feed.podcast.description?.slice(0, 400) ??
|
||||
"No description.")
|
||||
: "Drill in to see episodes across every feed."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{it.episode.episodeNumber
|
||||
? `#${it.episode.episodeNumber} `
|
||||
: ""}
|
||||
{it.episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(it.episode.duration)}</text>
|
||||
<Show when={downloadLabel(it.episode.id)}>
|
||||
<text fg={downloadColor(it.episode.id)}>
|
||||
{downloadLabel(it.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{it.feed.customName || it.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={it.feed.podcast.author}>
|
||||
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{it.episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
|
||||
parentLabel="Up"
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
|
||||
@@ -204,19 +204,19 @@ export function MyShowsPage() {
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
const lf = () => nav.depthFocus(0);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, false)}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), false)}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
|
||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
||||
<text fg={muted()}>({feed.episodes.length})</text>
|
||||
</box>
|
||||
);
|
||||
@@ -242,26 +242,26 @@ export function MyShowsPage() {
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = focusedShowIdx();
|
||||
const lf = () => focusedShowIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
@@ -282,33 +282,33 @@ export function MyShowsPage() {
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = focusedEpIdx();
|
||||
const lf = () => focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.info}>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
|
||||
@@ -1,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 (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
{/* ── pane 0: now playing ─────────────────────────────────────────── */}
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Player</text>
|
||||
// ── parent pane: the tab list (muted) ──────────────────────────────────────
|
||||
const parentContent = () => <TabListPane muted />;
|
||||
|
||||
// ── current pane: now playing ───────────────────────────────────────────────
|
||||
const currentContent = () => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text}>
|
||||
<strong>Now Playing</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||
{progressPercent()}%)
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive()}
|
||||
border
|
||||
borderColor={border()}
|
||||
backgroundColor={theme.background}
|
||||
|
||||
<Show when={audio.error()}>
|
||||
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={audio.currentEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode loaded.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>Now Playing</strong>
|
||||
<strong>{ep().title}</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||
{progressPercent()}%)
|
||||
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||
</text>
|
||||
|
||||
<RealtimeWaveform
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
return {
|
||||
bars: viz.bars,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={audio.error()}>
|
||||
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||
</Show>
|
||||
<PlaybackControls
|
||||
isPlaying={audio.isPlaying()}
|
||||
volume={audio.volume()}
|
||||
speed={audio.speed()}
|
||||
backendName={audio.backendName()}
|
||||
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
||||
onToggle={audio.togglePlayback}
|
||||
onPrev={() => audio.seek(0)}
|
||||
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
|
||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={audio.currentEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode loaded.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{ep().title}</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{ep().description?.slice(0, 500) ??
|
||||
"No description available."}
|
||||
</text>
|
||||
|
||||
<RealtimeWaveform
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
return {
|
||||
bars: viz.bars,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<PlaybackControls
|
||||
isPlaying={audio.isPlaying()}
|
||||
volume={audio.volume()}
|
||||
speed={audio.speed()}
|
||||
backendName={audio.backendName()}
|
||||
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
||||
onToggle={audio.togglePlayback}
|
||||
onPrev={() => audio.seek(0)}
|
||||
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
|
||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||
/>
|
||||
|
||||
<box height={1} />
|
||||
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
{"P play/pause N next B prev </ seek · h back"}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
parentLabel="Up"
|
||||
currentLabel="Player"
|
||||
panes={2}
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* SearchPage — yazi-style 3-pane view.
|
||||
* SearchPage — yazi depth-stack view of podcast search.
|
||||
*
|
||||
* pane 1 (parent) — query input with recent-search history (clickable)
|
||||
* pane 2 (current) — search results list (navigate j/k)
|
||||
* pane 3 (preview) — detail of the focused search result
|
||||
* depth 0 (current) — query input row + recent-searches list (navigable
|
||||
* with j/k when the input is defocused). Parent pane
|
||||
* shows the tab list (muted); preview shows a hint.
|
||||
* depth 1 (current) — search results list. Parent pane shows the submitted
|
||||
* query (muted, read-only); preview shows the detail of
|
||||
* the focused result.
|
||||
*
|
||||
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
|
||||
* on tab enter so the user lands on the results pane. Swipe left (h) to pane
|
||||
* 1 to type a query — the Shell
|
||||
* router skips keys while `nav.inputFocused()` is true so the `<input>`
|
||||
* element captures typing natively. Press Enter (onSubmit) to search and
|
||||
* auto-swipe to the results pane.
|
||||
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||||
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||
* input (or on a focused recent at depth 0) submits the query and pushes to
|
||||
* depth 1 (results). `h` pops: results→query, query→tab root.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -28,15 +30,17 @@ import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const SearchPaneCount = 3;
|
||||
export const SearchPaneCount = 1;
|
||||
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
@@ -45,69 +49,75 @@ function SearchPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const INPUT = PaneSlot.PARENT; // 1 (input row)
|
||||
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
|
||||
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// depth 1's ctx carries the submitted query string.
|
||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||
|
||||
// ── input focusing ────────────────────────────────────────────────────────
|
||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||||
// Depth transitions also drive it: typing is the default on the query depth.
|
||||
let prevDepth = depth();
|
||||
onMount(() => nav.setInputFocused(true));
|
||||
onCleanup(() => nav.setInputFocused(false));
|
||||
createEffect(() => {
|
||||
const d = depth();
|
||||
if (d !== prevDepth) {
|
||||
nav.setInputFocused(d === 0);
|
||||
prevDepth = d;
|
||||
}
|
||||
});
|
||||
|
||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||
const results = () => searchStore.results();
|
||||
|
||||
// The focused result tracks pane 1's focused row.
|
||||
const focusedResultIdx = () =>
|
||||
results().length === 0 ? 0 : Math.min(focus(1), results().length - 1);
|
||||
const focusedResult = createMemo(() => {
|
||||
const list = results();
|
||||
if (list.length === 0) return undefined;
|
||||
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1);
|
||||
return list[idx];
|
||||
return list[focusedResultIdx()];
|
||||
});
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by result id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => results()[i]?.podcast.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => results()[i]?.podcast.id,
|
||||
);
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
// ── recents (depth 0) ────────────────────────────────────────────────────
|
||||
const recents = () => searchStore.history();
|
||||
const curLen = () => (depth() === 0 ? recents().length : results().length);
|
||||
|
||||
// Keep results focus in range after searches complete.
|
||||
const ensureFocus = () => {
|
||||
const list = results();
|
||||
if (list.length === 0) return;
|
||||
const cur = nav.focusedIndex(RESULTS);
|
||||
if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
|
||||
if (depth() === 1 && results().length > 0 && focus(1) >= results().length)
|
||||
nav.setDepthFocus(results().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
// ── input pane: set inputFocused so Shell router yields keys to <input> ─────
|
||||
createEffect(() => {
|
||||
const isInputPane = nav.activePane() === INPUT;
|
||||
nav.setInputFocused(isInputPane);
|
||||
});
|
||||
// Register a visual-mode resolver for the results list (depth 1).
|
||||
onMount(() => {
|
||||
onCleanup(() => nav.setInputFocused(false));
|
||||
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
||||
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
const handleSubmit = () => {
|
||||
const query = inputValue().trim();
|
||||
if (!query) return;
|
||||
searchStore.search(query).catch(() => {});
|
||||
nav.setFocusedIndex(RESULTS, 0);
|
||||
nav.setActivePane(RESULTS);
|
||||
const runSearch = (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
searchStore.search(q).catch(() => {});
|
||||
nav.pushDepth({
|
||||
kind: "search:results",
|
||||
ctx: q,
|
||||
focus: 0,
|
||||
} as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
};
|
||||
|
||||
const handleHistorySelect = (query: string) => {
|
||||
const handleSubmit = () => runSearch(inputValue());
|
||||
|
||||
const selectRecent = (query: string) => {
|
||||
setInputValue(query);
|
||||
searchStore.search(query).catch(() => {});
|
||||
nav.setFocusedIndex(RESULTS, 0);
|
||||
nav.setActivePane(RESULTS);
|
||||
runSearch(query);
|
||||
};
|
||||
|
||||
const handleSubscribe = (result: SearchResult) => {
|
||||
@@ -115,45 +125,48 @@ function SearchPage() {
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === RESULTS || p === DETAIL) {
|
||||
const result = focusedResult();
|
||||
if (result) handleSubscribe(result);
|
||||
}
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === RESULTS) {
|
||||
const result = focusedResult();
|
||||
if (result) nav.toggleSelected(result.podcast.id);
|
||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||
"move-down": () => step(1),
|
||||
"move-up": () => step(-1),
|
||||
"jump-down": () => step(5),
|
||||
"jump-up": () => step(-5),
|
||||
"page-down": () => step(10),
|
||||
"page-up": () => step(-10),
|
||||
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() === 1) {
|
||||
const r = focusedResult();
|
||||
if (r) nav.toggleSelected(r.podcast.id);
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
nav.setActivePane(INPUT);
|
||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||
if (depth() === 0) nav.setInputFocused(true);
|
||||
},
|
||||
refresh: () => {
|
||||
if (inputValue().trim()) {
|
||||
searchStore.search(inputValue().trim()).catch(() => {});
|
||||
}
|
||||
const q = submittedQuery() || inputValue().trim();
|
||||
if (q) searchStore.search(q).catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === RESULTS) return results().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function 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 (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0: query input ──────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Search</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={false}
|
||||
border
|
||||
borderColor={border(INPUT)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={muted()}>Query:</text>
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onSubmit={() => handleSubmit()}
|
||||
placeholder="Enter podcast name..."
|
||||
focused={isActive(INPUT)}
|
||||
width={28}
|
||||
/>
|
||||
</box>
|
||||
<text fg={muted()}>Enter to search · h/l: panes</text>
|
||||
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text fg={theme.warning}>Searching...</text>
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text fg={theme.error}>{searchStore.error()}</text>
|
||||
</Show>
|
||||
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent</text>
|
||||
<Show
|
||||
when={searchStore.history().length > 0}
|
||||
fallback={<text fg={muted()}>No recent searches</text>}
|
||||
>
|
||||
<For each={searchStore.history().slice(0, 12)}>
|
||||
{(query) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
onMouseDown={() => handleHistorySelect(query)}
|
||||
>
|
||||
<text fg={muted()}>
|
||||
{">"} {query}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textSecondary}>Query</text>
|
||||
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>h: back to query</text>
|
||||
</box>
|
||||
</Show>
|
||||
);
|
||||
|
||||
{/* ── pane 1: results ──────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Results · {results().length}</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(RESULTS)}
|
||||
border
|
||||
borderColor={border(RESULTS)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
// ── current pane ────────────────────────────────────────────────────────────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
{/* query input row + recent searches */}
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={muted()}>Query:</text>
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onSubmit={() => handleSubmit()}
|
||||
placeholder="Enter podcast name..."
|
||||
focused={inputActive()}
|
||||
width={28}
|
||||
/>
|
||||
</box>
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text fg={theme.warning}>Searching...</text>
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text fg={theme.error}>{searchStore.error()}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent</text>
|
||||
<Show
|
||||
when={results().length > 0}
|
||||
when={recents().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{inputActive()
|
||||
? "Enter to search"
|
||||
: "s to type · Enter to search"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<For each={results()}>
|
||||
{(result, index) => (
|
||||
<For each={recents()}>
|
||||
{(query, index) => {
|
||||
const lf = () => focus(0);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
{inputActive()
|
||||
? "Enter to search · Esc to defocus"
|
||||
: "j/k recents · s to type · h back"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={depth() >= 1}>
|
||||
{/* results list */}
|
||||
<Show
|
||||
when={results().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={results()}>
|
||||
{(result, index) => {
|
||||
const fi = () => focusedResultIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), RESULTS)}
|
||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(RESULTS);
|
||||
nav.setFocusedIndex(RESULTS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), RESULTS)}>
|
||||
{index() === nav.focusedIndex(RESULTS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{index() === fi() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), RESULTS)}>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{result.podcast.title}
|
||||
</text>
|
||||
<Show when={result.podcast.isSubscribed}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: theme.success
|
||||
}
|
||||
>
|
||||
<text fg={index() === fi() ? theme.surface : theme.success}>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={result.podcast.author}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {result.podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── preview pane ────────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>Search</strong>
|
||||
</text>
|
||||
<text fg={muted()}>Type a query, press Enter to search.</text>
|
||||
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||
<For each={recents().slice(0, 6)}>
|
||||
{(q) => <text fg={muted()}>‣ {q}</text>}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2: detail ───────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Detail</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(DETAIL)}
|
||||
border
|
||||
borderColor={border(DETAIL)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={focusedResult()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No result focused</text>
|
||||
) : (
|
||||
<Show
|
||||
when={focusedResult()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No result focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(result) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg={muted()}>by {result().podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{result().podcast.description!.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(result) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||
</text>
|
||||
<Show when={result().sourceName}>
|
||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg={muted()}>by {result().podcast.author}</text>
|
||||
</Show>
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Search · ${recents().length} recent`
|
||||
: `Results · ${results().length}`;
|
||||
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{result().podcast.description!.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(result().podcast.description?.length ?? 0) > 400
|
||||
? "…"
|
||||
: ""}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||
</text>
|
||||
|
||||
<Show when={result().sourceName}>
|
||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
||||
</Show>
|
||||
|
||||
<box height={1} />
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe h/l: panes</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>(() => {
|
||||
const d = depth();
|
||||
@@ -278,7 +288,7 @@ export function SettingsPage() {
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
label={section.label}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={false}
|
||||
/>
|
||||
@@ -307,7 +317,7 @@ export function SettingsPage() {
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
label={section.label}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={isActive()}
|
||||
onMouseDown={() => {
|
||||
@@ -356,8 +366,13 @@ export function SettingsPage() {
|
||||
|
||||
// ── preview pane ──────────────────────────────────────────────────────────
|
||||
const previewContent = () => (
|
||||
<box padding={1}>
|
||||
<MultiLine text={previewText()} />
|
||||
<box padding={1} flexDirection="column">
|
||||
{/* Keep everything on a stable root so Solid re-resolves the swap
|
||||
between plain help text and the theme breakdown on focus move. */}
|
||||
<Show when={isThemeItem()} fallback={<MultiLine text={previewText()} />}>
|
||||
<MultiLine text={previewText()} />
|
||||
<ThemeBreakdown />
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -458,6 +473,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› <Label> (<HEX>)) of the resolved theme. */
|
||||
function ThemeBreakdown() {
|
||||
const { theme, selected } = useTheme();
|
||||
return (
|
||||
<box flexDirection="column" paddingTop={1} gap={1}>
|
||||
<text fg={theme.accent}>Theme · {selected()}</text>
|
||||
<For each={THEME_ROLES}>
|
||||
{(role) => {
|
||||
const color = theme[role.key] as RGBA | undefined;
|
||||
return (
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text backgroundColor={color}> </text>
|
||||
<text fg={theme.text}>{role.label}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme.textMuted}>
|
||||
{color ? rgbToHex(color).toUpperCase() : "n/a"}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a string with `\n` newlines as stacked <text> lines. */
|
||||
function MultiLine(props: { text: string }) {
|
||||
const lines = () => props.text.split("\n");
|
||||
|
||||
@@ -78,7 +78,7 @@ function mpvSocketPath(): string {
|
||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
||||
|
||||
class MpvBackend implements AudioBackend {
|
||||
export class MpvBackend implements AudioBackend {
|
||||
readonly name: BackendName = "mpv";
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private socketPath = mpvSocketPath();
|
||||
|
||||
@@ -22,12 +22,10 @@
|
||||
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
|
||||
* switch tabs; focus keeps its context (root iff already at the root,
|
||||
* otherwise the content `DEPTH_CENTER_PANE`).
|
||||
* • `h`/`l` are `swipe-prev`/`swipe-next` in content:
|
||||
* - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth
|
||||
* when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
|
||||
* where the tab becomes CURRENT again.
|
||||
* - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to
|
||||
* [1, paneCount]; `h` on the first content pane stays (no tab overflow).
|
||||
* • `h`/`l` are `swipe-prev`/`swipe-next` in content (every tab is a
|
||||
* depth-tab): `l` at the current pane drills in (emits `open`); `h` pops
|
||||
* a depth when depth > 0; at depth 0 `h` returns to the tab root
|
||||
* (`backToTabRoot`), where the tab becomes CURRENT again.
|
||||
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
|
||||
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
|
||||
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
|
||||
@@ -36,7 +34,7 @@
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
|
||||
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
|
||||
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
|
||||
import { TABS, TabsCount } from "@/utils/navigation";
|
||||
import { emit } from "@/utils/event-bus";
|
||||
|
||||
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
|
||||
@@ -182,33 +180,23 @@ export function createDispatcher(deps: DispatcherDeps) {
|
||||
if (action === "swipe-prev") break;
|
||||
}
|
||||
// ── pane swipe / depth nav ──
|
||||
// Depth-tabs: l at the center drills in (emits `open`); h at the
|
||||
// center pops a depth; at depth 0 h returns to the tab root (the tab
|
||||
// becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
|
||||
// h/l swipe across [1, paneCount]; h on the first pane stays.
|
||||
// Every tab is a depth-tab: `l` at the center drills in (emits `open`);
|
||||
// `h` at the center pops a depth when depth > 0, and at depth 0 returns
|
||||
// to the tab root (the tab becomes CURRENT again).
|
||||
if (action === "swipe-prev") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
if (nav.currentDepth() > 0) nav.popDepth();
|
||||
else nav.backToTabRoot(); // depth 0 → tab root
|
||||
} else if (nav.activePane() > DEPTH_CENTER_PANE) {
|
||||
nav.swipe(-1, TabPaneCount[tab]); // content 1..n
|
||||
}
|
||||
// fixed first content pane (1): no-op, stays (special tabs)
|
||||
if (nav.currentDepth() > 0) nav.popDepth();
|
||||
else nav.backToTabRoot(); // depth 0 → tab root
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
emit("nav.action", {
|
||||
action: "open",
|
||||
tab,
|
||||
pane: DEPTH_CENTER_PANE,
|
||||
mode: nav.mode(),
|
||||
});
|
||||
} else {
|
||||
nav.swipe(1, TabPaneCount[tab]);
|
||||
}
|
||||
emit("nav.action", {
|
||||
action: "open",
|
||||
tab,
|
||||
pane: DEPTH_CENTER_PANE,
|
||||
mode: nav.mode(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
// ── audio transport (global) ──
|
||||
|
||||
@@ -14,12 +14,15 @@ export enum TABS {
|
||||
export const TabsCount = 6;
|
||||
|
||||
/** Tabs that use the yazi depth-stack model (prev | current | preview
|
||||
* columns, infinite drill via push/pop). Search and Player keep the legacy
|
||||
* fixed-pane model. */
|
||||
* columns, infinite drill via push/pop). Search drills query→results, and
|
||||
* Player drills into its single now-playing pane under the tab list (the
|
||||
* parent=/tabs, current=player, preview hidden). */
|
||||
export const DEPTH_TABS: ReadonlySet<TABS> = new Set([
|
||||
TABS.FEED,
|
||||
TABS.MYSHOWS,
|
||||
TABS.DISCOVER,
|
||||
TABS.SEARCH,
|
||||
TABS.PLAYER,
|
||||
TABS.SETTINGS,
|
||||
]);
|
||||
|
||||
@@ -35,6 +38,10 @@ export function rootFrameFor(
|
||||
return { kind: "shows", focus: 0 };
|
||||
case TABS.DISCOVER:
|
||||
return { kind: "discover:categories", focus: 0 };
|
||||
case TABS.SEARCH:
|
||||
return { kind: "search:query", focus: 0 };
|
||||
case TABS.PLAYER:
|
||||
return { kind: "player:nowplaying", focus: 0 };
|
||||
case TABS.SETTINGS:
|
||||
return { kind: "settings:sections", focus: 0 };
|
||||
default:
|
||||
@@ -62,16 +69,15 @@ export const PANE_RATIO = {
|
||||
// Number of *focusable* content panes per tab. The three visible columns
|
||||
// (parent | current | preview) are a *render* concern, NOT three panes — for
|
||||
// depth-tabs only the current column (index 0) is focusable, so this is 1.
|
||||
// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
|
||||
// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
|
||||
// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
|
||||
// here (after TABS) to avoid re-introducing the old NavigationContext
|
||||
// top-level-init circular deadlock.
|
||||
// Every tab is now a depth-tab: each drills with `l` (push) and pops with `h`
|
||||
// (returns to the tab root at depth 0) via the Shell dispatch. Defined here
|
||||
// (after TABS) to avoid re-introducing the old NavigationContext top-level-
|
||||
// init circular deadlock.
|
||||
export const TabPaneCount: Record<TABS, number> = {
|
||||
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
||||
[TABS.DISCOVER]: 1, // depth: categories → results → preview
|
||||
[TABS.SEARCH]: 3, // fixed: query | results | detail
|
||||
[TABS.PLAYER]: 1, // single pane
|
||||
[TABS.SEARCH]: 1, // depth: query → results, preview=detail
|
||||
[TABS.PLAYER]: 1, // depth: now-playing (2-pane, no preview)
|
||||
[TABS.SETTINGS]: 1, // depth: sections → items → editor
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user