/** * MyShowsPage — yazi depth-stack view of subscribed shows. * * depth 0 (current) — subscribed shows. Parent pane shows the muted * placeholder (1/7 slot kept). * depth 1 (current) — episodes of the drilled show. Parent pane = shows. * preview — detail of the hovered item in the current column. * * Renders entirely through ``; no bespoke 3-column flexbox JSX * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at * 0). j/k move only within the current column. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { DownloadStatus } from "@/types/episode"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useNavigation, NavMode, DEPTH_CENTER_PANE, type PaneId, type DepthFrame, } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { YaziPaneRow } from "@/components/YaziPaneRow"; import { TabListPane } from "@/components/TabPanel"; export const MyShowsPaneCount = 1; export function MyShowsPage() { const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); const audioNav = useAudioNavStore(); const audio = useAudio(); const { theme } = useTheme(); 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); const shows = () => feedStore.getFilteredFeeds(); const focusedShowIdx = () => shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()]; // depth-1 frame ctx = the drilled feed id const drilledShowId = (): string => stack()[1]?.ctx ?? ""; const episodes = createMemo(() => { if (depth() < 1) return []; const id = drilledShowId(); const show = shows().find((s) => s.id === id); if (!show) return []; return [...show.episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), ); }); const focusedEpIdx = () => episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1); const focusedEpisode = () => episodes()[focusedEpIdx()]; const curLen = () => (depth() === 0 ? shows().length : episodes().length); const ensureFocus = () => { if (shows().length > 0 && focus(0) >= shows().length) nav.setDepthFocus(shows().length - 1, 0); if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length) nav.setDepthFocus(episodes().length - 1, 1); }; onMount(ensureFocus); onMount(() => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { if (depth() === 0) return shows()[i]?.id; return episodes()[i]?.id; }); }); // ── helpers ───────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDuration = (s: number) => { const mins = Math.floor(s / 60); const hrs = Math.floor(mins / 60); return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; }; const downloadLabel = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { case DownloadStatus.QUEUED: return "[Q]"; case DownloadStatus.DOWNLOADING: return `[${downloadStore.getDownloadProgress(id)}%]`; case DownloadStatus.COMPLETED: return "[DL]"; case DownloadStatus.FAILED: return "[ERR]"; default: return ""; } }; const downloadColor = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { case DownloadStatus.QUEUED: return theme.warning; case DownloadStatus.DOWNLOADING: return theme.primary; case DownloadStatus.COMPLETED: return theme.success; case DownloadStatus.FAILED: return theme.error; default: return muted(); } }; const playEpisode = (ep: Episode) => { audio.play(ep).catch(() => {}); audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); }; // ── drill / open ─────────────────────────────────────────────────────────── function open() { if (depth() === 0) { const show = selectedShow(); if (!show) return; nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame); nav.setActivePane(DEPTH_CENTER_PANE); audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id); return; } if (depth() >= 1) { const ep = focusedEpisode(); if (ep) playEpisode(ep); } } // ── nav.action ────────────────────────────────────────────────────────────── const PAGE_ACTIONS: Partial void>> = { "move-down": () => step(1), "move-up": () => step(-1), "jump-down": () => step(5), "jump-up": () => step(-5), "page-down": () => step(10), "page-up": () => step(-10), "goto-top": () => nav.gotoIndex(0, curLen()), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), open: () => open(), "toggle-select": () => { if (depth() >= 1) { const ep = focusedEpisode(); if (ep) nav.toggleSelected(ep.id); } }, refresh: () => { const show = selectedShow(); if (show) feedStore.refreshFeed(show.id).catch(() => {}); }, }; function step(delta: number) { nav.move(delta, curLen()); } const onAction = (data: { action: KeybindActionName; pane: PaneId; mode: NavMode; }) => { if (data.pane !== DEPTH_CENTER_PANE) return; if (nav.activePane() !== DEPTH_CENTER_PANE) return; ensureFocus(); PAGE_ACTIONS[data.action]?.(); }; onMount(() => { on("nav.action", onAction); onCleanup(() => off("nav.action", onAction)); }); // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const focusBg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined; const focusFg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.surface : theme.text; const showTitle = (f: Feed) => f.customName || f.podcast.title; const currentLabel = () => depth() === 0 ? `Shows (${shows().length})` : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; // ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ────────── // Stable gate (not a ternary root swap) so the parent list // mounts/unmounts cleanly on depth change. const parentContent = () => ( = 1} fallback={}> {(feed, index) => { const lf = () => nav.depthFocus(0); return ( {index() === lf() ? "❯" : " "} {showTitle(feed)} ({feed.episodes.length}) ); }} ); // ── current pane: the current-depth list ─────────────────────────────────── const currentContent = () => ( <> {/* depth 0: shows — stable sibling so the swap disposes cleanly */} 0} fallback={ No shows. Subscribe from Discover/Search. } > {(feed, index) => { const lf = () => focusedShowIdx(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); }} > {index() === lf() ? "❯" : " "} {showTitle(feed)} ({feed.episodes.length}) ); }} {/* depth ≥1: episodes */} = 1}> 0} fallback={ No episodes. :refresh } > {(ep, index) => { const lf = () => focusedEpIdx(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} > {index() === lf() ? "❯" : " "} {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} {ep.title} {formatDate(ep.pubDate)} {formatDuration(ep.duration)} {downloadLabel(ep.id)} ); }} ); // ── preview pane ─────────────────────────────────────────────────────────── const previewContent = () => depth() === 0 ? ( // depth 0 preview: hovered show No show focused } > {(show) => ( {showTitle(show())} by {show().podcast.author} {show().episodes.length} episodes {show().podcast.description?.slice(0, 400) ?? "No description."} enter/l: open · h: back )} ) : ( // depth ≥1 preview: hovered episode No episode focused } > {(ep) => ( {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} {ep().title} {formatDate(ep().pubDate)} {formatDuration(ep().duration)} {downloadLabel(ep().id)} by {selectedShow()!.podcast.author} {ep().description?.slice(0, 400) ?? "No description available."} {(ep().description?.length ?? 0) > 400 ? "…" : ""} enter: play · space: select · h: back )} ); return ( (depth() >= 1 ? "Shows" : "Up")} currentLabel={currentLabel} previewLabel="Detail" focused={isActive} /> ); }