Feed and My Shows rows could grow to 4+ lines when a long title was shrunk by the current pane: flexible text wrapped instead of truncating, shifting every row below while scrolling. Add wrapMode=none + truncate to flexible text and flexShrink=0 to fixed-width cells so rows stay one line tall. Feed rows also move the podcast name onto its own line. Adds a rendered-layout regression test at 70 columns (35-col current pane).
524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
|
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
|
* 0). j/k move only within the current column.
|
|
*/
|
|
|
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
|
import { useFeedStore } from "@/stores/feed";
|
|
import { useDownloadStore } from "@/stores/download";
|
|
import { useAppStore } from "@/stores/app";
|
|
import { DownloadStatus } from "@/types/episode";
|
|
import { format } from "date-fns";
|
|
import { useTheme } from "@/context/ThemeContext";
|
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
|
import {
|
|
useNavigation,
|
|
NavMode,
|
|
DEPTH_CENTER_PANE,
|
|
type PaneId,
|
|
type DepthFrame,
|
|
} from "@/context/NavigationContext";
|
|
import { useAudio } from "@/hooks/useAudio";
|
|
import { on, off } from "@/utils/event-bus";
|
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
|
import type { Episode } from "@/types/episode";
|
|
import type { Feed } from "@/types/feed";
|
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
|
import { PaneRow } from "@/components/PaneRow";
|
|
import { TabListPane } from "@/components/TabPanel";
|
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
|
|
|
export const MyShowsPaneCount = 1;
|
|
|
|
export function MyShowsPage() {
|
|
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();
|
|
|
|
const focusedShowIdx = () =>
|
|
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
|
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
|
|
|
// depth-1 frame ctx = the drilled feed id
|
|
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
|
const episodes = createMemo<Episode[]>(() => {
|
|
if (depth() < 1) return [];
|
|
const id = drilledShowId();
|
|
const show = shows().find((s) => s.id === id);
|
|
if (!show) return [];
|
|
return [...show.episodes].sort(
|
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
|
);
|
|
});
|
|
const focusedEpIdx = () =>
|
|
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
|
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
|
|
|
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
|
|
|
const ensureFocus = () => {
|
|
if (shows().length > 0 && focus(0) >= shows().length)
|
|
nav.setDepthFocus(shows().length - 1, 0);
|
|
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
|
nav.setDepthFocus(episodes().length - 1, 1);
|
|
};
|
|
onMount(ensureFocus);
|
|
|
|
onMount(() => {
|
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
|
if (depth() === 0) return shows()[i]?.id;
|
|
return episodes()[i]?.id;
|
|
});
|
|
});
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
const formatDuration = (s: number) => {
|
|
const mins = Math.floor(s / 60);
|
|
const hrs = Math.floor(mins / 60);
|
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
};
|
|
const downloadLabel = (id: string) => {
|
|
switch (downloadStore.getDownloadStatus(id)) {
|
|
case DownloadStatus.QUEUED:
|
|
return "[Q]";
|
|
case DownloadStatus.DOWNLOADING:
|
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
|
case DownloadStatus.COMPLETED:
|
|
return "[DL]";
|
|
case DownloadStatus.FAILED:
|
|
return "[ERR]";
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
const downloadColor = (id: string) => {
|
|
switch (downloadStore.getDownloadStatus(id)) {
|
|
case DownloadStatus.QUEUED:
|
|
return theme.warning;
|
|
case DownloadStatus.DOWNLOADING:
|
|
return theme.primary;
|
|
case DownloadStatus.COMPLETED:
|
|
return theme.success;
|
|
case DownloadStatus.FAILED:
|
|
return theme.error;
|
|
default:
|
|
return muted();
|
|
}
|
|
};
|
|
const playEpisode = (ep: Episode) => {
|
|
audio.play(ep).catch(() => {});
|
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
|
};
|
|
|
|
// ── drill / open ───────────────────────────────────────────────────────────
|
|
function open() {
|
|
if (depth() === 0) {
|
|
const show = selectedShow();
|
|
if (!show) return;
|
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
|
return;
|
|
}
|
|
if (depth() >= 1) {
|
|
const ep = focusedEpisode();
|
|
if (ep) playEpisode(ep);
|
|
}
|
|
}
|
|
|
|
// ── nav.action ──────────────────────────────────────────────────────────────
|
|
const PAGE_ACTIONS: Partial<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": () => {
|
|
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() < 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 focusBg = (i: number, lf: number, active: boolean) =>
|
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
|
i === lf && active
|
|
? theme.surface
|
|
: i === lf
|
|
? theme.selectedListItemText ?? theme.text
|
|
: theme.text;
|
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
|
|
|
const currentLabel = () =>
|
|
depth() === 0
|
|
? `Shows (${shows().length})`
|
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
|
|
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
|
// mounts/unmounts cleanly on depth change.
|
|
const parentContent = () => (
|
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
|
<For each={shows()}>
|
|
{(feed, index) => {
|
|
const lf = () => nav.depthFocus(0);
|
|
const ref = useScrollIntoView(() => index() === lf());
|
|
return (
|
|
<box
|
|
ref={ref}
|
|
flexDirection="row"
|
|
gap={1}
|
|
paddingRight={1}
|
|
backgroundColor={focusBg(index(), lf(), false)}
|
|
>
|
|
<text fg={focusFg(index(), lf(), false)}>
|
|
{index() === lf() ? marker() : " "}
|
|
</text>
|
|
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
|
<text fg={muted()}>({feed.episodes.length})</text>
|
|
</box>
|
|
);
|
|
}}
|
|
</For>
|
|
</Show>
|
|
);
|
|
|
|
// ── current pane: the current-depth list ───────────────────────────────────
|
|
const currentContent = () => (
|
|
<>
|
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
|
<Show when={depth() === 0}>
|
|
<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) => {
|
|
const lf = () => focusedShowIdx();
|
|
const ref = useScrollIntoView(() => index() === lf());
|
|
const wlScope =
|
|
app.state().preferences.autoDownloadScope === "whitelist";
|
|
const wlInList = (
|
|
app.state().preferences.autoDownloadWhitelist ?? []
|
|
).includes(feed.id);
|
|
return (
|
|
<box
|
|
ref={ref}
|
|
flexDirection="row"
|
|
gap={1}
|
|
paddingRight={1}
|
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
|
onMouseDown={() => {
|
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
nav.setDepthFocus(index(), 0);
|
|
}}
|
|
>
|
|
<text fg={focusFg(index(), lf(), isActive())}>
|
|
{index() === lf() ? marker() : " "}
|
|
</text>
|
|
<text fg={focusFg(index(), lf(), isActive())}>
|
|
{showTitle(feed)}
|
|
</text>
|
|
<text fg={index() === lf() ? theme.surface : muted()}>
|
|
({feed.episodes.length})
|
|
</text>
|
|
<Show when={wlScope}>
|
|
<text
|
|
fg={
|
|
index() === lf()
|
|
? theme.surface
|
|
: wlInList
|
|
? theme.warning
|
|
: muted()
|
|
}
|
|
>
|
|
{wlInList ? "●" : "○"}
|
|
</text>
|
|
</Show>
|
|
</box>
|
|
);
|
|
}}
|
|
</For>
|
|
</Show>
|
|
</Show>
|
|
{/* depth ≥1: episodes */}
|
|
<Show when={depth() >= 1}>
|
|
<Show
|
|
when={episodes().length > 0}
|
|
fallback={
|
|
<box padding={1}>
|
|
<text fg={muted()}>No episodes. :refresh</text>
|
|
</box>
|
|
}
|
|
>
|
|
<For each={episodes()}>
|
|
{(ep, index) => {
|
|
const lf = () => focusedEpIdx();
|
|
const ref = useScrollIntoView(() => index() === lf());
|
|
return (
|
|
<box
|
|
ref={ref}
|
|
flexDirection="column"
|
|
gap={0}
|
|
paddingRight={1}
|
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
|
onMouseDown={() => {
|
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
nav.setDepthFocus(index(), 1);
|
|
}}
|
|
>
|
|
<box flexDirection="row" gap={1}>
|
|
<text
|
|
flexShrink={0}
|
|
fg={focusFg(index(), lf(), isActive())}
|
|
>
|
|
{index() === lf() ? marker() : " "}
|
|
</text>
|
|
<text
|
|
wrapMode="none"
|
|
truncate
|
|
fg={focusFg(index(), lf(), isActive())}
|
|
>
|
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
|
{ep.title}
|
|
</text>
|
|
</box>
|
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
<text
|
|
flexShrink={0}
|
|
fg={index() === lf() ? theme.surface : theme.info}
|
|
>
|
|
{formatDate(ep.pubDate)}
|
|
</text>
|
|
<text
|
|
flexShrink={0}
|
|
fg={index() === lf() ? theme.surface : muted()}
|
|
>
|
|
{formatDuration(ep.duration)}
|
|
</text>
|
|
<Show when={nav.isSelected(ep.id)}>
|
|
<text flexShrink={0} fg={theme.warning}>
|
|
●
|
|
</text>
|
|
</Show>
|
|
<Show when={downloadLabel(ep.id)}>
|
|
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
|
{downloadLabel(ep.id)}
|
|
</text>
|
|
</Show>
|
|
</box>
|
|
</box>
|
|
);
|
|
}}
|
|
</For>
|
|
<Show when={feedStore.isLoadingMore()}>
|
|
<box paddingLeft={2} paddingTop={1}>
|
|
<LoadingIndicator label="Loading more…" />
|
|
</box>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
</>
|
|
);
|
|
|
|
// ── preview pane ───────────────────────────────────────────────────────────
|
|
const previewContent = () =>
|
|
depth() === 0 ? (
|
|
// depth 0 preview: hovered show
|
|
<Show
|
|
when={selectedShow()}
|
|
fallback={
|
|
<box padding={1}>
|
|
<text fg={muted()}>No show focused</text>
|
|
</box>
|
|
}
|
|
>
|
|
{(show) => (
|
|
<box flexDirection="column" gap={1} padding={1}>
|
|
<text fg={theme.textPrimary ?? theme.text}>
|
|
<strong>{showTitle(show())}</strong>
|
|
</text>
|
|
<Show when={show().podcast.author}>
|
|
<text fg={muted()}>by {show().podcast.author}</text>
|
|
</Show>
|
|
<text fg={theme.textSecondary}>
|
|
{show().episodes.length} episodes
|
|
</text>
|
|
<text fg={muted()}>
|
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
|
</text>
|
|
<box height={1} />
|
|
<text fg={muted()}>
|
|
enter/l: open · h: back · x: unsubscribe
|
|
{app.state().preferences.autoDownloadScope === "whitelist"
|
|
? (app.state().preferences.autoDownloadWhitelist ??
|
|
[]
|
|
).includes(show().id)
|
|
? " · w: un-whitelist"
|
|
: " · w: whitelist"
|
|
: ""}
|
|
</text>
|
|
</box>
|
|
)}
|
|
</Show>
|
|
) : (
|
|
// depth ≥1 preview: hovered episode
|
|
<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 · d: download
|
|
{downloadStore.getDownloadStatus(ep().id) !==
|
|
DownloadStatus.NONE
|
|
? " · D: delete"
|
|
: ""}
|
|
{app.state().preferences.autoDownloadScope === "whitelist"
|
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
|
drilledShowId(),
|
|
)
|
|
? " · w: un-whitelist"
|
|
: " · w: whitelist"
|
|
: ""}{" "}
|
|
· space: select · h: back
|
|
</text>
|
|
</box>
|
|
)}
|
|
</Show>
|
|
);
|
|
|
|
return (
|
|
<PaneRow
|
|
parent={parentContent}
|
|
current={currentContent}
|
|
preview={previewContent}
|
|
currentLabel={currentLabel}
|
|
focused={isActive}
|
|
/>
|
|
);
|
|
}
|