cleaning up code
This commit is contained in:
@@ -18,21 +18,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/uuid": "^11.0.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
|
||||||
"@opentui/core": "^0.1.77",
|
"@opentui/core": "^0.1.77",
|
||||||
"@opentui/solid": "^0.1.77",
|
"@opentui/solid": "^0.1.77",
|
||||||
"babel-preset-solid": "1.9.9",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"solid-js": "^1.9.9",
|
"solid-js": "^1.9.9"
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"zustand": "^5.0.11"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
|
|||||||
} else if (a === "--from") {
|
} else if (a === "--from") {
|
||||||
flags.from = rest[++i];
|
flags.from = rest[++i];
|
||||||
} else {
|
} else {
|
||||||
flags[a.slice(2)] = rest[++i] ?? true;
|
throw new Error(`unknown flag: ${a}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
positional.push(a);
|
positional.push(a);
|
||||||
@@ -222,53 +222,69 @@ function parseMods(positional: string[]): Mod[] {
|
|||||||
return mods;
|
return mods;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAction(cmd: string, positional: string[]): Action | null {
|
// Per-command builders. Leading positional tokens that name a modifier
|
||||||
|
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
|
||||||
|
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
|
||||||
const mods = parseMods(positional);
|
const mods = parseMods(positional);
|
||||||
const first = positional[0];
|
return mods.length ? mods : undefined;
|
||||||
switch (cmd) {
|
};
|
||||||
case "key":
|
|
||||||
if (!first) throw new Error("key requires a <key> argument");
|
const BUILDERS: Record<string, (positional: string[]) => Action> = {
|
||||||
return { t: "key", k: first, mods: mods.length ? mods : undefined };
|
key: (p) => {
|
||||||
case "arrow":
|
if (!p[0]) throw new Error("key requires a <key> argument");
|
||||||
if (!first || !["up", "down", "left", "right"].includes(first))
|
return { t: "key", k: p[0], mods: modsOrUndefined(p) };
|
||||||
|
},
|
||||||
|
arrow: (p) => {
|
||||||
|
if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
|
||||||
throw new Error("arrow requires up|down|left|right");
|
throw new Error("arrow requires up|down|left|right");
|
||||||
return {
|
return {
|
||||||
t: "arrow",
|
t: "arrow",
|
||||||
d: first as any,
|
d: p[0] as "up" | "down" | "left" | "right",
|
||||||
mods: mods.length ? mods : undefined,
|
mods: modsOrUndefined(p),
|
||||||
};
|
};
|
||||||
case "enter":
|
},
|
||||||
case "escape":
|
enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
|
||||||
case "tab":
|
escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
|
||||||
case "space":
|
tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
|
||||||
case "backspace":
|
space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
|
||||||
return { t: cmd, mods: mods.length ? mods : undefined };
|
backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
|
||||||
case "type":
|
type: (p) => {
|
||||||
if (first === undefined) throw new Error("type requires <text>");
|
if (p[0] === undefined) throw new Error("type requires <text>");
|
||||||
// Re-join the rest in case text had spaces; positional[0] already is first token,
|
// Re-join the rest in case text had spaces; p[0] already is first token,
|
||||||
// caller should quote. We join all positional as the text.
|
// caller should quote. We join all positional as the text.
|
||||||
return { t: "type", s: positional.join(" ") };
|
return { t: "type", s: p.join(" ") };
|
||||||
case "wait":
|
},
|
||||||
if (!first) throw new Error("wait requires <ms>");
|
wait: (p) => {
|
||||||
return { t: "wait", ms: parseInt(first, 10) || 0 };
|
if (!p[0]) throw new Error("wait requires <ms>");
|
||||||
case "resize":
|
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
|
||||||
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
|
},
|
||||||
|
resize: (p) => {
|
||||||
|
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
|
||||||
return {
|
return {
|
||||||
t: "resize",
|
t: "resize",
|
||||||
w: parseInt(first, 10) || 100,
|
w: parseInt(p[0], 10) || 100,
|
||||||
h: parseInt(positional[1], 10) || 30,
|
h: parseInt(p[1], 10) || 30,
|
||||||
};
|
};
|
||||||
case "frame":
|
},
|
||||||
case "state":
|
};
|
||||||
case "reset":
|
|
||||||
case "actions":
|
function buildAction(cmd: string, positional: string[]): Action | null {
|
||||||
case "init":
|
const builder = BUILDERS[cmd];
|
||||||
case "seed":
|
if (builder) return builder(positional);
|
||||||
|
// Local-only commands return early in main before this is reached; keep
|
||||||
|
// the null contract so the public behavior is unchanged.
|
||||||
|
if (
|
||||||
|
cmd === "frame" ||
|
||||||
|
cmd === "state" ||
|
||||||
|
cmd === "reset" ||
|
||||||
|
cmd === "actions" ||
|
||||||
|
cmd === "init" ||
|
||||||
|
cmd === "seed"
|
||||||
|
)
|
||||||
return null;
|
return null;
|
||||||
default:
|
// Single table-miss error for any unknown command.
|
||||||
throw new Error(`unknown command: ${cmd}`);
|
throw new Error(`unknown command: ${cmd}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Execute one action against a mounted setup ──────────────────────────────
|
// ── Execute one action against a mounted setup ──────────────────────────────
|
||||||
function fmtMods(mods?: Mod[]): Record<string, boolean> | undefined {
|
function fmtMods(mods?: Mod[]): Record<string, boolean> | undefined {
|
||||||
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
|
|||||||
await new Promise((r) => setTimeout(r, 40));
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────────────
|
// ── Mount, snapshot & output (extracted from main) ─────────────────────────
|
||||||
async function main() {
|
// A line is "visually empty" if it's either fully blank OR contains only
|
||||||
activateSandbox();
|
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
||||||
captureIssues();
|
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
||||||
|
// 24-row pane costs 1 line, not 18.
|
||||||
|
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
||||||
|
const isVisuallyEmpty = (l: string): boolean =>
|
||||||
|
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
function trimFrame(plainFrame: string): string {
|
||||||
const cmd = argv[0] ?? "frame";
|
const lines = plainFrame
|
||||||
const { flags, positional } = parseFlags(argv.slice(1));
|
.replace(/\n+$/, "")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.replace(/\s+$/, ""));
|
||||||
|
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
||||||
|
lines.pop();
|
||||||
|
const out: string[] = [];
|
||||||
|
let blank = 0;
|
||||||
|
const flushBlanks = () => {
|
||||||
|
if (blank >= 3) out.push(` …${blank} empty`);
|
||||||
|
else for (let i = 0; i < blank; i++) out.push("");
|
||||||
|
blank = 0;
|
||||||
|
};
|
||||||
|
for (const l of lines) {
|
||||||
|
if (isVisuallyEmpty(l)) {
|
||||||
|
blank++;
|
||||||
|
} else {
|
||||||
|
flushBlanks();
|
||||||
|
out.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBlanks();
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// Local-only commands that don't mount.
|
// Local-only commands that don't mount. Returns true if handled (main returns).
|
||||||
|
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
|
||||||
if (cmd === "reset") {
|
if (cmd === "reset") {
|
||||||
saveActions([]);
|
saveActions([]);
|
||||||
console.log("✔ actions log cleared.");
|
console.log("✔ actions log cleared.");
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd === "actions") {
|
if (cmd === "actions") {
|
||||||
const a = loadActions();
|
const a = loadActions();
|
||||||
console.log(`Action log (${a.length}):`);
|
console.log(`Action log (${a.length}):`);
|
||||||
console.log(JSON.stringify(a, null, 2));
|
console.log(JSON.stringify(a, null, 2));
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd === "seed") {
|
if (cmd === "seed") {
|
||||||
const from = String(
|
const from = String(
|
||||||
@@ -349,9 +392,29 @@ async function main() {
|
|||||||
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
||||||
cpSync(from, dest, { recursive: true });
|
cpSync(from, dest, { recursive: true });
|
||||||
console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
|
console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
|
||||||
return;
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FrameCapture = {
|
||||||
|
lines: { spans: Span[] }[];
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
cursor: [number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mountApp(
|
||||||
|
flags: Record<string, string | boolean>,
|
||||||
|
cmd: string,
|
||||||
|
positional: string[],
|
||||||
|
): Promise<{
|
||||||
|
setup: any;
|
||||||
|
spans: FrameCapture;
|
||||||
|
plainFrame: string;
|
||||||
|
audioControls: any;
|
||||||
|
actions: Action[];
|
||||||
|
}> {
|
||||||
// Size settings.
|
// Size settings.
|
||||||
let width = 100;
|
let width = 100;
|
||||||
let height = 30;
|
let height = 30;
|
||||||
@@ -448,12 +511,7 @@ async function main() {
|
|||||||
// Final settle + capture.
|
// Final settle + capture.
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
await new Promise((r) => setTimeout(r, 60));
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
const spans = setup.captureSpans() as {
|
const spans = setup.captureSpans() as FrameCapture;
|
||||||
lines: { spans: Span[] }[];
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
cursor: [number, number];
|
|
||||||
};
|
|
||||||
const plainFrame = setup.captureCharFrame();
|
const plainFrame = setup.captureCharFrame();
|
||||||
|
|
||||||
// Dump structured spans + plain frame.
|
// Dump structured spans + plain frame.
|
||||||
@@ -462,6 +520,10 @@ async function main() {
|
|||||||
writeFileSync(FRAME_TXT, plainFrame);
|
writeFileSync(FRAME_TXT, plainFrame);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
return { setup, spans, plainFrame, audioControls, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
|
||||||
// Store state snapshot.
|
// Store state snapshot.
|
||||||
const state: Record<string, unknown> = {};
|
const state: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
@@ -514,54 +576,31 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
writeFileSync(STATE_JSON, JSON.stringify(state));
|
writeFileSync(STATE_JSON, JSON.stringify(state));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Output ──────────────────────────────────────────────────────────────
|
function emitOutput(p: {
|
||||||
|
spans: FrameCapture;
|
||||||
|
plainFrame: string;
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
actions: Action[];
|
||||||
|
cmd: string;
|
||||||
|
flags: Record<string, string | boolean>;
|
||||||
|
positional: string[];
|
||||||
|
}): void {
|
||||||
// Compact by default: trimmed frame, one-line state per section, no styles
|
// Compact by default: trimmed frame, one-line state per section, no styles
|
||||||
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
||||||
const verbose = !!flags.verbose;
|
const verbose = !!p.flags.verbose;
|
||||||
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
|
const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
|
||||||
|
|
||||||
// A line is "visually empty" if it's either fully blank OR contains only
|
|
||||||
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
|
||||||
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
|
||||||
// 24-row pane costs 1 line, not 18.
|
|
||||||
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
|
||||||
const isVisuallyEmpty = (l: string): boolean =>
|
|
||||||
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
|
||||||
const frameTrimmed = (() => {
|
|
||||||
const lines = plainFrame
|
|
||||||
.replace(/\n+$/, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.replace(/\s+$/, ""));
|
|
||||||
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
|
||||||
lines.pop();
|
|
||||||
const out: string[] = [];
|
|
||||||
let blank = 0;
|
|
||||||
const flushBlanks = () => {
|
|
||||||
if (blank >= 3) out.push(` …${blank} empty`);
|
|
||||||
else for (let i = 0; i < blank; i++) out.push("");
|
|
||||||
blank = 0;
|
|
||||||
};
|
|
||||||
for (const l of lines) {
|
|
||||||
if (isVisuallyEmpty(l)) {
|
|
||||||
blank++;
|
|
||||||
} else {
|
|
||||||
flushBlanks();
|
|
||||||
out.push(l);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushBlanks();
|
|
||||||
return out.join("\n");
|
|
||||||
})();
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`,
|
`FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
|
||||||
);
|
);
|
||||||
console.log(frameTrimmed);
|
console.log(trimFrame(p.plainFrame));
|
||||||
|
|
||||||
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
||||||
if (scope === "all" && (flags.styles || verbose)) {
|
if (scope === "all" && (p.flags.styles || verbose)) {
|
||||||
const styles = distinctStyles(spans);
|
const styles = distinctStyles(p.spans);
|
||||||
if (styles.length) {
|
if (styles.length) {
|
||||||
console.log("-- styles (top 20) --");
|
console.log("-- styles (top 20) --");
|
||||||
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
|
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
|
||||||
@@ -572,9 +611,9 @@ async function main() {
|
|||||||
const want = (k: string) => scope === "all" || scope === k;
|
const want = (k: string) => scope === "all" || scope === k;
|
||||||
const compact = (obj: unknown): string =>
|
const compact = (obj: unknown): string =>
|
||||||
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
||||||
if (want("nav")) console.log("nav " + compact(state.nav));
|
if (want("nav")) console.log("nav " + compact(p.state.nav));
|
||||||
if (want("audio")) console.log("audio " + compact(state.audio));
|
if (want("audio")) console.log("audio " + compact(p.state.audio));
|
||||||
if (want("feed")) console.log("feed " + compact(state.feed));
|
if (want("feed")) console.log("feed " + compact(p.state.feed));
|
||||||
if (want("app")) console.log("app (not dumped in v1)");
|
if (want("app")) console.log("app (not dumped in v1)");
|
||||||
|
|
||||||
// ── issues: terse ──
|
// ── issues: terse ──
|
||||||
@@ -586,12 +625,14 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Footer is identical every run — only print on init or --verbose.
|
// Footer is identical every run — only print on init or --verbose.
|
||||||
if (cmd === "init" || verbose) {
|
if (p.cmd === "init" || verbose) {
|
||||||
console.log(
|
console.log(
|
||||||
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teardown(setup: any, audioControls: any): Promise<void> {
|
||||||
// Tear down child processes (audio backend) before exit to avoid orphans.
|
// Tear down child processes (audio backend) before exit to avoid orphans.
|
||||||
try {
|
try {
|
||||||
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
||||||
@@ -606,6 +647,32 @@ async function main() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
activateSandbox();
|
||||||
|
captureIssues();
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const cmd = argv[0] ?? "frame";
|
||||||
|
const { flags, positional } = parseFlags(argv.slice(1));
|
||||||
|
|
||||||
|
// Local-only commands that don't mount.
|
||||||
|
if (runLocal(cmd, flags)) return;
|
||||||
|
|
||||||
|
const m = await mountApp(flags, cmd, positional);
|
||||||
|
const state = await snapshotState(m.audioControls);
|
||||||
|
emitOutput({
|
||||||
|
spans: m.spans,
|
||||||
|
plainFrame: m.plainFrame,
|
||||||
|
state,
|
||||||
|
actions: m.actions,
|
||||||
|
cmd,
|
||||||
|
flags,
|
||||||
|
positional,
|
||||||
|
});
|
||||||
|
await teardown(m.setup, m.audioControls);
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("HARNESS FAILED:", err?.stack || err);
|
console.error("HARNESS FAILED:", err?.stack || err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { Episode } from "../types/episode"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import type { PodcastSource } from "../types/source"
|
|
||||||
import { parseRSSFeed } from "@/api/rss-parser"
|
|
||||||
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
|
|
||||||
|
|
||||||
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(feedUrl)
|
|
||||||
if (!response.ok) return []
|
|
||||||
const xml = await response.text()
|
|
||||||
return parseRSSFeed(xml, feedUrl).episodes
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchFeeds = async (
|
|
||||||
sourceIds: string[],
|
|
||||||
sources: PodcastSource[]
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
const active = sources.filter((source) => sourceIds.includes(source.id))
|
|
||||||
const feeds: Feed[] = []
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
active.map(async (source) => {
|
|
||||||
try {
|
|
||||||
if (source.type === "rss") {
|
|
||||||
const rssFeeds = await handleRSSSource(source)
|
|
||||||
feeds.push(...rssFeeds)
|
|
||||||
} else if (source.type === "api") {
|
|
||||||
const apiFeeds = await handleAPISource(source, "")
|
|
||||||
feeds.push(...apiFeeds)
|
|
||||||
} else {
|
|
||||||
const customFeeds = await handleCustomSource(source, "")
|
|
||||||
feeds.push(...customFeeds)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore individual source errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return feeds
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchPodcasts = async (
|
|
||||||
query: string,
|
|
||||||
sources: PodcastSource[]
|
|
||||||
): Promise<Podcast[]> => {
|
|
||||||
const results: Podcast[] = []
|
|
||||||
await Promise.all(
|
|
||||||
sources.map(async (source) => {
|
|
||||||
try {
|
|
||||||
if (source.type === "rss") {
|
|
||||||
const feeds = await handleRSSSource(source)
|
|
||||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
|
||||||
} else if (source.type === "api") {
|
|
||||||
const feeds = await handleAPISource(source, query)
|
|
||||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
|
||||||
} else {
|
|
||||||
const feeds = await handleCustomSource(source, query)
|
|
||||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { PodcastSource } from "../types/source"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import { parseRSSFeed } from "./rss-parser"
|
|
||||||
|
|
||||||
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
|
|
||||||
return {
|
|
||||||
id: `${sourceId}-${podcast.id}`,
|
|
||||||
podcast,
|
|
||||||
episodes: [],
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isPinned: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
|
|
||||||
if (!source.baseUrl) return []
|
|
||||||
const response = await fetch(source.baseUrl)
|
|
||||||
if (!response.ok) return []
|
|
||||||
const xml = await response.text()
|
|
||||||
const parsed = parseRSSFeed(xml, source.baseUrl)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: `${source.id}-${parsed.feedUrl}`,
|
|
||||||
podcast: {
|
|
||||||
id: parsed.id,
|
|
||||||
title: parsed.title,
|
|
||||||
description: parsed.description,
|
|
||||||
feedUrl: parsed.feedUrl,
|
|
||||||
author: parsed.author,
|
|
||||||
categories: parsed.categories,
|
|
||||||
lastUpdated: parsed.lastUpdated,
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: parsed.episodes,
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId: source.id,
|
|
||||||
lastUpdated: parsed.lastUpdated,
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleAPISource = async (
|
|
||||||
source: PodcastSource,
|
|
||||||
query: string
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
|
|
||||||
url.searchParams.set("term", query || "podcast")
|
|
||||||
url.searchParams.set("media", "podcast")
|
|
||||||
url.searchParams.set("entity", "podcast")
|
|
||||||
url.searchParams.set("country", source.country || "US")
|
|
||||||
url.searchParams.set("lang", source.language || "en_us")
|
|
||||||
|
|
||||||
const response = await fetch(url.toString())
|
|
||||||
if (!response.ok) return []
|
|
||||||
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
|
|
||||||
const results = data.results ?? []
|
|
||||||
|
|
||||||
return results
|
|
||||||
.filter((item) => item.collectionName && item.feedUrl)
|
|
||||||
.map((item) => {
|
|
||||||
const podcast: Podcast = {
|
|
||||||
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
|
|
||||||
title: item.collectionName || "Untitled Podcast",
|
|
||||||
description: item.collectionName || "",
|
|
||||||
feedUrl: item.feedUrl || "",
|
|
||||||
author: item.artistName,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
}
|
|
||||||
return buildFeedFromPodcast(podcast, source.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleCustomSource = async (
|
|
||||||
source: PodcastSource,
|
|
||||||
query: string
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
if (!query) return []
|
|
||||||
const podcast: Podcast = {
|
|
||||||
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
|
|
||||||
title: `${query} Highlights`,
|
|
||||||
description: `Curated results for ${query}`,
|
|
||||||
feedUrl: source.baseUrl || "",
|
|
||||||
author: source.name,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
}
|
|
||||||
return [buildFeedFromPodcast(podcast, source.id)]
|
|
||||||
}
|
|
||||||
@@ -69,16 +69,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
||||||
* We deliberately do NOT use Solid's `children()` helper here: that helper
|
* We deliberately avoid Solid's `children()` helper: it flattens accessor
|
||||||
* flattens accessor children into a stable resolved-nodes array and is the
|
* children into a stable resolved-nodes array and won't re-resolve on a
|
||||||
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
|
* truthy→truthy root swap (e.g. the current pane switching between a
|
||||||
* switching between a depth-1 list fragment and a depth-2 editor — both
|
* depth-1 list fragment and a depth-2 editor), freezing the previous
|
||||||
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
|
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
|
||||||
* root swap, freezing the previous subtree in place. Instead we hand the
|
* expression — a tracked `insert` effect that disposes the old subtree and
|
||||||
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
|
* mounts the new whenever the accessor returns a different element identity. */
|
||||||
* which Solid compiles into a tracked `insert` effect that disposes the old
|
|
||||||
* subtree and mounts the new whenever the accessor returns a different
|
|
||||||
* element identity. */
|
|
||||||
function normalizeContent(
|
function normalizeContent(
|
||||||
v: PaneContent | undefined,
|
v: PaneContent | undefined,
|
||||||
): () => JSX.Element | undefined {
|
): () => JSX.Element | undefined {
|
||||||
@@ -129,19 +126,6 @@ function Pane(props: {
|
|||||||
borderColor={borderColor()}
|
borderColor={borderColor()}
|
||||||
backgroundColor={theme.background}
|
backgroundColor={theme.background}
|
||||||
>
|
>
|
||||||
{/*
|
|
||||||
* Render the content accessor directly via a reactive expression.
|
|
||||||
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
|
|
||||||
* effect that re-runs whenever the accessor's tracked signals
|
|
||||||
* change (e.g. `depth()` swapping the root from a list fragment to
|
|
||||||
* an editor). Solid disposes the previously-rendered subtree and
|
|
||||||
* mounts the new element identity. `null`/`undefined` falls back
|
|
||||||
* to the muted placeholder so the parent pane keeps its 1/7 slot
|
|
||||||
* visibly blank at depth 0. This is the correct tool for root
|
|
||||||
* swapping — unlike Solid's `children()` / `<Show>`-children,
|
|
||||||
* which only react to truthiness flips, not truthy<@->truthy root
|
|
||||||
* identity changes.
|
|
||||||
*/}
|
|
||||||
{props.content() ?? <Placeholder color={muted} />}
|
{props.content() ?? <Placeholder color={muted} />}
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import { useTheme } from "@/context/ThemeContext";
|
|||||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore } from "@/stores/audio-nav";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { emit } from "@/utils/event-bus";
|
import { emit, on } from "@/utils/event-bus";
|
||||||
import { LayerGraph } from "@/utils/layer-graph";
|
import { LayerGraph } from "@/utils/layer-graph";
|
||||||
import { TABS, TabPaneCount } from "@/utils/navigation";
|
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
@@ -48,6 +49,18 @@ export function Shell() {
|
|||||||
|
|
||||||
const [showHelp, setShowHelp] = createSignal(false);
|
const [showHelp, setShowHelp] = createSignal(false);
|
||||||
|
|
||||||
|
// ── Auto jump to Player on podcast start ───────────────────────────────────
|
||||||
|
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
|
||||||
|
// "player.started" — distinct from "player.play", which also fires on
|
||||||
|
// resume), switch to the Player tab and drop into its content pane.
|
||||||
|
on("player.started", () => {
|
||||||
|
const app = useAppStore();
|
||||||
|
if (app.state().preferences.autoJumpToPlayer) {
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
||||||
* within its feed's episode list. Updates audio-nav context accordingly. */
|
* within its feed's episode list. Updates audio-nav context accordingly. */
|
||||||
function advanceEpisode(offset: number) {
|
function advanceEpisode(offset: number) {
|
||||||
@@ -83,74 +96,60 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Command bar dispatch ────────────────────────────────────────────────────
|
// ── Command bar dispatch ────────────────────────────────────────────────────
|
||||||
function runCommand(raw: string) {
|
const COMMANDS: Record<string, (arg: string) => void> = {
|
||||||
const cmd = raw.trim();
|
quit: () => process.exit(0),
|
||||||
if (!cmd) return;
|
exit: () => process.exit(0),
|
||||||
const name = cmd.split(/\s+/)[0].toLowerCase();
|
q: () => process.exit(0),
|
||||||
const arg = cmd.slice(name.length).trim();
|
refresh: () =>
|
||||||
switch (name) {
|
|
||||||
case "q":
|
|
||||||
case "quit":
|
|
||||||
case "exit":
|
|
||||||
return process.exit(0);
|
|
||||||
case "refresh":
|
|
||||||
case "r":
|
|
||||||
emit("nav.action", {
|
emit("nav.action", {
|
||||||
action: "refresh",
|
action: "refresh",
|
||||||
tab: nav.activeTab(),
|
tab: nav.activeTab(),
|
||||||
pane: nav.activePane(),
|
pane: nav.activePane(),
|
||||||
mode: nav.mode(),
|
mode: nav.mode(),
|
||||||
});
|
}),
|
||||||
break;
|
r: () =>
|
||||||
case "play":
|
emit("nav.action", {
|
||||||
case "pause":
|
action: "refresh",
|
||||||
case "p":
|
tab: nav.activeTab(),
|
||||||
audio.togglePlayback().catch(() => {});
|
pane: nav.activePane(),
|
||||||
break;
|
mode: nav.mode(),
|
||||||
case "next":
|
}),
|
||||||
case "n":
|
play: () => audio.togglePlayback().catch(() => {}),
|
||||||
advanceEpisode(1);
|
pause: () => audio.togglePlayback().catch(() => {}),
|
||||||
break;
|
p: () => audio.togglePlayback().catch(() => {}),
|
||||||
case "prev":
|
next: () => advanceEpisode(1),
|
||||||
advanceEpisode(-1);
|
n: () => advanceEpisode(1),
|
||||||
break;
|
prev: () => advanceEpisode(-1),
|
||||||
case "seek": {
|
seek: (arg) => {
|
||||||
const n = Number(arg) || 0;
|
const n = Number(arg) || 0;
|
||||||
audio.seek(n).catch(() => {});
|
audio.seek(n).catch(() => {});
|
||||||
break;
|
},
|
||||||
}
|
feed: () => nav.setActiveTab(TABS.FEED),
|
||||||
case "feed":
|
f: () => nav.setActiveTab(TABS.FEED),
|
||||||
case "f":
|
shows: () => nav.setActiveTab(TABS.MYSHOWS),
|
||||||
nav.setActiveTab(TABS.FEED);
|
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
|
||||||
break;
|
discover: () => nav.setActiveTab(TABS.DISCOVER),
|
||||||
case "shows":
|
d: () => nav.setActiveTab(TABS.DISCOVER),
|
||||||
case "myshows":
|
search: () => nav.setActiveTab(TABS.SEARCH),
|
||||||
nav.setActiveTab(TABS.MYSHOWS);
|
player: () => nav.setActiveTab(TABS.PLAYER),
|
||||||
break;
|
settings: () => nav.setActiveTab(TABS.SETTINGS),
|
||||||
case "discover":
|
set: () => nav.setActiveTab(TABS.SETTINGS),
|
||||||
case "d":
|
help: () => setShowHelp((v) => !v),
|
||||||
nav.setActiveTab(TABS.DISCOVER);
|
h: () => setShowHelp((v) => !v),
|
||||||
break;
|
};
|
||||||
case "search":
|
|
||||||
nav.setActiveTab(TABS.SEARCH);
|
function runCommand(raw: string) {
|
||||||
break;
|
const cmd = raw.trim();
|
||||||
case "player":
|
if (!cmd) return;
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
const name = cmd.split(/\s+/)[0].toLowerCase();
|
||||||
break;
|
const arg = cmd.slice(name.length).trim();
|
||||||
case "settings":
|
const unknownCommand = () => {
|
||||||
case "set":
|
|
||||||
nav.setActiveTab(TABS.SETTINGS);
|
|
||||||
break;
|
|
||||||
case "help":
|
|
||||||
case "h":
|
|
||||||
setShowHelp((v) => !v);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nav.setCommandError(`unknown command: ${name}`);
|
nav.setCommandError(`unknown command: ${name}`);
|
||||||
// re-enter command mode so the user sees the error + can correct
|
// re-enter command mode so the user sees the error + can correct
|
||||||
nav.enterCommand();
|
nav.enterCommand();
|
||||||
nav.setCommandBuffer(cmd);
|
nav.setCommandBuffer(cmd);
|
||||||
}
|
};
|
||||||
|
(COMMANDS[name] ?? unknownCommand)(arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command-mode key handling ───────────────────────────────────────────────
|
// ── Command-mode key handling ───────────────────────────────────────────────
|
||||||
@@ -458,18 +457,5 @@ function k_match_escape(evt: any): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
|
|
||||||
* search) into the player tab. */
|
|
||||||
export function playEpisodeAndSwitch(
|
|
||||||
nav: ReturnType<typeof useNavigation>,
|
|
||||||
audio: ReturnType<typeof useAudio>,
|
|
||||||
episode: import("@/types/episode").Episode,
|
|
||||||
) {
|
|
||||||
audio.play(episode);
|
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
|
||||||
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
|
|
||||||
useAudioNavStore().setSource(AudioSource.FEED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export Episode type for callers building pane trees.
|
// Re-export Episode type for callers building pane trees.
|
||||||
export type { Episode } from "@/types/episode";
|
export type { Episode } from "@/types/episode";
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { For } from "solid-js";
|
|
||||||
import { shortcuts } from "@/config/shortcuts";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
/** Yazi-style keybind reference. The Shell has its own overlay; this component
|
|
||||||
* is kept for embedding inside Settings or other surfaces. */
|
|
||||||
export function ShortcutHelp() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
title="Shortcuts"
|
|
||||||
style={{ flexDirection: "column", padding: 1 }}
|
|
||||||
>
|
|
||||||
<box style={{ flexDirection: "column" }}>
|
|
||||||
<For each={shortcuts}>
|
|
||||||
{(s) => (
|
|
||||||
<box style={{ flexDirection: "row" }} gap={2}>
|
|
||||||
<text fg={theme.accent}>{s.keys}</text>
|
|
||||||
<text fg={theme.text}>{s.action}</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { TABS, TabsCount } from "@/utils/navigation";
|
|
||||||
import { For } from "solid-js";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
|
||||||
|
|
||||||
export const tabs: TabDefinition[] = [
|
|
||||||
{ id: TABS.FEED, label: "Feed" },
|
|
||||||
{ id: TABS.MYSHOWS, label: "My Shows" },
|
|
||||||
{ id: TABS.DISCOVER, label: "Discover" },
|
|
||||||
{ id: TABS.SEARCH, label: "Search" },
|
|
||||||
{ id: TABS.PLAYER, label: "Player" },
|
|
||||||
{ id: TABS.SETTINGS, label: "Settings" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TabNavigation() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const { activeTab, setActiveTab, activeDepth } = useNavigation();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
|
|
||||||
backgroundColor={"transparent"}
|
|
||||||
style={{
|
|
||||||
flexDirection: "column",
|
|
||||||
width: 12,
|
|
||||||
height: TabsCount * 3 + 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={tabs}>
|
|
||||||
{(tab) => (
|
|
||||||
<SelectableBox
|
|
||||||
border
|
|
||||||
height={3}
|
|
||||||
selected={() => tab.id == activeTab()}
|
|
||||||
onMouseDown={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => tab.id == activeTab()}
|
|
||||||
primary
|
|
||||||
alignSelf="center"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</SelectableText>
|
|
||||||
</SelectableBox>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TabDefinition = {
|
|
||||||
id: TABS;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
|
|
||||||
* Shown in help overlays; the canonical source remains keybinds.jsonc.
|
|
||||||
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
|
|
||||||
*/
|
|
||||||
export const shortcuts = [
|
|
||||||
{ keys: "j / k", action: "Move down / up (within pane)" },
|
|
||||||
{ keys: "h / l", action: "Swipe to prev / next pane" },
|
|
||||||
{ keys: "J / K", action: "Jump 5 lines down / up" },
|
|
||||||
{ keys: "ctrl-d / u", action: "Half page down / up" },
|
|
||||||
{ keys: "g g / G", action: "Go to top / bottom of list" },
|
|
||||||
{ keys: "1-6", action: "Go to tab 1-6" },
|
|
||||||
{ keys: "[ / ]", action: "Previous / next tab" },
|
|
||||||
{ keys: "Enter", action: "Open / activate focused item" },
|
|
||||||
{ keys: "Space", action: "Toggle selection on item" },
|
|
||||||
{ keys: "v", action: "Enter visual (range) select mode" },
|
|
||||||
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
|
|
||||||
{ 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" },
|
|
||||||
{ keys: "~ / F1", action: "Help" },
|
|
||||||
{ keys: "q", action: "Quit" },
|
|
||||||
] as const;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export const syncFormats = {
|
|
||||||
json: {
|
|
||||||
version: "1.0",
|
|
||||||
extension: ".json",
|
|
||||||
},
|
|
||||||
xml: {
|
|
||||||
version: "1.0",
|
|
||||||
extension: ".xml",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]
|
|
||||||
@@ -72,20 +72,7 @@ export type KeybindActionName =
|
|||||||
| "audio-next"
|
| "audio-next"
|
||||||
| "audio-prev"
|
| "audio-prev"
|
||||||
| "audio-seek-forward"
|
| "audio-seek-forward"
|
||||||
| "audio-seek-backward"
|
| "audio-seek-backward";
|
||||||
// legacy compat (kept so older callers don't crash)
|
|
||||||
| "select"
|
|
||||||
| "leader"
|
|
||||||
| "inverseModifier"
|
|
||||||
| "cycle"
|
|
||||||
| "dive"
|
|
||||||
| "out"
|
|
||||||
| "up"
|
|
||||||
| "down"
|
|
||||||
| "left"
|
|
||||||
| "right"
|
|
||||||
| "audio-pause"
|
|
||||||
| "audio-play";
|
|
||||||
|
|
||||||
/** Resolved config: action -> list of alternative stroke-sequences. */
|
/** Resolved config: action -> list of alternative stroke-sequences. */
|
||||||
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
||||||
@@ -146,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
||||||
export function strokeFromEvent(evt: {
|
function strokeFromEvent(evt: {
|
||||||
name: string;
|
name: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
@@ -154,7 +141,7 @@ export function strokeFromEvent(evt: {
|
|||||||
}): Stroke {
|
}): Stroke {
|
||||||
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
||||||
return {
|
return {
|
||||||
key: (evt.name ?? "").toLowerCase(),
|
key: evt.name.toLowerCase(),
|
||||||
ctrl: !!evt.ctrl,
|
ctrl: !!evt.ctrl,
|
||||||
shift: !!evt.shift,
|
shift: !!evt.shift,
|
||||||
meta: !!evt.meta,
|
meta: !!evt.meta,
|
||||||
@@ -171,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A human label for a stroke, for the status bar / help. */
|
/** A human label for a stroke, for the status bar / help. */
|
||||||
export function strokeLabel(s: Stroke): string {
|
function strokeLabel(s: Stroke): string {
|
||||||
let out = "";
|
let out = "";
|
||||||
if (s.ctrl) out += "C-";
|
if (s.ctrl) out += "C-";
|
||||||
if (s.meta) out += "M-";
|
if (s.meta) out += "M-";
|
||||||
@@ -180,7 +167,7 @@ export function strokeLabel(s: Stroke): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sequenceLabel(seq: Stroke[]): string {
|
function sequenceLabel(seq: Stroke[]): string {
|
||||||
return seq.map(strokeLabel).join(" ");
|
return seq.map(strokeLabel).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
|
|
||||||
// so it always reports false. Migrated callers should use tryMatch().
|
|
||||||
function isInverting(_evt: {
|
|
||||||
name: string;
|
|
||||||
ctrl?: boolean;
|
|
||||||
meta?: boolean;
|
|
||||||
shift?: boolean;
|
|
||||||
}): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load().catch(() => {});
|
load().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -366,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
pending,
|
pending,
|
||||||
match,
|
match,
|
||||||
tryMatch,
|
tryMatch,
|
||||||
isInverting,
|
|
||||||
print,
|
print,
|
||||||
save,
|
save,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ function startPolling(): void {
|
|||||||
const progressStore = useProgressStore();
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
// Update platform media position
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.setPosition(pos);
|
media.setPosition(pos);
|
||||||
}
|
}
|
||||||
@@ -215,6 +214,9 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
|
|
||||||
startPolling();
|
startPolling();
|
||||||
emit("player.play", { episodeId: episode.id });
|
emit("player.play", { episodeId: episode.id });
|
||||||
|
// Distinct from "player.play" (which also fires on resume): signals a
|
||||||
|
// fresh episode start so Shell can honor the auto-jump-to-player pref.
|
||||||
|
emit("player.started", { episodeId: episode.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Playback failed");
|
setError(err instanceof Error ? err.message : "Playback failed");
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -285,7 +287,6 @@ async function stop(): Promise<void> {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
// Clear platform media controls
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -332,12 +333,8 @@ async function doSetSpeed(spd: number): Promise<void> {
|
|||||||
setSpeed(clamped);
|
setSpeed(clamped);
|
||||||
|
|
||||||
// Sync back to app store
|
// Sync back to app store
|
||||||
try {
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
appStore.updateSettings({ playbackSpeed: clamped });
|
appStore.updateSettings({ playbackSpeed: clamped });
|
||||||
} catch {
|
|
||||||
// Store may not be available
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchBackend(name: BackendName): Promise<void> {
|
async function switchBackend(name: BackendName): Promise<void> {
|
||||||
@@ -347,14 +344,12 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
const vol = volume();
|
const vol = volume();
|
||||||
const spd = speed();
|
const spd = speed();
|
||||||
|
|
||||||
// Stop current backend
|
|
||||||
if (backend) {
|
if (backend) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
backend.dispose();
|
backend.dispose();
|
||||||
backend = null;
|
backend = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new backend
|
|
||||||
backend = createAudioBackend(name);
|
backend = createAudioBackend(name);
|
||||||
setBackendName(backend.name);
|
setBackendName(backend.name);
|
||||||
setAvailablePlayers(detectPlayers());
|
setAvailablePlayers(detectPlayers());
|
||||||
@@ -388,15 +383,11 @@ export function useAudio(): AudioControls {
|
|||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed from app store
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
try {
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
if (storeSpeed && storeSpeed !== speed()) {
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
setSpeed(storeSpeed);
|
setSpeed(storeSpeed);
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Store may not be available yet
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refCount++;
|
refCount++;
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { createSignal, onCleanup } from "solid-js"
|
|
||||||
|
|
||||||
type CacheOptions<T> = {
|
|
||||||
fetcher: () => Promise<T>
|
|
||||||
intervalMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCachedData = <T,>(options: CacheOptions<T>) => {
|
|
||||||
const [data, setData] = createSignal<T | null>(null)
|
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const value = await options.fetcher()
|
|
||||||
setData(() => value)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to load data")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
if (options.intervalMs) {
|
|
||||||
const interval = setInterval(refresh, options.intervalMs)
|
|
||||||
onCleanup(() => clearInterval(interval))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data, loading, error, refresh }
|
|
||||||
}
|
|
||||||
306
src/index.tsx
306
src/index.tsx
@@ -1,3 +1,6 @@
|
|||||||
|
import type { Feed } from "./types/feed"
|
||||||
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.2.1";
|
const VERSION = "0.2.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
@@ -37,160 +40,173 @@ if (cliArgs.version) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Find the most recent episode across all feeds */
|
||||||
|
function findLatestEpisode(
|
||||||
|
feeds: Feed[],
|
||||||
|
): { feed: Feed; episode: Episode } | null {
|
||||||
|
let latest: { feed: Feed; episode: Episode } | null = null
|
||||||
|
let latestDate = 0
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (feed.episodes.length === 0) continue
|
||||||
|
const ep = feed.episodes[0]
|
||||||
|
const epDate =
|
||||||
|
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
|
||||||
|
if (epDate > latestDate) {
|
||||||
|
latestDate = epDate
|
||||||
|
latest = { feed, episode: ep }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search feeds by title and print matching shows */
|
||||||
|
function handleQuery(feeds: Feed[], query: string): void {
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
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 === 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve and play an episode from `arg` (title path or "latest") */
|
||||||
|
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||||
|
const normalizedArg = arg.toLowerCase()
|
||||||
|
|
||||||
|
let feedResult: Feed | null = null
|
||||||
|
let episodeResult: Episode | null = null
|
||||||
|
|
||||||
|
if (normalizedArg === "latest") {
|
||||||
|
const latest = findLatestEpisode(feeds)
|
||||||
|
if (latest) {
|
||||||
|
feedResult = latest.feed
|
||||||
|
episodeResult = latest.episode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parts = normalizedArg.split("/")
|
||||||
|
const showQuery = parts[0]
|
||||||
|
const episodeQuery = parts[1]
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cliArgs.query !== null || cliArgs.play !== null) {
|
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||||
import("./utils/feeds-persistence")
|
import("./utils/feeds-persistence")
|
||||||
.then(async ({ loadFeedsFromFile }) => {
|
.then(async ({ loadFeedsFromFile }) => {
|
||||||
const feeds = await loadFeedsFromFile();
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
if (cliArgs.query !== null) {
|
if (cliArgs.query !== null) {
|
||||||
const query = cliArgs.query;
|
handleQuery(feeds, cliArgs.query)
|
||||||
const normalizedQuery = query.toLowerCase();
|
|
||||||
|
|
||||||
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 === 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliArgs.play !== null) {
|
if (cliArgs.play !== null) {
|
||||||
const playArg = cliArgs.play;
|
await handlePlay(feeds, cliArgs.play)
|
||||||
const normalizedArg = playArg.toLowerCase();
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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];
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* PodcastCard component - Reusable card for displaying podcast info
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Show, For } from "solid-js";
|
|
||||||
import type { Podcast } from "@/types/podcast";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
type PodcastCardProps = {
|
|
||||||
podcast: Podcast;
|
|
||||||
selected: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
onSelect?: () => void;
|
|
||||||
onSubscribe?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PodcastCard(props: PodcastCardProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const handleSubscribeClick = () => {
|
|
||||||
props.onSubscribe?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.selected}
|
|
||||||
flexDirection="column"
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={props.onSelect}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<SelectableText selected={() => props.selected} primary>
|
|
||||||
<strong>{props.podcast.title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
|
|
||||||
<Show when={props.podcast.isSubscribed}>
|
|
||||||
<text fg={theme.success}>[+]</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Author */}
|
|
||||||
<Show when={props.podcast.author && !props.compact}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
by {props.podcast.author}
|
|
||||||
</SelectableText>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Show when={props.podcast.description && !props.compact}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{props.podcast.description!.length > 80
|
|
||||||
? props.podcast.description!.slice(0, 80) + "..."
|
|
||||||
: props.podcast.description}
|
|
||||||
</SelectableText>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/**<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
marginTop={props.compact ? 0 : 1}
|
|
||||||
/>**/}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
|
||||||
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
|
||||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show when={props.selected}>
|
|
||||||
<box onMouseDown={handleSubscribeClick}>
|
|
||||||
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
|
|
||||||
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed detail view component for PodTUI
|
|
||||||
* Shows podcast info and episode list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import type { Feed } from "@/types/feed";
|
|
||||||
import type { Episode } from "@/types/episode";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
interface FeedDetailProps {
|
|
||||||
feed: Feed;
|
|
||||||
focused?: boolean;
|
|
||||||
onBack?: () => void;
|
|
||||||
onPlayEpisode?: (episode: Episode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedDetail(props: FeedDetailProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
||||||
const [showInfo, setShowInfo] = createSignal(true);
|
|
||||||
|
|
||||||
const episodes = () => {
|
|
||||||
// Sort episodes by publication date (newest first)
|
|
||||||
return [...props.feed.episodes].sort(
|
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
if (hrs > 0) {
|
|
||||||
return `${hrs}h ${mins % 60}m`;
|
|
||||||
}
|
|
||||||
return `${mins}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return format(date, "MMM d, yyyy");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string }) => {
|
|
||||||
const eps = episodes();
|
|
||||||
|
|
||||||
if (key.name === "escape" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "i") {
|
|
||||||
setShowInfo((v) => !v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "v") {
|
|
||||||
props.feed.podcast.onToggleVisibility?.(props.feed.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
|
||||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
const episode = eps[selectedIndex()];
|
|
||||||
if (episode && props.onPlayEpisode) {
|
|
||||||
props.onPlayEpisode(episode);
|
|
||||||
}
|
|
||||||
} else if (key.name === "home" || key.name === "g") {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (key.name === "end") {
|
|
||||||
setSelectedIndex(eps.length - 1);
|
|
||||||
} else if (key.name === "pageup") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
|
||||||
} else if (key.name === "pagedown") {
|
|
||||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
handleKeyPress(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
{/* Header with back button */}
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Podcast info section */}
|
|
||||||
<Show when={showInfo()}>
|
|
||||||
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>
|
|
||||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
{props.feed.podcast.author && (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>by</SelectableText>
|
|
||||||
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
<box height={1} />
|
|
||||||
<SelectableText selected={() => false} tertiary>
|
|
||||||
{props.feed.podcast.description?.slice(0, 200)}
|
|
||||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
|
||||||
</SelectableText>
|
|
||||||
<box height={1} />
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
|
|
||||||
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
|
|
||||||
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
|
|
||||||
</box>
|
|
||||||
<SelectableText selected={() => false} tertiary>
|
|
||||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
|
||||||
</SelectableText>
|
|
||||||
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Episodes header */}
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<SelectableText selected={() => false} primary>
|
|
||||||
<strong>Episodes</strong>
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
|
||||||
<For each={episodes()}>
|
|
||||||
{(episode, index) => (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => index() === selectedIndex()}
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={() => {
|
|
||||||
setSelectedIndex(index());
|
|
||||||
if (props.onPlayEpisode) {
|
|
||||||
props.onPlayEpisode(episode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => index() === selectedIndex()}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{index() === selectedIndex() ? ">" : " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => index() === selectedIndex()}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
|
||||||
{episode.title}
|
|
||||||
</SelectableText>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
|
|
||||||
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
|
|
||||||
</box>
|
|
||||||
</SelectableBox>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed filter component for PodTUI
|
|
||||||
* Toggle and filter options for feed list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
|
||||||
import type { FeedFilter } from "@/types/feed";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface FeedFilterProps {
|
|
||||||
filter: FeedFilter;
|
|
||||||
focused?: boolean;
|
|
||||||
onFilterChange: (filter: FeedFilter) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
|
|
||||||
|
|
||||||
export function FeedFilterComponent(props: FeedFilterProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
|
||||||
const [searchValue, setSearchValue] = createSignal(
|
|
||||||
props.filter.searchQuery || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
if (focusField() === "visibility") {
|
|
||||||
cycleVisibility();
|
|
||||||
} else if (focusField() === "sort") {
|
|
||||||
cycleSort();
|
|
||||||
} else if (focusField() === "pinned") {
|
|
||||||
togglePinned();
|
|
||||||
} else if (focusField() === "private") {
|
|
||||||
togglePrivate();
|
|
||||||
}
|
|
||||||
} else if (key.name === "space") {
|
|
||||||
if (focusField() === "pinned") {
|
|
||||||
togglePinned();
|
|
||||||
} else if (focusField() === "private") {
|
|
||||||
togglePrivate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleVisibility = () => {
|
|
||||||
const current = props.filter.visibility;
|
|
||||||
let next: FeedVisibility | "all";
|
|
||||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
|
||||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
|
||||||
else next = "all";
|
|
||||||
props.onFilterChange({ ...props.filter, visibility: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleSort = () => {
|
|
||||||
const sortOptions: FeedSortField[] = [
|
|
||||||
FeedSortField.UPDATED,
|
|
||||||
FeedSortField.TITLE,
|
|
||||||
FeedSortField.EPISODE_COUNT,
|
|
||||||
FeedSortField.LATEST_EPISODE,
|
|
||||||
];
|
|
||||||
const currentIndex = sortOptions.indexOf(
|
|
||||||
props.filter.sortBy as FeedSortField,
|
|
||||||
);
|
|
||||||
const nextIndex = (currentIndex + 1) % sortOptions.length;
|
|
||||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePinned = () => {
|
|
||||||
props.onFilterChange({
|
|
||||||
...props.filter,
|
|
||||||
pinnedOnly: !props.filter.pinnedOnly,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePrivate = () => {
|
|
||||||
props.onFilterChange({
|
|
||||||
...props.filter,
|
|
||||||
showPrivate: !props.filter.showPrivate,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchInput = (value: string) => {
|
|
||||||
setSearchValue(value);
|
|
||||||
props.onFilterChange({ ...props.filter, searchQuery: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityLabel = () => {
|
|
||||||
const vis = props.filter.visibility;
|
|
||||||
if (vis === "all") return "All";
|
|
||||||
if (vis === "public") return "Public";
|
|
||||||
return "Private";
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityColor = () => {
|
|
||||||
const vis = props.filter.visibility;
|
|
||||||
if (vis === "public") return theme.success;
|
|
||||||
if (vis === "private") return theme.warning;
|
|
||||||
return theme.text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortLabel = () => {
|
|
||||||
const sort = props.filter.sortBy;
|
|
||||||
switch (sort) {
|
|
||||||
case "title":
|
|
||||||
return "Title";
|
|
||||||
case "episodeCount":
|
|
||||||
return "Episodes";
|
|
||||||
case "latestEpisode":
|
|
||||||
return "Latest";
|
|
||||||
case "updated":
|
|
||||||
default:
|
|
||||||
return "Updated";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Filter Feeds</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
|
||||||
{/* Visibility filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
|
|
||||||
borderColor={theme.border}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
|
|
||||||
Show:
|
|
||||||
</text>
|
|
||||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Sort filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
|
|
||||||
<text fg={theme.text}>{sortLabel()}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Pinned filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
|
|
||||||
Pinned:
|
|
||||||
</text>
|
|
||||||
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
|
|
||||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Private filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
|
|
||||||
Private:
|
|
||||||
</text>
|
|
||||||
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
|
|
||||||
{props.filter.showPrivate ? "Yes" : "No"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Search box */}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
|
|
||||||
<input
|
|
||||||
value={searchValue()}
|
|
||||||
onInput={handleSearchInput}
|
|
||||||
placeholder="Filter by name..."
|
|
||||||
focused={props.focused && focusField() === "search"}
|
|
||||||
width={25}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed item component for PodTUI
|
|
||||||
* Displays a single feed/podcast in the list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Feed, FeedVisibility } from "@/types/feed";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
interface FeedItemProps {
|
|
||||||
feed: Feed;
|
|
||||||
isSelected: boolean;
|
|
||||||
showEpisodeCount?: boolean;
|
|
||||||
showLastUpdated?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedItem(props: FeedItemProps) {
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return format(date, "MMM d");
|
|
||||||
};
|
|
||||||
|
|
||||||
const episodeCount = () => props.feed.episodes.length;
|
|
||||||
const unplayedCount = () => {
|
|
||||||
// This would be calculated based on episode status
|
|
||||||
return props.feed.episodes.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityIcon = () => {
|
|
||||||
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityColor = () => {
|
|
||||||
return props.feed.visibility === "public" ? theme.success : theme.warning;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pinnedIndicator = () => {
|
|
||||||
return props.feed.isPinned ? "*" : " ";
|
|
||||||
};
|
|
||||||
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
if (props.compact) {
|
|
||||||
// Compact single-line view
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={() => {}}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{props.isSelected ? ">" : " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{visibilityIcon()}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{props.feed.customName || props.feed.podcast.title}
|
|
||||||
</SelectableText>
|
|
||||||
{props.showEpisodeCount && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
({episodeCount()})
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full view with details
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={() => {}}
|
|
||||||
>
|
|
||||||
{/* Title row */}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{props.isSelected ? ">" : " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{visibilityIcon()}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
secondary
|
|
||||||
>
|
|
||||||
{pinnedIndicator()}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
|
||||||
{props.showEpisodeCount && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{episodeCount()} episodes ({unplayedCount()} new)
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
{props.showLastUpdated && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
Updated: {formatDate(props.feed.lastUpdated)}
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{props.feed.podcast.description && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
paddingLeft={4}
|
|
||||||
paddingTop={0}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{props.feed.podcast.description.slice(0, 60)}
|
|
||||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed list component for PodTUI
|
|
||||||
* Scrollable list of feeds with keyboard navigation and mouse support
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { FeedItem } from "./FeedItem";
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
|
||||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
|
||||||
import type { Feed } from "@/types/feed";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface FeedListProps {
|
|
||||||
focused?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
showEpisodeCount?: boolean;
|
|
||||||
showLastUpdated?: boolean;
|
|
||||||
onSelectFeed?: (feed: Feed) => void;
|
|
||||||
onOpenFeed?: (feed: Feed) => void;
|
|
||||||
onFocusChange?: (focused: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedList(props: FeedListProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const feedStore = useFeedStore();
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
||||||
|
|
||||||
const filteredFeeds = () => feedStore.getFilteredFeeds();
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string }) => {
|
|
||||||
if (key.name === "escape") {
|
|
||||||
props.onFocusChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const feeds = filteredFeeds();
|
|
||||||
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
|
||||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
const feed = feeds[selectedIndex()];
|
|
||||||
if (feed && props.onOpenFeed) {
|
|
||||||
props.onOpenFeed(feed);
|
|
||||||
}
|
|
||||||
} else if (key.name === "home" || key.name === "g") {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (key.name === "end") {
|
|
||||||
setSelectedIndex(feeds.length - 1);
|
|
||||||
} else if (key.name === "pageup") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 5));
|
|
||||||
} else if (key.name === "pagedown") {
|
|
||||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
|
|
||||||
} else if (key.name === "p") {
|
|
||||||
// Toggle pin on selected feed
|
|
||||||
const feed = feeds[selectedIndex()];
|
|
||||||
if (feed) {
|
|
||||||
feedStore.togglePinned(feed.id);
|
|
||||||
}
|
|
||||||
} else if (key.name === "v") {
|
|
||||||
// Toggle visibility on selected feed
|
|
||||||
const feed = feeds[selectedIndex()];
|
|
||||||
if (feed) {
|
|
||||||
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
|
|
||||||
feedStore.updateFeed(feed.id, { visibility: newVisibility });
|
|
||||||
}
|
|
||||||
} else if (key.name === "f") {
|
|
||||||
// Cycle visibility filter
|
|
||||||
cycleVisibilityFilter();
|
|
||||||
} else if (key.name === "s") {
|
|
||||||
// Cycle sort
|
|
||||||
cycleSortField();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify selection change
|
|
||||||
const selectedFeed = feeds[selectedIndex()];
|
|
||||||
if (selectedFeed && props.onSelectFeed) {
|
|
||||||
props.onSelectFeed(selectedFeed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
handleKeyPress(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cycleVisibilityFilter = () => {
|
|
||||||
const current = feedStore.filter().visibility;
|
|
||||||
let next: FeedVisibility | "all";
|
|
||||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
|
||||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
|
||||||
else next = "all";
|
|
||||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleSortField = () => {
|
|
||||||
const sortOptions: FeedSortField[] = [
|
|
||||||
FeedSortField.UPDATED,
|
|
||||||
FeedSortField.TITLE,
|
|
||||||
FeedSortField.EPISODE_COUNT,
|
|
||||||
FeedSortField.LATEST_EPISODE,
|
|
||||||
];
|
|
||||||
const current = feedStore.filter().sortBy as FeedSortField;
|
|
||||||
const idx = sortOptions.indexOf(current);
|
|
||||||
const next = sortOptions[(idx + 1) % sortOptions.length];
|
|
||||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityLabel = () => {
|
|
||||||
const vis = feedStore.filter().visibility;
|
|
||||||
if (vis === "all") return "All";
|
|
||||||
if (vis === "public") return "Public";
|
|
||||||
return "Private";
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortLabel = () => {
|
|
||||||
const sort = feedStore.filter().sortBy;
|
|
||||||
switch (sort) {
|
|
||||||
case "title":
|
|
||||||
return "Title";
|
|
||||||
case "episodeCount":
|
|
||||||
return "Episodes";
|
|
||||||
case "latestEpisode":
|
|
||||||
return "Latest";
|
|
||||||
default:
|
|
||||||
return "Updated";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeedClick = (feed: Feed, index: number) => {
|
|
||||||
setSelectedIndex(index);
|
|
||||||
if (props.onSelectFeed) {
|
|
||||||
props.onSelectFeed(feed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeedDoubleClick = (feed: Feed) => {
|
|
||||||
if (props.onOpenFeed) {
|
|
||||||
props.onOpenFeed(feed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
{/* Header with filter controls */}
|
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>My Feeds</strong>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
|
|
||||||
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
|
|
||||||
<text fg={theme.primary}>[s] {sortLabel()}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Feed list in scrollbox */}
|
|
||||||
<Show
|
|
||||||
when={filteredFeeds().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box border padding={2} borderColor={theme.border}>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<scrollbox height={15} focused={props.focused}>
|
|
||||||
<For each={filteredFeeds()}>
|
|
||||||
{(feed, index) => (
|
|
||||||
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
|
||||||
<FeedItem
|
|
||||||
feed={feed}
|
|
||||||
isSelected={index() === selectedIndex()}
|
|
||||||
compact={props.compact}
|
|
||||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
|
||||||
showLastUpdated={props.showLastUpdated ?? true}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Navigation help */}
|
|
||||||
<box paddingTop={0}>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Show } from "solid-js";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { SourceBadge } from "./SourceBadge";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
type ResultCardProps = {
|
|
||||||
result: SearchResult;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onSubscribe?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ResultCard(props: ResultCardProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const podcast = () => props.result.podcast;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.selected}
|
|
||||||
flexDirection="column"
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={props.onSelect}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
<strong>{podcast().title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
<SourceBadge
|
|
||||||
sourceId={props.result.sourceId}
|
|
||||||
sourceName={props.result.sourceName}
|
|
||||||
sourceType={props.result.sourceType}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
<Show when={podcast().isSubscribed}>
|
|
||||||
<text fg={theme.success}>[Subscribed]</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show when={podcast().author}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
by {podcast().author}
|
|
||||||
</SelectableText>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={podcast().description}>
|
|
||||||
{(description) => (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{description().length > 120
|
|
||||||
? description().slice(0, 120) + "..."
|
|
||||||
: description()}
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={(podcast().categories ?? []).length > 0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
|
||||||
<text fg={theme.warning}>[{category}]</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!podcast().isSubscribed}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
width={18}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.stopPropagation?.();
|
|
||||||
props.onSubscribe?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[+] Add to Feeds</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Show } from "solid-js";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { SourceBadge } from "./SourceBadge";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
type ResultDetailProps = {
|
|
||||||
result?: SearchResult;
|
|
||||||
onSubscribe?: (result: SearchResult) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ResultDetail(props: ResultDetailProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
|
|
||||||
<Show
|
|
||||||
when={props.result}
|
|
||||||
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
|
|
||||||
>
|
|
||||||
{(result) => (
|
|
||||||
<>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>{result().podcast.title}</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<SourceBadge
|
|
||||||
sourceId={result().sourceId}
|
|
||||||
sourceName={result().sourceName}
|
|
||||||
sourceType={result().sourceType}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Show when={result().podcast.author}>
|
|
||||||
<text fg={theme.textMuted}>by {result().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={result().podcast.description}>
|
|
||||||
<text fg={theme.textMuted}>{result().podcast.description}</text>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
{(result().podcast.categories ?? []).map((category) => (
|
|
||||||
<text fg={theme.warning}>[{category}]</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<Show when={!result().podcast.isSubscribed}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
width={18}
|
|
||||||
onMouseDown={() => props.onSubscribe?.(result())}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[+] Add to Feeds</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={result().podcast.isSubscribed}>
|
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchHistory component for displaying and managing search history
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For, Show } from "solid-js"
|
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable"
|
|
||||||
|
|
||||||
type SearchHistoryProps = {
|
|
||||||
history: string[]
|
|
||||||
focused: boolean
|
|
||||||
selectedIndex: number
|
|
||||||
onSelect?: (query: string) => void
|
|
||||||
onRemove?: (query: string) => void
|
|
||||||
onClear?: () => void
|
|
||||||
onChange?: (index: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchHistory(props: SearchHistoryProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const handleSearchClick = (index: number, query: string) => {
|
|
||||||
props.onChange?.(index)
|
|
||||||
props.onSelect?.(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveClick = (query: string) => {
|
|
||||||
props.onRemove?.(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<text fg={theme.textMuted}>Recent Searches</text>
|
|
||||||
<Show when={props.history.length > 0}>
|
|
||||||
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
|
||||||
<text fg={theme.error}>[Clear All]</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={props.history.length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={theme.textMuted}>No recent searches</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<scrollbox height={10}>
|
|
||||||
<box flexDirection="column">
|
|
||||||
<For each={props.history}>
|
|
||||||
{(query, index) => {
|
|
||||||
const isSelected = () => index() === props.selectedIndex && props.focused
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={isSelected}
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={() => handleSearchClick(index(), query)}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{">"}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{query}
|
|
||||||
</SelectableText>
|
|
||||||
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
|
||||||
<text fg={theme.error}>[x]</text>
|
|
||||||
</box>
|
|
||||||
</SelectableBox>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchResults component for displaying podcast search results
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For, Show } from "solid-js";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { ResultCard } from "./ResultCard";
|
|
||||||
import { ResultDetail } from "./ResultDetail";
|
|
||||||
|
|
||||||
type SearchResultsProps = {
|
|
||||||
results: SearchResult[];
|
|
||||||
selectedIndex: number;
|
|
||||||
focused: boolean;
|
|
||||||
onSelect?: (result: SearchResult) => void;
|
|
||||||
onChange?: (index: number) => void;
|
|
||||||
isSearching?: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SearchResults(props: SearchResultsProps) {
|
|
||||||
const handleSelect = (index: number) => {
|
|
||||||
props.onChange?.(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={!props.isSearching}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="yellow">Searching...</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!props.error}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="red">{props.error}</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.results.length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="gray">
|
|
||||||
No results found. Try a different search term.
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1} height="100%">
|
|
||||||
<box flexDirection="column" flexGrow={1}>
|
|
||||||
<scrollbox height="100%">
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<For each={props.results}>
|
|
||||||
{(result, index) => (
|
|
||||||
<ResultCard
|
|
||||||
result={result}
|
|
||||||
selected={index() === props.selectedIndex}
|
|
||||||
onSelect={() => handleSelect(index())}
|
|
||||||
onSubscribe={() => props.onSelect?.(result)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
<box width={36}>
|
|
||||||
<ResultDetail
|
|
||||||
result={props.results[props.selectedIndex]}
|
|
||||||
onSubscribe={(result) => props.onSelect?.(result)}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { SourceType } from "@/types/source";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
type SourceBadgeProps = {
|
|
||||||
sourceId: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceType?: SourceType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeLabel = (sourceType?: SourceType) => {
|
|
||||||
if (sourceType === SourceType.API) return "API";
|
|
||||||
if (sourceType === SourceType.RSS) return "RSS";
|
|
||||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
|
||||||
return "Source";
|
|
||||||
};
|
|
||||||
|
|
||||||
// No module-level typeColor here — it needs the theme from the component.
|
|
||||||
// The correct definition lives inside SourceBadge below.
|
|
||||||
export function SourceBadge(props: SourceBadgeProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const label = () => props.sourceName || props.sourceId;
|
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
|
||||||
if (sourceType === SourceType.API) return theme.primary;
|
|
||||||
if (sourceType === SourceType.RSS) return theme.success;
|
|
||||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
|
||||||
return theme.textMuted;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={1} padding={0}>
|
|
||||||
<text fg={typeColor(props.sourceType)}>
|
|
||||||
[{typeLabel(props.sourceType)}]
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>{label()}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -90,5 +90,17 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
autoDownload: !prefs().autoDownload,
|
autoDownload: !prefs().autoDownload,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "autoJumpToPlayer",
|
||||||
|
label: "Auto Jump to Player",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updatePreferences({
|
||||||
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
|
}),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const defaultSettings: AppSettings = {
|
|||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoJumpToPlayer: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -43,7 +44,7 @@ const defaultState: AppState = {
|
|||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAppStore() {
|
function createAppStore() {
|
||||||
// Start with defaults; async load will update once ready
|
// Start with defaults; async load will update once ready
|
||||||
const [state, setState] = createSignal<AppState>(defaultState);
|
const [state, setState] = createSignal<AppState>(defaultState);
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const defaultNavState: AudioNavState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Create audio navigation store */
|
/** Create audio navigation store */
|
||||||
export function createAudioNavStore() {
|
function createAudioNavStore() {
|
||||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
/** Persist current navigation state to file (fire-and-forget) */
|
/** Persist current navigation state to file (fire-and-forget) */
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ export function createDiscoverStore() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the podcast list from the manifest entries
|
|
||||||
const fetched = manifest.podcasts.map(entryToPodcast);
|
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||||
cachedAt = now;
|
cachedAt = now;
|
||||||
setPodcasts(fetched);
|
setPodcasts(fetched);
|
||||||
@@ -173,7 +172,6 @@ export function createDiscoverStore() {
|
|||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
// Remove the feed from the feed store
|
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
feedStore.removeFeedByUrl(podcast.feedUrl);
|
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface QueueItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create download store */
|
/** Create download store */
|
||||||
export function createDownloadStore() {
|
function createDownloadStore() {
|
||||||
const [downloads, setDownloads] = createSignal<
|
const [downloads, setDownloads] = createSignal<
|
||||||
Map<string, DownloadedEpisode>
|
Map<string, DownloadedEpisode>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
@@ -48,7 +48,6 @@ export function createDownloadStore() {
|
|||||||
/** Active AbortControllers keyed by episodeId */
|
/** Active AbortControllers keyed by episodeId */
|
||||||
const abortControllers = new Map<string, AbortController>();
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
// Load persisted downloads on init
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const loaded = await loadDownloads();
|
const loaded = await loadDownloads();
|
||||||
if (loaded.size > 0) setDownloads(loaded);
|
if (loaded.size > 0) setDownloads(loaded);
|
||||||
@@ -153,7 +152,6 @@ export function createDownloadStore() {
|
|||||||
const slotsAvailable = MAX_CONCURRENT - current;
|
const slotsAvailable = MAX_CONCURRENT - current;
|
||||||
const toStart = q.slice(0, slotsAvailable);
|
const toStart = q.slice(0, slotsAvailable);
|
||||||
|
|
||||||
// Remove started items from queue
|
|
||||||
if (toStart.length > 0) {
|
if (toStart.length > 0) {
|
||||||
setQueue((prev) => prev.slice(toStart.length));
|
setQueue((prev) => prev.slice(toStart.length));
|
||||||
}
|
}
|
||||||
@@ -250,7 +248,6 @@ export function createDownloadStore() {
|
|||||||
return; // Already downloading or queued
|
return; // Already downloading or queued
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create download entry
|
|
||||||
const entry: DownloadedEpisode = {
|
const entry: DownloadedEpisode = {
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
feedId,
|
feedId,
|
||||||
@@ -269,7 +266,6 @@ export function createDownloadStore() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
const queueItem: QueueItem = {
|
const queueItem: QueueItem = {
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
feedId,
|
feedId,
|
||||||
@@ -291,10 +287,8 @@ export function createDownloadStore() {
|
|||||||
abortControllers.delete(episodeId);
|
abortControllers.delete(episodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from queue
|
|
||||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||||
|
|
||||||
// Update status
|
|
||||||
updateDownload(episodeId, {
|
updateDownload(episodeId, {
|
||||||
status: DownloadStatus.NONE,
|
status: DownloadStatus.NONE,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function saveSources(sources: PodcastSource[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
export function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
...DEFAULT_SOURCES,
|
...DEFAULT_SOURCES,
|
||||||
@@ -62,22 +62,18 @@ export function createFeedStore() {
|
|||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
|
|
||||||
// Filter by visibility
|
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
|
||||||
if (f.sourceId) {
|
if (f.sourceId) {
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by pinned
|
|
||||||
if (f.pinnedOnly) {
|
if (f.pinnedOnly) {
|
||||||
result = result.filter((feed) => feed.isPinned);
|
result = result.filter((feed) => feed.isPinned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
|
||||||
if (f.searchQuery) {
|
if (f.searchQuery) {
|
||||||
const query = f.searchQuery.toLowerCase();
|
const query = f.searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
@@ -88,7 +84,6 @@ export function createFeedStore() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by selected field
|
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
switch (f.sortBy) {
|
switch (f.sortBy) {
|
||||||
@@ -111,7 +106,6 @@ export function createFeedStore() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pinned feeds always first
|
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
@@ -224,7 +218,6 @@ export function createFeedStore() {
|
|||||||
newEpisodes: Episode[],
|
newEpisodes: Episode[],
|
||||||
count: number,
|
count: number,
|
||||||
) => {
|
) => {
|
||||||
try {
|
|
||||||
const dlStore = useDownloadStore();
|
const dlStore = useDownloadStore();
|
||||||
// Sort by pubDate descending (newest first)
|
// Sort by pubDate descending (newest first)
|
||||||
const sorted = [...newEpisodes].sort(
|
const sorted = [...newEpisodes].sort(
|
||||||
@@ -241,9 +234,6 @@ export function createFeedStore() {
|
|||||||
dlStore.startDownload(ep, feedId);
|
dlStore.startDownload(ep, feedId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Download store may not be available yet
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export function createSearchStore() {
|
|||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Add to history
|
|
||||||
addToHistory(q);
|
addToHistory(q);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -122,7 +121,6 @@ export function createSearchStore() {
|
|||||||
/** Add query to history */
|
/** Add query to history */
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
// Remove duplicates and add to front
|
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||||
saveHistory(updated);
|
saveHistory(updated);
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import type {
|
import type { ThemeColors } from "../types/settings"
|
||||||
DesktopTheme,
|
|
||||||
ThemeColors,
|
|
||||||
ThemeDefinition,
|
|
||||||
ThemeName,
|
|
||||||
ThemeToken,
|
|
||||||
ThemeVariant,
|
|
||||||
} from "../types/settings"
|
|
||||||
import type { ColorValue } from "./theme-schema"
|
|
||||||
|
|
||||||
// Base theme colors
|
// Base theme colors
|
||||||
export const BASE_THEME_COLORS: ThemeColors = {
|
export const BASE_THEME_COLORS: ThemeColors = {
|
||||||
@@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
|
|||||||
layer2: "#161b22",
|
layer2: "#161b22",
|
||||||
layer3: "#0d1117",
|
layer3: "#0d1117",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme tokens
|
|
||||||
export const BASE_THEME_TOKENS: ThemeToken = {
|
|
||||||
"background": "transparent",
|
|
||||||
"surface": "#1b1f27",
|
|
||||||
"primary": "#6fa8ff",
|
|
||||||
"secondary": "#a9b1d6",
|
|
||||||
"accent": "#f6c177",
|
|
||||||
"text": "#e6edf3",
|
|
||||||
"muted": "#7d8590",
|
|
||||||
"warning": "#f0b429",
|
|
||||||
"error": "#f47067",
|
|
||||||
"success": "#3fb950",
|
|
||||||
"layer0": "transparent",
|
|
||||||
"layer1": "#1e222e",
|
|
||||||
"layer2": "#161b22",
|
|
||||||
"layer3": "#0d1117",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop theme structure
|
|
||||||
export const THEMES_DESKTOP: DesktopTheme = {
|
|
||||||
name: "PodTUI",
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
name: "catppuccin",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#1e1e2e",
|
|
||||||
primary: "#89b4fa",
|
|
||||||
secondary: "#cba6f7",
|
|
||||||
accent: "#f9e2af",
|
|
||||||
text: "#cdd6f4",
|
|
||||||
textPrimary: "#cdd6f4",
|
|
||||||
textSecondary: "#cba6f7",
|
|
||||||
textTertiary: "#7f849c",
|
|
||||||
textSelectedPrimary: "#1e1e2e",
|
|
||||||
textSelectedSecondary: "#cdd6f4",
|
|
||||||
textSelectedTertiary: "#cba6f7",
|
|
||||||
muted: "#7f849c",
|
|
||||||
warning: "#fab387",
|
|
||||||
error: "#f38ba8",
|
|
||||||
success: "#a6e3a1",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#181825",
|
|
||||||
layer2: "#11111b",
|
|
||||||
layer3: "#0a0a0f",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gruvbox",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#282828",
|
|
||||||
primary: "#fabd2f",
|
|
||||||
secondary: "#83a598",
|
|
||||||
accent: "#fe8019",
|
|
||||||
text: "#ebdbb2",
|
|
||||||
textPrimary: "#ebdbb2",
|
|
||||||
textSecondary: "#83a598",
|
|
||||||
textTertiary: "#928374",
|
|
||||||
textSelectedPrimary: "#282828",
|
|
||||||
textSelectedSecondary: "#ebdbb2",
|
|
||||||
textSelectedTertiary: "#83a598",
|
|
||||||
muted: "#928374",
|
|
||||||
warning: "#fabd2f",
|
|
||||||
error: "#fb4934",
|
|
||||||
success: "#b8bb26",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#32302a",
|
|
||||||
layer2: "#1d2021",
|
|
||||||
layer3: "#0d0c0c",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tokyo",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#1a1b26",
|
|
||||||
primary: "#7aa2f7",
|
|
||||||
secondary: "#bb9af7",
|
|
||||||
accent: "#e0af68",
|
|
||||||
text: "#c0caf5",
|
|
||||||
textPrimary: "#c0caf5",
|
|
||||||
textSecondary: "#bb9af7",
|
|
||||||
textTertiary: "#565f89",
|
|
||||||
textSelectedPrimary: "#1a1b26",
|
|
||||||
textSelectedSecondary: "#c0caf5",
|
|
||||||
textSelectedTertiary: "#bb9af7",
|
|
||||||
muted: "#565f89",
|
|
||||||
warning: "#e0af68",
|
|
||||||
error: "#f7768e",
|
|
||||||
success: "#9ece6a",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#16161e",
|
|
||||||
layer2: "#0f0f15",
|
|
||||||
layer3: "#08080b",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nord",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#2e3440",
|
|
||||||
primary: "#88c0d0",
|
|
||||||
secondary: "#81a1c1",
|
|
||||||
accent: "#ebcb8b",
|
|
||||||
text: "#eceff4",
|
|
||||||
textPrimary: "#eceff4",
|
|
||||||
textSecondary: "#81a1c1",
|
|
||||||
textTertiary: "#4c566a",
|
|
||||||
textSelectedPrimary: "#2e3440",
|
|
||||||
textSelectedSecondary: "#eceff4",
|
|
||||||
textSelectedTertiary: "#81a1c1",
|
|
||||||
muted: "#4c566a",
|
|
||||||
warning: "#ebcb8b",
|
|
||||||
error: "#bf616a",
|
|
||||||
success: "#a3be8c",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#3b4252",
|
|
||||||
layer2: "#242933",
|
|
||||||
layer3: "#1a1c23",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
defaultVariant: "catppuccin",
|
|
||||||
tokens: BASE_THEME_TOKENS,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get theme by name
|
|
||||||
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
|
|
||||||
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get default theme
|
|
||||||
export function getDefaultTheme(): ThemeVariant {
|
|
||||||
return THEMES_DESKTOP.variants.find(
|
|
||||||
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
|
|
||||||
)!
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ThemeJsonFile = ThemeDefinition
|
|
||||||
|
|
||||||
export function isColorReference(value: ColorValue): value is string {
|
|
||||||
return typeof value === "string" && !value.startsWith("#")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export type AppSettings = {
|
|||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
showExplicit: boolean;
|
showExplicit: boolean;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
|
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||||
|
autoJumpToPlayer: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export type SyncData = {
|
|
||||||
version: string
|
|
||||||
lastSyncedAt: string
|
|
||||||
feeds: {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
isPrivate: boolean
|
|
||||||
}[]
|
|
||||||
sources: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
}[]
|
|
||||||
settings: {
|
|
||||||
theme: string
|
|
||||||
playbackSpeed: number
|
|
||||||
downloadPath: string
|
|
||||||
}
|
|
||||||
preferences: {
|
|
||||||
showExplicit: boolean
|
|
||||||
autoDownload: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export type SyncDataXML = {
|
|
||||||
version: string
|
|
||||||
lastSyncedAt: string
|
|
||||||
feeds: {
|
|
||||||
feed: {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
isPrivate: boolean
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
sources: {
|
|
||||||
source: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
settings: {
|
|
||||||
theme: string
|
|
||||||
playbackSpeed: number
|
|
||||||
downloadPath: string
|
|
||||||
}
|
|
||||||
preferences: {
|
|
||||||
showExplicit: boolean
|
|
||||||
autoDownload: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,6 +38,7 @@ const defaultSettings: AppSettings = {
|
|||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoJumpToPlayer: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -135,10 +135,8 @@ export class AudioStreamReader {
|
|||||||
this.writePos = 0;
|
this.writePos = 0;
|
||||||
this.totalSamplesWritten = 0;
|
this.totalSamplesWritten = 0;
|
||||||
|
|
||||||
// Capture generation for this run
|
|
||||||
const myGeneration = this.generation;
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
// Start async reading loop
|
|
||||||
this.readLoop(myGeneration);
|
this.readLoop(myGeneration);
|
||||||
|
|
||||||
// Detect process exit
|
// Detect process exit
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audio waveform analysis for PodTUI
|
|
||||||
*
|
|
||||||
* Extracts amplitude data from audio files using ffmpeg (when available)
|
|
||||||
* Results are cache in-memory keyed by audio URL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Number of amplitude data points to generate */
|
|
||||||
const DEFAULT_RESOLUTION = 128;
|
|
||||||
|
|
||||||
/** In-memory cache: audioUrl -> amplitude data */
|
|
||||||
const waveformCache = new Map<string, number[]>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to extract real waveform data from an audio URL using ffmpeg.
|
|
||||||
* Returns null if ffmpeg is not available or the extraction fails.
|
|
||||||
*/
|
|
||||||
async function extractWithFfmpeg(
|
|
||||||
audioUrl: string,
|
|
||||||
resolution: number,
|
|
||||||
): Promise<number[] | null> {
|
|
||||||
try {
|
|
||||||
if (!Bun.which("ffmpeg")) return null;
|
|
||||||
|
|
||||||
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
|
|
||||||
// -t 300: read at most 5 minutes (enough data to fill the waveform)
|
|
||||||
const proc = Bun.spawn(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-i",
|
|
||||||
audioUrl,
|
|
||||||
"-t",
|
|
||||||
"300",
|
|
||||||
"-ac",
|
|
||||||
"1", // mono
|
|
||||||
"-ar",
|
|
||||||
"8000", // low sample rate to keep data small
|
|
||||||
"-f",
|
|
||||||
"s16le", // raw signed 16-bit PCM
|
|
||||||
"-v",
|
|
||||||
"quiet",
|
|
||||||
"-",
|
|
||||||
],
|
|
||||||
{ stdout: "pipe", stderr: "ignore" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = await new Response(proc.stdout).arrayBuffer();
|
|
||||||
await proc.exited;
|
|
||||||
|
|
||||||
if (output.byteLength === 0) return null;
|
|
||||||
|
|
||||||
const samples = new Int16Array(output);
|
|
||||||
if (samples.length === 0) return null;
|
|
||||||
|
|
||||||
// Downsample to `resolution` buckets by taking the max absolute amplitude
|
|
||||||
// in each bucket.
|
|
||||||
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
|
|
||||||
const data: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < resolution; i++) {
|
|
||||||
const start = i * bucketSize;
|
|
||||||
const end = Math.min(start + bucketSize, samples.length);
|
|
||||||
let maxAbs = 0;
|
|
||||||
for (let j = start; j < end; j++) {
|
|
||||||
const abs = Math.abs(samples[j]);
|
|
||||||
if (abs > maxAbs) maxAbs = abs;
|
|
||||||
}
|
|
||||||
// Normalise to 0-1
|
|
||||||
data.push(Number((maxAbs / 32768).toFixed(3)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get waveform data for an audio URL.
|
|
||||||
*
|
|
||||||
* Returns cached data if available, otherwise attempts ffmpeg extraction
|
|
||||||
*/
|
|
||||||
export async function getWaveformData(
|
|
||||||
audioUrl: string,
|
|
||||||
resolution: number = DEFAULT_RESOLUTION,
|
|
||||||
): Promise<number[]> {
|
|
||||||
const cacheKey = `${audioUrl}:${resolution}`;
|
|
||||||
const cached = waveformCache.get(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const real = await extractWithFfmpeg(audioUrl, resolution);
|
|
||||||
if (real) {
|
|
||||||
waveformCache.set(cacheKey, real);
|
|
||||||
return real;
|
|
||||||
} else {
|
|
||||||
console.error("generation failure");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearWaveformCache(): void {
|
|
||||||
waveformCache.clear();
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
type CacheEntry<T> = {
|
|
||||||
value: T
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_KEY = "podtui_cache"
|
|
||||||
const DEFAULT_TTL = 1000 * 60 * 60
|
|
||||||
|
|
||||||
const loadCache = (): Record<string, CacheEntry<unknown>> => {
|
|
||||||
if (typeof localStorage === "undefined") return {}
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(CACHE_KEY)
|
|
||||||
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = loadCache()
|
|
||||||
|
|
||||||
export const cacheValue = <T,>(key: string, value: T) => {
|
|
||||||
cache[key] = { value, timestamp: Date.now() }
|
|
||||||
saveCache(cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
|
|
||||||
const entry = cache[key] as CacheEntry<T> | undefined
|
|
||||||
if (!entry) return null
|
|
||||||
if (Date.now() - entry.timestamp > ttl) {
|
|
||||||
delete cache[key]
|
|
||||||
saveCache(cache)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
export const invalidateCache = (prefix?: string) => {
|
|
||||||
if (!prefix) {
|
|
||||||
Object.keys(cache).forEach((key) => delete cache[key])
|
|
||||||
saveCache(cache)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(cache)
|
|
||||||
.filter((key) => key.startsWith(prefix))
|
|
||||||
.forEach((key) => delete cache[key])
|
|
||||||
saveCache(cache)
|
|
||||||
}
|
|
||||||
@@ -106,7 +106,7 @@ export namespace Clipboard {
|
|||||||
/**
|
/**
|
||||||
* Read text from the clipboard.
|
* Read text from the clipboard.
|
||||||
*/
|
*/
|
||||||
export async function readText(): Promise<string | undefined> {
|
async function readText(): Promise<string | undefined> {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
if (os === "darwin") {
|
if (os === "darwin") {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import path from "path"
|
|||||||
const APP_DIR_NAME = "podtui"
|
const APP_DIR_NAME = "podtui"
|
||||||
|
|
||||||
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
||||||
export function getXdgConfigHome(): string {
|
function getXdgConfigHome(): string {
|
||||||
const xdg = process.env.XDG_CONFIG_HOME
|
const xdg = process.env.XDG_CONFIG_HOME
|
||||||
if (xdg) return xdg
|
if (xdg) return xdg
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
||||||
export function getXdgDataHome(): string {
|
function getXdgDataHome(): string {
|
||||||
const xdg = process.env.XDG_DATA_HOME
|
const xdg = process.env.XDG_DATA_HOME
|
||||||
if (xdg) return xdg
|
if (xdg) return xdg
|
||||||
|
|
||||||
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get the application-specific data directory path */
|
/** Get the application-specific data directory path */
|
||||||
export function getDataDir(): string {
|
function getDataDir(): string {
|
||||||
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the downloads directory path */
|
/** Get the downloads directory path */
|
||||||
export function getDownloadsDir(): string {
|
function getDownloadsDir(): string {
|
||||||
return path.join(getDataDir(), "downloads")
|
return path.join(getDataDir(), "downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validates JSON structure of config files, handles corrupted files
|
|
||||||
* gracefully (falling back to defaults), and provides a single
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfigFilePath } from "./config-dir";
|
|
||||||
// --- Validation helpers ---
|
|
||||||
|
|
||||||
/** Check that a value is a non-null object */
|
|
||||||
function isObject(v: unknown): v is Record<string, unknown> {
|
|
||||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate AppState JSON structure */
|
|
||||||
export function validateAppState(data: unknown): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (!isObject(data)) {
|
|
||||||
return { valid: false, errors: ["app-state.json is not an object"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// settings
|
|
||||||
if (data.settings !== undefined) {
|
|
||||||
if (!isObject(data.settings)) {
|
|
||||||
errors.push("settings must be an object");
|
|
||||||
} else {
|
|
||||||
const s = data.settings as Record<string, unknown>;
|
|
||||||
if (s.theme !== undefined && typeof s.theme !== "string")
|
|
||||||
errors.push("settings.theme must be a string");
|
|
||||||
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
|
|
||||||
errors.push("settings.fontSize must be a number");
|
|
||||||
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
|
|
||||||
errors.push("settings.playbackSpeed must be a number");
|
|
||||||
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
|
|
||||||
errors.push("settings.downloadPath must be a string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// preferences
|
|
||||||
if (data.preferences !== undefined) {
|
|
||||||
if (!isObject(data.preferences)) {
|
|
||||||
errors.push("preferences must be an object");
|
|
||||||
} else {
|
|
||||||
const p = data.preferences as Record<string, unknown>;
|
|
||||||
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
|
|
||||||
errors.push("preferences.showExplicit must be a boolean");
|
|
||||||
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
|
|
||||||
errors.push("preferences.autoDownload must be a boolean");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// customTheme
|
|
||||||
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
|
||||||
errors.push("customTheme must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate feeds JSON structure */
|
|
||||||
export function validateFeeds(data: unknown): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
return { valid: false, errors: ["feeds.json is not an array"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
const feed = data[i];
|
|
||||||
if (!isObject(feed)) {
|
|
||||||
errors.push(`feeds[${i}] is not an object`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof feed.id !== "string")
|
|
||||||
errors.push(`feeds[${i}].id must be a string`);
|
|
||||||
if (!isObject(feed.podcast))
|
|
||||||
errors.push(`feeds[${i}].podcast must be an object`);
|
|
||||||
if (!Array.isArray(feed.episodes))
|
|
||||||
errors.push(`feeds[${i}].episodes must be an array`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate progress JSON structure */
|
|
||||||
export function validateProgress(data: unknown): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (!isObject(data)) {
|
|
||||||
return { valid: false, errors: ["progress.json is not an object"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
if (!isObject(value)) {
|
|
||||||
errors.push(`progress["${key}"] is not an object`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const p = value as Record<string, unknown>;
|
|
||||||
if (typeof p.episodeId !== "string")
|
|
||||||
errors.push(`progress["${key}"].episodeId must be a string`);
|
|
||||||
if (typeof p.position !== "number")
|
|
||||||
errors.push(`progress["${key}"].position must be a number`);
|
|
||||||
if (typeof p.duration !== "number")
|
|
||||||
errors.push(`progress["${key}"].duration must be a number`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Safe config file reading ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely read and validate a config file.
|
|
||||||
* Returns the parsed data if valid, or null if the file is missing/corrupt.
|
|
||||||
*/
|
|
||||||
export async function safeReadConfigFile<T>(
|
|
||||||
filename: string,
|
|
||||||
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
|
||||||
): Promise<{ data: T | null; errors: string[] }> {
|
|
||||||
try {
|
|
||||||
const filePath = getConfigFilePath(filename);
|
|
||||||
const file = Bun.file(filePath);
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
return { data: null, errors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await file.text();
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return { data: null, errors: [`${filename}: invalid JSON`] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = validator(parsed);
|
|
||||||
if (!result.valid) {
|
|
||||||
return { data: null, errors: result.errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: parsed as T, errors: [] };
|
|
||||||
} catch (err) {
|
|
||||||
return { data: null, errors: [`${filename}: ${String(err)}`] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,11 +73,6 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Await all pending config writes (used by sync/export flows). */
|
|
||||||
export async function flushConfig(): Promise<void> {
|
|
||||||
await writeChain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Guards so migration runs exactly once per process. */
|
/** Guards so migration runs exactly once per process. */
|
||||||
let migrationDone = false;
|
let migrationDone = false;
|
||||||
let migrationPromise: Promise<void> | null = null;
|
let migrationPromise: Promise<void> | null = null;
|
||||||
@@ -98,7 +93,7 @@ async function migrateOnce(): Promise<void> {
|
|||||||
* Safe to call on every startup — no-op once config.json exists (except for
|
* Safe to call on every startup — no-op once config.json exists (except for
|
||||||
* backup cleanup, which runs unconditionally since those files are now dead).
|
* backup cleanup, which runs unconditionally since those files are now dead).
|
||||||
*/
|
*/
|
||||||
export async function migrateLegacyConfig(): Promise<void> {
|
async function migrateLegacyConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await ensureConfigDir();
|
||||||
const dir = getConfigDir();
|
const dir = getConfigDir();
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { Episode } from "../types/episode"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import { cacheValue, getCachedValue } from "./cache"
|
|
||||||
import { fetchEpisodes } from "@/api/client"
|
|
||||||
|
|
||||||
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
|
|
||||||
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
|
|
||||||
const searchKey = (query: string) => `search:${query.toLowerCase()}`
|
|
||||||
|
|
||||||
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
|
|
||||||
const cached = getCachedValue<Feed>(feedKey(feedUrl))
|
|
||||||
if (cached) return cached
|
|
||||||
try {
|
|
||||||
const episodes = await fetchEpisodes(feedUrl)
|
|
||||||
const feed: Feed = {
|
|
||||||
id: feedUrl,
|
|
||||||
podcast: {
|
|
||||||
id: feedUrl,
|
|
||||||
title: feedUrl,
|
|
||||||
description: "",
|
|
||||||
feedUrl,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes,
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isPinned: false,
|
|
||||||
}
|
|
||||||
cacheValue(feedKey(feedUrl), feed)
|
|
||||||
return feed
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
|
|
||||||
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
|
|
||||||
if (cached) return cached
|
|
||||||
const episodes = await fetchEpisodes(feedUrl)
|
|
||||||
cacheValue(episodesKey(feedUrl), episodes)
|
|
||||||
return episodes
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchWithCache = async (
|
|
||||||
query: string,
|
|
||||||
fetcher: () => Promise<Podcast[]>
|
|
||||||
): Promise<Podcast[]> => {
|
|
||||||
const cached = getCachedValue<Podcast[]>(searchKey(query))
|
|
||||||
if (cached) return cached
|
|
||||||
const results = await fetcher()
|
|
||||||
cacheValue(searchKey(query), results)
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||||
export function tabByDigit(action: KeybindActionName): TABS | null {
|
function tabByDigit(action: KeybindActionName): TABS | null {
|
||||||
if (action.startsWith("tab-goto-")) {
|
if (action.startsWith("tab-goto-")) {
|
||||||
const n = Number(action.slice("tab-goto-".length));
|
const n = Number(action.slice("tab-goto-".length));
|
||||||
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Singleton event bus instance
|
// Singleton event bus instance
|
||||||
export const EventBus = createEventBus();
|
const EventBus = createEventBus();
|
||||||
|
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { TABS } from "@/utils/navigation";
|
import type { TABS } from "@/utils/navigation";
|
||||||
@@ -105,6 +105,8 @@ export type AppEvents = {
|
|||||||
"player.play": { episodeId: string };
|
"player.play": { episodeId: string };
|
||||||
"player.pause": { episodeId: string };
|
"player.pause": { episodeId: string };
|
||||||
"player.stop": {};
|
"player.stop": {};
|
||||||
|
// Emitted when a NEW episode begins playback (not on resume).
|
||||||
|
"player.started": { episodeId: string };
|
||||||
"toast.show": {
|
"toast.show": {
|
||||||
message: string;
|
message: string;
|
||||||
variant: "info" | "success" | "warning" | "error";
|
variant: "info" | "success" | "warning" | "error";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
/**
|
/**
|
||||||
* Remove JSONC comments from a string
|
* Remove JSONC comments from a string
|
||||||
*/
|
*/
|
||||||
export function stripComments(jsonString: string): string {
|
function stripComments(jsonString: string): string {
|
||||||
const comments = [
|
const comments = [
|
||||||
{ pattern: /\/\/.*$/gm, replacement: "" },
|
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||||
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
const targetFile = Bun.file(targetPath);
|
const targetFile = Bun.file(targetPath);
|
||||||
if (await targetFile.exists()) return;
|
if (await targetFile.exists()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { searchSourceByType } from "./source-searcher";
|
import { searchSourceByType } from "./source-searcher";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source";
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
import type { Episode } from "../types/episode";
|
|
||||||
|
|
||||||
type SearchCacheEntry = {
|
type SearchCacheEntry = {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -114,61 +113,4 @@ export const searchPodcasts = async (
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ItunesEpisodeResult = {
|
|
||||||
trackId?: number;
|
|
||||||
trackName?: string;
|
|
||||||
description?: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
releaseDate?: string;
|
|
||||||
trackTimeMillis?: number;
|
|
||||||
episodeUrl?: string;
|
|
||||||
previewUrl?: string;
|
|
||||||
trackViewUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ItunesEpisodeResponse = {
|
|
||||||
resultCount: number;
|
|
||||||
results: ItunesEpisodeResult[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchEpisodes = async (
|
|
||||||
query: string,
|
|
||||||
feedId: string,
|
|
||||||
): Promise<Episode[]> => {
|
|
||||||
const trimmed = query.trim();
|
|
||||||
if (!trimmed) return [];
|
|
||||||
|
|
||||||
const url = new URL("https://itunes.apple.com/search");
|
|
||||||
url.searchParams.set("term", trimmed);
|
|
||||||
url.searchParams.set("media", "podcast");
|
|
||||||
url.searchParams.set("entity", "podcastEpisode");
|
|
||||||
url.searchParams.set("country", "US");
|
|
||||||
url.searchParams.set("lang", "en_us");
|
|
||||||
|
|
||||||
const response = await fetch(url.toString());
|
|
||||||
if (!response.ok) return [];
|
|
||||||
|
|
||||||
const data = (await response.json()) as ItunesEpisodeResponse;
|
|
||||||
return data.results
|
|
||||||
.map((item) => {
|
|
||||||
if (!item.trackName) return null;
|
|
||||||
const id = item.trackId
|
|
||||||
? `episode-${item.trackId}`
|
|
||||||
: `episode-${item.trackName}`;
|
|
||||||
const audioUrl =
|
|
||||||
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
podcastId: feedId,
|
|
||||||
title: item.trackName,
|
|
||||||
description: item.description || item.shortDescription || "",
|
|
||||||
audioUrl,
|
|
||||||
duration: item.trackTimeMillis
|
|
||||||
? Math.round(item.trackTimeMillis / 1000)
|
|
||||||
: 0,
|
|
||||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is Episode => Boolean(item));
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchRSSSource = async (
|
const searchRSSSource = async (
|
||||||
query: string,
|
query: string,
|
||||||
source: PodcastSource
|
source: PodcastSource
|
||||||
): Promise<SearcherResult> => {
|
): Promise<SearcherResult> => {
|
||||||
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchAPISource = async (
|
const searchAPISource = async (
|
||||||
query: string,
|
query: string,
|
||||||
source: PodcastSource
|
source: PodcastSource
|
||||||
): Promise<SearcherResult> => {
|
): Promise<SearcherResult> => {
|
||||||
@@ -173,7 +173,7 @@ export const searchAPISource = async (
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchCustomSource = async (
|
const searchCustomSource = async (
|
||||||
query: string,
|
query: string,
|
||||||
source: PodcastSource
|
source: PodcastSource
|
||||||
): Promise<SearcherResult> => {
|
): Promise<SearcherResult> => {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { SyncData } from "../types/sync-json"
|
|
||||||
import type { SyncDataXML } from "../types/sync-xml"
|
|
||||||
import { syncFormats } from "../constants/sync-formats"
|
|
||||||
|
|
||||||
const isObject = (value: unknown): value is { [key: string]: unknown } =>
|
|
||||||
typeof value === "object" && value !== null
|
|
||||||
|
|
||||||
const hasVersion = (value: unknown): value is { version: string } =>
|
|
||||||
isObject(value) && typeof value.version === "string"
|
|
||||||
|
|
||||||
export function validateJSONSync(data: unknown): SyncData {
|
|
||||||
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
|
|
||||||
throw { message: "Unsupported sync format" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as SyncData
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateXMLSync(data: unknown): SyncDataXML {
|
|
||||||
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
|
|
||||||
throw { message: "Unsupported sync format" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as SyncDataXML
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { SyncData } from "../types/sync-json"
|
|
||||||
import type { SyncDataXML } from "../types/sync-xml"
|
|
||||||
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
|
||||||
import { syncFormats } from "../constants/sync-formats"
|
|
||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
|
|
||||||
export function exportToJSON(data: SyncData): string {
|
|
||||||
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importFromJSON(json: string): SyncData {
|
|
||||||
const data = json
|
|
||||||
return validateJSONSync(data as unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportToXML(data: SyncDataXML): string {
|
|
||||||
const feedItems = ""
|
|
||||||
const sourceItems = ""
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
||||||
`<podcastSync version="${syncFormats.xml.version}">\n` +
|
|
||||||
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
|
|
||||||
` <feeds>\n` +
|
|
||||||
feedItems +
|
|
||||||
` </feeds>\n` +
|
|
||||||
` <sources>\n` +
|
|
||||||
sourceItems +
|
|
||||||
` </sources>\n` +
|
|
||||||
` <settings>\n` +
|
|
||||||
` <theme>${data.settings.theme}</theme>\n` +
|
|
||||||
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
|
|
||||||
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
|
|
||||||
` </settings>\n` +
|
|
||||||
` <preferences>\n` +
|
|
||||||
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
|
|
||||||
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
|
|
||||||
` </preferences>\n` +
|
|
||||||
`</podcastSync>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importFromXML(xml: string): SyncDataXML {
|
|
||||||
const version = syncFormats.xml.version
|
|
||||||
const data = {
|
|
||||||
version,
|
|
||||||
lastSyncedAt: "",
|
|
||||||
feeds: { feed: [] },
|
|
||||||
sources: { source: [] },
|
|
||||||
settings: {
|
|
||||||
theme: "system",
|
|
||||||
playbackSpeed: 1,
|
|
||||||
downloadPath: "",
|
|
||||||
},
|
|
||||||
preferences: {
|
|
||||||
showExplicit: false,
|
|
||||||
autoDownload: false,
|
|
||||||
},
|
|
||||||
} as SyncDataXML
|
|
||||||
|
|
||||||
return validateXMLSync(data)
|
|
||||||
}
|
|
||||||
@@ -13,15 +13,6 @@ export function clearPaletteCache() {
|
|||||||
cached = null;
|
cached = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectSystemTheme(colors: TerminalColors) {
|
|
||||||
const bg = RGBA.fromHex(
|
|
||||||
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
|
|
||||||
);
|
|
||||||
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
|
|
||||||
const mode = luminance > 0.5 ? "light" : "dark";
|
|
||||||
return { mode, background: bg };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateSystemTheme(
|
export function generateSystemTheme(
|
||||||
colors: TerminalColors,
|
colors: TerminalColors,
|
||||||
mode: "dark" | "light",
|
mode: "dark" | "light",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { RGBA } from "@opentui/core"
|
|
||||||
import type { ColorValue } from "../types/theme-schema"
|
|
||||||
|
|
||||||
const toCss = (value: ColorValue | RGBA) => {
|
|
||||||
if (value instanceof RGBA) {
|
|
||||||
const r = Math.round(value.r * 255)
|
|
||||||
const g = Math.round(value.g * 255)
|
|
||||||
const b = Math.round(value.b * 255)
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${value.a})`
|
|
||||||
}
|
|
||||||
if (typeof value === "number") return `var(--ansi-${value})`
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
return value.dark
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
|
|
||||||
const root = document.documentElement
|
|
||||||
for (const [key, value] of Object.entries(theme)) {
|
|
||||||
if (key === "layerBackgrounds" && typeof value === "object") {
|
|
||||||
const layers = value as Record<string, RGBA | ColorValue>
|
|
||||||
for (const [layer, color] of Object.entries(layers)) {
|
|
||||||
root.style.setProperty(`--color-${layer}`, toCss(color))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setThemeAttribute(themeName: string) {
|
|
||||||
document.documentElement.setAttribute("data-theme", themeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveColorReference(value: ColorValue) {
|
|
||||||
return toCss(value)
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,4 @@
|
|||||||
import path from "path"
|
|
||||||
import type { ThemeJson } from "../types/theme-schema"
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
import { THEME_JSON } from "../constants/themes"
|
|
||||||
|
|
||||||
export async function loadTheme(name: string) {
|
|
||||||
if (THEME_JSON[name]) return THEME_JSON[name]
|
|
||||||
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
|
|
||||||
return loadThemeFromPath(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadThemeFromPath(file: string) {
|
|
||||||
const json = (await Bun.file(file).json()) as ThemeJson
|
|
||||||
validateTheme(json, file)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllThemes() {
|
|
||||||
return { ...THEME_JSON, ...(await getCustomThemes()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCustomThemes() {
|
|
||||||
const dirs = [
|
|
||||||
path.join(process.env.HOME ?? "", ".config/podtui/themes"),
|
|
||||||
path.resolve(process.cwd(), ".podtui/themes"),
|
|
||||||
path.resolve(process.cwd(), "themes"),
|
|
||||||
]
|
|
||||||
|
|
||||||
const result: Record<string, ThemeJson> = {}
|
|
||||||
for (const dir of dirs) {
|
|
||||||
const glob = new Bun.Glob("*.json")
|
|
||||||
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
|
|
||||||
const name = path.basename(item, ".json")
|
|
||||||
const json = (await Bun.file(item).json()) as ThemeJson
|
|
||||||
validateTheme(json, item)
|
|
||||||
result[name] = json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateTheme(theme: ThemeJson, source?: string) {
|
export function validateTheme(theme: ThemeJson, source?: string) {
|
||||||
if (!theme || typeof theme !== "object") {
|
if (!theme || typeof theme !== "object") {
|
||||||
|
|||||||
@@ -7,40 +7,12 @@
|
|||||||
* - Tracking theme change state
|
* - Tracking theme change state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { emit, on, off, type EventHandler } from "./event-bus"
|
import { emit } from "./event-bus"
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to theme reload events.
|
|
||||||
* These are triggered by SIGUSR2 signals.
|
|
||||||
*/
|
|
||||||
export function onThemeReload(handler: EventHandler<{}>): () => void {
|
|
||||||
return on("theme.reload", handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to theme changed events.
|
|
||||||
* These are triggered when the theme selection changes.
|
|
||||||
*/
|
|
||||||
export function onThemeChanged(
|
|
||||||
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
|
|
||||||
): () => void {
|
|
||||||
return on("theme.changed", handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to theme mode changed events.
|
|
||||||
* These are triggered when switching between dark/light mode.
|
|
||||||
*/
|
|
||||||
export function onThemeModeChanged(
|
|
||||||
handler: EventHandler<{ mode: "dark" | "light" }>
|
|
||||||
): () => void {
|
|
||||||
return on("theme.mode.changed", handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit a theme reload event.
|
* Emit a theme reload event.
|
||||||
*/
|
*/
|
||||||
export function emitThemeReload(): void {
|
function emitThemeReload(): void {
|
||||||
emit("theme.reload", {})
|
emit("theme.reload", {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void {
|
|||||||
process.off("SIGUSR2", handler)
|
process.off("SIGUSR2", handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a debounced theme change handler to prevent rapid consecutive updates.
|
|
||||||
*
|
|
||||||
* @param handler - The handler to debounce
|
|
||||||
* @param delay - Delay in milliseconds (default: 100ms)
|
|
||||||
*/
|
|
||||||
export function createDebouncedThemeHandler<T>(
|
|
||||||
handler: (event: T) => void,
|
|
||||||
delay: number = 100
|
|
||||||
): (event: T) => void {
|
|
||||||
let timeout: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
return (event: T) => {
|
|
||||||
if (timeout) {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
handler(event)
|
|
||||||
timeout = null
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,67 +3,13 @@
|
|||||||
* Handles dynamic theme switching by updating CSS custom properties
|
* Handles dynamic theme switching by updating CSS custom properties
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RGBA, type TerminalColors } from "@opentui/core";
|
import type { TerminalColors } from "@opentui/core";
|
||||||
import type { ThemeColors } from "../types/settings";
|
import type { ThemeJson } from "../types/theme-schema";
|
||||||
import type { ColorValue, ThemeJson } from "../types/theme-schema";
|
|
||||||
import { THEME_JSON } from "../constants/themes";
|
import { THEME_JSON } from "../constants/themes";
|
||||||
import { getCustomThemes } from "./custom-themes";
|
import { getCustomThemes } from "./custom-themes";
|
||||||
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
||||||
import { generateSystemTheme } from "./system-theme";
|
import { generateSystemTheme } from "./system-theme";
|
||||||
|
|
||||||
const toCss = (value: ColorValue | RGBA) => {
|
|
||||||
if (value instanceof RGBA) {
|
|
||||||
const r = Math.round(value.r * 255);
|
|
||||||
const g = Math.round(value.g * 255);
|
|
||||||
const b = Math.round(value.b * 255);
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${value.a})`;
|
|
||||||
}
|
|
||||||
if (typeof value === "number") return `var(--ansi-${value})`;
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
return value.dark;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.style.setProperty(
|
|
||||||
"--color-background",
|
|
||||||
toCss(theme.background as ColorValue),
|
|
||||||
);
|
|
||||||
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue));
|
|
||||||
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue));
|
|
||||||
root.style.setProperty(
|
|
||||||
"--color-secondary",
|
|
||||||
toCss(theme.secondary as ColorValue),
|
|
||||||
);
|
|
||||||
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue));
|
|
||||||
root.style.setProperty("--color-text", toCss(theme.text as ColorValue));
|
|
||||||
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue));
|
|
||||||
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue));
|
|
||||||
root.style.setProperty("--color-error", toCss(theme.error as ColorValue));
|
|
||||||
root.style.setProperty("--color-success", toCss(theme.success as ColorValue));
|
|
||||||
|
|
||||||
const layers = theme.layerBackgrounds as
|
|
||||||
| Record<string, ColorValue>
|
|
||||||
| undefined;
|
|
||||||
if (layers) {
|
|
||||||
root.style.setProperty("--color-layer0", toCss(layers.layer0));
|
|
||||||
root.style.setProperty("--color-layer1", toCss(layers.layer1));
|
|
||||||
root.style.setProperty("--color-layer2", toCss(layers.layer2));
|
|
||||||
root.style.setProperty("--color-layer3", toCss(layers.layer3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get theme mode from system preference
|
|
||||||
*/
|
|
||||||
export function getSystemThemeMode(): "dark" | "light" {
|
|
||||||
if (typeof window === "undefined") return "dark";
|
|
||||||
|
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
return prefersDark ? "dark" : "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply CSS variable data-theme attribute
|
* Apply CSS variable data-theme attribute
|
||||||
*/
|
*/
|
||||||
@@ -77,15 +23,6 @@ export async function loadThemes() {
|
|||||||
return await getCustomThemes();
|
return await getCustomThemes();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadTheme(name: string) {
|
|
||||||
const themes = await loadThemes();
|
|
||||||
return themes[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
|
||||||
return resolveThemeJson(theme, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTerminalTheme(
|
export function resolveTerminalTheme(
|
||||||
themes: Record<string, ThemeJson>,
|
themes: Record<string, ThemeJson>,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
43
tests/auto-jump.test.ts
Normal file
43
tests/auto-jump.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* auto-jump.test.ts — "auto jump to player on podcast start" feature.
|
||||||
|
*
|
||||||
|
* The Shell subscribes to the `player.started` event (emitted by useAudio when
|
||||||
|
* a NEW episode begins, not on resume) and, when `autoJumpToPlayer` is enabled,
|
||||||
|
* performs `nav.setActiveTab(TABS.PLAYER)` + `nav.enterTabContent()` to land in
|
||||||
|
* the Player content pane. These tests pin that jump contract against the real
|
||||||
|
* nav store (the exact two calls the Shell handler makes) and pin the default
|
||||||
|
* value of the new preference.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRoot } from "solid-js";
|
||||||
|
import {
|
||||||
|
createNavigation,
|
||||||
|
type NavigationState,
|
||||||
|
} from "../src/context/navigation-store";
|
||||||
|
import { TABS } from "../src/utils/navigation";
|
||||||
|
import { loadAppStateFromFile } from "../src/utils/app-persistence";
|
||||||
|
|
||||||
|
function withNav(fn: (nav: NavigationState) => void) {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const nav = createNavigation();
|
||||||
|
fn(nav);
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("auto-jump: PLAYER + enterTabContent lands in the Player content pane", () => {
|
||||||
|
withNav((nav) => {
|
||||||
|
// Exact call sequence the Shell handler runs on `player.started`.
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activeTab()).toBe(TABS.PLAYER);
|
||||||
|
// Content entered: tab list is no longer the CURRENT pane.
|
||||||
|
expect(nav.atRootTab()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auto-jump: preference defaults to true on a config without the field", async () => {
|
||||||
|
// Existing configs predate the field; load must merge in the default true.
|
||||||
|
const state = await loadAppStateFromFile();
|
||||||
|
expect(state.preferences.autoJumpToPlayer).toBe(true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user