start revive

This commit is contained in:
2026-07-30 21:25:03 -04:00
parent b7c4938c54
commit d8f11040bc
25 changed files with 5344 additions and 2559 deletions

View File

@@ -1,185 +1,353 @@
/**
* DiscoverPage component - Main discover/browse interface for PodTUI
* DiscoverPage — yazi-style 3-pane view.
*
* pane 0 (parent) — category list (the "containers")
* pane 1 (current) — podcast results for the focused category (landing pane)
* pane 2 (preview) — detail of the focused podcast + subscribe action
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes.
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering.
*/
import { createSignal, For, Show, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Podcast } from "@/types/podcast";
import { PANE_RATIO } from "@/utils/navigation";
enum DiscoverPagePaneType {
CATEGORIES = 1,
SHOWS = 2,
export const DiscoverPaneCount = 3;
function DiscoverPage() {
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
const categories = () => DISCOVER_CATEGORIES;
const podcasts = () => discoverStore.filteredPodcasts();
const focusedCategory = createMemo(() => {
const list = categories();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
});
// ── keep category + results focus in range ───────────────────────────────
const ensureFocus = () => {
const cl = categories();
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
nav.setFocusedIndex(CATS, cl.length - 1);
const pl = podcasts();
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
nav.setFocusedIndex(RESULTS, pl.length - 1);
};
onMount(ensureFocus);
const focusedPodcast = createMemo(() => {
const list = podcasts();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
});
// Register a resolver so visual-mode range selection grows by podcast id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
});
onCleanup(() => unsub());
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubscribe = (podcast: Podcast) => {
discoverStore.toggleSubscription(podcast.id);
};
// ── 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 === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
nav.swipe(1, DiscoverPaneCount); // dive to results
return;
}
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) handleSubscribe(pod);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
},
refresh: () => {
discoverStore.refresh().catch(() => {});
},
};
function len(pane: PaneId): number {
if (pane === CATS) return categories().length;
if (pane === RESULTS) return podcasts().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
}
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
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)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
focused={isActive(CATS)}
border
borderColor={border(CATS)}
backgroundColor={theme.background}
>
<For each={categories()}>
{(cat, index) => {
const selected = () =>
cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
selected() && !isActive(CATS)
? theme.border
: focusBg(index(), CATS)
}
onMouseDown={() => {
nav.setActivePane(CATS);
nav.setFocusedIndex(CATS, index());
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), CATS)}>
{index() === nav.focusedIndex(CATS) ? "" : " "}
</text>
<text fg={focusFg(index(), CATS)}>{cat.name}</text>
<Show when={selected()}>
<text
fg={
index() === nav.focusedIndex(CATS)
? theme.surface
: theme.accent
}
>
*
</text>
</Show>
</box>
);
}}
</For>
</scrollbox>
</box>
{/* ── pane 1 (current, center): results ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{focusedCategory()?.name ?? "Discover"} · {podcasts().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
<Show when={podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREVIEW)}
border
borderColor={border(PREVIEW)}
backgroundColor={theme.background}
>
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes r: refresh</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
}
export const DiscoverPaneCount = 2;
export function DiscoverPage() {
const discoverStore = useDiscoverStore();
const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0);
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
setShowIndex(showIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length === 0) return;
if (isDown && !isInverting()) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if (isUp && isInverting()) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
}
},
{ release: false },
);
});
const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId);
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
if (index >= 0) setCategoryIndex(index);
setShowIndex(0);
};
const handleShowSelect = (index: number) => {
setShowIndex(index);
};
const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id);
};
const { theme } = useTheme();
return (
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
<box
border
padding={1}
borderColor={
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
? theme.border
: theme.accent
}
flexDirection="column"
gap={1}
>
<text
fg={
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
? theme.accent
: theme.text
}
>
Categories:
</text>
<box flexDirection="column" gap={1}>
<For each={discoverStore.categories}>
{(category) => {
const isSelected = () =>
discoverStore.selectedCategory() === category.id;
return (
<SelectableBox
selected={isSelected}
onMouseDown={() => handleCategorySelect(category.id)}
>
<SelectableText selected={isSelected} primary>
{category.icon} {category.name}
</SelectableText>
</SelectableBox>
);
}}
</For>
</box>
</box>
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() == DiscoverPagePaneType.SHOWS
? theme.accent
: theme.border
}
>
<box padding={1}>
<SelectableText
selected={() => false}
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
Trending in{" "}
{DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All"}
</SelectableText>
</box>
<box flexDirection="column" height="100%">
<Show
fallback={
<box padding={2}>
{discoverStore.filteredPodcasts().length !== 0 ? (
<text fg={theme.warning}>Loading trending shows...</text>
) : (
<text fg={theme.textMuted}>
No podcasts found in this category.
</text>
)}
</box>
}
when={
!discoverStore.isLoading() &&
discoverStore.filteredPodcasts().length === 0
}
>
<scrollbox
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
<box flexDirection="column">
<For each={discoverStore.filteredPodcasts()}>
{(podcast, index) => (
<PodcastCard
podcast={podcast}
selected={
index() === showIndex() &&
nav.activeDepth() == DiscoverPagePaneType.SHOWS
}
onSelect={() => handleShowSelect(index())}
onSubscribe={() => handleSubscribe(podcast)}
/>
)}
</For>
</box>
</scrollbox>
</Show>
</box>
</box>
</box>
);
}
export { DiscoverPage };

View File

@@ -1,195 +1,460 @@
/**
* FeedPage - Shows latest episodes across all subscribed shows
* Reverse chronological order, grouped by date
* FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
*
* pane 0 (parent) — subscribed feeds list (the "containers"); an implicit
* "All Feeds" entry at index 0 shows every episode.
* pane 1 (current) — flat episodes list for the focused feed (reverse
* chronological). This is the landing pane.
* pane 2 (preview) — detail of the focused episode.
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio.
*/
import { createSignal, For, Show, onMount } from "solid-js";
import {
createMemo,
For,
Show,
onMount,
onCleanup,
createEffect,
} 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,
PaneSlot,
type PaneId,
} 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 { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { TABS } from "@/utils/navigation";
import { useKeyboard } from "@opentui/solid";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum FeedPaneType {
FEED = 1,
export const FeedPaneCount = 3;
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
function FeedPage() {
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 FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent)
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane)
const PREV = PaneSlot.PREVIEW; // 2 — episode detail
// ── feeds pane data ──────────────────────────────────────────────────────
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
const feedList = createMemo<FeedListItem[]>(() => {
const all: FeedListItem[] = [{ kind: "all" }];
for (const f of feedStore.getFilteredFeeds())
all.push({ kind: "feed", feed: f });
return all;
});
const focusedFeedItem = createMemo(() => {
const list = feedList();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)];
});
// ── episodes pane data (filtered by focused feed, or all) ────────────────
type EpItem = { episode: Episode; feed: Feed };
const episodes = createMemo<EpItem[]>(() => {
const item = focusedFeedItem();
if (!item || item.kind === "all")
return feedStore.getAllEpisodesChronological() as EpItem[];
return [...item.feed.episodes]
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
.map((episode) => ({ episode, feed: item.feed }));
});
// Reset episodes focus when the feed filter changes.
createEffect(() => {
focusedFeedItem();
nav.setFocusedIndex(EPS, 0);
});
const focusedItem = createMemo<EpItem | undefined>(() => {
const list = episodes();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(EPS), list.length - 1)];
});
// Keep resolvers fresh so visual-mode range selection grows by id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.episode.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.episode.id,
);
});
onCleanup(() => unsub());
});
const ensureFocus = () => {
const eps = episodes();
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length)
nav.setFocusedIndex(EPS, eps.length - 1);
const fl = feedList();
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
nav.setFocusedIndex(FEEDS, fl.length - 1);
};
onMount(ensureFocus);
// ── 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 = (item: EpItem | undefined) => {
if (!item) return;
audio.play(item.episode).catch(() => {});
audioNav.setSource(AudioSource.FEED);
};
// ── 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 === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
if (p === EPS) playEpisode(focusedItem());
},
"toggle-select": (p) => {
if (p === EPS) {
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(() => {});
},
};
function len(pane: PaneId): number {
if (pane === FEEDS) return feedList().length;
if (pane === EPS) return episodes().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
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)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
</box>
<scrollbox
height="100%"
focused={isActive(FEEDS)}
border
borderColor={border(FEEDS)}
backgroundColor={theme.background}
>
<Show
when={feedList().length > 1}
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const label = () =>
item.kind === "all"
? "All Feeds"
: item.feed.customName || item.feed.podcast.title;
const count = () =>
item.kind === "all"
? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length;
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), FEEDS)}
onMouseDown={() => {
nav.setActivePane(FEEDS);
nav.setFocusedIndex(FEEDS, index());
}}
>
<text fg={focusFg(index(), FEEDS)}>
{index() === nav.focusedIndex(FEEDS) ? "" : " "}
</text>
<text fg={focusFg(index(), FEEDS)}>{label()}</text>
<text
fg={
index() === nav.focusedIndex(FEEDS)
? theme.surface
: muted()
}
>
({count()})
</text>
</box>
);
}}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{(() => {
const fi = focusedFeedItem();
if (fi?.kind === "feed")
return fi.feed.customName || fi.feed.podcast.title;
return "All Episodes";
})()} · {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), EPS)}
onMouseDown={() => {
nav.setActivePane(EPS);
nav.setFocusedIndex(EPS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}>
{index() === nav.focusedIndex(EPS) ? "" : " "}
</text>
<text fg={focusFg(index(), EPS)}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(item.episode.pubDate)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(item.episode.duration)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? 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>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREV)}
border
borderColor={border(PREV)}
backgroundColor={theme.background}
>
<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>
</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>
<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/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
}
export const FeedPaneCount = 1;
const ITEMS_PER_BATCH = 50;
export function FeedPage() {
const feedStore = useFeedStore();
const nav = useNavigation();
const { theme } = useTheme();
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
string | undefined
>();
const allEpisodes = () => feedStore.getAllEpisodesChronological();
const keybind = useKeybinds();
const [focusedIndex, setFocusedIndex] = createSignal(0);
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const episodes = allEpisodes();
if (episodes.length > 0 && episodes[focusedIndex()]) {
setSelectedEpisodeID(episodes[focusedIndex()].episode.id);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== FeedPaneType.FEED) return;
const episodes = allEpisodes();
if (episodes.length === 0) return;
if (isDown && !isInverting()) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if (isUp && isInverting()) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
}
},
{ release: false },
);
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const groupEpisodesByDate = () => {
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
for (const item of allEpisodes()) {
const dateKey = formatDate(new Date(item.episode.pubDate));
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
}
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
// Convert date strings back to Date objects for proper chronological sorting
const dateA = new Date(a);
const dateB = new Date(b);
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime();
});
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
return (
<box
border
borderColor={
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
}
backgroundColor={theme.background}
flexDirection="column"
height="100%"
width="100%"
>
<Show
when={allEpisodes().length > 0}
fallback={
<box padding={2}>
<text fg={theme.textMuted}>
No episodes yet. Subscribe to shows from Discover or Search.
</text>
</box>
}
>
<scrollbox
height="100%"
focused={nav.activeDepth() == FeedPaneType.FEED}
>
<For each={groupEpisodesByDate()}>
{([date, items]) => (
<box flexDirection="column" gap={1} padding={1}>
<SelectableText selected={() => false} primary>
{date}
</SelectableText>
<For each={items}>
{(item) => {
const isSelected = () => {
if (
nav.activeTab() == TABS.FEED &&
nav.activeDepth() == FeedPaneType.FEED &&
selectedEpisodeID() &&
selectedEpisodeID() === item.episode.id
) {
return true;
}
return false;
};
const isFocused = () => {
const episodes = allEpisodes();
const currentIndex = episodes.findIndex(
(e: any) => e.episode.id === item.episode.id,
);
return currentIndex === focusedIndex();
};
return (
<SelectableBox
selected={isSelected}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
onMouseDown={() => {
setSelectedEpisodeID(item.episode.id);
const episodes = allEpisodes();
setFocusedIndex(
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
);
}}
>
<SelectableText selected={isSelected} primary>
{item.episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={isSelected} primary>
{item.feed.podcast.title}
</SelectableText>
<SelectableText selected={isSelected} tertiary>
{formatDuration(item.episode.duration)}
</SelectableText>
</box>
</SelectableBox>
);
}}
</For>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
);
}
export { FeedPage };

View File

@@ -1,325 +1,431 @@
/**
* MyShowsPage - Two-panel file-explorer style view
* Left panel: list of subscribed shows
* Right panel: episodes for the selected show
* MyShowsPage — yazi-style 3-pane view (canonical reference migration).
*
* pane 0 (parent) — subscribed shows
* pane 1 (current) — episodes of the focused show
* pane 2 (preview) — detail of the focused episode
*
* Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the
* Shell router via the `nav.action` event bus; this page only subscribes and
* translates actions against its own data. h/l swipe between panes is handled
* by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane)
* so the page is purely reactive.
*/
import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
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 } from "@/context/NavigationContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} 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 { LoadingIndicator } from "@/components/LoadingIndicator";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum MyShowsPaneType {
SHOWS = 1,
EPISODES = 2,
}
export const MyShowsPaneCount = 2;
export const MyShowsPaneCount = 3;
export function MyShowsPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [showIndex, setShowIndex] = createSignal(0);
const [episodeIndex, setEpisodeIndex] = createSignal(0);
const { theme } = useTheme();
const mutedColor = () => theme.muted || theme.text;
const nav = useNavigation();
const keybind = useKeybinds();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
const SHOWS = PaneSlot.PARENT;
const EPS = PaneSlot.CURRENT;
const PREV = PaneSlot.PREVIEW;
const shows = feedStore.getFilteredFeeds();
const episodesList = episodes();
const shows = () => feedStore.getFilteredFeeds();
if (isSelect) {
if (shows.length > 0 && showIndex() < shows.length) {
setShowIndex(showIndex() + 1);
}
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
setEpisodeIndex(episodeIndex() + 1);
}
return;
}
// The selected show tracks the focused row of pane 0.
const selectedShow = createMemo(() => {
const list = shows();
if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
return list[idx];
});
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return;
const episodes = createMemo(() => {
const show = selectedShow();
if (!show) return [] as Episode[];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
if (episodesList.length > 0) {
if (isDown && !isInverting()) {
setEpisodeIndex((i) => (i + 1) % episodesList.length);
} else if (isUp && isInverting()) {
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setEpisodeIndex((i) => (i + 1) % episodesList.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
}
}
},
{ release: false },
);
});
// Register a resolver so visual-mode range selection grows by episode id.
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id);
// keep the resolver fresh as the episode list changes
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.id,
);
});
onCleanup(() => unsub());
});
/** Threshold: load more when within this many items of the end */
const LOAD_MORE_THRESHOLD = 5;
// Keep shows-focus in range after feeds load/change.
const ensureShowsFocus = () => {
const list = shows();
if (list.length === 0) return;
const cur = nav.focusedIndex(SHOWS);
if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1);
};
onMount(ensureShowsFocus);
const shows = () => feedStore.getFilteredFeeds();
// When the show changes, reset episode focus + set audio-nav source + show count.
const onShowChanged = () => {
const show = selectedShow();
if (!show) return;
if (nav.focusedIndex(EPS) > episodes().length - 1)
nav.setFocusedIndex(EPS, 0);
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
};
onMount(onShowChanged);
const selectedShow = createMemo(() => {
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation
});
const focusedEpisode = createMemo(() => {
const eps = episodes();
if (eps.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1);
return eps[idx];
});
const episodes = createMemo(() => {
const show = selectedShow();
if (!show) return [];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
// ── 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 formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
// ── 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 === SHOWS) {
nav.swipe(1, MyShowsPaneCount);
onShowChanged();
} else if (p === EPS) {
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
},
"toggle-select": (p) => {
if (p === EPS) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
refresh: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
},
};
/** Get download status label for an episode */
const downloadLabel = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING: {
const pct = downloadStore.getDownloadProgress(episodeId);
return `[${pct}%]`;
}
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
function len(pane: PaneId): number {
if (pane === SHOWS) return shows().length;
if (pane === EPS) return episodes().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === SHOWS) {
// clamp episode focus + re-resolve after show change
nav.setFocusedIndex(
EPS,
Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)),
);
onShowChanged();
}
}
const handleRefresh = async () => {
const show = selectedShow();
if (!show) return;
setIsRefreshing(true);
await feedStore.refreshFeed(show.id);
setIsRefreshing(false);
};
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
// Only react when our tab is active.
// (Shell always emits; router guarantees our tab is active.)
ensureShowsFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
// visual selection growth is handled inside nav.move/registerResolver
};
const handleUnsubscribe = () => {
const show = selectedShow();
if (!show) return;
feedStore.removeFeed(show.id);
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
/** Get download status color */
const downloadColor = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return theme.warning.toString();
case DownloadStatus.DOWNLOADING:
return theme.primary.toString();
case DownloadStatus.COMPLETED:
return theme.success.toString();
case DownloadStatus.FAILED:
return theme.error.toString();
default:
return mutedColor().toString();
}
};
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
return (
<box flexDirection="row" flexGrow={1} width="100%">
<box flexDirection="column" height="100%">
<Show when={isRefreshing()}>
<text fg={theme.warning}>Refreshing...</text>
</Show>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={theme.muted}>
No shows yet. Subscribe from Discover or Search.
</text>
</box>
}
>
<scrollbox
border
height="100%"
borderColor={
nav.activeDepth() == MyShowsPaneType.SHOWS
? theme.accent
: theme.border
}
focused={nav.activeDepth() == MyShowsPaneType.SHOWS}
>
<For each={shows()}>
{(feed, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
index() === showIndex() ? theme.primary : undefined
}
onMouseDown={() => {
setShowIndex(index());
setEpisodeIndex(0);
audioNav.setSource(
AudioSource.MY_SHOWS,
selectedShow()?.podcast.id,
);
}}
>
<text
fg={index() === showIndex() ? theme.surface : theme.text}
>
{index() === showIndex() ? ">" : " "}
</text>
<text
fg={index() === showIndex() ? theme.surface : theme.text}
>
{feed.customName || feed.podcast.title}
</text>
<text fg={index() === showIndex() ? undefined : theme.text}>
({feed.episodes.length})
</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
<box flexDirection="column" height="100%">
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={theme.muted}>Select a show</text>
</box>
}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text>
</box>
}
>
<scrollbox
border
height="100%"
borderColor={
nav.activeDepth() == MyShowsPaneType.EPISODES
? theme.accent
: theme.border
}
focused={nav.activeDepth() == MyShowsPaneType.EPISODES}
>
<For each={episodes()}>
{(episode, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={
index() === episodeIndex() ? theme.primary : undefined
}
onMouseDown={() => setEpisodeIndex(index())}
>
<box flexDirection="row" gap={1}>
<text
fg={
index() === episodeIndex()
? theme.surface
: theme.text
}
>
{index() === episodeIndex() ? ">" : " "}
</text>
<text
fg={
index() === episodeIndex()
? theme.surface
: theme.text
}
>
{episode.episodeNumber
? `#${episode.episodeNumber} `
: ""}
{episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={index() === episodeIndex() ? undefined : theme.info}
>
{formatDate(episode.pubDate)}
</text>
<text fg={theme.muted}>
{formatDuration(episode.duration)}
</text>
<Show when={downloadLabel(episode.id)}>
<text fg={downloadColor(episode.id)}>
{downloadLabel(episode.id)}
</text>
</Show>
</box>
</box>
)}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
<Show
when={
!feedStore.isLoadingMore() &&
selectedShow() &&
feedStore.hasMoreEpisodes(selectedShow()!.id)
}
>
<box paddingLeft={2} paddingTop={1}>
<text fg={theme.muted}>Scroll down for more episodes</text>
</box>
</Show>
</scrollbox>
</Show>
</Show>
</box>
</box>
);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: shows ─────────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
</box>
<scrollbox
height="100%"
focused={isActive(SHOWS)}
border
borderColor={border(SHOWS)}
backgroundColor={theme.background}
>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), SHOWS)}
onMouseDown={() => {
nav.setActivePane(SHOWS);
nav.setFocusedIndex(SHOWS, index());
onShowChanged();
}}
>
<text fg={focusFg(index(), SHOWS)}>
{index() === nav.focusedIndex(SHOWS) ? "" : " "}
</text>
<text fg={focusFg(index(), SHOWS)}>
{feed.customName || feed.podcast.title}
</text>
<text
fg={
index() === nav.focusedIndex(SHOWS)
? theme.surface
: muted()
}
>
({feed.episodes.length})
</text>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 1: episodes ──────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{selectedShow()?.customName ||
selectedShow()?.podcast.title ||
"Episodes"}{" "}
· {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), EPS)}
onMouseDown={() => {
nav.setActivePane(EPS);
nav.setFocusedIndex(EPS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}>
{index() === nav.focusedIndex(EPS) ? "" : " "}
</text>
<text fg={focusFg(index(), EPS)}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(ep.pubDate)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
)}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</scrollbox>
</box>
{/* ── pane 2: preview ───────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREV)}
border
borderColor={border(PREV)}
backgroundColor={theme.background}
>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ??
"No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
}

View File

@@ -1,112 +1,122 @@
/**
* PlayerPage — single-pane audio now-playing view.
*
* 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).
*/
import { Show } from "solid-js";
import { PlaybackControls } from "./PlaybackControls";
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 { useKeybinds } from "@/context/KeybindContext";
import { useKeyboard } from "@opentui/solid";
import { onMount } from "solid-js";
enum PlayerPaneType {
PLAYER = 1,
}
export const PlayerPaneCount = 1;
export function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const muted = () => theme.muted || theme.text;
const keybind = useKeybinds();
// Single pane — always active.
const isActive = () => true;
const border = () => theme.accent;
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isInverting = keybind.isInverting(keyEvent);
const progressPercent = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
if (keybind.match("audio-toggle", keyEvent)) {
audio.togglePlayback();
return;
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
if (keybind.match("audio-seek-forward", keyEvent)) {
audio.seek(audio.currentEpisode()?.duration ?? 0);
return;
}
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>
</box>
<scrollbox
height="100%"
focused={isActive()}
border
borderColor={border()}
backgroundColor={theme.background}
>
<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>
if (keybind.match("audio-seek-backward", keyEvent)) {
audio.seek(0);
return;
}
},
{ release: false },
);
});
<Show when={audio.error()}>
{(err) => <text fg={theme.error}>{err()}</text>}
</Show>
const progressPercent = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
<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>
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
)}
</Show>
return (
<box flexDirection="column" gap={1} width="100%">
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={theme.muted}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box>
<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)}
/>
{audio.error() && <text fg={theme.error}>{audio.error()}</text>}
<box
border
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border}
padding={1}
flexDirection="column"
gap={1}
>
<text fg={theme.text}>
<strong>{audio.currentEpisode()?.title}</strong>
</text>
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
<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)} //TODO: get next chronological(if feed) or episode(if MyShows)
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
</box>
);
<box height={1} />
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
</box>
</scrollbox>
</box>
);
}

View File

@@ -1,210 +1,395 @@
/**
* SearchPage component - Main search interface for PodTUI
* SearchPage — yazi-style 3-pane view.
*
* pane 0 (parent) — query input with recent-search history (clickable)
* pane 1 (current) — search results list (navigate j/k)
* pane 2 (preview) — detail of the focused search result
*
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
* the results pane. Swipe left (h) to pane 0 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.
*/
import { createSignal, createEffect, Show, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import {
createSignal,
createMemo,
createEffect,
For,
Show,
onMount,
onCleanup,
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
import { MyShowsPage } from "../MyShows/MyShowsPage";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} 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";
enum SearchPaneType {
INPUT = 1,
RESULTS = 2,
HISTORY = 3,
}
export const SearchPaneCount = 3;
export function SearchPage() {
const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0);
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
function SearchPage() {
const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
const INPUT = PaneSlot.PARENT; // 0
const RESULTS = PaneSlot.CURRENT; // 1
const DETAIL = PaneSlot.PREVIEW; // 2
if (isSelect) {
const results = searchStore.results();
if (results.length > 0 && resultIndex() < results.length) {
setResultIndex(resultIndex() + 1);
}
return;
}
const results = () => searchStore.results();
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== SearchPaneType.RESULTS) return;
// The focused result tracks pane 1's focused row.
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];
});
const results = searchStore.results();
if (results.length === 0) return;
// 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());
});
if (isDown && !isInverting()) {
setResultIndex((i) => (i + 1) % results.length);
} else if (isUp && isInverting()) {
setResultIndex((i) => (i - 1 + results.length) % results.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setResultIndex((i) => (i + 1) % results.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setResultIndex((i) => (i - 1 + results.length) % results.length);
}
},
{ release: false },
);
});
// 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);
};
onMount(ensureFocus);
const handleSearch = async () => {
const query = inputValue().trim();
if (query) {
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
}
};
// ── input pane: set inputFocused so Shell router yields keys to <input> ─────
createEffect(() => {
const isInputPane = nav.activePane() === INPUT;
nav.setInputFocused(isInputPane);
});
onMount(() => {
onCleanup(() => nav.setInputFocused(false));
});
const handleHistorySelect = async (query: string) => {
setInputValue(query);
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
};
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleResultSelect = (result: SearchResult) => {
//props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id);
};
const handleSubmit = () => {
const query = inputValue().trim();
if (!query) return;
searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
};
return (
<box flexDirection="column" height="100%" gap={1} width="100%">
{/* Search Header */}
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>Search Podcasts</strong>
</text>
const handleHistorySelect = (query: string) => {
setInputValue(query);
searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
};
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Search:</text>
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={nav.activeDepth() === SearchPaneType.INPUT}
width={50}
/>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={handleSearch}
>
<text fg={theme.primary}>[Enter] Search</text>
</box>
</box>
const handleSubscribe = (result: SearchResult) => {
searchStore.markSubscribed(result.podcast.id);
};
{/* Status */}
<Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
</box>
// ── 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);
}
},
search: () => {
nav.setActivePane(INPUT);
},
refresh: () => {
if (inputValue().trim()) {
searchStore.search(inputValue().trim()).catch(() => {});
}
},
};
{/* Main Content - Results or History */}
<box flexDirection="row" height="100%" gap={2}>
{/* Results Panel */}
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.accent
: theme.border
}
>
<box padding={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.primary
: theme.muted
}
>
Results ({searchStore.results().length})
</text>
</box>
<Show
when={searchStore.results().length > 0}
fallback={
<box padding={2}>
<text fg={theme.muted}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<SearchResults
results={searchStore.results()}
selectedIndex={resultIndex()}
focused={nav.activeDepth() === SearchPaneType.RESULTS}
onSelect={handleResultSelect}
onChange={setResultIndex}
isSearching={searchStore.isSearching()}
error={searchStore.error()}
/>
</Show>
</box>
function len(pane: PaneId): number {
if (pane === RESULTS) return results().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
{/* History Sidebar */}
<box width={30} border borderColor={theme.border}>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.HISTORY
? theme.primary
: theme.muted
}
>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={nav.activeDepth() === SearchPaneType.HISTORY}
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory}
onChange={setHistoryIndex}
/>
</box>
</box>
</box>
</box>
);
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
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)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? 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>
</box>
{/* ── 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}
>
<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) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>
{result.podcast.title}
</text>
<Show when={result.podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={result.podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</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>
</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>
</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>
);
}
export { SearchPage };

View File

@@ -1,119 +1,185 @@
import { createSignal, For, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
/**
* SettingsPage — yazi-style 2-pane view.
*
* pane 0 (parent) — section list (Sync, Sources, Preferences, ...)
* pane 1 (current) — active panel for the focused section
*
* Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list.
* The panel (pane 1) reactively shows the focused section's content.
* Audio transport and tab/pane swipes are handled by the Shell router.
*/
import { For, Show, onMount, onCleanup } from "solid-js";
import { SourceManager } from "./SourceManager";
import { useTheme } from "@/context/ThemeContext";
import { PreferencesPanel } from "./PreferencesPanel";
import { SyncPanel } from "./SyncPanel";
import { VisualizerSettings } from "./VisualizerSettings";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import { useTheme } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum SettingsPaneType {
SYNC = 1,
SOURCES = 2,
PREFERENCES = 3,
VISUALIZER = 4,
ACCOUNT = 5,
}
export const SettingsPaneCount = 5;
export const SettingsPaneCount = 2;
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
{ id: SettingsPaneType.SYNC, label: "Sync" },
{ id: SettingsPaneType.SOURCES, label: "Sources" },
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
];
const SECTIONS = [
{ id: 0, label: "Sync" },
{ id: 1, label: "Sources" },
{ id: 2, label: "Preferences" },
{ id: 3, label: "Visualizer" },
{ id: 4, label: "Account" },
] as const;
export function SettingsPage() {
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
// Helper function to check if a depth is active
const isActive = (depth: SettingsPaneType): boolean => {
return nav.activeDepth() === depth;
};
const SECTIONS_PANE = PaneSlot.PARENT; // 0
const PANEL = PaneSlot.CURRENT; // 1
// Helper function to get the current depth as a number
const currentDepth = () => nav.activeDepth() as number;
// The focused section tracks pane 0's focused index.
const focusedSection = () => {
const idx = nav.focusedIndex(SECTIONS_PANE);
return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0];
};
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
// Register a resolver so visual-mode range selection grows by section id.
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) =>
SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
);
});
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return;
// ── 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 === SECTIONS_PANE) {
nav.swipe(1, SettingsPaneCount);
}
},
};
if (isDown && !isInverting()) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
} else if (isUp && isInverting()) {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
}
},
{ release: false },
);
});
function len(pane: PaneId): number {
if (pane === SECTIONS_PANE) return SECTIONS.length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
return (
<box flexDirection="column" gap={1} height="100%" width="100%">
<box flexDirection="row" gap={1}>
<For each={SECTIONS}>
{(section, index) => (
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
currentDepth() === section.id ? theme.primary : undefined
}
onMouseDown={() => nav.setActiveDepth(section.id)}
>
<text
fg={
currentDepth() === section.id ? theme.text : theme.textMuted
}
>
[{index() + 1}] {section.label}
</text>
</box>
)}
</For>
</box>
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
<box
border
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border}
flexGrow={1}
padding={1}
flexDirection="column"
gap={1}
>
{isActive(SettingsPaneType.SYNC) && <SyncPanel />}
{isActive(SettingsPaneType.SOURCES) && (
<SourceManager focused />
)}
{isActive(SettingsPaneType.PREFERENCES) && (
<PreferencesPanel />
)}
{isActive(SettingsPaneType.VISUALIZER) && (
<VisualizerSettings />
)}
{isActive(SettingsPaneType.ACCOUNT) && (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Account</text>
</box>
)}
</box>
</box>
);
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)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: sections ─────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Settings</text>
</box>
<scrollbox
height="100%"
focused={isActive(SECTIONS_PANE)}
border
borderColor={border(SECTIONS_PANE)}
backgroundColor={theme.background}
>
<For each={SECTIONS}>
{(section, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), SECTIONS_PANE)}
onMouseDown={() => {
nav.setActivePane(SECTIONS_PANE);
nav.setFocusedIndex(SECTIONS_PANE, index());
}}
>
<text fg={focusFg(index(), SECTIONS_PANE)}>
{index() === nav.focusedIndex(SECTIONS_PANE) ? "" : " "}
</text>
<text fg={focusFg(index(), SECTIONS_PANE)}>
{section.label}
</text>
</box>
)}
</For>
</scrollbox>
</box>
{/* ── pane 1: panel ─────────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>{focusedSection().label}</text>
</box>
<scrollbox
height="100%"
focused={isActive(PANEL)}
border
borderColor={border(PANEL)}
backgroundColor={theme.background}
>
<Show when={focusedSection().id === 0}>
<SyncPanel />
</Show>
<Show when={focusedSection().id === 1}>
<SourceManager focused />
</Show>
<Show when={focusedSection().id === 2}>
<PreferencesPanel />
</Show>
<Show when={focusedSection().id === 3}>
<VisualizerSettings />
</Show>
<Show when={focusedSection().id === 4}>
<box padding={1} flexDirection="column" gap={1}>
<text fg={muted()}>Account settings (not yet implemented)</text>
</box>
</Show>
</scrollbox>
</box>
</box>
);
}