docs: mark AUR package as pending registration reopen
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
*
|
||||
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
|
||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
||||
@@ -20,7 +20,7 @@
|
||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||
*
|
||||
* Example:
|
||||
* <YaziPaneRow
|
||||
* <PaneRow
|
||||
* parent={parentList}
|
||||
* current={currentList}
|
||||
* preview={detail}
|
||||
@@ -41,7 +41,7 @@ import { PANE_RATIO } from "@/utils/navigation";
|
||||
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||
type PaneLabel = string | (() => string);
|
||||
|
||||
export type YaziPaneRowProps = {
|
||||
export type PaneRowProps = {
|
||||
/** Parent column content (previous-depth list, or null for a muted
|
||||
* placeholder — the 1/7 slot is always preserved). */
|
||||
parent?: PaneContent;
|
||||
@@ -95,7 +95,7 @@ function Placeholder(props: { color: () => RGBA }) {
|
||||
}
|
||||
|
||||
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||
function YaziPane(props: {
|
||||
function Pane(props: {
|
||||
grow: number;
|
||||
label: () => string;
|
||||
content: () => JSX.Element | undefined;
|
||||
@@ -149,7 +149,7 @@ function YaziPane(props: {
|
||||
}
|
||||
|
||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
export function PaneRow(props: PaneRowProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
/** true → the current column gets the active-border focus ring. */
|
||||
@@ -180,7 +180,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||
<YaziPane
|
||||
<Pane
|
||||
grow={PANE_RATIO.parent}
|
||||
label={parentLabel}
|
||||
content={parentContent}
|
||||
@@ -188,7 +188,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||
<YaziPane
|
||||
<Pane
|
||||
grow={currentGrow()}
|
||||
label={currentLabel}
|
||||
content={currentContent}
|
||||
@@ -197,7 +197,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
/>
|
||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<YaziPane
|
||||
<Pane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
content={previewContent}
|
||||
@@ -25,7 +25,7 @@ import { LayerGraph } from "@/utils/layer-graph";
|
||||
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||
import { createDispatcher } from "@/utils/dispatch";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
@@ -252,7 +252,7 @@ export function Shell() {
|
||||
}
|
||||
>
|
||||
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={
|
||||
<box padding={1}>
|
||||
<text fg={t.textMuted}>—</text>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { For } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
@@ -67,8 +68,10 @@ export function TabListPane(props: { muted?: boolean }) {
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
const ref = useScrollIntoView(isCursor);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"sort": [","],
|
||||
"toggle-hidden": ["."],
|
||||
"refresh": ["r"],
|
||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||
|
||||
// ── Audio transport (preserved) ──────────────────────────────────────────
|
||||
// Kept on shifted single keys so they never collide with the yazi core
|
||||
|
||||
@@ -18,6 +18,7 @@ export const shortcuts = [
|
||||
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
||||
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
||||
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
||||
{ keys: "x", action: "Unsubscribe focused show (My Shows)" },
|
||||
{ keys: ", / .", action: "Sort / toggle hidden" },
|
||||
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
||||
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
||||
|
||||
@@ -67,6 +67,7 @@ export type KeybindActionName =
|
||||
| "sort"
|
||||
| "toggle-hidden"
|
||||
| "refresh"
|
||||
| "unsubscribe"
|
||||
| "audio-toggle"
|
||||
| "audio-next"
|
||||
| "audio-prev"
|
||||
|
||||
115
src/hooks/useScrollIntoView.ts
Normal file
115
src/hooks/useScrollIntoView.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* useScrollIntoView — keeps the ref'd row visible inside its enclosing
|
||||
* `<scrollbox>` whenever the focus accessor is true.
|
||||
*
|
||||
* OpenTUI's `ScrollBoxRenderable` has built-in *keyboard* scrolling but does
|
||||
* NOT auto-scroll to follow a programmatically-focused child (the app moves
|
||||
* its own cursor via the yazi nav store, so the scrollbox never sees a key
|
||||
* for row movement). Every scrollable panel therefore drifts out of view the
|
||||
* moment the cursor crosses the viewport edge.
|
||||
*
|
||||
* Attach the returned `ref` callback to the element that represents the
|
||||
* focused row of a scrollable list and call the hook with a `when()` that is
|
||||
* true for exactly that row (e.g. `() => index() === focus()`). Whenever the
|
||||
* accessor flips true, the nearest ScrollBoxRenderable is scrolled just enough
|
||||
* to bring the element back into the viewport — a "nearest-edge" scroll:
|
||||
* • scroll up only if the row's top is clipped above the viewport,
|
||||
* • scroll down only if the row's bottom is clipped below the viewport,
|
||||
* never snapping more than necessary (matches yazi list behaviour).
|
||||
*
|
||||
* Timing: for ordinary cursor movement (j/k) the list layout does not change
|
||||
* — only background colour and the cursor glyph flip — so the focused row's
|
||||
* Yoga-computed position is already valid when this effect fires, and the
|
||||
* scroll is applied synchronously. On first mount / content population the
|
||||
* layout for the new rows has not yet been computed, so the hook polls on a
|
||||
* short timer until layout resolves (bounded so it can never loop forever).
|
||||
*/
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
|
||||
/** Walk up the renderable parent chain to the nearest ScrollBoxRenderable,
|
||||
* identified by its `viewport` + `content` + numeric `scrollTop`. */
|
||||
function findScrollBox(node: any): any | null {
|
||||
let p: any = node?.parent;
|
||||
while (p) {
|
||||
if (p.viewport && p.content && typeof p.scrollTop === "number") return p;
|
||||
p = p.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Maximum number of retries while waiting for Yoga layout to populate the
|
||||
* row/viewport dimensions (handles the first-mount frame). */
|
||||
const MAX_RETRIES = 12;
|
||||
const RETRY_MS = 16;
|
||||
|
||||
export function useScrollIntoView(when: () => boolean) {
|
||||
let el: any = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const ref = (node: any) => {
|
||||
el = node;
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Compute the target scrollTop that brings `el` into the viewport of its
|
||||
* enclosing scrollbox, or `null` if no scroll is possible / needed yet.
|
||||
* Returns the decision so the caller knows whether to poll again. */
|
||||
const compute = (): { scroll: number | null; ready: boolean } => {
|
||||
const node = el;
|
||||
if (!node) return { scroll: null, ready: false };
|
||||
const sb = findScrollBox(node);
|
||||
if (!sb) return { scroll: null, ready: false };
|
||||
const vp = sb.viewport;
|
||||
const top: number = sb.scrollTop ?? 0;
|
||||
const vpH: number = vp?.height ?? 0;
|
||||
// The scrollbar's onChange sets `content.translateY = -scrollTop`, so
|
||||
// the child's cumulative `.y` already includes `-scrollTop`; subtracting
|
||||
// the viewport's stable `.y` and re-adding `scrollTop` recovers the
|
||||
// row's layout-space offset within the content (scroll-independent).
|
||||
const childTop: number = node.y ?? 0;
|
||||
const childH: number = node.height ?? 0;
|
||||
if (!vpH || !childH) return { scroll: null, ready: false };
|
||||
|
||||
const offset = childTop - (vp.y ?? 0) + top;
|
||||
let target = top;
|
||||
if (offset < top) target = offset;
|
||||
else if (offset + childH > top + vpH) target = offset + childH - vpH;
|
||||
const max = Math.max(0, (sb.scrollHeight ?? 0) - vpH);
|
||||
if (target > max) target = max;
|
||||
if (target < 0) target = 0;
|
||||
target = Math.round(target);
|
||||
if (target === Math.round(top)) return { scroll: null, ready: true };
|
||||
return { scroll: target, ready: true };
|
||||
};
|
||||
|
||||
const tryScroll = (retriesLeft: number) => {
|
||||
const { scroll, ready } = compute();
|
||||
if (!ready) {
|
||||
if (retriesLeft > 0)
|
||||
timer = setTimeout(() => tryScroll(retriesLeft - 1), RETRY_MS);
|
||||
return;
|
||||
}
|
||||
if (scroll != null) {
|
||||
const sb = findScrollBox(el);
|
||||
if (sb) sb.scrollTo(scroll);
|
||||
}
|
||||
clearTimer();
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!when()) return;
|
||||
clearTimer();
|
||||
tryScroll(MAX_RETRIES);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearTimer();
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
387
src/index.tsx
387
src/index.tsx
@@ -1,225 +1,238 @@
|
||||
const VERSION = "0.2.0";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
query: string | null;
|
||||
play: string | null;
|
||||
version: boolean;
|
||||
query: string | null;
|
||||
play: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(): CliArgs {
|
||||
const args = process.argv.slice(2);
|
||||
const result: CliArgs = {
|
||||
version: false,
|
||||
query: null,
|
||||
play: null,
|
||||
};
|
||||
const args = process.argv.slice(2);
|
||||
const result: CliArgs = {
|
||||
version: false,
|
||||
query: null,
|
||||
play: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--version" || arg === "-v") {
|
||||
result.version = true;
|
||||
} else if (arg === "--query" || arg === "-q") {
|
||||
result.query = args[i + 1] || "";
|
||||
i++;
|
||||
} else if (arg === "--play" || arg === "-p") {
|
||||
result.play = args[i + 1] || "";
|
||||
i++;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--version" || arg === "-v") {
|
||||
result.version = true;
|
||||
} else if (arg === "--query" || arg === "-q") {
|
||||
result.query = args[i + 1] || "";
|
||||
i++;
|
||||
} else if (arg === "--play" || arg === "-p") {
|
||||
result.play = args[i + 1] || "";
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
const cliArgs = parseArgs();
|
||||
|
||||
if (cliArgs.version) {
|
||||
console.log(`PodTUI version ${VERSION}`);
|
||||
process.exit(0);
|
||||
console.log(`PodTUI version ${VERSION}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => {
|
||||
const feeds = await loadFeedsFromFile();
|
||||
import("./utils/feeds-persistence")
|
||||
.then(async ({ loadFeedsFromFile }) => {
|
||||
const feeds = await loadFeedsFromFile();
|
||||
|
||||
if (cliArgs.query !== null) {
|
||||
const query = cliArgs.query;
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
if (cliArgs.query !== null) {
|
||||
const query = cliArgs.query;
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
const matches = feeds.filter((feed) => {
|
||||
const title = feed.podcast.title.toLowerCase();
|
||||
return title.includes(normalizedQuery);
|
||||
});
|
||||
const matches = feeds.filter((feed) => {
|
||||
const title = feed.podcast.title.toLowerCase();
|
||||
return title.includes(normalizedQuery);
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log(`No shows found matching: ${query}`);
|
||||
if (feeds.length > 0) {
|
||||
console.log("\nAvailable shows:");
|
||||
feeds.slice(0, 5).forEach((feed) => {
|
||||
console.log(` - ${feed.podcast.title}`);
|
||||
});
|
||||
if (feeds.length > 5) {
|
||||
console.log(` ... and ${feeds.length - 5} more`);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
console.log(`No shows found matching: ${query}`);
|
||||
if (feeds.length > 0) {
|
||||
console.log("\nAvailable shows:");
|
||||
feeds.slice(0, 5).forEach((feed) => {
|
||||
console.log(` - ${feed.podcast.title}`);
|
||||
});
|
||||
if (feeds.length > 5) {
|
||||
console.log(` ... and ${feeds.length - 5} more`);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
const feed = matches[0];
|
||||
console.log(`\n${feed.podcast.title}`);
|
||||
if (feed.podcast.description) {
|
||||
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
|
||||
}
|
||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
|
||||
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
const feed = matches[0];
|
||||
console.log(`\n${feed.podcast.title}`);
|
||||
if (feed.podcast.description) {
|
||||
console.log(
|
||||
feed.podcast.description.substring(0, 200) +
|
||||
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
|
||||
);
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
const date =
|
||||
ep.pubDate instanceof Date
|
||||
? ep.pubDate.toLocaleDateString()
|
||||
: String(ep.pubDate);
|
||||
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\nClosest matches for "${query}":`);
|
||||
matches.slice(0, 5).forEach((feed, idx) => {
|
||||
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`\nClosest matches for "${query}":`);
|
||||
matches.slice(0, 5).forEach((feed, idx) => {
|
||||
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cliArgs.play !== null) {
|
||||
const playArg = cliArgs.play;
|
||||
const normalizedArg = playArg.toLowerCase();
|
||||
if (cliArgs.play !== null) {
|
||||
const playArg = cliArgs.play;
|
||||
const normalizedArg = playArg.toLowerCase();
|
||||
|
||||
let feedResult: typeof feeds[0] | null = null;
|
||||
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
||||
let feedResult: (typeof feeds)[0] | null = null;
|
||||
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
|
||||
|
||||
if (normalizedArg === "latest") {
|
||||
let latestFeed: typeof feeds[0] | null = null;
|
||||
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
||||
let latestDate = 0;
|
||||
if (normalizedArg === "latest") {
|
||||
let latestFeed: (typeof feeds)[0] | null = null;
|
||||
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
|
||||
let latestDate = 0;
|
||||
|
||||
for (const feed of feeds) {
|
||||
if (feed.episodes.length > 0) {
|
||||
const ep = feed.episodes[0];
|
||||
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
||||
if (epDate > latestDate) {
|
||||
latestDate = epDate;
|
||||
latestFeed = feed;
|
||||
latestEpisode = ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const feed of feeds) {
|
||||
if (feed.episodes.length > 0) {
|
||||
const ep = feed.episodes[0];
|
||||
const epDate =
|
||||
ep.pubDate instanceof Date
|
||||
? ep.pubDate.getTime()
|
||||
: Number(ep.pubDate);
|
||||
if (epDate > latestDate) {
|
||||
latestDate = epDate;
|
||||
latestFeed = feed;
|
||||
latestEpisode = ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feedResult = latestFeed;
|
||||
episodeResult = latestEpisode;
|
||||
} else {
|
||||
const parts = normalizedArg.split("/");
|
||||
const showQuery = parts[0];
|
||||
const episodeQuery = parts[1];
|
||||
feedResult = latestFeed;
|
||||
episodeResult = latestEpisode;
|
||||
} else {
|
||||
const parts = normalizedArg.split("/");
|
||||
const showQuery = parts[0];
|
||||
const episodeQuery = parts[1];
|
||||
|
||||
const matchingFeeds = feeds.filter((feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(showQuery)
|
||||
);
|
||||
const matchingFeeds = feeds.filter((feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||
);
|
||||
|
||||
if (matchingFeeds.length === 0) {
|
||||
console.log(`No show found matching: ${showQuery}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (matchingFeeds.length === 0) {
|
||||
console.log(`No show found matching: ${showQuery}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const feed = matchingFeeds[0];
|
||||
const feed = matchingFeeds[0];
|
||||
|
||||
if (!episodeQuery) {
|
||||
if (feed.episodes.length > 0) {
|
||||
feedResult = feed;
|
||||
episodeResult = feed.episodes[0];
|
||||
} else {
|
||||
console.log(`No episodes available for: ${feed.podcast.title}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (episodeQuery === "latest") {
|
||||
feedResult = feed;
|
||||
episodeResult = feed.episodes[0];
|
||||
} else {
|
||||
const matchingEpisode = feed.episodes.find((ep) =>
|
||||
ep.title.toLowerCase().includes(episodeQuery)
|
||||
);
|
||||
if (!episodeQuery) {
|
||||
if (feed.episodes.length > 0) {
|
||||
feedResult = feed;
|
||||
episodeResult = feed.episodes[0];
|
||||
} else {
|
||||
console.log(`No episodes available for: ${feed.podcast.title}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (episodeQuery === "latest") {
|
||||
feedResult = feed;
|
||||
episodeResult = feed.episodes[0];
|
||||
} else {
|
||||
const matchingEpisode = feed.episodes.find((ep) =>
|
||||
ep.title.toLowerCase().includes(episodeQuery),
|
||||
);
|
||||
|
||||
if (matchingEpisode) {
|
||||
feedResult = feed;
|
||||
episodeResult = matchingEpisode;
|
||||
} else {
|
||||
console.log(`Episode not found: ${episodeQuery}`);
|
||||
console.log(`Available episodes for ${feed.podcast.title}:`);
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
console.log(` ${idx + 1}. ${ep.title}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchingEpisode) {
|
||||
feedResult = feed;
|
||||
episodeResult = matchingEpisode;
|
||||
} else {
|
||||
console.log(`Episode not found: ${episodeQuery}`);
|
||||
console.log(`Available episodes for ${feed.podcast.title}:`);
|
||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||
console.log(` ${idx + 1}. ${ep.title}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!feedResult || !episodeResult) {
|
||||
console.log("Could not find episode to play");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!feedResult || !episodeResult) {
|
||||
console.log("Could not find episode to play");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nPlaying: ${episodeResult.title}`);
|
||||
console.log(`Show: ${feedResult.podcast.title}`);
|
||||
console.log(`\nPlaying: ${episodeResult.title}`);
|
||||
console.log(`Show: ${feedResult.podcast.title}`);
|
||||
|
||||
try {
|
||||
const { createAudioBackend } = await import("./utils/audio-player");
|
||||
const backend = createAudioBackend();
|
||||
if (episodeResult.audioUrl) {
|
||||
await backend.play(episodeResult.audioUrl);
|
||||
console.log("Playback started (use the UI to control)");
|
||||
} else {
|
||||
console.log("No audio URL available for this episode");
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Playback error:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error("Error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
try {
|
||||
const { createAudioBackend } = await import("./utils/audio-player");
|
||||
const backend = createAudioBackend();
|
||||
if (episodeResult.audioUrl) {
|
||||
await backend.play(episodeResult.audioUrl);
|
||||
console.log("Playback started (use the UI to control)");
|
||||
} else {
|
||||
console.log("No audio URL available for this episode");
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Playback error:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
||||
const { App } = await import("./App");
|
||||
const { ThemeProvider } = await import("./context/ThemeContext");
|
||||
const toast = await import("./ui/toast");
|
||||
const { KeybindProvider } = await import("./context/KeybindContext");
|
||||
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||
const { DialogProvider } = await import("./ui/dialog");
|
||||
const { CommandProvider } = await import("./ui/command");
|
||||
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
||||
const { App } = await import("./App");
|
||||
const { ThemeProvider } = await import("./context/ThemeContext");
|
||||
const toast = await import("./ui/toast");
|
||||
const { KeybindProvider } = await import("./context/KeybindContext");
|
||||
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||
const { DialogProvider } = await import("./ui/dialog");
|
||||
const { CommandProvider } = await import("./ui/command");
|
||||
|
||||
function RendererSetup(props: { children: unknown }) {
|
||||
const renderer = useRenderer();
|
||||
renderer.disableStdoutInterception();
|
||||
return props.children;
|
||||
}
|
||||
function RendererSetup(props: { children: unknown }) {
|
||||
const renderer = useRenderer();
|
||||
renderer.disableStdoutInterception();
|
||||
return props.children;
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<RendererSetup>
|
||||
<toast.ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<NavigationProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<toast.Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</NavigationProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</toast.ToastProvider>
|
||||
</RendererSetup>
|
||||
),
|
||||
{ useThread: false },
|
||||
);
|
||||
});
|
||||
render(
|
||||
() => (
|
||||
<RendererSetup>
|
||||
<toast.ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<NavigationProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<toast.Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</NavigationProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</toast.ToastProvider>
|
||||
</RendererSetup>
|
||||
),
|
||||
{ useThread: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* preview — detail of the hovered item (category summary, or
|
||||
* podcast detail + subscribe action).
|
||||
*
|
||||
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||
* column. Moving through categories at depth 0 updates the store's selected
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
@@ -39,7 +40,6 @@ function DiscoverPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
@@ -160,22 +160,27 @@ function DiscoverPage() {
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.name}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
{(cat, index) => {
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.name}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
);
|
||||
@@ -188,9 +193,10 @@ function DiscoverPage() {
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = () => focusedCatIdx();
|
||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
@@ -206,11 +212,6 @@ function DiscoverPage() {
|
||||
{index() === lf() ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
@@ -229,8 +230,10 @@ function DiscoverPage() {
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = () => focusedPodIdx();
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
@@ -276,7 +279,7 @@ function DiscoverPage() {
|
||||
// ── preview pane ───────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered category
|
||||
// depth 0 preview: shows for the hovered category
|
||||
<Show
|
||||
when={focusedCategory()}
|
||||
fallback={
|
||||
@@ -286,16 +289,35 @@ function DiscoverPage() {
|
||||
}
|
||||
>
|
||||
{(cat) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<box flexDirection="column" gap={0} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{cat().name}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{(cat() as any).description ??
|
||||
`Browse top podcasts in ${cat().name}.`}
|
||||
</text>
|
||||
<Show when={(cat() as any).description}>
|
||||
<text fg={theme.textSecondary}>{(cat() as any).description}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
No shows in this category yet. :refresh
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(pod) => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg={theme.text}>{pod.title}</text>
|
||||
<Show when={pod.author}>
|
||||
<text fg={muted()} paddingLeft={2}>
|
||||
by {pod.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
@@ -347,7 +369,7 @@ function DiscoverPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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
|
||||
* Renders entirely through `<PaneRow>` (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.
|
||||
@@ -35,8 +35,9 @@ 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 { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
@@ -187,8 +188,10 @@ function FeedPage() {
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = () => focusedEpIdx();
|
||||
const ref = useScrollIntoView(() => index() === fi());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
@@ -290,7 +293,7 @@ function FeedPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||
* preview — detail of the hovered item in the current column.
|
||||
*
|
||||
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* 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.
|
||||
*/
|
||||
@@ -31,8 +31,9 @@ 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 { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
@@ -164,6 +165,16 @@ export function MyShowsPage() {
|
||||
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());
|
||||
@@ -205,8 +216,10 @@ export function MyShowsPage() {
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
@@ -243,8 +256,10 @@ export function MyShowsPage() {
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = () => focusedShowIdx();
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
@@ -283,8 +298,10 @@ export function MyShowsPage() {
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = () => focusedEpIdx();
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
@@ -361,7 +378,7 @@ export function MyShowsPage() {
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
@@ -408,7 +425,7 @@ export function MyShowsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* depth 0 (parent) — tab list (muted, read-only).
|
||||
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
||||
*
|
||||
* No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause,
|
||||
* No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
|
||||
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
||||
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
||||
* tab root.
|
||||
@@ -17,7 +17,7 @@ import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const PlayerPaneCount = 1;
|
||||
@@ -116,7 +116,7 @@ export function PlayerPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
parentLabel="Up"
|
||||
|
||||
@@ -38,8 +38,9 @@ import {
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const SearchPaneCount = 1;
|
||||
|
||||
@@ -256,8 +257,10 @@ function SearchPage() {
|
||||
<For each={recents()}>
|
||||
{(query, index) => {
|
||||
const lf = () => focus(0);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
@@ -302,8 +305,10 @@ function SearchPage() {
|
||||
<For each={results()}>
|
||||
{(result, index) => {
|
||||
const fi = () => focusedResultIdx();
|
||||
const ref = useScrollIntoView(() => index() === fi());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
@@ -419,7 +424,7 @@ function SearchPage() {
|
||||
: `Results · ${results().length}`;
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
|
||||
124
src/pages/Settings/DownloadManager.tsx
Normal file
124
src/pages/Settings/DownloadManager.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import type { DownloadedEpisode } from "@/types/episode";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
/** Format a byte count as a compact human string. */
|
||||
function fmtBytes(n: number): string {
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/** Short status badge for an episode download. */
|
||||
function statusLabel(s: DownloadStatus): string {
|
||||
switch (s) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "queued";
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return "downloading";
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "done";
|
||||
case DownloadStatus.FAILED:
|
||||
return "failed";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Episode title for a download, resolved from the feed store (reactive). */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Show title for a download's feed id. */
|
||||
function feedTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
feedId: string,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(feedId);
|
||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
||||
}
|
||||
|
||||
export function useDownloadItems(): SettingItem[] {
|
||||
const downloadStore = useDownloadStore();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
const downloads = () => downloadStore.getAllDownloads();
|
||||
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
id: "clear-all",
|
||||
label: "Delete All Downloads",
|
||||
kind: "action",
|
||||
display: () => `${downloads().length} files`,
|
||||
help: () =>
|
||||
`Delete every downloaded episode (files + metadata) and clear the\nqueue. Enter to run.`,
|
||||
run: () => {
|
||||
for (const d of downloads()) {
|
||||
downloadStore.cancelDownload(d.episodeId);
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Group downloads by feed so each show gets a delete-by-show item.
|
||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||
for (const d of downloads()) {
|
||||
const arr = byFeed.get(d.feedId) ?? [];
|
||||
arr.push(d);
|
||||
byFeed.set(d.feedId, arr);
|
||||
}
|
||||
for (const [feedId, eps] of byFeed) {
|
||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||
items.push({
|
||||
id: `feed:${feedId}`,
|
||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
||||
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(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One item per individual episode download.
|
||||
for (const d of downloads()) {
|
||||
items.push({
|
||||
id: `ep:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* depth 1 — the focused section's items as a navigable list
|
||||
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
||||
*
|
||||
* Renders entirely through `<YaziPaneRow>` (parent | current | preview):
|
||||
* Renders entirely through `<PaneRow>` (parent | current | preview):
|
||||
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
||||
* blank placeholder at depth 0 (1/7 slot kept).
|
||||
* current = the current-depth list (or editor at depth 2); the only
|
||||
@@ -33,8 +33,10 @@ import { usePreferencesItems } from "./PreferencesPanel";
|
||||
import { useVisualizerItems } from "./VisualizerSettings";
|
||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||
import { useSourceItems } from "./SourceManager";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { useDownloadItems } from "./DownloadManager";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
|
||||
export const SettingsPaneCount = 1;
|
||||
|
||||
@@ -64,6 +66,11 @@ const SECTIONS: SettingsSectionDef[] = [
|
||||
label: "Account",
|
||||
description: "Account login & OAuth (not yet implemented).",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: "Downloads",
|
||||
description: "Manage downloaded episodes — delete by show or individually.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolve the items for a section id at render time. Section 4 (Account) has
|
||||
@@ -78,6 +85,8 @@ function sectionItems(sectionId: number): SettingItem[] {
|
||||
return usePreferencesItems();
|
||||
case 3:
|
||||
return useVisualizerItems();
|
||||
case 5:
|
||||
return useDownloadItems();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -377,7 +386,7 @@ export function SettingsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
<PaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
@@ -422,8 +431,10 @@ function Row(props: {
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () => (props.focused && props.active ? theme.surface : theme.text);
|
||||
const ref = useScrollIntoView(() => props.focused);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
|
||||
@@ -6,355 +6,379 @@
|
||||
* download queue (max 2 concurrent).
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { DownloadStatus } from "../types/episode"
|
||||
import type { DownloadedEpisode } from "../types/episode"
|
||||
import type { Episode } from "../types/episode"
|
||||
import { downloadEpisode } from "../utils/episode-downloader"
|
||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir"
|
||||
import { backupConfigFile } from "../utils/config-backup"
|
||||
import { createSignal } from "solid-js";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
import type { DownloadedEpisode } from "../types/episode";
|
||||
import type { Episode } from "../types/episode";
|
||||
import { downloadEpisode } from "../utils/episode-downloader";
|
||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||
import { backupConfigFile } from "../utils/config-backup";
|
||||
|
||||
const DOWNLOADS_FILE = "downloads.json"
|
||||
const MAX_CONCURRENT = 2
|
||||
const DOWNLOADS_FILE = "downloads.json";
|
||||
const MAX_CONCURRENT = 2;
|
||||
|
||||
/** Serializable download record for persistence */
|
||||
interface DownloadRecord {
|
||||
episodeId: string
|
||||
feedId: string
|
||||
status: DownloadStatus
|
||||
filePath: string | null
|
||||
downloadedAt: string | null
|
||||
fileSize: number
|
||||
error: string | null
|
||||
audioUrl: string
|
||||
episodeTitle: string
|
||||
episodeId: string;
|
||||
feedId: string;
|
||||
status: DownloadStatus;
|
||||
filePath: string | null;
|
||||
downloadedAt: string | null;
|
||||
fileSize: number;
|
||||
error: string | null;
|
||||
audioUrl: string;
|
||||
episodeTitle: string;
|
||||
}
|
||||
|
||||
/** Queue item for pending downloads */
|
||||
interface QueueItem {
|
||||
episodeId: string
|
||||
feedId: string
|
||||
audioUrl: string
|
||||
episodeTitle: string
|
||||
episodeId: string;
|
||||
feedId: string;
|
||||
audioUrl: string;
|
||||
episodeTitle: string;
|
||||
}
|
||||
|
||||
/** Create download store */
|
||||
export function createDownloadStore() {
|
||||
const [downloads, setDownloads] = createSignal<Map<string, DownloadedEpisode>>(new Map())
|
||||
const [queue, setQueue] = createSignal<QueueItem[]>([])
|
||||
const [activeCount, setActiveCount] = createSignal(0)
|
||||
const [downloads, setDownloads] = createSignal<
|
||||
Map<string, DownloadedEpisode>
|
||||
>(new Map());
|
||||
const [queue, setQueue] = createSignal<QueueItem[]>([]);
|
||||
const [activeCount, setActiveCount] = createSignal(0);
|
||||
|
||||
/** Active AbortControllers keyed by episodeId */
|
||||
const abortControllers = new Map<string, AbortController>()
|
||||
/** Active AbortControllers keyed by episodeId */
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
// Load persisted downloads on init
|
||||
;(async () => {
|
||||
const loaded = await loadDownloads()
|
||||
if (loaded.size > 0) setDownloads(loaded)
|
||||
// Resume any queued downloads from previous session
|
||||
resumeIncomplete()
|
||||
})()
|
||||
// Load persisted downloads on init
|
||||
(async () => {
|
||||
const loaded = await loadDownloads();
|
||||
if (loaded.size > 0) setDownloads(loaded);
|
||||
// Resume any queued downloads from previous session
|
||||
resumeIncomplete();
|
||||
})();
|
||||
|
||||
/** Load downloads from JSON file */
|
||||
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
||||
const file = Bun.file(filePath)
|
||||
if (!(await file.exists())) return new Map()
|
||||
/** Load downloads from JSON file */
|
||||
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return new Map();
|
||||
|
||||
const raw: DownloadRecord[] = await file.json()
|
||||
if (!Array.isArray(raw)) return new Map()
|
||||
const raw: DownloadRecord[] = await file.json();
|
||||
if (!Array.isArray(raw)) return new Map();
|
||||
|
||||
const map = new Map<string, DownloadedEpisode>()
|
||||
for (const rec of raw) {
|
||||
map.set(rec.episodeId, {
|
||||
episodeId: rec.episodeId,
|
||||
feedId: rec.feedId,
|
||||
status: rec.status === DownloadStatus.DOWNLOADING ? DownloadStatus.QUEUED : rec.status,
|
||||
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
||||
filePath: rec.filePath,
|
||||
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
||||
speed: 0,
|
||||
fileSize: rec.fileSize,
|
||||
error: rec.error,
|
||||
})
|
||||
}
|
||||
return map
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
const map = new Map<string, DownloadedEpisode>();
|
||||
for (const rec of raw) {
|
||||
map.set(rec.episodeId, {
|
||||
episodeId: rec.episodeId,
|
||||
feedId: rec.feedId,
|
||||
status:
|
||||
rec.status === DownloadStatus.DOWNLOADING
|
||||
? DownloadStatus.QUEUED
|
||||
: rec.status,
|
||||
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
||||
filePath: rec.filePath,
|
||||
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
||||
speed: 0,
|
||||
fileSize: rec.fileSize,
|
||||
error: rec.error,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist downloads to JSON file */
|
||||
async function saveDownloads(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir()
|
||||
await backupConfigFile(DOWNLOADS_FILE)
|
||||
const map = downloads()
|
||||
const records: DownloadRecord[] = []
|
||||
for (const [, dl] of map) {
|
||||
// Find the audioUrl from queue or use empty string
|
||||
const qItem = queue().find((q) => q.episodeId === dl.episodeId)
|
||||
records.push({
|
||||
episodeId: dl.episodeId,
|
||||
feedId: dl.feedId,
|
||||
status: dl.status,
|
||||
filePath: dl.filePath,
|
||||
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||
fileSize: dl.fileSize,
|
||||
error: dl.error,
|
||||
audioUrl: qItem?.audioUrl ?? "",
|
||||
episodeTitle: qItem?.episodeTitle ?? "",
|
||||
})
|
||||
}
|
||||
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
||||
await Bun.write(filePath, JSON.stringify(records, null, 2))
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
}
|
||||
/** Persist downloads to JSON file */
|
||||
async function saveDownloads(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
await backupConfigFile(DOWNLOADS_FILE);
|
||||
const map = downloads();
|
||||
const records: DownloadRecord[] = [];
|
||||
for (const [, dl] of map) {
|
||||
// Find the audioUrl from queue or use empty string
|
||||
const qItem = queue().find((q) => q.episodeId === dl.episodeId);
|
||||
records.push({
|
||||
episodeId: dl.episodeId,
|
||||
feedId: dl.feedId,
|
||||
status: dl.status,
|
||||
filePath: dl.filePath,
|
||||
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||
fileSize: dl.fileSize,
|
||||
error: dl.error,
|
||||
audioUrl: qItem?.audioUrl ?? "",
|
||||
episodeTitle: qItem?.episodeTitle ?? "",
|
||||
});
|
||||
}
|
||||
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(records, null, 2));
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume incomplete downloads from a previous session */
|
||||
function resumeIncomplete(): void {
|
||||
const map = downloads()
|
||||
for (const [, dl] of map) {
|
||||
if (dl.status === DownloadStatus.QUEUED) {
|
||||
// Re-queue — but we lack audioUrl from persistence alone.
|
||||
// These will sit as QUEUED until the user re-triggers them.
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Resume incomplete downloads from a previous session */
|
||||
function resumeIncomplete(): void {
|
||||
const map = downloads();
|
||||
for (const [, dl] of map) {
|
||||
if (dl.status === DownloadStatus.QUEUED) {
|
||||
// Re-queue — but we lack audioUrl from persistence alone.
|
||||
// These will sit as QUEUED until the user re-triggers them.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a single download entry and trigger reactivity */
|
||||
function updateDownload(episodeId: string, updates: Partial<DownloadedEpisode>): void {
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(episodeId)
|
||||
if (existing) {
|
||||
next.set(episodeId, { ...existing, ...updates })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
/** Update a single download entry and trigger reactivity */
|
||||
function updateDownload(
|
||||
episodeId: string,
|
||||
updates: Partial<DownloadedEpisode>,
|
||||
): void {
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(episodeId);
|
||||
if (existing) {
|
||||
next.set(episodeId, { ...existing, ...updates });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
|
||||
function processQueue(): void {
|
||||
const current = activeCount()
|
||||
const q = queue()
|
||||
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
|
||||
function processQueue(): void {
|
||||
const current = activeCount();
|
||||
const q = queue();
|
||||
|
||||
if (current >= MAX_CONCURRENT || q.length === 0) return
|
||||
if (current >= MAX_CONCURRENT || q.length === 0) return;
|
||||
|
||||
const slotsAvailable = MAX_CONCURRENT - current
|
||||
const toStart = q.slice(0, slotsAvailable)
|
||||
const slotsAvailable = MAX_CONCURRENT - current;
|
||||
const toStart = q.slice(0, slotsAvailable);
|
||||
|
||||
// Remove started items from queue
|
||||
if (toStart.length > 0) {
|
||||
setQueue((prev) => prev.slice(toStart.length))
|
||||
}
|
||||
// Remove started items from queue
|
||||
if (toStart.length > 0) {
|
||||
setQueue((prev) => prev.slice(toStart.length));
|
||||
}
|
||||
|
||||
for (const item of toStart) {
|
||||
executeDownload(item)
|
||||
}
|
||||
}
|
||||
for (const item of toStart) {
|
||||
executeDownload(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute a single download */
|
||||
async function executeDownload(item: QueueItem): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
abortControllers.set(item.episodeId, controller)
|
||||
setActiveCount((c) => c + 1)
|
||||
/** Execute a single download */
|
||||
async function executeDownload(item: QueueItem): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
abortControllers.set(item.episodeId, controller);
|
||||
setActiveCount((c) => c + 1);
|
||||
|
||||
updateDownload(item.episodeId, {
|
||||
status: DownloadStatus.DOWNLOADING,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
error: null,
|
||||
})
|
||||
updateDownload(item.episodeId, {
|
||||
status: DownloadStatus.DOWNLOADING,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await downloadEpisode(
|
||||
item.audioUrl,
|
||||
item.episodeTitle,
|
||||
item.feedId,
|
||||
(progress) => {
|
||||
updateDownload(item.episodeId, {
|
||||
progress: progress.percent >= 0 ? progress.percent : 0,
|
||||
speed: progress.speed,
|
||||
fileSize: progress.totalBytes,
|
||||
})
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
const result = await downloadEpisode(
|
||||
item.audioUrl,
|
||||
item.episodeTitle,
|
||||
item.feedId,
|
||||
(progress) => {
|
||||
updateDownload(item.episodeId, {
|
||||
progress: progress.percent >= 0 ? progress.percent : 0,
|
||||
speed: progress.speed,
|
||||
fileSize: progress.totalBytes,
|
||||
});
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
abortControllers.delete(item.episodeId)
|
||||
setActiveCount((c) => Math.max(0, c - 1))
|
||||
abortControllers.delete(item.episodeId);
|
||||
setActiveCount((c) => Math.max(0, c - 1));
|
||||
|
||||
if (result.success) {
|
||||
updateDownload(item.episodeId, {
|
||||
status: DownloadStatus.COMPLETED,
|
||||
progress: 100,
|
||||
filePath: result.filePath,
|
||||
fileSize: result.fileSize,
|
||||
downloadedAt: new Date(),
|
||||
speed: 0,
|
||||
error: null,
|
||||
})
|
||||
} else {
|
||||
updateDownload(item.episodeId, {
|
||||
status: DownloadStatus.FAILED,
|
||||
speed: 0,
|
||||
error: result.error ?? "Unknown error",
|
||||
})
|
||||
}
|
||||
if (result.success) {
|
||||
updateDownload(item.episodeId, {
|
||||
status: DownloadStatus.COMPLETED,
|
||||
progress: 100,
|
||||
filePath: result.filePath,
|
||||
fileSize: result.fileSize,
|
||||
downloadedAt: new Date(),
|
||||
speed: 0,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
updateDownload(item.episodeId, {
|
||||
status: DownloadStatus.FAILED,
|
||||
speed: 0,
|
||||
error: result.error ?? "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
saveDownloads().catch(() => {})
|
||||
// Process next items in queue
|
||||
processQueue()
|
||||
}
|
||||
saveDownloads().catch(() => {});
|
||||
// Process next items in queue
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/** Get download status for an episode */
|
||||
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE
|
||||
}
|
||||
/** Get download status for an episode */
|
||||
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
||||
};
|
||||
|
||||
/** Get download progress for an episode (0-100) */
|
||||
const getDownloadProgress = (episodeId: string): number => {
|
||||
return downloads().get(episodeId)?.progress ?? 0
|
||||
}
|
||||
/** Get download progress for an episode (0-100) */
|
||||
const getDownloadProgress = (episodeId: string): number => {
|
||||
return downloads().get(episodeId)?.progress ?? 0;
|
||||
};
|
||||
|
||||
/** Get full download info for an episode */
|
||||
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||
return downloads().get(episodeId)
|
||||
}
|
||||
/** Get full download info for an episode */
|
||||
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||
return downloads().get(episodeId);
|
||||
};
|
||||
|
||||
/** Get the local file path for a completed download */
|
||||
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||
const dl = downloads().get(episodeId)
|
||||
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||
return dl.filePath
|
||||
}
|
||||
return null
|
||||
}
|
||||
/** Get the local file path for a completed download */
|
||||
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||
const dl = downloads().get(episodeId);
|
||||
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||
return dl.filePath;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Start downloading an episode */
|
||||
const startDownload = (episode: Episode, feedId: string): void => {
|
||||
const existing = downloads().get(episode.id)
|
||||
if (existing?.status === DownloadStatus.DOWNLOADING || existing?.status === DownloadStatus.QUEUED) {
|
||||
return // Already downloading or queued
|
||||
}
|
||||
/** Start downloading an episode */
|
||||
const startDownload = (episode: Episode, feedId: string): void => {
|
||||
const existing = downloads().get(episode.id);
|
||||
if (
|
||||
existing?.status === DownloadStatus.DOWNLOADING ||
|
||||
existing?.status === DownloadStatus.QUEUED
|
||||
) {
|
||||
return; // Already downloading or queued
|
||||
}
|
||||
|
||||
// Create download entry
|
||||
const entry: DownloadedEpisode = {
|
||||
episodeId: episode.id,
|
||||
feedId,
|
||||
status: DownloadStatus.QUEUED,
|
||||
progress: 0,
|
||||
filePath: null,
|
||||
downloadedAt: null,
|
||||
speed: 0,
|
||||
fileSize: episode.fileSize ?? 0,
|
||||
error: null,
|
||||
}
|
||||
// Create download entry
|
||||
const entry: DownloadedEpisode = {
|
||||
episodeId: episode.id,
|
||||
feedId,
|
||||
status: DownloadStatus.QUEUED,
|
||||
progress: 0,
|
||||
filePath: null,
|
||||
downloadedAt: null,
|
||||
speed: 0,
|
||||
fileSize: episode.fileSize ?? 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(episode.id, entry)
|
||||
return next
|
||||
})
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(episode.id, entry);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Add to queue
|
||||
const queueItem: QueueItem = {
|
||||
episodeId: episode.id,
|
||||
feedId,
|
||||
audioUrl: episode.audioUrl,
|
||||
episodeTitle: episode.title,
|
||||
}
|
||||
setQueue((prev) => [...prev, queueItem])
|
||||
// Add to queue
|
||||
const queueItem: QueueItem = {
|
||||
episodeId: episode.id,
|
||||
feedId,
|
||||
audioUrl: episode.audioUrl,
|
||||
episodeTitle: episode.title,
|
||||
};
|
||||
setQueue((prev) => [...prev, queueItem]);
|
||||
|
||||
saveDownloads().catch(() => {})
|
||||
processQueue()
|
||||
}
|
||||
saveDownloads().catch(() => {});
|
||||
processQueue();
|
||||
};
|
||||
|
||||
/** Cancel a download */
|
||||
const cancelDownload = (episodeId: string): void => {
|
||||
// Abort active download
|
||||
const controller = abortControllers.get(episodeId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
abortControllers.delete(episodeId)
|
||||
}
|
||||
/** Cancel a download */
|
||||
const cancelDownload = (episodeId: string): void => {
|
||||
// Abort active download
|
||||
const controller = abortControllers.get(episodeId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllers.delete(episodeId);
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
|
||||
// Remove from queue
|
||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||
|
||||
// Update status
|
||||
updateDownload(episodeId, {
|
||||
status: DownloadStatus.NONE,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
error: null,
|
||||
})
|
||||
// Update status
|
||||
updateDownload(episodeId, {
|
||||
status: DownloadStatus.NONE,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
saveDownloads().catch(() => {})
|
||||
}
|
||||
saveDownloads().catch(() => {});
|
||||
};
|
||||
|
||||
/** Remove a completed download (delete file and metadata) */
|
||||
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||
const dl = downloads().get(episodeId)
|
||||
if (dl?.filePath) {
|
||||
try {
|
||||
const { unlink } = await import("fs/promises")
|
||||
await unlink(dl.filePath)
|
||||
} catch {
|
||||
// File may already be gone
|
||||
}
|
||||
}
|
||||
/** Remove a completed download (delete file and metadata) */
|
||||
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||
const dl = downloads().get(episodeId);
|
||||
if (dl?.filePath) {
|
||||
try {
|
||||
const { unlink } = await import("fs/promises");
|
||||
await unlink(dl.filePath);
|
||||
} catch {
|
||||
// File may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(episodeId)
|
||||
return next
|
||||
})
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(episodeId);
|
||||
return next;
|
||||
});
|
||||
|
||||
saveDownloads().catch(() => {})
|
||||
}
|
||||
saveDownloads().catch(() => {});
|
||||
};
|
||||
|
||||
/** Get all downloads as an array */
|
||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||
return Array.from(downloads().values())
|
||||
}
|
||||
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
||||
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
|
||||
const eps = Array.from(downloads().values()).filter(
|
||||
(d) => d.feedId === feedId,
|
||||
);
|
||||
for (const d of eps) {
|
||||
cancelDownload(d.episodeId);
|
||||
await removeDownload(d.episodeId);
|
||||
}
|
||||
};
|
||||
|
||||
/** Get the current queue */
|
||||
const getQueue = (): QueueItem[] => {
|
||||
return queue()
|
||||
}
|
||||
/** Get all downloads as an array */
|
||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||
return Array.from(downloads().values());
|
||||
};
|
||||
|
||||
/** Get count of active downloads */
|
||||
const getActiveCount = (): number => {
|
||||
return activeCount()
|
||||
}
|
||||
/** Get the current queue */
|
||||
const getQueue = (): QueueItem[] => {
|
||||
return queue();
|
||||
};
|
||||
|
||||
return {
|
||||
// Getters
|
||||
getDownloadStatus,
|
||||
getDownloadProgress,
|
||||
getDownload,
|
||||
getDownloadedFilePath,
|
||||
getAllDownloads,
|
||||
getQueue,
|
||||
getActiveCount,
|
||||
/** Get count of active downloads */
|
||||
const getActiveCount = (): number => {
|
||||
return activeCount();
|
||||
};
|
||||
|
||||
// Actions
|
||||
startDownload,
|
||||
cancelDownload,
|
||||
removeDownload,
|
||||
}
|
||||
return {
|
||||
// Getters
|
||||
getDownloadStatus,
|
||||
getDownloadProgress,
|
||||
getDownload,
|
||||
getDownloadedFilePath,
|
||||
getAllDownloads,
|
||||
getQueue,
|
||||
getActiveCount,
|
||||
|
||||
// Actions
|
||||
startDownload,
|
||||
cancelDownload,
|
||||
removeDownload,
|
||||
removeDownloadsForFeed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton download store */
|
||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null
|
||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
||||
|
||||
export function useDownloadStore() {
|
||||
if (!downloadStoreInstance) {
|
||||
downloadStoreInstance = createDownloadStore()
|
||||
}
|
||||
return downloadStoreInstance
|
||||
if (!downloadStoreInstance) {
|
||||
downloadStoreInstance = createDownloadStore();
|
||||
}
|
||||
return downloadStoreInstance;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
"sort",
|
||||
"toggle-hidden",
|
||||
"refresh",
|
||||
"unsubscribe",
|
||||
]);
|
||||
|
||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||
|
||||
@@ -63,6 +63,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||
sort: [","],
|
||||
"toggle-hidden": ["."],
|
||||
refresh: ["r"],
|
||||
unsubscribe: ["x"],
|
||||
// audio transport (preserved; shifted single keys, no collisions)
|
||||
"audio-toggle": ["P"],
|
||||
"audio-next": ["N"],
|
||||
|
||||
Reference in New Issue
Block a user