feat(download): unsubscribed-show downloads from search
Episode search results gain d (download), D (delete), x (unsubscribe) and enter (play for subscribed shows); downloads of unsubscribed shows are recorded with the show's metadata under a deterministic synthetic feed id and listed under an "Unsubscribed Show Downloads" section in My Shows and the settings Download Manager. Classified at render time by feed id or feed URL, so subscribing re-classifies the downloads and unsubscribing purges them.
This commit is contained in:
@@ -33,7 +33,7 @@ import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
@@ -62,9 +62,28 @@ export function MyShowsPage() {
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds();
|
||||
|
||||
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||
// listed as their own section under the shows list. Reads feeds() so an
|
||||
// entry drops out the moment the user subscribes to its show.
|
||||
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||
|
||||
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
|
||||
const depth0Count = () => shows().length + unsubs().length;
|
||||
|
||||
const focusedShowIdx = () =>
|
||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
||||
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||
* row (past the shows list). */
|
||||
const focusedOnUnsub = () =>
|
||||
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
|
||||
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||
if (!focusedOnUnsub()) return undefined;
|
||||
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
|
||||
};
|
||||
const selectedShow = (): Feed | undefined => {
|
||||
if (focusedOnUnsub()) return undefined;
|
||||
return shows()[focusedShowIdx()];
|
||||
};
|
||||
|
||||
// depth-1 frame ctx = the drilled feed id
|
||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||
@@ -104,11 +123,11 @@ export function MyShowsPage() {
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||
|
||||
const curLen = () => (depth() === 0 ? shows().length : rowCount());
|
||||
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (shows().length > 0 && focus(0) >= shows().length)
|
||||
nav.setDepthFocus(shows().length - 1, 0);
|
||||
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||
nav.setDepthFocus(rowCount() - 1, 1);
|
||||
};
|
||||
@@ -116,7 +135,10 @@ export function MyShowsPage() {
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return shows()[i]?.id;
|
||||
if (depth() === 0) {
|
||||
if (i < shows().length) return shows()[i]?.id;
|
||||
return unsubs()[i - shows().length]?.episodeId;
|
||||
}
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
});
|
||||
@@ -172,9 +194,31 @@ export function MyShowsPage() {
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||
};
|
||||
|
||||
/** Stream an unsubscribed-show download. The record carries only what was
|
||||
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||
audio
|
||||
.play({
|
||||
id: d.episodeId,
|
||||
podcastId: d.feedId,
|
||||
title: d.episodeTitle ?? d.episodeId,
|
||||
description: "",
|
||||
audioUrl: d.audioUrl ?? "",
|
||||
duration: 0,
|
||||
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||
})
|
||||
.catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const d = focusedUnsub();
|
||||
if (d) {
|
||||
playUnsubscribedDownload(d);
|
||||
return;
|
||||
}
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||
@@ -215,6 +259,14 @@ export function MyShowsPage() {
|
||||
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||
},
|
||||
"delete-download": () => {
|
||||
if (depth() === 0) {
|
||||
const d = focusedUnsub();
|
||||
if (d) {
|
||||
downloadStore.cancelDownload(d.episodeId);
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (depth() < 1) return;
|
||||
const ep = focusedEpisode();
|
||||
if (!ep) return;
|
||||
@@ -283,7 +335,9 @@ export function MyShowsPage() {
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Shows (${shows().length})`
|
||||
? `Shows (${shows().length})${
|
||||
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||
}`
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
@@ -321,7 +375,7 @@ export function MyShowsPage() {
|
||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
when={depth0Count() > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
@@ -377,6 +431,71 @@ export function MyShowsPage() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={unsubs().length > 0}>
|
||||
<box paddingLeft={1} paddingTop={1}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Unsubscribed Show Downloads
|
||||
</text>
|
||||
</box>
|
||||
<For each={unsubs()}>
|
||||
{(d, index) => {
|
||||
// Rows continue after the shows list.
|
||||
const rowIdx = () => shows().length + index();
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => rowIdx() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(rowIdx(), 0);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||
>
|
||||
{rowIdx() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||
>
|
||||
{d.episodeTitle ?? d.episodeId}
|
||||
</text>
|
||||
<Show when={downloadLabel(d.episodeId)}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={downloadColor(d.episodeId)}
|
||||
>
|
||||
{downloadLabel(d.episodeId)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={
|
||||
rowIdx() === lf()
|
||||
? theme.surface
|
||||
: theme.textSecondary
|
||||
}
|
||||
>
|
||||
{d.podcastTitle ?? d.feedId}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
{/* depth ≥1: episodes */}
|
||||
@@ -491,39 +610,78 @@ export function MyShowsPage() {
|
||||
// ── preview pane ───────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered show
|
||||
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||
// hovered show.
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
when={focusedUnsub()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No show focused</text>
|
||||
</box>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
{(d) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{showTitle(show())}</strong>
|
||||
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>
|
||||
{show().episodes.length} episodes
|
||||
{d().podcastTitle ?? d().feedId}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={d().pubDate}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(new Date(d().pubDate!))}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(d().episodeId)}>
|
||||
<text fg={downloadColor(d().episodeId)}>
|
||||
{downloadLabel(d().episodeId)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
Downloaded from episode search — the show is not
|
||||
subscribed.
|
||||
</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"
|
||||
: ""}
|
||||
enter: play · D: delete download · h: back
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from "solid-js";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
@@ -55,6 +59,9 @@ export const SearchPaneCount = 1;
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audio = useAudio();
|
||||
const audioNav = useAudioNavStore();
|
||||
const toast = useToast();
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const { theme } = useTheme();
|
||||
@@ -134,6 +141,35 @@ function SearchPage() {
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
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 runSearch = (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
@@ -185,6 +221,49 @@ function SearchPage() {
|
||||
if (feed) searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
/** The subscribed feed backing a search result, if any (matched by
|
||||
* directory id or feed URL). */
|
||||
const feedForResult = (r: SearchResult) =>
|
||||
feedStore.feeds().find(
|
||||
(f) =>
|
||||
f.podcast.id === r.podcast.id ||
|
||||
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
|
||||
);
|
||||
|
||||
/** Download the focused episode: under its subscribed feed when the show
|
||||
* is subscribed, otherwise as an "unsubscribed show" download (listed
|
||||
* under Unsubscribed Show Downloads in My Shows / the download manager). */
|
||||
const downloadFocusedEpisode = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
const feed = feedForResult(r);
|
||||
if (feed) downloadStore.startDownload(r.episode, feed.id);
|
||||
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
|
||||
};
|
||||
|
||||
const playFocusedEpisode = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
audio.play(r.episode).catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
|
||||
};
|
||||
|
||||
const unsubscribeFocused = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || !r.podcast.isSubscribed) return;
|
||||
const feed = feedForResult(r);
|
||||
if (feed) {
|
||||
feedStore.removeFeed(feed.id);
|
||||
downloadStore
|
||||
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
|
||||
.catch(() => {});
|
||||
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||
"move-down": () => step(1),
|
||||
@@ -205,6 +284,17 @@ function SearchPage() {
|
||||
);
|
||||
}
|
||||
},
|
||||
download: () => downloadFocusedEpisode(),
|
||||
"delete-download": () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
const id = r.episode.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
unsubscribe: () => unsubscribeFocused(),
|
||||
search: () => {
|
||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||
if (depth() === 0) nav.setInputFocused(true);
|
||||
@@ -230,7 +320,13 @@ function SearchPage() {
|
||||
}
|
||||
if (depth() === 1) {
|
||||
const r = focusedResult();
|
||||
if (r) handleSubscribe(r);
|
||||
if (!r) return;
|
||||
if (r.kind === "episode" && r.podcast.isSubscribed) {
|
||||
// Subscribed show's episode → stream it (matches Feed/My Shows).
|
||||
playFocusedEpisode();
|
||||
return;
|
||||
}
|
||||
handleSubscribe(r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +570,13 @@ function SearchPage() {
|
||||
{(result, index) => {
|
||||
const fi = () => focusedResultIdx();
|
||||
const ref = useScrollIntoView(() => index() === fi());
|
||||
// Episode download status badge ("" when absent).
|
||||
const dlLabel = () =>
|
||||
result.kind === "episode"
|
||||
? downloadLabel(result.episode.id)
|
||||
: "";
|
||||
const dlEpId = () =>
|
||||
result.kind === "episode" ? result.episode.id : "";
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
@@ -495,6 +598,11 @@ function SearchPage() {
|
||||
? result.episode.title
|
||||
: result.podcast.title}
|
||||
</text>
|
||||
<Show when={dlLabel()}>
|
||||
<text fg={downloadColor(dlEpId())}>
|
||||
{dlLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={result.podcast.isSubscribed}>
|
||||
<text
|
||||
fg={index() === fi() ? theme.surface : theme.success}
|
||||
@@ -576,9 +684,16 @@ function SearchPage() {
|
||||
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Published: {formatDate(r.episode.pubDate)}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={muted()}>
|
||||
Published: {formatDate(r.episode.pubDate)}
|
||||
</text>
|
||||
<Show when={downloadLabel(r.episode.id)}>
|
||||
<text fg={downloadColor(r.episode.id)}>
|
||||
{downloadLabel(r.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||
@@ -594,12 +709,28 @@ function SearchPage() {
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
<text fg={theme.success}>
|
||||
Subscribed · x: unsubscribe
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: subscribe to show · h: back to query
|
||||
</text>
|
||||
<Show
|
||||
when={r.podcast.isSubscribed}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
enter: subscribe · d: download · h: back to query
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={muted()}>
|
||||
enter: play · d: download · x: unsubscribe
|
||||
{downloadStore.getDownloadStatus(r.episode.id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""}{" "}
|
||||
· h: back to query
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -640,10 +771,16 @@ function SearchPage() {
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
<text fg={theme.success}>
|
||||
Subscribed · x: unsubscribe
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||
<text fg={muted()}>
|
||||
enter: subscribe
|
||||
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
|
||||
· h: back to query
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||
*
|
||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||
* • one item per show — action item; Enter deletes all that show's
|
||||
* downloads (file + metadata, aborts in-flight).
|
||||
* • one item per episode — action item; Enter deletes a single download.
|
||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||
* • one item per subscribed show — action item; Enter deletes all that
|
||||
* show's downloads (file + metadata, aborts
|
||||
* in-flight).
|
||||
* • "Unsubscribed Show Downloads" — downloads made from episode search for
|
||||
* shows that aren't subscribed, grouped
|
||||
* under their own header.
|
||||
* • one item per episode — action item; Enter deletes a single download.
|
||||
*
|
||||
* Titles resolve from the feed store at render time (reactive), falling back
|
||||
* to the episode id when the feed is no longer loaded. Movement flows through
|
||||
* nav.action — no own useKeyboard (matches the other panels).
|
||||
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||
* panels).
|
||||
*/
|
||||
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
@@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Episode title for a download, resolved from the feed store (reactive). */
|
||||
/** Episode title for a download, resolved from the feed store (reactive);
|
||||
* falls back to the persisted title (kept for unsubscribed-show downloads). */
|
||||
function episodeTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
d: DownloadedEpisode,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(d.feedId);
|
||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||
return ep?.title ?? d.episodeId;
|
||||
return ep?.title ?? d.episodeTitle ?? d.episodeId;
|
||||
}
|
||||
|
||||
/** Show title for a download's feed id. */
|
||||
/** Show title for a download's feed id; falls back to the persisted show
|
||||
* title (unsubscribed-show downloads have no feed to resolve from). */
|
||||
function feedTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
feedId: string,
|
||||
d: DownloadedEpisode,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(feedId);
|
||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
||||
const feed = feedStore.getFeed(d.feedId);
|
||||
if (feed) return feed.customName || feed.podcast.title;
|
||||
return d.podcastTitle ?? d.feedId;
|
||||
}
|
||||
|
||||
export function useDownloadItems(): SettingItem[] {
|
||||
@@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] {
|
||||
},
|
||||
];
|
||||
|
||||
// Group downloads by feed so each show gets a delete-by-show item.
|
||||
// Group downloads by feed so each subscribed show gets a delete-by-show
|
||||
// item. Unsubscribed-show downloads (search downloads, synthetic feed
|
||||
// ids) are kept out of these groups and listed under their own section
|
||||
// below.
|
||||
const unsubscribed = downloadStore.getUnsubscribedDownloads();
|
||||
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
|
||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||
for (const d of downloads()) {
|
||||
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||
const arr = byFeed.get(d.feedId) ?? [];
|
||||
arr.push(d);
|
||||
byFeed.set(d.feedId, arr);
|
||||
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
|
||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||
items.push({
|
||||
id: `feed:${feedId}`,
|
||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
||||
label: `Show: ${feedTitle(feedStore, eps[0])}`,
|
||||
kind: "action",
|
||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||
help: () =>
|
||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
|
||||
downloadStore
|
||||
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One item per individual episode download.
|
||||
// Unsubscribed-show downloads: a section header + one item per episode.
|
||||
if (unsubscribed.length > 0) {
|
||||
items.push({
|
||||
id: "unsubscribed-header",
|
||||
label: "Unsubscribed Show Downloads",
|
||||
kind: "info",
|
||||
display: () => `${unsubscribed.length} files`,
|
||||
help: () =>
|
||||
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
|
||||
});
|
||||
}
|
||||
for (const d of unsubscribed) {
|
||||
items.push({
|
||||
id: `unsub:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One item per individual (subscribed-show) episode download.
|
||||
for (const d of downloads()) {
|
||||
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||
items.push({
|
||||
id: `ep:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
|
||||
Reference in New Issue
Block a user