/** * Shell — yazi-style application chrome. * * Renders the active page (which owns its own three-column parent | current | * preview panes) full-width, with a bottom status/command bar that also * carries the tab strip. A single `useKeyboard` router translates keystrokes * (via the sequence-aware keybind matcher) into actions: the unified router * in `@/utils/dispatch` handles tabs (digits `1`-`6`, `[`/`]`), h/l depth * drill/pop + fixed-pane swipe, modes, audio, quit, help, and command; the * pane/list ones are dispatched to the active page over the `nav.action` * event bus. There is no sidebar pane. */ import { createEffect, createSignal, onCleanup, Show, For } from "solid-js"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"; import { useTheme } from "@/context/ThemeContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useNavigation, NavMode } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { useAudioNavStore } from "@/stores/audio-nav"; import { useFeedStore } from "@/stores/feed"; import { useAppStore } from "@/stores/app"; import { useToast } from "@/ui/toast"; import { emit, on } from "@/utils/event-bus"; import { LayerGraph } from "@/utils/layer-graph"; import { TABS } from "@/utils/navigation"; import { createDispatcher } from "@/utils/dispatch"; import { TabListPane } from "@/components/TabPanel"; import { PaneRow } from "@/components/PaneRow"; import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator"; export function Shell() { const theme = useTheme(); const t = theme.theme; const nav = useNavigation(); const k = useKeybinds(); const audio = useAudio(); const renderer = useRenderer(); const audioNav = useAudioNavStore(); const toast = useToast(); const feedStore = useFeedStore(); const [showHelp, setShowHelp] = createSignal(false); // ── Auto jump to Player on podcast start ─────────────────────────────────── // Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see // "player.started" — distinct from "player.play", which also fires on // resume), switch to the Player tab and drop into its content pane. on("player.started", () => { const app = useAppStore(); if (app.state().preferences.autoJumpToPlayer) { nav.setActiveTab(TABS.PLAYER); nav.enterTabContent(); // PLAYER is a depth-tab — enter its content. } }); /** Play the episode adjacent (offset ±1) to the currently-playing one, * within its feed's episode list. Updates audio-nav context accordingly. */ function advanceEpisode(offset: number) { const cur = audio.currentEpisode(); if (!cur) { toast.show({ message: "Nothing playing", variant: "warning" }); return; } const pid = audioNav.getPodcastId(); const feeds = feedStore.getFilteredFeeds(); const feed = feeds.find((f) => f.podcast.id === pid) ?? feeds.find((f) => f.episodes.some((e) => e.id === cur.id)); if (!feed) { toast.show({ message: "Show not found", variant: "warning" }); return; } const eps = [...feed.episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), ); const idx = eps.findIndex((e) => e.id === cur.id); const next = eps[idx + offset]; if (!next) { toast.show({ message: offset > 0 ? "No next episode" : "No previous episode", variant: "warning", }); return; } audio.play(next).catch(() => {}); audioNav.next(eps.length - 1 - (idx + offset) >= 0 ? idx + offset : idx); toast.show({ message: `♪ ${next.title}`.slice(0, 60), variant: "info" }); } // ── Command bar dispatch ──────────────────────────────────────────────────── const COMMANDS: Record void> = { quit: () => process.exit(0), exit: () => process.exit(0), q: () => process.exit(0), refresh: () => emit("nav.action", { action: "refresh", tab: nav.activeTab(), pane: nav.activePane(), mode: nav.mode(), }), r: () => emit("nav.action", { action: "refresh", tab: nav.activeTab(), pane: nav.activePane(), mode: nav.mode(), }), play: () => audio.togglePlayback().catch(() => {}), pause: () => audio.togglePlayback().catch(() => {}), p: () => audio.togglePlayback().catch(() => {}), next: () => advanceEpisode(1), n: () => advanceEpisode(1), prev: () => advanceEpisode(-1), seek: (arg) => { const n = Number(arg) || 0; audio.seek(n).catch(() => {}); }, feed: () => nav.setActiveTab(TABS.FEED), f: () => nav.setActiveTab(TABS.FEED), shows: () => nav.setActiveTab(TABS.MYSHOWS), myshows: () => nav.setActiveTab(TABS.MYSHOWS), discover: () => nav.setActiveTab(TABS.DISCOVER), d: () => nav.setActiveTab(TABS.DISCOVER), search: () => nav.setActiveTab(TABS.SEARCH), player: () => nav.setActiveTab(TABS.PLAYER), settings: () => nav.setActiveTab(TABS.SETTINGS), set: () => nav.setActiveTab(TABS.SETTINGS), help: () => setShowHelp((v) => !v), h: () => setShowHelp((v) => !v), }; function runCommand(raw: string) { const cmd = raw.trim(); if (!cmd) return; const name = cmd.split(/\s+/)[0].toLowerCase(); const arg = cmd.slice(name.length).trim(); const unknownCommand = () => { nav.setCommandError(`unknown command: ${name}`); // re-enter command mode so the user sees the error + can correct nav.enterCommand(); nav.setCommandBuffer(cmd); }; (COMMANDS[name] ?? unknownCommand)(arg); } // ── Command-mode key handling ─────────────────────────────────────────────── function handleCommandKey(evt: any) { if (k.match("escape", evt) || evt.name === "ctrl-c") { evt.preventDefault(); nav.exitCommand(); return; } if (evt.name === "return" || evt.name === "enter") { evt.preventDefault(); const cmd = nav.commandBuffer(); runCommand(cmd); nav.exitCommand(); return; } if (evt.name === "backspace") { evt.preventDefault(); const buf = nav.commandBuffer(); if (buf.length === 0) { nav.exitCommand(); return; } nav.backspaceCommand(); return; } if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { evt.preventDefault(); nav.appendCommand(evt.name); return; } } // ── Unified router (normal + visual) ─────────────────────────────────────── const { dispatch } = createDispatcher({ nav, audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative, }, k, setShowHelp, advanceEpisode, }); useKeyboard( (evt: any) => { // 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); // Actually blur the focused renderable too — setting the flag alone // leaves the opentui input owning keys, so nav keys would still be // typed into it. Blurring fires our useInputFocusNav BLURRED handler // (and re-blurs the SearchPage input via its `focused` prop). renderer.currentFocusedRenderable?.blur(); } return; } if (nav.mode() === NavMode.COMMAND) { handleCommandKey(evt); return; } const action = k.tryMatch(evt); if (action) dispatch(action, evt); }, { release: false }, ); // ── Status bar fragments ────────────────────────────────────────────────── // Now-playing text carries the podcast name (custom name when set) when // the episode's feed is resolvable, mirroring advanceEpisode's lookup. const nowPlayingText = () => { const ep = audio.currentEpisode(); if (!ep) return null; const feeds = feedStore.getFilteredFeeds(); const feed = feeds.find((f) => f.podcast.id === ep.podcastId) ?? feeds.find((f) => f.episodes.some((e) => e.id === ep.id)); return feed ? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}` : `♪ ${ep.title}`; }; const modeLabel = () => nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`; const pendingLabel = () => k .pending() .map((s) => s.key) .join(" "); // ── Now-playing marquee ──────────────────────────────────────────────────── // The now-playing segment takes the full remaining status-bar width and // marquee-scrolls when its text overflows; when it fits (or the bar is too // narrow to show anything) it renders statically. Each pass scrolls at // SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS // before scrolling again. const dims = useTerminalDimensions(); const GAP = 3; const SCROLL_STEP_MS = 150; const SCROLL_HOLD_MS = 10_000; const [scrollOffset, setScrollOffset] = createSignal(0); const leftFixed = () => modeLabel().length + (nav.selectedIds().length > 0 ? 4 + String(nav.selectedIds().length).length : 0); const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3; const availableWidth = () => Math.max(0, dims().width - leftFixed() - rightFixed() - 2); const visible = () => { const text = nowPlayingText(); const avail = availableWidth(); if (!text || avail <= 0) return ""; if (text.length <= avail) return text; // Double the text with a gap so the wrap is seamless: the window // slides over text + gap + text without ever hitting the tail. return (text + " ".repeat(GAP) + text).slice( scrollOffset(), scrollOffset() + avail, ); }; createEffect(() => { const text = nowPlayingText(); const avail = availableWidth(); setScrollOffset(0); if (!text || avail <= 0 || text.length <= avail) return; const cycle = text.length + GAP - avail; // Hold at the start position for SCROLL_HOLD_MS, scroll one pass, // then hold again before the next pass. let holdId: ReturnType | null = null; let scrollId: ReturnType | null = null; const startHold = () => { setScrollOffset(0); holdId = setTimeout(() => { scrollId = setInterval(() => { const next = scrollOffset() + 1; if (next >= cycle) { clearInterval(scrollId!); startHold(); } else { setScrollOffset(next); } }, SCROLL_STEP_MS); }, SCROLL_HOLD_MS); }; startHold(); onCleanup(() => { if (holdId) clearTimeout(holdId); if (scrollId) clearInterval(scrollId); }); }); return ( {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} {LayerGraph[nav.activeTab()]()} } > {/* app root: the tab list is the CURRENT pane, nothing in UP */} } current={} preview={ j/k move · l/Enter open a tab } currentLabel="Tabs" focused /> {/* ── Bottom status / command bar ─────────────────────────────────────── */} {modeLabel()} 0}> ● {nav.selectedIds().length} {/* content prop (not a text child): the babel-preset-solid JSX * transform HTML-escapes static string children (`<` → `<`), * which opentui renders verbatim; content bypasses that. */} {pendingLabel()} ~ } > : {nav.commandBuffer()} {nav.commandError()} {/* ── Help overlay ─────────────────────────────────────────────────────── */} setShowHelp(false)} sections={helpSections(k)} theme={t as any} /> {/* ── Global activity indicator (top-right overlay) ─────────────────────── */} ); } function helpSections(k: ReturnType) { const p = (a: KeybindActionName) => k.print(a); return [ { group: "Move", items: [ ["j/k", "move"], ["J/K", "5 lines"], ["ctrl-d/u", "half page"], ["gg/G", "top/bottom"], ], }, { group: "Panes", items: [ ["j/k", "switch tab (tab panel)"], ["l/enter", "enter tab content"], ["h", "back to tab panel"], ["1-6 / [ ]", "switch tabs"], [":", "command"], ["~", "help"], ], }, { group: "Select", items: [ ["space", "toggle"], ["v", "visual"], ["ctrl-a", "all"], ["esc", "clear"], ], }, { group: "Audio", items: [ [p("audio-toggle"), "play/pause"], [p("audio-next"), "next"], [p("audio-seek-forward"), "fwd 10s"], [p("audio-seek-backward"), "back 10s"], ], }, { group: "List", items: [ ["enter", "open"], ["r", "refresh"], ["s", "search"], [p("search-scope-toggle"), "shows/episodes"], ["f", "filter"], [",", "sort"], [".", "hidden"], ], }, ]; } function HelpOverlay(props: { onClose: () => void; sections: { group: string; items: string[][] }[]; theme: any; }) { useKeyboard((evt: any) => { if (k_match_escape(evt)) { evt.preventDefault(); props.onClose(); } }); const th = props.theme; return ( props.onClose()} > e.stopPropagation()} > Yazi-style keybinds — press ~ or Esc to close {(sec) => ( {sec.group} {(it) => ( {String(it[0]).padEnd(14, " ")} {it[1]} )} )} Edit ~/.config/podtui/keybinds.jsonc to remap. ); } function k_match_escape(evt: any): boolean { return ( evt.name === "escape" || evt.name === "~" || (evt.ctrl && evt.name === "[") ); } // Re-export Episode type for callers building pane trees. export type { Episode } from "@/types/episode";