/** * MyShowsPage — yazi depth-stack view of subscribed shows. * * depth 0 (current) — subscribed shows. Parent pane shows the muted * placeholder (1/5 slot kept). * depth 1 (current) — episodes of the drilled show. Parent pane = shows. * preview — detail of the hovered item in the current column. * * Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior * as the Feed tab) that loads the next batch of episodes for that show. * * 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, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import type { RGBA } from "@opentui/core"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; import { DownloadStatus } from "@/types/episode"; 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 { supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode, DownloadedEpisode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { EpisodeRow, FetchMoreRow, EpisodePreview, FetchMorePreview, formatDate, } from "@/components/EpisodeList"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; // ── render components ──────────────────────────────────────────────────────── // Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their // preview panes are My Shows-specific; episode rows/previews are shared with // the Feed page (see EpisodeList.tsx). /** A subscribed-show row (depth 0). */ function ShowRow(props: { feed: Feed; title: string; index: () => number; focused: () => number; active: () => boolean; marker: () => string; wlScope: () => boolean; wlInList: () => boolean; onMouseDown: () => void; }) { const { theme } = useTheme(); const muted = () => theme.muted || theme.text; const ref = useScrollIntoView(() => props.index() === props.focused()); const isFocused = () => props.index() === props.focused(); const bg = () => isFocused() && props.active() ? theme.primary : isFocused() ? theme.border : undefined; const fg = () => isFocused() && props.active() ? theme.surface : isFocused() ? theme.selectedListItemText ?? theme.text : theme.text; return ( {isFocused() ? props.marker() : " "} {props.title} ({props.feed.episodes.length}) {props.wlInList() ? "●" : "○"} ); } /** An unsubscribed-show download row (depth 0, below the shows list). */ function UnsubscribedRow(props: { d: DownloadedEpisode; index: () => number; focused: () => number; active: () => boolean; marker: () => string; downloadLabel: () => string; downloadColor: () => RGBA; onMouseDown: () => void; }) { const { theme } = useTheme(); const ref = useScrollIntoView(() => props.index() === props.focused()); const isFocused = () => props.index() === props.focused(); const bg = () => isFocused() && props.active() ? theme.primary : isFocused() ? theme.border : undefined; const fg = () => isFocused() && props.active() ? theme.surface : isFocused() ? theme.selectedListItemText ?? theme.text : theme.text; return ( {isFocused() ? props.marker() : " "} {props.d.episodeTitle ?? props.d.episodeId} {props.downloadLabel()} {props.d.podcastTitle ?? props.d.feedId} ); } /** Depth-0 preview: the hovered subscribed show. */ function ShowPreview(props: { show: () => Feed; title: () => string; hint: () => string; }) { const { theme } = useTheme(); const muted = () => theme.muted || theme.text; const show = props.show; return ( {props.title()} by {show().podcast.author} {show().episodes.length} episodes {show().podcast.description?.slice(0, 400) ?? "No description."} {props.hint()} ); } /** Depth-0 preview: the hovered unsubscribed-show download. */ function UnsubscribedPreview(props: { d: () => DownloadedEpisode; downloadLabel: () => string; downloadColor: () => RGBA; }) { const { theme } = useTheme(); const muted = () => theme.muted || theme.text; const d = props.d; return ( {d().episodeTitle ?? d().episodeId} {d().podcastTitle ?? d().feedId} {formatDate(new Date(d().pubDate!))} {props.downloadLabel()} Downloaded from episode search — the show is not subscribed. enter: play · D: delete download · h: back ); } export const MyShowsPaneCount = 1; export function MyShowsPage() { // Static: detection never changes mid-session. const nerd = supportsNerdFonts(); const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); const app = useAppStore(); const audioNav = useAudioNavStore(); const audio = useAudio(); const { theme } = useTheme(); const muted = () => theme.muted || theme.text; const nav = useNavigation(); const marker = useSelectionMarker(); const stack = nav.depthStack; const depth = nav.currentDepth; const focus = (d: number = depth()) => nav.depthFocus(d); const shows = () => feedStore.getFilteredFeeds(); // Downloads of shows that are NOT subscribed (made from episode search) — // listed as their own section under the shows list. Reads feeds() so an // entry drops out the moment the user subscribes to its show. const unsubs = () => downloadStore.getUnsubscribedDownloads(); const depth0Count = () => shows().length + unsubs().length; const focusedShowIdx = () => shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); /** True when the depth-0 cursor sits on an unsubscribed-show download * row (past the shows list). */ const focusedOnUnsub = () => depth() === 0 && focus(0) >= shows().length && unsubs().length > 0; const focusedUnsub = (): DownloadedEpisode | undefined => { if (!focusedOnUnsub()) return undefined; return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)]; }; const selectedShow = (): Feed | undefined => { if (focusedOnUnsub()) return undefined; return 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(), ); }); // ── Fetch More ─────────────────────────────────────────────────────────── // A "[Fetch More]" row at the bottom of a drilled show's episode list // advances that show's loaded window by 50 episodes — the per-show // counterpart to the Feed page's row (which loads every feed). manual // mode: Enter on the row. auto mode: reaching the bottom row fetches // automatically (see the effect below). const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto"; const showFetchMore = () => depth() >= 1 && !!drilledShowId() && feedStore.hasMoreEpisodes(drilledShowId()); const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const focusedRow = () => rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1); const focusedOnMore = () => showFetchMore() && focusedRow() === episodes().length; // -1 while the Fetch More row is focused so no episode row renders the // cursor/highlight (the button is the focused row, not the last episode). const focusedEpIdx = () => focusedOnMore() ? -1 : Math.min(focusedRow(), Math.max(episodes().length - 1, 0)); const focusedEpisode = () => focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; const curLen = () => (depth() === 0 ? depth0Count() : rowCount()); const ensureFocus = () => { if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count()) nav.setDepthFocus(depth0Count() - 1, 0); if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount()) nav.setDepthFocus(rowCount() - 1, 1); }; onMount(ensureFocus); onMount(() => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { if (depth() === 0) { if (i < shows().length) return shows()[i]?.id; return unsubs()[i - shows().length]?.episodeId; } return episodes()[i]?.id; }); }); // Auto mode: reaching the bottom of a drilled show's list loads its next // batch. Guarded by isLoadingMore so concurrent loads never stack. createEffect(() => { if (depth() < 1) return; if (fetchMoreMode() !== "auto") return; if (!showFetchMore()) return; if (feedStore.isLoadingMore()) return; if (focusedRow() < rowCount() - 1) return; feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {}); }); // ── helpers ───────────────────────────────────────────────────────────────── 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); }; /** Stream an unsubscribed-show download. The record carries only what was * persisted at download time, so a minimal Episode is reconstructed. */ const playUnsubscribedDownload = (d: DownloadedEpisode) => { audio .play({ id: d.episodeId, podcastId: d.feedId, title: d.episodeTitle ?? d.episodeId, description: "", audioUrl: d.audioUrl ?? "", duration: 0, pubDate: d.pubDate ? new Date(d.pubDate) : new Date(), }) .catch(() => {}); audioNav.setSource(AudioSource.SEARCH, d.feedId); }; // ── drill / open ─────────────────────────────────────────────────────────── function open() { if (depth() === 0) { const d = focusedUnsub(); if (d) { playUnsubscribedDownload(d); return; } 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) { if (focusedOnMore()) { feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {}); return; } 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); } }, download: () => { if (depth() < 1) return; const ep = focusedEpisode(); if (ep) downloadStore.startDownload(ep, drilledShowId()); }, "delete-download": () => { if (depth() === 0) { const d = focusedUnsub(); if (d) { downloadStore.cancelDownload(d.episodeId); downloadStore.removeDownload(d.episodeId).catch(() => {}); } return; } if (depth() < 1) return; const ep = focusedEpisode(); if (!ep) return; const id = ep.id; if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return; downloadStore.cancelDownload(id); downloadStore.removeDownload(id).catch(() => {}); }, "whitelist-toggle": () => { const prefs = app.state().preferences; if (prefs.autoDownloadScope !== "whitelist") return; // depth 0: the focused show; depth ≥1: the drilled show. const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id; if (!id) return; const cur = prefs.autoDownloadWhitelist ?? []; const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; app.updatePreferences({ autoDownloadWhitelist: next }); feedStore.runAutoDownload(); }, refresh: () => { const show = selectedShow(); if (show) feedStore.refreshFeed(show.id).catch(() => {}); }, unsubscribe: () => { if (depth() !== 0) return; const show = selectedShow(); if (show) { // unsubscribe = remove feed + purge its downloaded files feedStore.removeFeed(show.id); downloadStore.removeDownloadsForFeed(show.id).catch(() => {}); ensureFocus(); } }, }; 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 showTitle = (f: Feed) => f.customName || f.podcast.title; const currentLabel = () => depth() === 0 ? `Shows (${shows().length})${ unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : "" }` : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; // ── 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); const ref = useScrollIntoView(() => index() === lf()); const focused = () => index() === lf(); const fg = () => focused() ? theme.selectedListItemText ?? theme.text : theme.text; return ( {focused() ? marker() : " "} {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) => ( app.state().preferences.autoDownloadScope === "whitelist" } wlInList={() => (app.state().preferences.autoDownloadWhitelist ?? []).includes( feed.id, ) } onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); }} /> )} 0}> Unsubscribed Show Downloads {(d, index) => ( shows().length + index()} focused={() => nav.depthFocus(0)} active={isActive} marker={marker} downloadLabel={() => downloadLabel(d.episodeId)} downloadColor={() => downloadColor(d.episodeId)} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(shows().length + index(), 0); }} /> )} {/* depth ≥1: episodes */} = 1}> 0} fallback={ No episodes. :refresh } > {(ep, index) => ( nav.isSelected(ep.id)} downloadLabel={() => downloadLabel(ep.id)} downloadColor={() => downloadColor(ep.id)} marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} /> )} episodes().length} focused={focusedRow} onMore={focusedOnMore} active={isActive} isLoadingMore={() => feedStore.isLoadingMore()} nerd={nerd} marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(episodes().length, 1); }} /> ); // ── preview pane ─────────────────────────────────────────────────────────── const episodeHint = (epId: string) => `enter: play · d: download${ downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE ? " · D: delete" : "" }${ app.state().preferences.autoDownloadScope === "whitelist" ? (app.state().preferences.autoDownloadWhitelist ?? []).includes( drilledShowId(), ) ? " · w: un-whitelist" : " · w: whitelist" : "" } · space: select · h: back`; const showHint = (show: Feed) => `enter/l: open · h: back · x: unsubscribe${ app.state().preferences.autoDownloadScope === "whitelist" ? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id) ? " · w: un-whitelist" : " · w: whitelist" : "" }`; const previewContent = () => depth() === 0 ? ( // depth 0 preview: hovered unsubscribed-show download, else the // hovered show. No show focused } > {(show) => ( show()} title={() => showTitle(show())} hint={() => showHint(show())} /> )} } > {(d) => ( d()} downloadLabel={() => downloadLabel(d().episodeId)} downloadColor={() => downloadColor(d().episodeId)} /> )} ) : ( // depth ≥1 preview: hovered episode (or the Fetch More row) <> feedStore.isLoadingMore()} fetchMoreMode={fetchMoreMode} manualText={() => "Load the next batch of older episodes for this show (Enter)." } /> No episode focused } > {(ep) => ( ep()} author={() => selectedShow()?.podcast.author} downloadLabel={() => downloadLabel(ep().id)} downloadColor={() => downloadColor(ep().id)} hint={() => episodeHint(ep().id)} /> )} ); return ( ); }