Files
PodTui/src/pages/Feed/FeedPage.tsx
2026-08-07 13:38:32 -04:00

306 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FeedPage — flat chronological list of episodes across all subscribed feeds.
*
* depth 0 (current) — every episode from every feed, newest-first (the
* combined view the old "All Feeds" virtual row used to
* drill into). Parent pane shows the muted tab list.
* preview — detail of the hovered episode.
*
* This page does NOT drill: the previous depth-1 "episodes of one feed" panel
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
* shows the full flat episodes list immediately.
*
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
* root. j/k move only within the current column. The Shell router drives
* everything over `nav.action`; this page only handles list/preview data.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const FeedPaneCount = 1;
type EpItem = { episode: Episode; 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();
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
const episodes = createMemo<EpItem[]>(
() => feedStore.getAllEpisodesChronological() as EpItem[],
);
const focus = () => nav.depthFocus(0);
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
const curLen = () => episodes().length;
const ensureFocus = () => {
if (episodes().length > 0 && focus() >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 0);
};
onMount(ensureFocus);
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
(i) => episodes()[i]?.episode.id,
);
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (item: EpItem | undefined) => {
if (!item) return;
audio.play(item.episode).catch(() => {});
audioNav.setSource(AudioSource.FEED);
};
// ── open ───────────────────────────────────────────────────────────────────
function open() {
playEpisode(focusedItem());
}
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id);
},
refresh: () => {
feedStore.refreshAllFeeds().catch(() => {});
},
};
function step(delta: number) {
nav.move(delta, curLen());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
// ── parent pane: muted tab list (no parent list — Feed is one depth) ──────
const parentContent = () => <TabListPane muted />;
// ── current pane: the flat episodes list (the only focusable column) ──────
const currentContent = () => (
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = () => focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi() ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
);
// ── preview pane: hovered-episode detail ───────────────────────────────────
const previewContent = () => (
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</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 back</text>
</box>
)}
</Show>
);
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel="Up"
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}
export { FeedPage };