Compare commits

...

4 Commits

Author SHA1 Message Date
bfea6816ef dead 2026-02-06 15:02:21 -05:00
75f1f7d6af remove migration code 2026-02-06 15:00:21 -05:00
1e3b794b8e file ordering 2026-02-06 14:55:42 -05:00
1293d30225 starting janitorial work 2026-02-06 13:41:44 -05:00
111 changed files with 2526 additions and 5152 deletions

View File

@@ -1,30 +1,29 @@
import { createSignal, createMemo, ErrorBoundary } from "solid-js"; import { createSignal, createMemo, ErrorBoundary } from "solid-js";
import { useSelectionHandler } from "@opentui/solid"; import { useSelectionHandler } from "@opentui/solid";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation";
import { TabNavigation } from "./components/TabNavigation"; import { TabNavigation } from "./components/TabNavigation";
import { FeedPage } from "./components/FeedPage"; import { FeedPage } from "@/tabs/Feed/FeedPage";
import { MyShowsPage } from "./components/MyShowsPage"; import { MyShowsPage } from "@/tabs/MyShows/MyShowsPage";
import { LoginScreen } from "./components/LoginScreen"; import { LoginScreen } from "@/tabs/Settings/LoginScreen";
import { CodeValidation } from "./components/CodeValidation"; import { CodeValidation } from "@/components/CodeValidation";
import { OAuthPlaceholder } from "./components/OAuthPlaceholder"; import { OAuthPlaceholder } from "@/tabs/Settings/OAuthPlaceholder";
import { SyncProfile } from "./components/SyncProfile"; import { SyncProfile } from "@/tabs/Settings/SyncProfile";
import { SearchPage } from "./components/SearchPage"; import { SearchPage } from "@/tabs/Search/SearchPage";
import { DiscoverPage } from "./components/DiscoverPage"; import { DiscoverPage } from "@/tabs/Discover/DiscoverPage";
import { Player } from "./components/Player"; import { Player } from "@/tabs/Player/Player";
import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsScreen } from "@/tabs/Settings/SettingsScreen";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "./stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "./stores/app"; import { useAppStore } from "@/stores/app";
import { useAudio } from "./hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "./hooks/useMultimediaKeys"; import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "./types/feed"; import { FeedVisibility } from "@/types/feed";
import { useAppKeyboard } from "./hooks/useAppKeyboard"; import { useAppKeyboard } from "@/hooks/useAppKeyboard";
import { Clipboard } from "./utils/clipboard"; import { Clipboard } from "@/utils/clipboard";
import { emit } from "./utils/event-bus"; import { emit } from "@/utils/event-bus";
import type { TabId } from "./components/Tab"; import type { TabId } from "@/components/Tab";
import type { AuthScreen } from "./types/auth"; import type { AuthScreen } from "@/types/auth";
import type { Episode } from "./types/episode"; import type { Episode } from "@/types/episode";
export function App() { export function App() {
const [activeTab, setActiveTab] = createSignal<TabId>("feed"); const [activeTab, setActiveTab] = createSignal<TabId>("feed");
@@ -53,7 +52,9 @@ export function App() {
// My Shows page returns panel renderers // My Shows page returns panel renderers
const myShows = MyShowsPage({ const myShows = MyShowsPage({
get focused() { return activeTab() === "shows" && layerDepth() > 0 }, get focused() {
return activeTab() === "shows" && layerDepth() > 0;
},
onPlayEpisode: (episode, feed) => { onPlayEpisode: (episode, feed) => {
handlePlayEpisode(episode); handlePlayEpisode(episode);
}, },
@@ -69,8 +70,12 @@ export function App() {
setActiveTab(tab); setActiveTab(tab);
setInputFocused(false); setInputFocused(false);
}, },
get inputFocused() { return inputFocused() }, get inputFocused() {
get navigationEnabled() { return layerDepth() === 0 }, return inputFocused();
},
get navigationEnabled() {
return layerDepth() === 0;
},
layerDepth, layerDepth,
onLayerChange: (newDepth) => { onLayerChange: (newDepth) => {
setLayerDepth(newDepth); setLayerDepth(newDepth);
@@ -94,18 +99,20 @@ export function App() {
// Copy selected text to clipboard when selection ends (mouse release) // Copy selected text to clipboard when selection ends (mouse release)
useSelectionHandler((selection: any) => { useSelectionHandler((selection: any) => {
if (!selection) return if (!selection) return;
const text = selection.getSelectedText?.() const text = selection.getSelectedText?.();
if (!text || text.trim().length === 0) return if (!text || text.trim().length === 0) return;
Clipboard.copy(text).then(() => { Clipboard.copy(text)
emit("toast.show", { .then(() => {
message: "Copied to clipboard", emit("toast.show", {
variant: "info", message: "Copied to clipboard",
duration: 1500, variant: "info",
duration: 1500,
});
}) })
}).catch(() => {}) .catch(() => {});
}) });
const getPanels = createMemo(() => { const getPanels = createMemo(() => {
const tab = activeTab(); const tab = activeTab();
@@ -156,19 +163,21 @@ export function App() {
if (showAuthPanel()) { if (showAuthPanel()) {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
return { return {
panels: [{ panels: [
title: "Account", {
content: ( title: "Account",
<SyncProfile content: (
focused={layerDepth() > 0} <SyncProfile
onLogout={() => { focused={layerDepth() > 0}
auth.logout(); onLogout={() => {
setShowAuthPanel(false); auth.logout();
}} setShowAuthPanel(false);
onManageSync={() => setShowAuthPanel(false)} }}
/> onManageSync={() => setShowAuthPanel(false)}
), />
}], ),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "Esc back", hint: "Esc back",
}; };
@@ -203,104 +212,121 @@ export function App() {
}; };
return { return {
panels: [{ panels: [
title: "Sign In", {
content: authContent(), title: "Sign In",
}], content: authContent(),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "Esc back", hint: "Esc back",
}; };
} }
return { return {
panels: [{ panels: [
title: "Settings", {
content: ( title: "Settings",
<SettingsScreen content: (
onOpenAccount={() => setShowAuthPanel(true)} <SettingsScreen
accountLabel={ onOpenAccount={() => setShowAuthPanel(true)}
auth.isAuthenticated accountLabel={
? `Signed in as ${auth.user?.email}` auth.isAuthenticated
: "Not signed in" ? `Signed in as ${auth.user?.email}`
} : "Not signed in"
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"} }
onExit={() => setLayerDepth(0)} accountStatus={
/> auth.isAuthenticated ? "signed-in" : "signed-out"
), }
}], onExit={() => setLayerDepth(0)}
/>
),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "j/k navigate | Enter select | Esc back", hint: "j/k navigate | Enter select | Esc back",
}; };
case "discover": case "discover":
return { return {
panels: [{ panels: [
title: "Discover", {
content: ( title: "Discover",
<DiscoverPage content: (
focused={layerDepth() > 0} <DiscoverPage
onExit={() => setLayerDepth(0)} focused={layerDepth() > 0}
/> onExit={() => setLayerDepth(0)}
), />
}], ),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back", hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
}; };
case "search": case "search":
return { return {
panels: [{ panels: [
title: "Search", {
content: ( title: "Search",
<SearchPage content: (
focused={layerDepth() > 0} <SearchPage
onInputFocusChange={setInputFocused} focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)} onInputFocusChange={setInputFocused}
onSubscribe={(result) => { onExit={() => setLayerDepth(0)}
const feeds = feedStore.feeds(); onSubscribe={(result) => {
const alreadySubscribed = feeds.some( const feeds = feedStore.feeds();
(feed) => const alreadySubscribed = feeds.some(
feed.podcast.id === result.podcast.id || (feed) =>
feed.podcast.feedUrl === result.podcast.feedUrl, feed.podcast.id === result.podcast.id ||
); feed.podcast.feedUrl === result.podcast.feedUrl,
if (!alreadySubscribed) {
feedStore.addFeed(
{ ...result.podcast, isSubscribed: true },
result.sourceId,
FeedVisibility.PUBLIC,
); );
}
}} if (!alreadySubscribed) {
/> feedStore.addFeed(
), { ...result.podcast, isSubscribed: true },
}], result.sourceId,
FeedVisibility.PUBLIC,
);
}
}}
/>
),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "Tab switch focus | / search | Enter select | Esc back", hint: "Tab switch focus | / search | Enter select | Esc back",
}; };
case "player": case "player":
return { return {
panels: [{ panels: [
title: "Player", {
content: ( title: "Player",
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} /> content: (
), <Player
}], focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)}
/>
),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "Space play/pause | Esc back", hint: "Space play/pause | Esc back",
}; };
default: default:
return { return {
panels: [{ panels: [
title: tab, {
content: ( title: tab,
<box padding={2}> content: (
<text>Coming soon</text> <box padding={2}>
</box> <text>Coming soon</text>
), </box>
}], ),
},
],
activePanelIndex: 0, activePanelIndex: 0,
hint: "", hint: "",
}; };
@@ -308,24 +334,21 @@ export function App() {
}); });
return ( return (
<ErrorBoundary fallback={(err) => ( <ErrorBoundary
<box border padding={2}> fallback={(err) => (
<text fg="red"> <box border padding={2}>
Error: {err?.message ?? String(err)}{"\n"} <text fg="red">
Press a number key (1-6) to switch tabs. Error: {err?.message ?? String(err)}
</text> {"\n"}
</box> Press a number key (1-6) to switch tabs.
)}> </text>
</box>
)}
>
<Layout <Layout
header={ header={
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} /> <TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
} }
footer={
<box flexDirection="row" justifyContent="space-between" width="100%">
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
<text fg="gray">{getPanels().hint}</text>
</box>
}
panels={getPanels().panels} panels={getPanels().panels}
activePanelIndex={getPanels().activePanelIndex} activePanelIndex={getPanels().activePanelIndex}
/> />

View File

@@ -1,42 +0,0 @@
import type { JSX } from "solid-js"
type BoxLayoutProps = {
children?: JSX.Element
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly"
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
gap?: number
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
padding?: number
margin?: number
border?: boolean
title?: string
}
export function BoxLayout(props: BoxLayoutProps) {
return (
<box
style={{
flexDirection: props.flexDirection,
justifyContent: props.justifyContent,
alignItems: props.alignItems,
gap: props.gap,
width: props.width,
height: props.height,
padding: props.padding,
margin: props.margin,
}}
border={props.border}
title={props.title}
>
{props.children}
</box>
)
}

View File

@@ -3,97 +3,99 @@
* 8-character alphanumeric code input for sync authentication * 8-character alphanumeric code input for sync authentication
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { useAuthStore } from "../stores/auth" import { useAuthStore } from "@/stores/auth";
import { AUTH_CONFIG } from "../config/auth" import { AUTH_CONFIG } from "@/config/auth";
interface CodeValidationProps { interface CodeValidationProps {
focused?: boolean focused?: boolean;
onBack?: () => void onBack?: () => void;
} }
type FocusField = "code" | "submit" | "back" type FocusField = "code" | "submit" | "back";
export function CodeValidation(props: CodeValidationProps) { export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore() const auth = useAuthStore();
const [code, setCode] = createSignal("") const [code, setCode] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("code") const [focusField, setFocusField] = createSignal<FocusField>("code");
const [codeError, setCodeError] = createSignal<string | null>(null) const [codeError, setCodeError] = createSignal<string | null>(null);
const fields: FocusField[] = ["code", "submit", "back"] const fields: FocusField[] = ["code", "submit", "back"];
/** Format code as user types (uppercase, alphanumeric only) */ /** Format code as user types (uppercase, alphanumeric only) */
const handleCodeInput = (value: string) => { const handleCodeInput = (value: string) => {
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "") const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
// Limit to max length // Limit to max length
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength) const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
setCode(limited) setCode(limited);
// Clear error when typing // Clear error when typing
if (codeError()) { if (codeError()) {
setCodeError(null) setCodeError(null);
} }
} };
const validateCode = (value: string): boolean => { const validateCode = (value: string): boolean => {
if (!value) { if (!value) {
setCodeError("Code is required") setCodeError("Code is required");
return false return false;
} }
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) { if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
setCodeError(`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`) setCodeError(
return false `Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
);
return false;
} }
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) { if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
setCodeError("Code must contain only letters and numbers") setCodeError("Code must contain only letters and numbers");
return false return false;
} }
setCodeError(null) setCodeError(null);
return true return true;
} };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validateCode(code())) { if (!validateCode(code())) {
return return;
} }
const success = await auth.validateCode(code()) const success = await auth.validateCode(code());
if (!success && auth.error) { if (!success && auth.error) {
setCodeError(auth.error.message) setCodeError(auth.error.message);
} }
} };
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField()) const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length ? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length : (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]) setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
if (focusField() === "submit") { if (focusField() === "submit") {
handleSubmit() handleSubmit();
} else if (focusField() === "back" && props.onBack) { } else if (focusField() === "back" && props.onBack) {
props.onBack() props.onBack();
} }
} else if (key.name === "escape" && props.onBack) { } else if (key.name === "escape" && props.onBack) {
props.onBack() props.onBack();
} }
} };
const codeProgress = () => { const codeProgress = () => {
const len = code().length const len = code().length;
const max = AUTH_CONFIG.codeValidation.codeLength const max = AUTH_CONFIG.codeValidation.codeLength;
return `${len}/${max}` return `${len}/${max}`;
} };
const codeDisplay = () => { const codeDisplay = () => {
const current = code() const current = code();
const max = AUTH_CONFIG.codeValidation.codeLength const max = AUTH_CONFIG.codeValidation.codeLength;
const filled = current.split("") const filled = current.split("");
const empty = Array(max - filled.length).fill("_") const empty = Array(max - filled.length).fill("_");
return [...filled, ...empty].join(" ") return [...filled, ...empty].join(" ");
} };
return ( return (
<box flexDirection="column" border padding={2} gap={1}> <box flexDirection="column" border padding={2} gap={1}>
@@ -103,7 +105,9 @@ export function CodeValidation(props: CodeValidationProps) {
<box height={1} /> <box height={1} />
<text fg="gray">Enter your 8-character sync code to link your account.</text> <text fg="gray">
Enter your 8-character sync code to link your account.
</text>
<text fg="gray">You can get this code from the web portal.</text> <text fg="gray">You can get this code from the web portal.</text>
<box height={1} /> <box height={1} />
@@ -115,7 +119,13 @@ export function CodeValidation(props: CodeValidationProps) {
</text> </text>
<box border padding={1}> <box border padding={1}>
<text fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}> <text
fg={
code().length === AUTH_CONFIG.codeValidation.codeLength
? "green"
: "yellow"
}
>
{codeDisplay()} {codeDisplay()}
</text> </text>
</box> </box>
@@ -129,9 +139,7 @@ export function CodeValidation(props: CodeValidationProps) {
width={30} width={30}
/> />
{codeError() && ( {codeError() && <text fg="red">{codeError()}</text>}
<text fg="red">{codeError()}</text>
)}
</box> </box>
<box height={1} /> <box height={1} />
@@ -160,13 +168,11 @@ export function CodeValidation(props: CodeValidationProps) {
</box> </box>
{/* Auth error message */} {/* Auth error message */}
{auth.error && ( {auth.error && <text fg="red">{auth.error.message}</text>}
<text fg="red">{auth.error.message}</text>
)}
<box height={1} /> <box height={1} />
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text> <text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</box> </box>
) );
} }

View File

@@ -1,35 +0,0 @@
import type { JSX } from "solid-js"
type ColumnProps = {
children?: JSX.Element
gap?: number
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly"
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
padding?: number
}
export function Column(props: ColumnProps) {
return (
<box
style={{
flexDirection: "column",
gap: props.gap,
alignItems: props.alignItems,
justifyContent: props.justifyContent,
width: props.width,
height: props.height,
padding: props.padding,
}}
>
{props.children}
</box>
)
}

View File

@@ -1,17 +0,0 @@
type FileInfoProps = {
path: string
format: string
size: string
modifiedAt: string
}
export function FileInfo(props: FileInfoProps) {
return (
<box border title="File Info" style={{ padding: 1, flexDirection: "column" }}>
<text>Path: {props.path}</text>
<text>Format: {props.format}</text>
<text>Size: {props.size}</text>
<text>Modified: {props.modifiedAt}</text>
</box>
)
}

View File

@@ -1,17 +0,0 @@
import type { JSX } from "solid-js"
import type { TabId } from "./Tab"
/**
* @deprecated Use useAppKeyboard hook directly instead.
* This component is kept for backwards compatibility.
*/
type KeyboardHandlerProps = {
children?: JSX.Element
onTabSelect?: (tab: TabId) => void
}
export function KeyboardHandler(props: KeyboardHandlerProps) {
// Keyboard handling has been moved to useAppKeyboard hook
// This component is now just a passthrough
return <>{props.children}</>
}

View File

@@ -1,30 +0,0 @@
import { useTheme } from "../context/ThemeContext"
export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
const { theme } = useTheme()
const getLayerIndicator = () => {
const indicators = []
for (let i = 0; i < 4; i++) {
const isActive = i <= layerDepth
const color = isActive ? theme.accent : theme.textMuted
const size = isActive ? "●" : "○"
indicators.push(
<text fg={color} marginRight={1}>
{size}
</text>
)
}
return indicators
}
return (
<box flexDirection="row" alignItems="center">
<text fg={theme.textMuted} marginRight={1}>Depth:</text>
{getLayerIndicator()}
<text fg={theme.textMuted} marginLeft={1}>
{layerDepth}
</text>
</box>
)
}

View File

@@ -1,64 +1,63 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js";
import type { RGBA } from "@opentui/core" import type { RGBA } from "@opentui/core";
import { Show, For, createMemo } from "solid-js" import { Show, For } from "solid-js";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
type PanelConfig = { type PanelConfig = {
/** Panel content */ /** Panel content */
content: JSX.Element content: JSX.Element;
/** Panel title shown in header */ /** Panel title shown in header */
title?: string title?: string;
/** Fixed width (leave undefined for flex) */ /** Fixed width (leave undefined for flex) */
width?: number width?: number;
/** Whether this panel is currently focused */ /** Whether this panel is currently focused */
focused?: boolean focused?: boolean;
} };
type LayoutProps = { type LayoutProps = {
/** Top tab bar */ /** Top tab bar */
header?: JSX.Element header?: JSX.Element;
/** Bottom status bar */ /** Bottom status bar */
footer?: JSX.Element footer?: JSX.Element;
/** Panels to display left-to-right like a file explorer */ /** Panels to display left-to-right like a file explorer */
panels: PanelConfig[] panels: PanelConfig[];
/** Index of the currently active/focused panel */ /** Index of the currently active/focused panel */
activePanelIndex?: number activePanelIndex?: number;
} };
export function Layout(props: LayoutProps) { export function Layout(props: LayoutProps) {
const context = useTheme()
const panelBg = (index: number): RGBA => { const panelBg = (index: number): RGBA => {
const backgrounds = context.theme.layerBackgrounds const backgrounds = theme.layerBackgrounds;
const layers = [ const layers = [
backgrounds?.layer0 ?? context.theme.background, backgrounds?.layer0 ?? theme.background,
backgrounds?.layer1 ?? context.theme.backgroundPanel, backgrounds?.layer1 ?? theme.backgroundPanel,
backgrounds?.layer2 ?? context.theme.backgroundElement, backgrounds?.layer2 ?? theme.backgroundElement,
backgrounds?.layer3 ?? context.theme.backgroundMenu, backgrounds?.layer3 ?? theme.backgroundMenu,
] ];
return layers[Math.min(index, layers.length - 1)] return layers[Math.min(index, layers.length - 1)];
} };
const borderColor = (index: number): RGBA | string => { const borderColor = (index: number): RGBA | string => {
const isActive = index === (props.activePanelIndex ?? 0) const isActive = index === (props.activePanelIndex ?? 0);
return isActive return isActive
? (context.theme.accent ?? context.theme.primary) ? (theme.accent ?? theme.primary)
: (context.theme.border ?? context.theme.textMuted) : (theme.border ?? theme.textMuted);
} };
const { theme } = useTheme();
return ( return (
<box <box
flexDirection="column" flexDirection="column"
width="100%" width="100%"
height="100%" height="100%"
backgroundColor={context.theme.background} backgroundColor={theme.background}
> >
{/* Header - tab bar */} {/* Header - tab bar */}
<Show when={props.header}> <Show when={props.header}>
<box <box
style={{ style={{
height: 3, height: 3,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel, backgroundColor: theme.surface ?? theme.backgroundPanel,
}} }}
> >
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}> <box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
@@ -68,16 +67,13 @@ export function Layout(props: LayoutProps) {
</Show> </Show>
{/* Main content: side-by-side panels */} {/* Main content: side-by-side panels */}
<box <box flexDirection="row" style={{ flexGrow: 1 }}>
flexDirection="row"
style={{ flexGrow: 1 }}
>
<For each={props.panels}> <For each={props.panels}>
{(panel, index) => ( {(panel, index) => (
<box <box
flexDirection="column" flexDirection="column"
border border
borderColor={borderColor(index())} borderColor={theme.border}
backgroundColor={panelBg(index())} backgroundColor={panelBg(index())}
style={{ style={{
flexGrow: panel.width ? 0 : 1, flexGrow: panel.width ? 0 : 1,
@@ -92,13 +88,18 @@ export function Layout(props: LayoutProps) {
height: 1, height: 1,
paddingLeft: 1, paddingLeft: 1,
paddingRight: 1, paddingRight: 1,
backgroundColor: index() === (props.activePanelIndex ?? 0) backgroundColor:
? (context.theme.accent ?? context.theme.primary) index() === (props.activePanelIndex ?? 0)
: (context.theme.surface ?? context.theme.backgroundPanel), ? (theme.accent ?? theme.primary)
: (theme.surface ?? theme.backgroundPanel),
}} }}
> >
<text <text
fg={index() === (props.activePanelIndex ?? 0) ? "black" : undefined} fg={
index() === (props.activePanelIndex ?? 0)
? "black"
: undefined
}
> >
<strong>{panel.title}</strong> <strong>{panel.title}</strong>
</text> </text>
@@ -124,14 +125,12 @@ export function Layout(props: LayoutProps) {
<box <box
style={{ style={{
height: 2, height: 2,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel, backgroundColor: theme.surface ?? theme.backgroundPanel,
}} }}
> >
<box style={{ padding: 1 }}> <box style={{ padding: 1 }}>{props.footer}</box>
{props.footer}
</box>
</box> </box>
</Show> </Show>
</box> </box>
) );
} }

View File

@@ -1,97 +0,0 @@
/**
* MergedWaveform — unified progress bar + waveform display
*
* Shows waveform bars coloured to indicate played vs unplayed portions.
* The played section doubles as the progress indicator, replacing the
* separate progress bar. Click-to-seek is supported.
*/
import { createSignal, createEffect, onCleanup } from "solid-js";
import { getWaveformData } from "../utils/audio-waveform";
type MergedWaveformProps = {
/** Audio URL — used to generate or retrieve waveform data */
audioUrl: string;
/** Current playback position in seconds */
position: number;
/** Total duration in seconds */
duration: number;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Number of data points / columns */
resolution?: number;
/** Callback when user clicks to seek */
onSeek?: (seconds: number) => void;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
export function MergedWaveform(props: MergedWaveformProps) {
const resolution = () => props.resolution ?? 64;
// Waveform data — start with sync/cached, kick off async extraction
const [data, setData] = createSignal<number[]>();
// When the audioUrl changes, attempt async extraction for real data
createEffect(() => {
const url = props.audioUrl;
const res = resolution();
if (!url) return;
let cancelled = false;
getWaveformData(url, res).then((result) => {
if (!cancelled) setData(result);
});
onCleanup(() => {
cancelled = true;
});
});
const playedRatio = () =>
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
const renderLine = () => {
const d = data();
if (!d) {
console.error("no data recieved");
return;
}
const played = Math.floor(d.length * playedRatio());
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = d
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const futureChars = d
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
</box>
);
};
const handleClick = (event: { x: number }) => {
const d = data();
const ratio = !d || d.length === 0 ? 0 : event.x / d.length;
const next = Math.max(
0,
Math.min(props.duration, Math.round(props.duration * ratio)),
);
props.onSeek?.(next);
};
return (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
);
}

View File

@@ -1,154 +0,0 @@
import { useKeyboard } from "@opentui/solid"
import { PlaybackControls } from "./PlaybackControls"
import { MergedWaveform } from "./MergedWaveform"
import { RealtimeWaveform, isCavacoreAvailable } from "./RealtimeWaveform"
import { useAudio } from "../hooks/useAudio"
import { useAppStore } from "../stores/app"
import type { Episode } from "../types/episode"
type PlayerProps = {
focused: boolean
episode?: Episode | null
onExit?: () => void
}
const SAMPLE_EPISODE: Episode = {
id: "sample-ep",
podcastId: "sample-podcast",
title: "A Tour of the Productive Mind",
description: "A short guided session on building creative focus.",
audioUrl: "",
duration: 2780,
pubDate: new Date(),
}
export function Player(props: PlayerProps) {
const audio = useAudio()
// The episode to display — prefer a passed-in episode, then the
// currently-playing episode, then fall back to the sample.
const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE
const dur = () => audio.duration() || episode().duration || 1
useKeyboard((key: { name: string }) => {
if (!props.focused) return
if (key.name === "space") {
if (audio.currentEpisode()) {
audio.togglePlayback()
} else {
// Nothing loaded yet — start playing the displayed episode
const ep = episode()
if (ep.audioUrl) {
audio.play(ep)
}
}
return
}
if (key.name === "escape") {
props.onExit?.()
return
}
if (key.name === "left") {
audio.seekRelative(-10)
}
if (key.name === "right") {
audio.seekRelative(10)
}
if (key.name === "up") {
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))))
}
if (key.name === "down") {
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))))
}
if (key.name === "s") {
const next = audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2))
audio.setSpeed(next)
}
})
const progressPercent = () => {
const d = dur()
if (d <= 0) return 0
return Math.min(100, Math.round((audio.position() / d) * 100))
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${String(s).padStart(2, "0")}`
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Now Playing</strong>
</text>
<text fg="gray">
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
</text>
</box>
{audio.error() && (
<text fg="red">{audio.error()}</text>
)}
<box border padding={1} flexDirection="column" gap={1}>
<text fg="white">
<strong>{episode().title}</strong>
</text>
<text fg="gray">{episode().description}</text>
{isCavacoreAvailable() ? (
<RealtimeWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
speed={audio.speed()}
onSeek={(next: number) => audio.seek(next)}
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
}
})()}
/>
) : (
<MergedWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
onSeek={(next: number) => audio.seek(next)}
/>
)}
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!episode().audioUrl}
onToggle={() => {
if (audio.currentEpisode()) {
audio.togglePlayback()
} else {
const ep = episode()
if (ep.audioUrl) audio.play(ep)
}
}}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(dur())}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<text fg="gray">Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back</text>
</box>
)
}

View File

@@ -1,19 +0,0 @@
import { createMemo, type JSX } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
type ResponsiveContainerProps = {
children?: (size: "small" | "medium" | "large") => JSX.Element
}
export function ResponsiveContainer(props: ResponsiveContainerProps) {
const dimensions = useTerminalDimensions()
const size = createMemo<"small" | "medium" | "large">(() => {
const width = dimensions().width
if (width < 60) return "small"
if (width < 100) return "medium"
return "large"
})
return <>{props.children?.(size())}</>
}

View File

@@ -1,75 +0,0 @@
import { Show } from "solid-js"
import { format } from "date-fns"
import type { SearchResult } from "../types/source"
import { SourceBadge } from "./SourceBadge"
type ResultDetailProps = {
result?: SearchResult
onSubscribe?: (result: SearchResult) => void
}
export function ResultDetail(props: ResultDetailProps) {
return (
<box flexDirection="column" border padding={1} gap={1} height="100%">
<Show
when={props.result}
fallback={
<text fg="gray">Select a result to see details.</text>
}
>
{(result) => (
<>
<text fg="white">
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg="gray">by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg="gray">{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg="yellow">[{category}]</text>
))}
</box>
</Show>
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
<text fg="gray">
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="cyan">[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg="green">Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
)
}

View File

@@ -1,35 +0,0 @@
import type { JSX } from "solid-js"
type RowProps = {
children?: JSX.Element
gap?: number
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly"
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
padding?: number
}
export function Row(props: RowProps) {
return (
<box
style={{
flexDirection: "row",
gap: props.gap,
alignItems: props.alignItems,
justifyContent: props.justifyContent,
width: props.width,
height: props.height,
padding: props.padding,
}}
>
{props.children}
</box>
)
}

View File

@@ -1,4 +1,4 @@
import { shortcuts } from "../config/shortcuts" import { shortcuts } from "@/config/shortcuts";
export function ShortcutHelp() { export function ShortcutHelp() {
return ( return (
@@ -22,5 +22,5 @@ export function ShortcutHelp() {
</box> </box>
</box> </box>
</box> </box>
) );
} }

View File

@@ -1,34 +0,0 @@
import { SourceType } from "../types/source"
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"
}
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "cyan"
if (sourceType === SourceType.RSS) return "green"
if (sourceType === SourceType.CUSTOM) return "yellow"
return "gray"
}
export function SourceBadge(props: SourceBadgeProps) {
const label = () => props.sourceName || props.sourceId
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg="gray">{label()}</text>
</box>
)
}

View File

@@ -1,11 +1,17 @@
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
export type TabId = "feed" | "shows" | "discover" | "search" | "player" | "settings" export type TabId =
| "feed"
| "shows"
| "discover"
| "search"
| "player"
| "settings";
export type TabDefinition = { export type TabDefinition = {
id: TabId id: TabId;
label: string label: string;
} };
export const tabs: TabDefinition[] = [ export const tabs: TabDefinition[] = [
{ id: "feed", label: "Feed" }, { id: "feed", label: "Feed" },
@@ -14,27 +20,31 @@ export const tabs: TabDefinition[] = [
{ id: "search", label: "Search" }, { id: "search", label: "Search" },
{ id: "player", label: "Player" }, { id: "player", label: "Player" },
{ id: "settings", label: "Settings" }, { id: "settings", label: "Settings" },
] ];
type TabProps = { type TabProps = {
tab: TabDefinition tab: TabDefinition;
active: boolean active: boolean;
onSelect: (tab: TabId) => void onSelect: (tab: TabId) => void;
} };
export function Tab(props: TabProps) { export function Tab(props: TabProps) {
const { theme } = useTheme() const { theme } = useTheme();
return ( return (
<box <box
border border
borderColor={theme.border}
onMouseDown={() => props.onSelect(props.tab.id)} onMouseDown={() => props.onSelect(props.tab.id)}
style={{ padding: 1, backgroundColor: props.active ? theme.primary : "transparent" }} style={{
padding: 1,
backgroundColor: props.active ? theme.primary : "transparent",
}}
> >
<text> <text style={{ fg: theme.text }}>
{props.active ? "[" : " "} {props.active ? "[" : " "}
{props.tab.label} {props.tab.label}
{props.active ? "]" : " "} {props.active ? "]" : " "}
</text> </text>
</box> </box>
) );
} }

View File

@@ -1,19 +1,43 @@
import { Tab, type TabId } from "./Tab" import { Tab, type TabId } from "./Tab";
type TabNavigationProps = { type TabNavigationProps = {
activeTab: TabId activeTab: TabId;
onTabSelect: (tab: TabId) => void onTabSelect: (tab: TabId) => void;
} };
export function TabNavigation(props: TabNavigationProps) { export function TabNavigation(props: TabNavigationProps) {
return ( return (
<box style={{ flexDirection: "row", gap: 1 }}> <box style={{ flexDirection: "row", gap: 1 }}>
<Tab tab={{ id: "feed", label: "Feed" }} active={props.activeTab === "feed"} onSelect={props.onTabSelect} /> <Tab
<Tab tab={{ id: "shows", label: "My Shows" }} active={props.activeTab === "shows"} onSelect={props.onTabSelect} /> tab={{ id: "feed", label: "Feed" }}
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} /> active={props.activeTab === "feed"}
<Tab tab={{ id: "search", label: "Search" }} active={props.activeTab === "search"} onSelect={props.onTabSelect} /> onSelect={props.onTabSelect}
<Tab tab={{ id: "player", label: "Player" }} active={props.activeTab === "player"} onSelect={props.onTabSelect} /> />
<Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} /> <Tab
tab={{ id: "shows", label: "My Shows" }}
active={props.activeTab === "shows"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "discover", label: "Discover" }}
active={props.activeTab === "discover"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "search", label: "Search" }}
active={props.activeTab === "search"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "player", label: "Player" }}
active={props.activeTab === "player"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "settings", label: "Settings" }}
active={props.activeTab === "settings"}
onSelect={props.onTabSelect}
/>
</box> </box>
) );
} }

View File

@@ -1,52 +0,0 @@
type WaveformProps = {
data: number[]
position: number
duration: number
isPlaying: boolean
onSeek?: (next: number) => void
}
const bars = [".", "-", "~", "=", "#"]
export function Waveform(props: WaveformProps) {
const playedRatio = () => (props.duration === 0 ? 0 : props.position / props.duration)
const renderLine = () => {
const playedCount = Math.floor(props.data.length * playedRatio())
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
const futureColor = "#3b4252"
const played = props.data
.map((value, index) =>
index <= playedCount
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
: ""
)
.join("")
const upcoming = props.data
.map((value, index) =>
index > playedCount
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
: ""
)
.join("")
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{played || " "}</text>
<text fg={futureColor}>{upcoming || " "}</text>
</box>
)
}
const handleClick = (event: { x: number }) => {
const ratio = props.data.length === 0 ? 0 : event.x / props.data.length
const next = Math.max(0, Math.min(props.duration, Math.round(props.duration * ratio)))
props.onSeek?.(next)
}
return (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
)
}

View File

@@ -1,80 +1,91 @@
import { createEffect, createMemo, onMount, onCleanup } from "solid-js" import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store";
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid";
import type { ThemeName } from "../types/settings" import type { ThemeName } from "../types/settings";
import type { ThemeJson } from "../types/theme-schema" import type { ThemeJson } from "../types/theme-schema";
import { useAppStore } from "../stores/app" import { useAppStore } from "../stores/app";
import { THEME_JSON } from "../constants/themes" import { THEME_JSON } from "../constants/themes";
import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter" import {
import { resolveTerminalTheme, loadThemes } from "../utils/theme" generateSyntax,
import { createSimpleContext } from "./helper" generateSubtleSyntax,
import { setupThemeSignalHandler, emitThemeChanged, emitThemeModeChanged } from "../utils/theme-observer" } from "../utils/syntax-highlighter";
import { createTerminalPalette, type RGBA, type TerminalColors } from "@opentui/core" import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { createSimpleContext } from "./helper";
import {
setupThemeSignalHandler,
emitThemeChanged,
emitThemeModeChanged,
} from "../utils/theme-observer";
import {
createTerminalPalette,
type RGBA,
type TerminalColors,
} from "@opentui/core";
type ThemeResolved = { type ThemeResolved = {
primary: RGBA primary: RGBA;
secondary: RGBA secondary: RGBA;
accent: RGBA accent: RGBA;
error: RGBA error: RGBA;
warning: RGBA warning: RGBA;
success: RGBA success: RGBA;
info: RGBA info: RGBA;
text: RGBA text: RGBA;
textMuted: RGBA textMuted: RGBA;
selectedListItemText: RGBA selectedListItemText: RGBA;
background: RGBA background: RGBA;
backgroundPanel: RGBA backgroundPanel: RGBA;
backgroundElement: RGBA backgroundElement: RGBA;
backgroundMenu: RGBA backgroundMenu: RGBA;
border: RGBA border: RGBA;
borderActive: RGBA borderActive: RGBA;
borderSubtle: RGBA borderSubtle: RGBA;
diffAdded: RGBA diffAdded: RGBA;
diffRemoved: RGBA diffRemoved: RGBA;
diffContext: RGBA diffContext: RGBA;
diffHunkHeader: RGBA diffHunkHeader: RGBA;
diffHighlightAdded: RGBA diffHighlightAdded: RGBA;
diffHighlightRemoved: RGBA diffHighlightRemoved: RGBA;
diffAddedBg: RGBA diffAddedBg: RGBA;
diffRemovedBg: RGBA diffRemovedBg: RGBA;
diffContextBg: RGBA diffContextBg: RGBA;
diffLineNumber: RGBA diffLineNumber: RGBA;
diffAddedLineNumberBg: RGBA diffAddedLineNumberBg: RGBA;
diffRemovedLineNumberBg: RGBA diffRemovedLineNumberBg: RGBA;
markdownText: RGBA markdownText: RGBA;
markdownHeading: RGBA markdownHeading: RGBA;
markdownLink: RGBA markdownLink: RGBA;
markdownLinkText: RGBA markdownLinkText: RGBA;
markdownCode: RGBA markdownCode: RGBA;
markdownBlockQuote: RGBA markdownBlockQuote: RGBA;
markdownEmph: RGBA markdownEmph: RGBA;
markdownStrong: RGBA markdownStrong: RGBA;
markdownHorizontalRule: RGBA markdownHorizontalRule: RGBA;
markdownListItem: RGBA markdownListItem: RGBA;
markdownListEnumeration: RGBA markdownListEnumeration: RGBA;
markdownImage: RGBA markdownImage: RGBA;
markdownImageText: RGBA markdownImageText: RGBA;
markdownCodeBlock: RGBA markdownCodeBlock: RGBA;
syntaxComment: RGBA syntaxComment: RGBA;
syntaxKeyword: RGBA syntaxKeyword: RGBA;
syntaxFunction: RGBA syntaxFunction: RGBA;
syntaxVariable: RGBA syntaxVariable: RGBA;
syntaxString: RGBA syntaxString: RGBA;
syntaxNumber: RGBA syntaxNumber: RGBA;
syntaxType: RGBA syntaxType: RGBA;
syntaxOperator: RGBA syntaxOperator: RGBA;
syntaxPunctuation: RGBA syntaxPunctuation: RGBA;
muted?: RGBA muted?: RGBA;
surface?: RGBA surface?: RGBA;
layerBackgrounds?: { layerBackgrounds?: {
layer0: RGBA layer0: RGBA;
layer1: RGBA layer1: RGBA;
layer2: RGBA layer2: RGBA;
layer3: RGBA layer3: RGBA;
} };
_hasSelectedListItemText?: boolean _hasSelectedListItemText?: boolean;
thinkingOpacity?: number thinkingOpacity?: number;
} };
/** /**
* Theme context using the createSimpleContext pattern. * Theme context using the createSimpleContext pattern.
@@ -89,88 +100,104 @@ type ThemeResolved = {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme", name: "Theme",
init: (props: { mode: "dark" | "light" }) => { init: (props: { mode: "dark" | "light" }) => {
const appStore = useAppStore() const appStore = useAppStore();
const renderer = useRenderer() const renderer = useRenderer();
const [store, setStore] = createStore({ const [store, setStore] = createStore({
themes: THEME_JSON as Record<string, ThemeJson>, themes: THEME_JSON as Record<string, ThemeJson>,
mode: props.mode, mode: props.mode,
active: appStore.state().settings.theme as string, active: appStore.state().settings.theme as string,
system: undefined as undefined | TerminalColors, system: undefined as undefined | TerminalColors,
ready: false, ready: false,
}) });
function init() { function init() {
resolveSystemTheme() resolveSystemTheme();
loadThemes() loadThemes()
.then((custom) => { .then((custom) => {
setStore( setStore(
produce((draft) => { produce((draft) => {
Object.assign(draft.themes, custom) Object.assign(draft.themes, custom);
}) }),
) );
}) })
.catch(() => { .catch(() => {
// If custom themes fail to load, fall back to opencode theme // If custom themes fail to load, fall back to opencode theme
setStore("active", "opencode") setStore("active", "opencode");
}) })
.finally(() => { .finally(() => {
// Only set ready if not waiting for system theme // Only set ready if not waiting for system theme
if (store.active !== "system") { if (store.active !== "system") {
setStore("ready", true) setStore("ready", true);
} }
}) });
} }
async function waitForCapabilities(timeoutMs = 300) { async function waitForCapabilities(timeoutMs = 300) {
if (renderer.capabilities) return if (renderer.capabilities) return;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
let done = false let done = false;
const onCaps = () => { const onCaps = () => {
if (done) return if (done) return;
done = true done = true;
renderer.off("capabilities", onCaps) renderer.off("capabilities", onCaps);
clearTimeout(timer) clearTimeout(timer);
resolve() resolve();
} };
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (done) return if (done) return;
done = true done = true;
renderer.off("capabilities", onCaps) renderer.off("capabilities", onCaps);
resolve() resolve();
}, timeoutMs) }, timeoutMs);
renderer.on("capabilities", onCaps) renderer.on("capabilities", onCaps);
}) });
} }
async function resolveSystemTheme() { async function resolveSystemTheme() {
if (process.env.TMUX) { if (process.env.TMUX) {
await waitForCapabilities() await waitForCapabilities();
} }
let colors: TerminalColors | null = null let colors: TerminalColors | null = null;
try { try {
colors = await renderer.getPalette({ size: 16 }) colors = await renderer.getPalette({ size: 16 });
} catch { } catch {
colors = null colors = null;
} }
if (!colors?.palette?.[0] && process.env.TMUX) { if (!colors?.palette?.[0] && process.env.TMUX) {
const writeOut = (renderer as unknown as { writeOut?: (data: string | Buffer) => boolean }).writeOut const writeOut = (
const writeFn = typeof writeOut === "function" ? writeOut.bind(renderer) : process.stdout.write.bind(process.stdout) renderer as unknown as {
const detector = createTerminalPalette(process.stdin, process.stdout, writeFn, true) writeOut?: (data: string | Buffer) => boolean;
}
).writeOut;
const writeFn =
typeof writeOut === "function"
? writeOut.bind(renderer)
: process.stdout.write.bind(process.stdout);
const detector = createTerminalPalette(
process.stdin,
process.stdout,
writeFn,
true,
);
try { try {
const tmuxColors = await detector.detect({ size: 16, timeout: 1200 }) const tmuxColors = await detector.detect({ size: 16, timeout: 1200 });
if (tmuxColors?.palette?.[0]) { if (tmuxColors?.palette?.[0]) {
colors = tmuxColors colors = tmuxColors;
} }
} finally { } finally {
detector.cleanup() detector.cleanup();
} }
} }
const hasPalette = Boolean(colors?.palette?.some((value) => Boolean(value))) const hasPalette = Boolean(
const hasDefaultColors = Boolean(colors?.defaultBackground || colors?.defaultForeground) colors?.palette?.some((value) => Boolean(value)),
);
const hasDefaultColors = Boolean(
colors?.defaultBackground || colors?.defaultForeground,
);
if (!hasPalette && !hasDefaultColors) { if (!hasPalette && !hasDefaultColors) {
// No system colors available, fall back to default // No system colors available, fall back to default
@@ -179,89 +206,100 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.active = "opencode" draft.active = "opencode";
draft.ready = true draft.ready = true;
}) }),
) );
} }
return return;
} }
if (colors) { if (colors) {
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.system = colors draft.system = colors;
if (store.active === "system") { if (store.active === "system") {
draft.ready = true draft.ready = true;
} }
}) }),
) );
} }
} }
onMount(init) onMount(init);
// Setup SIGUSR2 signal handler for dynamic theme reload // Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending: // This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>` // `kill -USR2 <pid>`
const cleanupSignalHandler = setupThemeSignalHandler(() => { const cleanupSignalHandler = setupThemeSignalHandler(() => {
renderer.clearPaletteCache() renderer.clearPaletteCache();
init() init();
}) });
onCleanup(cleanupSignalHandler) onCleanup(cleanupSignalHandler);
// Sync active theme with app store settings // Sync active theme with app store settings
createEffect(() => { createEffect(() => {
const theme = appStore.state().settings.theme const theme = appStore.state().settings.theme;
if (theme) setStore("active", theme) if (theme) setStore("active", theme);
}) });
// Emit theme change events for observers // Emit theme change events for observers
createEffect(() => { createEffect(() => {
const theme = store.active const theme = store.active;
const mode = store.mode const mode = store.mode;
if (store.ready) { if (store.ready) {
emitThemeChanged(theme, mode) emitThemeChanged(theme, mode);
} }
}) });
const values = createMemo(() => { const values = createMemo(() => {
return resolveTerminalTheme(store.themes, store.active, store.mode, store.system) return resolveTerminalTheme(
}) store.themes,
store.active,
store.mode,
store.system,
);
});
const syntax = createMemo(() => generateSyntax(values() as unknown as Record<string, RGBA>)) const syntax = createMemo(() =>
generateSyntax(values() as unknown as Record<string, RGBA>),
);
const subtleSyntax = createMemo(() => const subtleSyntax = createMemo(() =>
generateSubtleSyntax(values() as unknown as Record<string, RGBA> & { thinkingOpacity?: number }) generateSubtleSyntax(
) values() as unknown as Record<string, RGBA> & {
thinkingOpacity?: number;
},
),
);
return { return {
theme: new Proxy(values(), { theme: new Proxy(values(), {
get(_target, prop) { get(_target, prop) {
// @ts-expect-error - dynamic property access // @ts-expect-error - dynamic property access
return values()[prop] return values()[prop];
}, },
}) as ThemeResolved, }) as ThemeResolved,
get selected() { get selected() {
return store.active return store.active;
}, },
all() { all() {
return store.themes return store.themes;
}, },
syntax, syntax,
subtleSyntax, subtleSyntax,
mode() { mode() {
return store.mode return store.mode;
}, },
setMode(mode: "dark" | "light") { setMode(mode: "dark" | "light") {
setStore("mode", mode) setStore("mode", mode);
emitThemeModeChanged(mode) emitThemeModeChanged(mode);
}, },
set(theme: string) { set(theme: string) {
appStore.setTheme(theme as ThemeName) appStore.setTheme(theme as ThemeName);
}, },
get ready() { get ready() {
return store.ready return store.ready;
}, },
} };
}, },
}) });

View File

@@ -1,36 +1,39 @@
// Hack: Force TERM to tmux-256color when running in tmux to enable // Hack: Force TERM to tmux-256color when running in tmux to enable
// correct palette detection in @opentui/core // correct palette detection in @opentui/core
if (process.env.TMUX && !process.env.TERM?.includes("tmux")) { //if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
process.env.TERM = "tmux-256color" //process.env.TERM = "tmux-256color"
} //}
import { render, useRenderer } from "@opentui/solid" import { render, useRenderer } from "@opentui/solid";
import { App } from "./App" import { App } from "./App";
import { ThemeProvider } from "./context/ThemeContext" import { ThemeProvider } from "./context/ThemeContext";
import { ToastProvider, Toast } from "./ui/toast" import { ToastProvider, Toast } from "./ui/toast";
import { KeybindProvider } from "./context/KeybindContext" import { KeybindProvider } from "./context/KeybindContext";
import { DialogProvider } from "./ui/dialog" import { DialogProvider } from "./ui/dialog";
import { CommandProvider } from "./ui/command" import { CommandProvider } from "./ui/command";
function RendererSetup(props: { children: unknown }) { function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer() const renderer = useRenderer();
renderer.disableStdoutInterception() renderer.disableStdoutInterception();
return props.children return props.children;
} }
render(() => ( render(
<RendererSetup> () => (
<ToastProvider> <RendererSetup>
<ThemeProvider mode="dark"> <ToastProvider>
<KeybindProvider> <ThemeProvider mode="dark">
<DialogProvider> <KeybindProvider>
<CommandProvider> <DialogProvider>
<App /> <CommandProvider>
<Toast /> <App />
</CommandProvider> <Toast />
</DialogProvider> </CommandProvider>
</KeybindProvider> </DialogProvider>
</ThemeProvider> </KeybindProvider>
</ToastProvider> </ThemeProvider>
</RendererSetup> </ToastProvider>
), { useThread: false }) </RendererSetup>
),
{ useThread: false },
);

View File

@@ -1,13 +1,20 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes" import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences, VisualizerSettings } from "../types/settings" import type {
import { resolveTheme } from "../utils/theme-resolver" AppSettings,
import type { ThemeJson } from "../types/theme-schema" AppState,
ThemeColors,
ThemeName,
ThemeMode,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { resolveTheme } from "../utils/theme-resolver";
import type { ThemeJson } from "../types/theme-schema";
import { import {
loadAppStateFromFile, loadAppStateFromFile,
saveAppStateToFile, saveAppStateToFile,
migrateAppStateFromLocalStorage, } from "../utils/app-persistence";
} from "../utils/app-persistence"
const defaultVisualizerSettings: VisualizerSettings = { const defaultVisualizerSettings: VisualizerSettings = {
bars: 32, bars: 32,
@@ -15,7 +22,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
} };
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
@@ -23,82 +30,84 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
} };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
} };
const defaultState: AppState = { const defaultState: AppState = {
settings: defaultSettings, settings: defaultSettings,
preferences: defaultPreferences, preferences: defaultPreferences,
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
} };
export function createAppStore() { export 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);
// Fire-and-forget async initialisation // Fire-and-forget async initialisation
const init = async () => { const init = async () => {
await migrateAppStateFromLocalStorage() const loaded = await loadAppStateFromFile();
const loaded = await loadAppStateFromFile() setState(loaded);
setState(loaded) };
} init();
init()
const saveState = (next: AppState) => { const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {}) saveAppStateToFile(next).catch(() => {});
} };
const updateState = (next: AppState) => { const updateState = (next: AppState) => {
setState(next) setState(next);
saveState(next) saveState(next);
} };
const updateSettings = (updates: Partial<AppSettings>) => { const updateSettings = (updates: Partial<AppSettings>) => {
const next = { const next = {
...state(), ...state(),
settings: { ...state().settings, ...updates }, settings: { ...state().settings, ...updates },
} };
updateState(next) updateState(next);
} };
const updatePreferences = (updates: Partial<UserPreferences>) => { const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = { const next = {
...state(), ...state(),
preferences: { ...state().preferences, ...updates }, preferences: { ...state().preferences, ...updates },
} };
updateState(next) updateState(next);
} };
const updateCustomTheme = (updates: Partial<ThemeColors>) => { const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = { const next = {
...state(), ...state(),
customTheme: { ...state().customTheme, ...updates }, customTheme: { ...state().customTheme, ...updates },
} };
updateState(next) updateState(next);
} };
const updateVisualizer = (updates: Partial<VisualizerSettings>) => { const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({ updateSettings({
visualizer: { ...state().settings.visualizer, ...updates }, visualizer: { ...state().settings.visualizer, ...updates },
}) });
} };
const setTheme = (theme: ThemeName) => { const setTheme = (theme: ThemeName) => {
updateSettings({ theme }) updateSettings({ theme });
} };
const resolveThemeColors = (): ThemeColors => { const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme const theme = state().settings.theme;
if (theme === "custom") return state().customTheme if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme] const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME if (!json) return DEFAULT_THEME;
return resolveTheme(json as ThemeJson, "dark" as ThemeMode) as unknown as ThemeColors return resolveTheme(
} json as ThemeJson,
"dark" as ThemeMode,
) as unknown as ThemeColors;
};
return { return {
state, state,
@@ -108,14 +117,14 @@ export function createAppStore() {
updateVisualizer, updateVisualizer,
setTheme, setTheme,
resolveTheme: resolveThemeColors, resolveTheme: resolveThemeColors,
} };
} }
let appStoreInstance: ReturnType<typeof createAppStore> | null = null let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
export function useAppStore() { export function useAppStore() {
if (!appStoreInstance) { if (!appStoreInstance) {
appStoreInstance = createAppStore() appStoreInstance = createAppStore();
} }
return appStoreInstance return appStoreInstance;
} }

View File

@@ -3,174 +3,193 @@
* Manages feed data, sources, and filtering * Manages feed data, sources, and filtering
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { FeedVisibility } from "../types/feed" import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed" import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast" import type { Podcast } from "../types/podcast";
import type { Episode, EpisodeStatus } from "../types/episode" import type { Episode, EpisodeStatus } from "../types/episode";
import type { PodcastSource, SourceType } from "../types/source" import type { PodcastSource, SourceType } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source" import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser" import { parseRSSFeed } from "../api/rss-parser";
import { import {
loadFeedsFromFile, loadFeedsFromFile,
saveFeedsToFile, saveFeedsToFile,
loadSourcesFromFile, loadSourcesFromFile,
saveSourcesToFile, saveSourcesToFile,
migrateFeedsFromLocalStorage, } from "../utils/feeds-persistence";
migrateSourcesFromLocalStorage, import { useDownloadStore } from "./download";
} from "../utils/feeds-persistence" import { DownloadStatus } from "../types/episode";
import { useDownloadStore } from "./download"
import { DownloadStatus } from "../types/episode"
/** Max episodes to load per page/chunk */ /** Max episodes to load per page/chunk */
const MAX_EPISODES_REFRESH = 50 const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */ /** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20 const MAX_EPISODES_SUBSCRIBE = 20;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */ /** Cache of all parsed episodes per feed (feedId -> Episode[]) */
const fullEpisodeCache = new Map<string, Episode[]>() const fullEpisodeCache = new Map<string, Episode[]>();
/** Track how many episodes are currently loaded per feed */ /** Track how many episodes are currently loaded per feed */
const episodeLoadCount = new Map<string, number>() const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */ /** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void { function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds).catch(() => {}) saveFeedsToFile(feeds).catch(() => {});
} }
/** Save sources to file (async, fire-and-forget) */ /** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void { function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources).catch(() => {}) saveSourcesToFile(sources).catch(() => {});
} }
/** Create feed store */ /** Create feed store */
export function createFeedStore() { export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]) const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES]) const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES,
]);
// Async initialization: migrate from localStorage, then load from file (async () => {
;(async () => { const loadedFeeds = await loadFeedsFromFile();
await migrateFeedsFromLocalStorage() if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
await migrateSourcesFromLocalStorage() const loadedSources = await loadSourcesFromFile<PodcastSource>();
const loadedFeeds = await loadFeedsFromFile() if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
if (loadedFeeds.length > 0) setFeeds(loadedFeeds) })();
const loadedSources = await loadSourcesFromFile<PodcastSource>()
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
})()
const [filter, setFilter] = createSignal<FeedFilter>({ const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all", visibility: "all",
sortBy: "updated" as FeedSortField, sortBy: "updated" as FeedSortField,
sortDirection: "desc", sortDirection: "desc",
}) });
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null) const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false) const [isLoadingMore, setIsLoadingMore] = createSignal(false);
/** Get filtered and sorted feeds */ /** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => { const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()] let result = [...feeds()];
const f = filter() const f = filter();
// Filter by visibility // 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 // 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 // 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 // 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(
(feed) => (feed) =>
feed.podcast.title.toLowerCase().includes(query) || feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) || feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query) feed.podcast.description?.toLowerCase().includes(query),
) );
} }
// Sort by selected field // 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) {
case "title": case "title":
return sortDir * (a.customName || a.podcast.title).localeCompare(b.customName || b.podcast.title) return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title,
)
);
case "episodeCount": case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length) return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode": case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0 const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0 const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest) return sortDir * (aLatest - bLatest);
case "updated": case "updated":
default: default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()) return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
} }
}) });
// Pinned feeds always first // 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;
return 0 return 0;
}) });
return result return result;
} };
/** Get episodes in reverse chronological order across all feeds */ /** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => { const getAllEpisodesChronological = (): Array<{
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [] episode: Episode;
feed: Feed;
}> => {
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
for (const feed of feeds()) { for (const feed of feeds()) {
for (const episode of feed.episodes) { for (const episode of feed.episodes) {
allEpisodes.push({ episode, feed }) allEpisodes.push({ episode, feed });
} }
} }
// Sort by publication date (newest first) // Sort by publication date (newest first)
allEpisodes.sort((a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime()) allEpisodes.sort(
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
);
return allEpisodes return allEpisodes;
} };
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise<Episode[]> => { const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[]> => {
try { try {
const response = await fetch(feedUrl, { const response = await fetch(feedUrl, {
headers: { headers: {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
"Accept": "application/rss+xml, application/xml, text/xml, */*", Accept: "application/rss+xml, application/xml, text/xml, */*",
}, },
}) });
if (!response.ok) return [] if (!response.ok) return [];
const xml = await response.text() const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl) const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = parsed.episodes const allEpisodes = parsed.episodes;
// Cache all parsed episodes for pagination // Cache all parsed episodes for pagination
if (feedId) { if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes) fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)) episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
} }
return allEpisodes.slice(0, limit) return allEpisodes.slice(0, limit);
} catch { } catch {
return [] return [];
} }
} };
/** Add a new feed and auto-fetch latest 20 episodes */ /** Add a new feed and auto-fetch latest 20 episodes */
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => { const addFeed = async (
const feedId = crypto.randomUUID() podcast: Podcast,
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId) sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => {
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
const newFeed: Feed = { const newFeed: Feed = {
id: feedId, id: feedId,
podcast, podcast,
@@ -179,220 +198,238 @@ export function createFeedStore() {
sourceId, sourceId,
lastUpdated: new Date(), lastUpdated: new Date(),
isPinned: false, isPinned: false,
} };
setFeeds((prev) => { setFeeds((prev) => {
const updated = [...prev, newFeed] const updated = [...prev, newFeed];
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
return newFeed return newFeed;
} };
/** Auto-download newest episodes for a feed */ /** Auto-download newest episodes for a feed */
const autoDownloadEpisodes = (feedId: string, newEpisodes: Episode[], count: number) => { const autoDownloadEpisodes = (
feedId: string,
newEpisodes: Episode[],
count: number,
) => {
try { 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(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime() (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
) );
// count = 0 means download all new episodes // count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) { for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id) const status = dlStore.getDownloadStatus(ep.id);
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) { if (
dlStore.startDownload(ep, feedId) status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
} }
} }
} catch { } catch {
// Download store may not be available yet // 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 */
const refreshFeed = async (feedId: string) => { const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId) const feed = getFeed(feedId);
if (!feed) return if (!feed) return;
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)) const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId) const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
// Auto-download new episodes if enabled for this feed // Auto-download new episodes if enabled for this feed
if (feed.autoDownload) { if (feed.autoDownload) {
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)) const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
if (newEpisodes.length > 0) { if (newEpisodes.length > 0) {
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0) autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
} }
} }
} };
/** Refresh all feeds */ /** Refresh all feeds */
const refreshAllFeeds = async () => { const refreshAllFeeds = async () => {
const currentFeeds = feeds() const currentFeeds = feeds();
for (const feed of currentFeeds) { for (const feed of currentFeeds) {
await refreshFeed(feed.id) await refreshFeed(feed.id);
} }
} };
/** Remove a feed */ /** Remove a feed */
const removeFeed = (feedId: string) => { const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId) fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId) episodeLoadCount.delete(feedId);
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.filter((f) => f.id !== feedId) const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} };
/** Update a feed */ /** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => { const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} };
/** Toggle feed pinned status */ /** Toggle feed pinned status */
const togglePinned = (feedId: string) => { const togglePinned = (feedId: string) => {
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} };
/** Add a source */ /** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => { const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = { const newSource: PodcastSource = {
...source, ...source,
id: crypto.randomUUID(), id: crypto.randomUUID(),
} };
setSources((prev) => { setSources((prev) => {
const updated = [...prev, newSource] const updated = [...prev, newSource];
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
return newSource return newSource;
} };
/** Update a source */ /** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => { const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => { setSources((prev) => {
const updated = prev.map((source) => const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source source.id === sourceId ? { ...source, ...updates } : source,
) );
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
} };
/** Remove a source */ /** Remove a source */
const removeSource = (sourceId: string) => { const removeSource = (sourceId: string) => {
// Don't remove default sources // Don't remove default sources
if (sourceId === "itunes" || sourceId === "rss") return false if (sourceId === "itunes" || sourceId === "rss") return false;
setSources((prev) => { setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId) const updated = prev.filter((s) => s.id !== sourceId);
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
return true return true;
} };
/** Toggle source enabled status */ /** Toggle source enabled status */
const toggleSource = (sourceId: string) => { const toggleSource = (sourceId: string) => {
setSources((prev) => { setSources((prev) => {
const updated = prev.map((s) => const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
) );
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
} };
/** Get feed by ID */ /** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => { const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId) return feeds().find((f) => f.id === feedId);
} };
/** Get selected feed */ /** Get selected feed */
const getSelectedFeed = (): Feed | undefined => { const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId() const id = selectedFeedId();
return id ? getFeed(id) : undefined return id ? getFeed(id) : undefined;
} };
/** Check if a feed has more episodes available beyond what's currently loaded */ /** Check if a feed has more episodes available beyond what's currently loaded */
const hasMoreEpisodes = (feedId: string): boolean => { const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId) const cached = fullEpisodeCache.get(feedId);
if (!cached) return false if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0 const loaded = episodeLoadCount.get(feedId) ?? 0;
return loaded < cached.length return loaded < cached.length;
} };
/** Load the next chunk of episodes for a feed from the cache. /** Load the next chunk of episodes for a feed from the cache.
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
const loadMoreEpisodes = async (feedId: string) => { const loadMoreEpisodes = async (feedId: string) => {
if (isLoadingMore()) return if (isLoadingMore()) return;
const feed = getFeed(feedId) const feed = getFeed(feedId);
if (!feed) return if (!feed) return;
setIsLoadingMore(true) setIsLoadingMore(true);
try { try {
let cached = fullEpisodeCache.get(feedId) let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed // If no cache, re-fetch and parse the full feed
if (!cached) { if (!cached) {
const response = await fetch(feed.podcast.feedUrl, { const response = await fetch(feed.podcast.feedUrl, {
headers: { headers: {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
"Accept": "application/rss+xml, application/xml, text/xml, */*", Accept: "application/rss+xml, application/xml, text/xml, */*",
}, },
}) });
if (!response.ok) return if (!response.ok) return;
const xml = await response.text() const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl) const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached) fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed // Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length) episodeLoadCount.set(feedId, feed.episodes.length);
} }
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(currentCount + MAX_EPISODES_REFRESH, cached.length) const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
);
if (newCount <= currentCount) return // nothing more to load if (newCount <= currentCount) return; // nothing more to load
episodeLoadCount.set(feedId, newCount) episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount) const episodes = cached.slice(0, newCount);
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f f.id === feedId ? { ...f, episodes } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} finally { } finally {
setIsLoadingMore(false) setIsLoadingMore(false);
} }
} };
/** Set auto-download settings for a feed */ /** Set auto-download settings for a feed */
const setAutoDownload = (feedId: string, enabled: boolean, count: number = 0) => { const setAutoDownload = (
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count }) feedId: string,
} enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
};
return { return {
// State // State
@@ -401,14 +438,14 @@ export function createFeedStore() {
filter, filter,
selectedFeedId, selectedFeedId,
isLoadingMore, isLoadingMore,
// Computed // Computed
getFilteredFeeds, getFilteredFeeds,
getAllEpisodesChronological, getAllEpisodesChronological,
getFeed, getFeed,
getSelectedFeed, getSelectedFeed,
hasMoreEpisodes, hasMoreEpisodes,
// Actions // Actions
setFilter, setFilter,
setSelectedFeedId, setSelectedFeedId,
@@ -424,15 +461,15 @@ export function createFeedStore() {
toggleSource, toggleSource,
updateSource, updateSource,
setAutoDownload, setAutoDownload,
} };
} }
/** Singleton feed store */ /** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() { export function useFeedStore() {
if (!feedStoreInstance) { if (!feedStoreInstance) {
feedStoreInstance = createFeedStore() feedStoreInstance = createFeedStore();
} }
return feedStoreInstance return feedStoreInstance;
} }

View File

@@ -5,55 +5,56 @@
* Tracks position, duration, completion, and last-played timestamp. * Tracks position, duration, completion, and last-played timestamp.
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import type { Progress } from "../types/episode" import type { Progress } from "../types/episode";
import { import {
loadProgressFromFile, loadProgressFromFile,
saveProgressToFile, saveProgressToFile,
migrateProgressFromLocalStorage, } from "../utils/app-persistence";
} from "../utils/app-persistence"
/** Threshold (fraction 0-1) at which an episode is considered completed */ /** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95 const COMPLETION_THRESHOLD = 0.95;
/** Minimum seconds of progress before persisting */ /** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5 const MIN_POSITION_TO_SAVE = 5;
// --- Singleton store --- // --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({}) const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
{},
);
/** Persist current progress map to file (fire-and-forget) */ /** Persist current progress map to file (fire-and-forget) */
function persist(): void { function persist(): void {
saveProgressToFile(progressMap()).catch(() => {}) saveProgressToFile(progressMap()).catch(() => {});
} }
/** Parse raw progress entries from file, reviving Date objects */ /** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> { function parseProgressEntries(
const result: Record<string, Progress> = {} raw: Record<string, unknown>,
): Record<string, Progress> {
const result: Record<string, Progress> = {};
for (const [key, value] of Object.entries(raw)) { for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown> const p = value as Record<string, unknown>;
result[key] = { result[key] = {
episodeId: p.episodeId as string, episodeId: p.episodeId as string,
position: p.position as number, position: p.position as number,
duration: p.duration as number, duration: p.duration as number,
timestamp: new Date(p.timestamp as string), timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined, playbackSpeed: p.playbackSpeed as number | undefined,
} };
} }
return result return result;
} }
/** Async initialisation — migrate from localStorage then load from file */
async function initProgress(): Promise<void> { async function initProgress(): Promise<void> {
await migrateProgressFromLocalStorage() const raw = await loadProgressFromFile();
const raw = await loadProgressFromFile() const parsed = parseProgressEntries(raw as Record<string, unknown>);
const parsed = parseProgressEntries(raw as Record<string, unknown>) setProgressMap(parsed);
setProgressMap(parsed)
} }
// Fire-and-forget init // Fire-and-forget init
initProgress() initProgress();
function createProgressStore() { function createProgressStore() {
return { return {
@@ -61,14 +62,14 @@ function createProgressStore() {
* Get progress for a specific episode. * Get progress for a specific episode.
*/ */
get(episodeId: string): Progress | undefined { get(episodeId: string): Progress | undefined {
return progressMap()[episodeId] return progressMap()[episodeId];
}, },
/** /**
* Get all progress entries. * Get all progress entries.
*/ */
all(): Record<string, Progress> { all(): Record<string, Progress> {
return progressMap() return progressMap();
}, },
/** /**
@@ -80,7 +81,7 @@ function createProgressStore() {
duration: number, duration: number,
playbackSpeed?: number, playbackSpeed?: number,
): void { ): void {
if (position < MIN_POSITION_TO_SAVE && duration > 0) return if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
setProgressMap((prev) => ({ setProgressMap((prev) => ({
...prev, ...prev,
@@ -91,34 +92,34 @@ function createProgressStore() {
timestamp: new Date(), timestamp: new Date(),
playbackSpeed, playbackSpeed,
}, },
})) }));
persist() persist();
}, },
/** /**
* Check if an episode is completed. * Check if an episode is completed.
*/ */
isCompleted(episodeId: string): boolean { isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId] const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD return p.position / p.duration >= COMPLETION_THRESHOLD;
}, },
/** /**
* Get progress percentage (0-100) for an episode. * Get progress percentage (0-100) for an episode.
*/ */
getPercent(episodeId: string): number { getPercent(episodeId: string): number {
const p = progressMap()[episodeId] const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0 if (!p || p.duration <= 0) return 0;
return Math.min(100, Math.round((p.position / p.duration) * 100)) return Math.min(100, Math.round((p.position / p.duration) * 100));
}, },
/** /**
* Mark an episode as completed (set position to duration). * Mark an episode as completed (set position to duration).
*/ */
markCompleted(episodeId: string): void { markCompleted(episodeId: string): void {
const p = progressMap()[episodeId] const p = progressMap()[episodeId];
const duration = p?.duration ?? 0 const duration = p?.duration ?? 0;
setProgressMap((prev) => ({ setProgressMap((prev) => ({
...prev, ...prev,
[episodeId]: { [episodeId]: {
@@ -128,8 +129,8 @@ function createProgressStore() {
timestamp: new Date(), timestamp: new Date(),
playbackSpeed: p?.playbackSpeed, playbackSpeed: p?.playbackSpeed,
}, },
})) }));
persist() persist();
}, },
/** /**
@@ -137,29 +138,29 @@ function createProgressStore() {
*/ */
remove(episodeId: string): void { remove(episodeId: string): void {
setProgressMap((prev) => { setProgressMap((prev) => {
const next = { ...prev } const next = { ...prev };
delete next[episodeId] delete next[episodeId];
return next return next;
}) });
persist() persist();
}, },
/** /**
* Clear all progress data. * Clear all progress data.
*/ */
clear(): void { clear(): void {
setProgressMap({}) setProgressMap({});
persist() persist();
}, },
} };
} }
// Singleton instance // Singleton instance
let instance: ReturnType<typeof createProgressStore> | null = null let instance: ReturnType<typeof createProgressStore> | null = null;
export function useProgressStore() { export function useProgressStore() {
if (!instance) { if (!instance) {
instance = createProgressStore() instance = createProgressStore();
} }
return instance return instance;
} }

View File

@@ -2,22 +2,22 @@
* CategoryFilter component - Horizontal category filter tabs * CategoryFilter component - Horizontal category filter tabs
*/ */
import { For } from "solid-js" import { For } from "solid-js";
import type { DiscoverCategory } from "../stores/discover" import type { DiscoverCategory } from "@/stores/discover";
type CategoryFilterProps = { type CategoryFilterProps = {
categories: DiscoverCategory[] categories: DiscoverCategory[];
selectedCategory: string selectedCategory: string;
focused: boolean focused: boolean;
onSelect?: (categoryId: string) => void onSelect?: (categoryId: string) => void;
} };
export function CategoryFilter(props: CategoryFilterProps) { export function CategoryFilter(props: CategoryFilterProps) {
return ( return (
<box flexDirection="row" gap={1} flexWrap="wrap"> <box flexDirection="row" gap={1} flexWrap="wrap">
<For each={props.categories}> <For each={props.categories}>
{(category) => { {(category) => {
const isSelected = () => props.selectedCategory === category.id const isSelected = () => props.selectedCategory === category.id;
return ( return (
<box <box
@@ -32,9 +32,9 @@ export function CategoryFilter(props: CategoryFilterProps) {
{category.icon} {category.name} {category.icon} {category.name}
</text> </text>
</box> </box>
) );
}} }}
</For> </For>
</box> </box>
) );
} }

View File

@@ -2,150 +2,158 @@
* DiscoverPage component - Main discover/browse interface for PodTUI * DiscoverPage component - Main discover/browse interface for PodTUI
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "../stores/discover" import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { CategoryFilter } from "./CategoryFilter" import { CategoryFilter } from "./CategoryFilter";
import { TrendingShows } from "./TrendingShows" import { TrendingShows } from "./TrendingShows";
type DiscoverPageProps = { type DiscoverPageProps = {
focused: boolean focused: boolean;
onExit?: () => void onExit?: () => void;
} };
type FocusArea = "categories" | "shows" type FocusArea = "categories" | "shows";
export function DiscoverPage(props: DiscoverPageProps) { export function DiscoverPage(props: DiscoverPageProps) {
const discoverStore = useDiscoverStore() const discoverStore = useDiscoverStore();
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows") const [focusArea, setFocusArea] = createSignal<FocusArea>("shows");
const [showIndex, setShowIndex] = createSignal(0) const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0) const [categoryIndex, setCategoryIndex] = createSignal(0);
// Keyboard navigation // Keyboard navigation
useKeyboard((key) => { useKeyboard((key) => {
if (!props.focused) return if (!props.focused) return;
const area = focusArea() const area = focusArea();
// Tab switches focus between categories and shows // Tab switches focus between categories and shows
if (key.name === "tab") { if (key.name === "tab") {
if (key.shift) { if (key.shift) {
setFocusArea((a) => (a === "categories" ? "shows" : "categories")) setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
} else { } else {
setFocusArea((a) => (a === "categories" ? "shows" : "categories")) setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
} }
return return;
} }
if ((key.name === "return" || key.name === "enter") && area === "categories") { if (
setFocusArea("shows") (key.name === "return" || key.name === "enter") &&
return area === "categories"
) {
setFocusArea("shows");
return;
} }
// Category navigation // Category navigation
if (area === "categories") { if (area === "categories") {
if (key.name === "left" || key.name === "h") { if (key.name === "left" || key.name === "h") {
const nextIndex = Math.max(0, categoryIndex() - 1) const nextIndex = Math.max(0, categoryIndex() - 1);
setCategoryIndex(nextIndex) setCategoryIndex(nextIndex);
const cat = DISCOVER_CATEGORIES[nextIndex] const cat = DISCOVER_CATEGORIES[nextIndex];
if (cat) discoverStore.setSelectedCategory(cat.id) if (cat) discoverStore.setSelectedCategory(cat.id);
setShowIndex(0) setShowIndex(0);
return return;
} }
if (key.name === "right" || key.name === "l") { if (key.name === "right" || key.name === "l") {
const nextIndex = Math.min(DISCOVER_CATEGORIES.length - 1, categoryIndex() + 1) const nextIndex = Math.min(
setCategoryIndex(nextIndex) DISCOVER_CATEGORIES.length - 1,
const cat = DISCOVER_CATEGORIES[nextIndex] categoryIndex() + 1,
if (cat) discoverStore.setSelectedCategory(cat.id) );
setShowIndex(0) setCategoryIndex(nextIndex);
return const cat = DISCOVER_CATEGORIES[nextIndex];
if (cat) discoverStore.setSelectedCategory(cat.id);
setShowIndex(0);
return;
} }
if (key.name === "return" || key.name === "enter") { if (key.name === "return" || key.name === "enter") {
// Select category and move to shows // Select category and move to shows
setFocusArea("shows") setFocusArea("shows");
return return;
} }
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
setFocusArea("shows") setFocusArea("shows");
return return;
} }
} }
// Shows navigation // Shows navigation
if (area === "shows") { if (area === "shows") {
const shows = discoverStore.filteredPodcasts() const shows = discoverStore.filteredPodcasts();
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
if (shows.length === 0) return if (shows.length === 0) return;
setShowIndex((i) => Math.min(i + 1, shows.length - 1)) setShowIndex((i) => Math.min(i + 1, shows.length - 1));
return return;
} }
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
if (shows.length === 0) { if (shows.length === 0) {
setFocusArea("categories") setFocusArea("categories");
return return;
} }
const newIndex = showIndex() - 1 const newIndex = showIndex() - 1;
if (newIndex < 0) { if (newIndex < 0) {
setFocusArea("categories") setFocusArea("categories");
} else { } else {
setShowIndex(newIndex) setShowIndex(newIndex);
} }
return return;
} }
if (key.name === "return" || key.name === "enter") { if (key.name === "return" || key.name === "enter") {
// Subscribe/unsubscribe // Subscribe/unsubscribe
const podcast = shows[showIndex()] const podcast = shows[showIndex()];
if (podcast) { if (podcast) {
discoverStore.toggleSubscription(podcast.id) discoverStore.toggleSubscription(podcast.id);
} }
return return;
} }
} }
if (key.name === "escape") { if (key.name === "escape") {
if (area === "shows") { if (area === "shows") {
setFocusArea("categories") setFocusArea("categories");
key.stopPropagation() key.stopPropagation();
} else { } else {
props.onExit?.() props.onExit?.();
} }
return return;
} }
// Refresh with 'r' // Refresh with 'r'
if (key.name === "r") { if (key.name === "r") {
discoverStore.refresh() discoverStore.refresh();
return return;
} }
}) });
const handleCategorySelect = (categoryId: string) => { const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId) discoverStore.setSelectedCategory(categoryId);
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId) const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
if (index >= 0) setCategoryIndex(index) if (index >= 0) setCategoryIndex(index);
setShowIndex(0) setShowIndex(0);
} };
const handleShowSelect = (index: number) => { const handleShowSelect = (index: number) => {
setShowIndex(index) setShowIndex(index);
setFocusArea("shows") setFocusArea("shows");
} };
const handleSubscribe = (podcast: { id: string }) => { const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id) discoverStore.toggleSubscription(podcast.id);
} };
return ( return (
<box flexDirection="column" height="100%" gap={1}> <box flexDirection="column" height="100%" gap={1}>
{/* Header */} {/* Header */}
<box flexDirection="row" justifyContent="space-between" alignItems="center"> <box
<text> flexDirection="row"
<strong>Discover Podcasts</strong> justifyContent="space-between"
</text> alignItems="center"
>
<text>
<strong>Discover Podcasts</strong>
</text>
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
<text fg="gray"> <text fg="gray">{discoverStore.filteredPodcasts().length} shows</text>
{discoverStore.filteredPodcasts().length} shows
</text>
<box onMouseDown={() => discoverStore.refresh()}> <box onMouseDown={() => discoverStore.refresh()}>
<text fg="cyan">[R] Refresh</text> <text fg="cyan">[R] Refresh</text>
</box> </box>
@@ -169,15 +177,14 @@ export function DiscoverPage(props: DiscoverPageProps) {
{/* Trending Shows */} {/* Trending Shows */}
<box flexDirection="column" flexGrow={1} border> <box flexDirection="column" flexGrow={1} border>
<box padding={1}> <box padding={1}>
<text fg={focusArea() === "shows" ? "cyan" : "gray"}> <text fg={focusArea() === "shows" ? "cyan" : "gray"}>
Trending in { Trending in{" "}
DISCOVER_CATEGORIES.find( {DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory() (c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All" )?.name ?? "All"}
} </text>
</text> </box>
</box>
<TrendingShows <TrendingShows
podcasts={discoverStore.filteredPodcasts()} podcasts={discoverStore.filteredPodcasts()}
selectedIndex={showIndex()} selectedIndex={showIndex()}
@@ -197,5 +204,5 @@ export function DiscoverPage(props: DiscoverPageProps) {
<text fg="gray">[R] Refresh</text> <text fg="gray">[R] Refresh</text>
</box> </box>
</box> </box>
) );
} }

View File

@@ -2,21 +2,21 @@
* PodcastCard component - Reusable card for displaying podcast info * PodcastCard component - Reusable card for displaying podcast info
*/ */
import { Show, For } from "solid-js" import { Show, For } from "solid-js";
import type { Podcast } from "../types/podcast" import type { Podcast } from "@/types/podcast";
type PodcastCardProps = { type PodcastCardProps = {
podcast: Podcast podcast: Podcast;
selected: boolean selected: boolean;
compact?: boolean compact?: boolean;
onSelect?: () => void onSelect?: () => void;
onSubscribe?: () => void onSubscribe?: () => void;
} };
export function PodcastCard(props: PodcastCardProps) { export function PodcastCard(props: PodcastCardProps) {
const handleSubscribeClick = () => { const handleSubscribeClick = () => {
props.onSubscribe?.() props.onSubscribe?.();
} };
return ( return (
<box <box
@@ -51,7 +51,11 @@ export function PodcastCard(props: PodcastCardProps) {
</Show> </Show>
{/* Categories and Subscribe Button */} {/* Categories and Subscribe Button */}
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}> <box
flexDirection="row"
justifyContent="space-between"
marginTop={props.compact ? 0 : 1}
>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<Show when={(props.podcast.categories ?? []).length > 0}> <Show when={(props.podcast.categories ?? []).length > 0}>
<For each={(props.podcast.categories ?? []).slice(0, 2)}> <For each={(props.podcast.categories ?? []).slice(0, 2)}>
@@ -69,5 +73,5 @@ export function PodcastCard(props: PodcastCardProps) {
</Show> </Show>
</box> </box>
</box> </box>
) );
} }

View File

@@ -2,18 +2,18 @@
* TrendingShows component - Grid/list of trending podcasts * TrendingShows component - Grid/list of trending podcasts
*/ */
import { For, Show } from "solid-js" import { For, Show } from "solid-js";
import type { Podcast } from "../types/podcast" import type { Podcast } from "@/types/podcast";
import { PodcastCard } from "./PodcastCard" import { PodcastCard } from "./PodcastCard";
type TrendingShowsProps = { type TrendingShowsProps = {
podcasts: Podcast[] podcasts: Podcast[];
selectedIndex: number selectedIndex: number;
focused: boolean focused: boolean;
isLoading: boolean isLoading: boolean;
onSelect?: (index: number) => void onSelect?: (index: number) => void;
onSubscribe?: (podcast: Podcast) => void onSubscribe?: (podcast: Podcast) => void;
} };
export function TrendingShows(props: TrendingShowsProps) { export function TrendingShows(props: TrendingShowsProps) {
return ( return (
@@ -47,5 +47,5 @@ export function TrendingShows(props: TrendingShowsProps) {
</scrollbox> </scrollbox>
</Show> </Show>
</box> </box>
) );
} }

View File

@@ -3,80 +3,80 @@
* Shows podcast info and episode list * Shows podcast info and episode list
*/ */
import { createSignal, For, Show } from "solid-js" import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import type { Feed } from "../types/feed" import type { Feed } from "@/types/feed";
import type { Episode } from "../types/episode" import type { Episode } from "@/types/episode";
import { format } from "date-fns" import { format } from "date-fns";
interface FeedDetailProps { interface FeedDetailProps {
feed: Feed feed: Feed;
focused?: boolean focused?: boolean;
onBack?: () => void onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void onPlayEpisode?: (episode: Episode) => void;
} }
export function FeedDetail(props: FeedDetailProps) { export function FeedDetail(props: FeedDetailProps) {
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0);
const [showInfo, setShowInfo] = createSignal(true) const [showInfo, setShowInfo] = createSignal(true);
const episodes = () => { const episodes = () => {
// Sort episodes by publication date (newest first) // Sort episodes by publication date (newest first)
return [...props.feed.episodes].sort( return [...props.feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime() (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
) );
} };
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60) const hrs = Math.floor(mins / 60);
if (hrs > 0) { if (hrs > 0) {
return `${hrs}h ${mins % 60}m` return `${hrs}h ${mins % 60}m`;
} }
return `${mins}m` return `${mins}m`;
} };
const formatDate = (date: Date): string => { const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy") return format(date, "MMM d, yyyy");
} };
const handleKeyPress = (key: { name: string }) => { const handleKeyPress = (key: { name: string }) => {
const eps = episodes() const eps = episodes();
if (key.name === "escape" && props.onBack) { if (key.name === "escape" && props.onBack) {
props.onBack() props.onBack();
return return;
} }
if (key.name === "i") { if (key.name === "i") {
setShowInfo((v) => !v) setShowInfo((v) => !v);
return return;
} }
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1)) setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") { } else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1)) setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
const episode = eps[selectedIndex()] const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) { if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode) props.onPlayEpisode(episode);
} }
} else if (key.name === "home" || key.name === "g") { } else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0) setSelectedIndex(0);
} else if (key.name === "end") { } else if (key.name === "end") {
setSelectedIndex(eps.length - 1) setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") { } else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10)) setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") { } else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10)) setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
} }
} };
useKeyboard((key) => { useKeyboard((key) => {
if (!props.focused) return if (!props.focused) return;
handleKeyPress(key) handleKeyPress(key);
}) });
return ( return (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
@@ -120,9 +120,7 @@ export function FeedDetail(props: FeedDetailProps) {
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}> <text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"} {props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</text> </text>
{props.feed.isPinned && ( {props.feed.isPinned && <text fg="yellow">[Pinned]</text>}
<text fg="yellow">[Pinned]</text>
)}
</box> </box>
</box> </box>
</Show> </Show>
@@ -145,9 +143,9 @@ export function FeedDetail(props: FeedDetailProps) {
padding={1} padding={1}
backgroundColor={index() === selectedIndex() ? "#333" : undefined} backgroundColor={index() === selectedIndex() ? "#333" : undefined}
onMouseDown={() => { onMouseDown={() => {
setSelectedIndex(index()) setSelectedIndex(index());
if (props.onPlayEpisode) { if (props.onPlayEpisode) {
props.onPlayEpisode(episode) props.onPlayEpisode(episode);
} }
}} }}
> >
@@ -174,5 +172,5 @@ export function FeedDetail(props: FeedDetailProps) {
j/k to navigate, Enter to play, i to toggle info, Esc to go back j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text> </text>
</box> </box>
) );
} }

View File

@@ -3,54 +3,56 @@
* Toggle and filter options for feed list * Toggle and filter options for feed list
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "../types/feed" import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "../types/feed" import type { FeedFilter } from "@/types/feed";
interface FeedFilterProps { interface FeedFilterProps {
filter: FeedFilter filter: FeedFilter;
focused?: boolean focused?: boolean;
onFilterChange: (filter: FeedFilter) => void onFilterChange: (filter: FeedFilter) => void;
} }
type FilterField = "visibility" | "sort" | "pinned" | "search" type FilterField = "visibility" | "sort" | "pinned" | "search";
export function FeedFilterComponent(props: FeedFilterProps) { export function FeedFilterComponent(props: FeedFilterProps) {
const [focusField, setFocusField] = createSignal<FilterField>("visibility") const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "") const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"] const fields: FilterField[] = ["visibility", "sort", "pinned", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField()) const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length ? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length : (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]) setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
if (focusField() === "visibility") { if (focusField() === "visibility") {
cycleVisibility() cycleVisibility();
} else if (focusField() === "sort") { } else if (focusField() === "sort") {
cycleSort() cycleSort();
} else if (focusField() === "pinned") { } else if (focusField() === "pinned") {
togglePinned() togglePinned();
} }
} else if (key.name === "space") { } else if (key.name === "space") {
if (focusField() === "pinned") { if (focusField() === "pinned") {
togglePinned() togglePinned();
} }
} }
} };
const cycleVisibility = () => { const cycleVisibility = () => {
const current = props.filter.visibility const current = props.filter.visibility;
let next: FeedVisibility | "all" let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all" else next = "all";
props.onFilterChange({ ...props.filter, visibility: next }) props.onFilterChange({ ...props.filter, visibility: next });
} };
const cycleSort = () => { const cycleSort = () => {
const sortOptions: FeedSortField[] = [ const sortOptions: FeedSortField[] = [
@@ -58,52 +60,54 @@ export function FeedFilterComponent(props: FeedFilterProps) {
FeedSortField.TITLE, FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT, FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE, FeedSortField.LATEST_EPISODE,
] ];
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField) const currentIndex = sortOptions.indexOf(
const nextIndex = (currentIndex + 1) % sortOptions.length props.filter.sortBy as FeedSortField,
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] }) );
} const nextIndex = (currentIndex + 1) % sortOptions.length;
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
};
const togglePinned = () => { const togglePinned = () => {
props.onFilterChange({ props.onFilterChange({
...props.filter, ...props.filter,
pinnedOnly: !props.filter.pinnedOnly, pinnedOnly: !props.filter.pinnedOnly,
}) });
} };
const handleSearchInput = (value: string) => { const handleSearchInput = (value: string) => {
setSearchValue(value) setSearchValue(value);
props.onFilterChange({ ...props.filter, searchQuery: value }) props.onFilterChange({ ...props.filter, searchQuery: value });
} };
const visibilityLabel = () => { const visibilityLabel = () => {
const vis = props.filter.visibility const vis = props.filter.visibility;
if (vis === "all") return "All" if (vis === "all") return "All";
if (vis === "public") return "Public" if (vis === "public") return "Public";
return "Private" return "Private";
} };
const visibilityColor = () => { const visibilityColor = () => {
const vis = props.filter.visibility const vis = props.filter.visibility;
if (vis === "public") return "green" if (vis === "public") return "green";
if (vis === "private") return "yellow" if (vis === "private") return "yellow";
return "white" return "white";
} };
const sortLabel = () => { const sortLabel = () => {
const sort = props.filter.sortBy const sort = props.filter.sortBy;
switch (sort) { switch (sort) {
case "title": case "title":
return "Title" return "Title";
case "episodeCount": case "episodeCount":
return "Episodes" return "Episodes";
case "latestEpisode": case "latestEpisode":
return "Latest" return "Latest";
case "updated": case "updated":
default: default:
return "Updated" return "Updated";
} }
} };
return ( return (
<box flexDirection="column" border padding={1} gap={1}> <box flexDirection="column" border padding={1} gap={1}>
@@ -119,7 +123,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
backgroundColor={focusField() === "visibility" ? "#333" : undefined} backgroundColor={focusField() === "visibility" ? "#333" : undefined}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text> <text fg={focusField() === "visibility" ? "cyan" : "gray"}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text> <text fg={visibilityColor()}>{visibilityLabel()}</text>
</box> </box>
</box> </box>
@@ -143,7 +149,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
backgroundColor={focusField() === "pinned" ? "#333" : undefined} backgroundColor={focusField() === "pinned" ? "#333" : undefined}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text> <text fg={focusField() === "pinned" ? "cyan" : "gray"}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}> <text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
{props.filter.pinnedOnly ? "Yes" : "No"} {props.filter.pinnedOnly ? "Yes" : "No"}
</text> </text>
@@ -165,5 +173,5 @@ export function FeedFilterComponent(props: FeedFilterProps) {
<text fg="gray">Tab to navigate, Enter/Space to toggle</text> <text fg="gray">Tab to navigate, Enter/Space to toggle</text>
</box> </box>
) );
} }

View File

@@ -3,39 +3,39 @@
* Displays a single feed/podcast in the list * Displays a single feed/podcast in the list
*/ */
import type { Feed, FeedVisibility } from "../types/feed" import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns" import { format } from "date-fns";
interface FeedItemProps { interface FeedItemProps {
feed: Feed feed: Feed;
isSelected: boolean isSelected: boolean;
showEpisodeCount?: boolean showEpisodeCount?: boolean;
showLastUpdated?: boolean showLastUpdated?: boolean;
compact?: boolean compact?: boolean;
} }
export function FeedItem(props: FeedItemProps) { export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => { const formatDate = (date: Date): string => {
return format(date, "MMM d") return format(date, "MMM d");
} };
const episodeCount = () => props.feed.episodes.length const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => { const unplayedCount = () => {
// This would be calculated based on episode status // This would be calculated based on episode status
return props.feed.episodes.length return props.feed.episodes.length;
} };
const visibilityIcon = () => { const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]" return props.feed.visibility === "public" ? "[P]" : "[*]";
} };
const visibilityColor = () => { const visibilityColor = () => {
return props.feed.visibility === "public" ? "green" : "yellow" return props.feed.visibility === "public" ? "green" : "yellow";
} };
const pinnedIndicator = () => { const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " " return props.feed.isPinned ? "*" : " ";
} };
if (props.compact) { if (props.compact) {
// Compact single-line view // Compact single-line view
@@ -54,11 +54,9 @@ export function FeedItem(props: FeedItemProps) {
<text fg={props.isSelected ? "white" : undefined}> <text fg={props.isSelected ? "white" : undefined}>
{props.feed.customName || props.feed.podcast.title} {props.feed.customName || props.feed.podcast.title}
</text> </text>
{props.showEpisodeCount && ( {props.showEpisodeCount && <text fg="gray">({episodeCount()})</text>}
<text fg="gray">({episodeCount()})</text>
)}
</box> </box>
) );
} }
// Full view with details // Full view with details
@@ -105,5 +103,5 @@ export function FeedItem(props: FeedItemProps) {
</box> </box>
)} )}
</box> </box>
) );
} }

View File

@@ -3,87 +3,87 @@
* Scrollable list of feeds with keyboard navigation and mouse support * Scrollable list of feeds with keyboard navigation and mouse support
*/ */
import { createSignal, For, Show } from "solid-js" import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { FeedItem } from "./FeedItem" import { FeedItem } from "./FeedItem";
import { useFeedStore } from "../stores/feed" import { useFeedStore } from "@/stores/feed";
import { FeedVisibility, FeedSortField } from "../types/feed" import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { Feed } from "../types/feed" import type { Feed } from "@/types/feed";
interface FeedListProps { interface FeedListProps {
focused?: boolean focused?: boolean;
compact?: boolean compact?: boolean;
showEpisodeCount?: boolean showEpisodeCount?: boolean;
showLastUpdated?: boolean showLastUpdated?: boolean;
onSelectFeed?: (feed: Feed) => void onSelectFeed?: (feed: Feed) => void;
onOpenFeed?: (feed: Feed) => void onOpenFeed?: (feed: Feed) => void;
onFocusChange?: (focused: boolean) => void onFocusChange?: (focused: boolean) => void;
} }
export function FeedList(props: FeedListProps) { export function FeedList(props: FeedListProps) {
const feedStore = useFeedStore() const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds() const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => { const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") { if (key.name === "escape") {
props.onFocusChange?.(false) props.onFocusChange?.(false);
return return;
} }
const feeds = filteredFeeds() const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1)) setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") { } else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1)) setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
const feed = feeds[selectedIndex()] const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) { if (feed && props.onOpenFeed) {
props.onOpenFeed(feed) props.onOpenFeed(feed);
} }
} else if (key.name === "home" || key.name === "g") { } else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0) setSelectedIndex(0);
} else if (key.name === "end") { } else if (key.name === "end") {
setSelectedIndex(feeds.length - 1) setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") { } else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5)) setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") { } else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5)) setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") { } else if (key.name === "p") {
// Toggle pin on selected feed // Toggle pin on selected feed
const feed = feeds[selectedIndex()] const feed = feeds[selectedIndex()];
if (feed) { if (feed) {
feedStore.togglePinned(feed.id) feedStore.togglePinned(feed.id);
} }
} else if (key.name === "f") { } else if (key.name === "f") {
// Cycle visibility filter // Cycle visibility filter
cycleVisibilityFilter() cycleVisibilityFilter();
} else if (key.name === "s") { } else if (key.name === "s") {
// Cycle sort // Cycle sort
cycleSortField() cycleSortField();
} }
// Notify selection change // Notify selection change
const selectedFeed = feeds[selectedIndex()] const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) { if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed) props.onSelectFeed(selectedFeed);
} }
} };
useKeyboard((key) => { useKeyboard((key) => {
if (!props.focused) return if (!props.focused) return;
handleKeyPress(key) handleKeyPress(key);
}) });
const cycleVisibilityFilter = () => { const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility const current = feedStore.filter().visibility;
let next: FeedVisibility | "all" let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all" else next = "all";
feedStore.setFilter({ ...feedStore.filter(), visibility: next }) feedStore.setFilter({ ...feedStore.filter(), visibility: next });
} };
const cycleSortField = () => { const cycleSortField = () => {
const sortOptions: FeedSortField[] = [ const sortOptions: FeedSortField[] = [
@@ -91,45 +91,49 @@ export function FeedList(props: FeedListProps) {
FeedSortField.TITLE, FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT, FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE, FeedSortField.LATEST_EPISODE,
] ];
const current = feedStore.filter().sortBy as FeedSortField const current = feedStore.filter().sortBy as FeedSortField;
const idx = sortOptions.indexOf(current) const idx = sortOptions.indexOf(current);
const next = sortOptions[(idx + 1) % sortOptions.length] const next = sortOptions[(idx + 1) % sortOptions.length];
feedStore.setFilter({ ...feedStore.filter(), sortBy: next }) feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
} };
const visibilityLabel = () => { const visibilityLabel = () => {
const vis = feedStore.filter().visibility const vis = feedStore.filter().visibility;
if (vis === "all") return "All" if (vis === "all") return "All";
if (vis === "public") return "Public" if (vis === "public") return "Public";
return "Private" return "Private";
} };
const sortLabel = () => { const sortLabel = () => {
const sort = feedStore.filter().sortBy const sort = feedStore.filter().sortBy;
switch (sort) { switch (sort) {
case "title": return "Title" case "title":
case "episodeCount": return "Episodes" return "Title";
case "latestEpisode": return "Latest" case "episodeCount":
default: return "Updated" return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
} }
} };
const handleFeedClick = (feed: Feed, index: number) => { const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index) setSelectedIndex(index);
if (props.onSelectFeed) { if (props.onSelectFeed) {
props.onSelectFeed(feed) props.onSelectFeed(feed);
} }
} };
const handleFeedDoubleClick = (feed: Feed) => { const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) { if (props.onOpenFeed) {
props.onOpenFeed(feed) props.onOpenFeed(feed);
} }
} };
return ( return (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
{/* Header with filter controls */} {/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}> <box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text> <text>
@@ -137,18 +141,10 @@ export function FeedList(props: FeedListProps) {
</text> </text>
<text fg="gray">({filteredFeeds().length} feeds)</text> <text fg="gray">({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<box <box border padding={0} onMouseDown={cycleVisibilityFilter}>
border
padding={0}
onMouseDown={cycleVisibilityFilter}
>
<text fg="cyan">[f] {visibilityLabel()}</text> <text fg="cyan">[f] {visibilityLabel()}</text>
</box> </box>
<box <box border padding={0} onMouseDown={cycleSortField}>
border
padding={0}
onMouseDown={cycleSortField}
>
<text fg="cyan">[s] {sortLabel()}</text> <text fg="cyan">[s] {sortLabel()}</text>
</box> </box>
</box> </box>
@@ -189,5 +185,5 @@ export function FeedList(props: FeedListProps) {
</text> </text>
</box> </box>
</box> </box>
) );
} }

View File

@@ -3,69 +3,69 @@
* Reverse chronological order, like an inbox/timeline * Reverse chronological order, like an inbox/timeline
*/ */
import { createSignal, For, Show } from "solid-js" import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "../stores/feed" import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns" import { format } from "date-fns";
import type { Episode } from "../types/episode" import type { Episode } from "@/types/episode";
import type { Feed } from "../types/feed" import type { Feed } from "@/types/feed";
type FeedPageProps = { type FeedPageProps = {
focused: boolean focused: boolean;
onPlayEpisode?: (episode: Episode, feed: Feed) => void onPlayEpisode?: (episode: Episode, feed: Feed) => void;
onExit?: () => void onExit?: () => void;
} };
export function FeedPage(props: FeedPageProps) { export function FeedPage(props: FeedPageProps) {
const feedStore = useFeedStore() const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0);
const [isRefreshing, setIsRefreshing] = createSignal(false) const [isRefreshing, setIsRefreshing] = createSignal(false);
const allEpisodes = () => feedStore.getAllEpisodesChronological() const allEpisodes = () => feedStore.getAllEpisodesChronological();
const formatDate = (date: Date): string => { const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy") return format(date, "MMM d, yyyy");
} };
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60) const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m` if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m` return `${mins}m`;
} };
const handleRefresh = async () => { const handleRefresh = async () => {
setIsRefreshing(true) setIsRefreshing(true);
await feedStore.refreshAllFeeds() await feedStore.refreshAllFeeds();
setIsRefreshing(false) setIsRefreshing(false);
} };
useKeyboard((key) => { useKeyboard((key) => {
if (!props.focused) return if (!props.focused) return;
const episodes = allEpisodes() const episodes = allEpisodes();
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1)) setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1));
} else if (key.name === "up" || key.name === "k") { } else if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1)) setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
const item = episodes[selectedIndex()] const item = episodes[selectedIndex()];
if (item) props.onPlayEpisode?.(item.episode, item.feed) if (item) props.onPlayEpisode?.(item.episode, item.feed);
} else if (key.name === "home" || key.name === "g") { } else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0) setSelectedIndex(0);
} else if (key.name === "end") { } else if (key.name === "end") {
setSelectedIndex(episodes.length - 1) setSelectedIndex(episodes.length - 1);
} else if (key.name === "pageup") { } else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10)) setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") { } else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10)) setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10));
} else if (key.name === "r") { } else if (key.name === "r") {
handleRefresh() handleRefresh();
} else if (key.name === "escape") { } else if (key.name === "escape") {
props.onExit?.() props.onExit?.();
} }
}) });
return ( return (
<box flexDirection="column" height="100%"> <box flexDirection="column" height="100%">
@@ -95,7 +95,9 @@ export function FeedPage(props: FeedPageProps) {
paddingRight={1} paddingRight={1}
paddingTop={0} paddingTop={0}
paddingBottom={0} paddingBottom={0}
backgroundColor={index() === selectedIndex() ? "#333" : undefined} backgroundColor={
index() === selectedIndex() ? "#333" : undefined
}
onMouseDown={() => setSelectedIndex(index())} onMouseDown={() => setSelectedIndex(index())}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -117,5 +119,5 @@ export function FeedPage(props: FeedPageProps) {
</scrollbox> </scrollbox>
</Show> </Show>
</box> </box>
) );
} }

View File

@@ -4,208 +4,218 @@
* Right panel: episodes for the selected show * Right panel: episodes for the selected show
*/ */
import { createSignal, For, Show, createMemo, createEffect } from "solid-js" import { createSignal, For, Show, createMemo, createEffect } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "../stores/feed" import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "../stores/download" import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "../types/episode" import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns" import { format } from "date-fns";
import type { Episode } from "../types/episode" import type { Episode } from "@/types/episode";
import type { Feed } from "../types/feed" import type { Feed } from "@/types/feed";
type MyShowsPageProps = { type MyShowsPageProps = {
focused: boolean focused: boolean;
onPlayEpisode?: (episode: Episode, feed: Feed) => void onPlayEpisode?: (episode: Episode, feed: Feed) => void;
onExit?: () => void onExit?: () => void;
} };
type FocusPane = "shows" | "episodes" type FocusPane = "shows" | "episodes";
export function MyShowsPage(props: MyShowsPageProps) { export function MyShowsPage(props: MyShowsPageProps) {
const feedStore = useFeedStore() const feedStore = useFeedStore();
const downloadStore = useDownloadStore() const downloadStore = useDownloadStore();
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows") const [focusPane, setFocusPane] = createSignal<FocusPane>("shows");
const [showIndex, setShowIndex] = createSignal(0) const [showIndex, setShowIndex] = createSignal(0);
const [episodeIndex, setEpisodeIndex] = createSignal(0) const [episodeIndex, setEpisodeIndex] = createSignal(0);
const [isRefreshing, setIsRefreshing] = createSignal(false) const [isRefreshing, setIsRefreshing] = createSignal(false);
/** Threshold: load more when within this many items of the end */ /** Threshold: load more when within this many items of the end */
const LOAD_MORE_THRESHOLD = 5 const LOAD_MORE_THRESHOLD = 5;
const shows = () => feedStore.getFilteredFeeds() const shows = () => feedStore.getFilteredFeeds();
const selectedShow = createMemo(() => { const selectedShow = createMemo(() => {
const s = shows() const s = shows();
const idx = showIndex() const idx = showIndex();
return idx < s.length ? s[idx] : undefined return idx < s.length ? s[idx] : undefined;
}) });
const episodes = createMemo(() => { const episodes = createMemo(() => {
const show = selectedShow() const show = selectedShow();
if (!show) return [] if (!show) return [];
return [...show.episodes].sort( return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime() (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
) );
}) });
// Detect when user navigates near the bottom and load more episodes // Detect when user navigates near the bottom and load more episodes
createEffect(() => { createEffect(() => {
const idx = episodeIndex() const idx = episodeIndex();
const eps = episodes() const eps = episodes();
const show = selectedShow() const show = selectedShow();
if (!show || eps.length === 0) return if (!show || eps.length === 0) return;
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD;
if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) { if (
feedStore.loadMoreEpisodes(show.id) nearBottom &&
feedStore.hasMoreEpisodes(show.id) &&
!feedStore.isLoadingMore()
) {
feedStore.loadMoreEpisodes(show.id);
} }
}) });
const formatDate = (date: Date): string => { const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy") return format(date, "MMM d, yyyy");
} };
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60) const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m` if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m` return `${mins}m`;
} };
/** Get download status label for an episode */ /** Get download status label for an episode */
const downloadLabel = (episodeId: string): string => { const downloadLabel = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId) const status = downloadStore.getDownloadStatus(episodeId);
switch (status) { switch (status) {
case DownloadStatus.QUEUED: case DownloadStatus.QUEUED:
return "[Q]" return "[Q]";
case DownloadStatus.DOWNLOADING: { case DownloadStatus.DOWNLOADING: {
const pct = downloadStore.getDownloadProgress(episodeId) const pct = downloadStore.getDownloadProgress(episodeId);
return `[${pct}%]` return `[${pct}%]`;
} }
case DownloadStatus.COMPLETED: case DownloadStatus.COMPLETED:
return "[DL]" return "[DL]";
case DownloadStatus.FAILED: case DownloadStatus.FAILED:
return "[ERR]" return "[ERR]";
default: default:
return "" return "";
} }
} };
/** Get download status color */ /** Get download status color */
const downloadColor = (episodeId: string): string => { const downloadColor = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId) const status = downloadStore.getDownloadStatus(episodeId);
switch (status) { switch (status) {
case DownloadStatus.QUEUED: case DownloadStatus.QUEUED:
return "yellow" return "yellow";
case DownloadStatus.DOWNLOADING: case DownloadStatus.DOWNLOADING:
return "cyan" return "cyan";
case DownloadStatus.COMPLETED: case DownloadStatus.COMPLETED:
return "green" return "green";
case DownloadStatus.FAILED: case DownloadStatus.FAILED:
return "red" return "red";
default: default:
return "gray" return "gray";
} }
} };
const handleRefresh = async () => { const handleRefresh = async () => {
const show = selectedShow() const show = selectedShow();
if (!show) return if (!show) return;
setIsRefreshing(true) setIsRefreshing(true);
await feedStore.refreshFeed(show.id) await feedStore.refreshFeed(show.id);
setIsRefreshing(false) setIsRefreshing(false);
} };
const handleUnsubscribe = () => { const handleUnsubscribe = () => {
const show = selectedShow() const show = selectedShow();
if (!show) return if (!show) return;
feedStore.removeFeed(show.id) feedStore.removeFeed(show.id);
setShowIndex((i) => Math.max(0, i - 1)) setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0) setEpisodeIndex(0);
} };
useKeyboard((key) => { useKeyboard((key) => {
if (!props.focused) return if (!props.focused) return;
const pane = focusPane() const pane = focusPane();
// Navigate between panes // Navigate between panes
if (key.name === "right" || key.name === "l") { if (key.name === "right" || key.name === "l") {
if (pane === "shows" && selectedShow()) { if (pane === "shows" && selectedShow()) {
setFocusPane("episodes") setFocusPane("episodes");
setEpisodeIndex(0) setEpisodeIndex(0);
} }
return return;
} }
if (key.name === "left" || key.name === "h") { if (key.name === "left" || key.name === "h") {
if (pane === "episodes") { if (pane === "episodes") {
setFocusPane("shows") setFocusPane("shows");
} }
return return;
} }
if (key.name === "tab") { if (key.name === "tab") {
if (pane === "shows" && selectedShow()) { if (pane === "shows" && selectedShow()) {
setFocusPane("episodes") setFocusPane("episodes");
setEpisodeIndex(0) setEpisodeIndex(0);
} else { } else {
setFocusPane("shows") setFocusPane("shows");
} }
return return;
} }
if (pane === "shows") { if (pane === "shows") {
const s = shows() const s = shows();
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
setShowIndex((i) => Math.min(s.length - 1, i + 1)) setShowIndex((i) => Math.min(s.length - 1, i + 1));
setEpisodeIndex(0) setEpisodeIndex(0);
} else if (key.name === "up" || key.name === "k") { } else if (key.name === "up" || key.name === "k") {
setShowIndex((i) => Math.max(0, i - 1)) setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0) setEpisodeIndex(0);
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
if (selectedShow()) { if (selectedShow()) {
setFocusPane("episodes") setFocusPane("episodes");
setEpisodeIndex(0) setEpisodeIndex(0);
} }
} else if (key.name === "d") { } else if (key.name === "d") {
handleUnsubscribe() handleUnsubscribe();
} else if (key.name === "r") { } else if (key.name === "r") {
handleRefresh() handleRefresh();
} else if (key.name === "escape") { } else if (key.name === "escape") {
props.onExit?.() props.onExit?.();
} }
} else if (pane === "episodes") { } else if (pane === "episodes") {
const eps = episodes() const eps = episodes();
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1)) setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "up" || key.name === "k") { } else if (key.name === "up" || key.name === "k") {
setEpisodeIndex((i) => Math.max(0, i - 1)) setEpisodeIndex((i) => Math.max(0, i - 1));
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
const ep = eps[episodeIndex()] const ep = eps[episodeIndex()];
const show = selectedShow() const show = selectedShow();
if (ep && show) props.onPlayEpisode?.(ep, show) if (ep && show) props.onPlayEpisode?.(ep, show);
} else if (key.name === "d") { } else if (key.name === "d") {
const ep = eps[episodeIndex()] const ep = eps[episodeIndex()];
const show = selectedShow() const show = selectedShow();
if (ep && show) { if (ep && show) {
const status = downloadStore.getDownloadStatus(ep.id) const status = downloadStore.getDownloadStatus(ep.id);
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) { if (
downloadStore.startDownload(ep, show.id) status === DownloadStatus.NONE ||
} else if (status === DownloadStatus.DOWNLOADING || status === DownloadStatus.QUEUED) { status === DownloadStatus.FAILED
downloadStore.cancelDownload(ep.id) ) {
downloadStore.startDownload(ep, show.id);
} else if (
status === DownloadStatus.DOWNLOADING ||
status === DownloadStatus.QUEUED
) {
downloadStore.cancelDownload(ep.id);
} }
} }
} else if (key.name === "pageup") { } else if (key.name === "pageup") {
setEpisodeIndex((i) => Math.max(0, i - 10)) setEpisodeIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") { } else if (key.name === "pagedown") {
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10)) setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10));
} else if (key.name === "r") { } else if (key.name === "r") {
handleRefresh() handleRefresh();
} else if (key.name === "escape") { } else if (key.name === "escape") {
setFocusPane("shows") setFocusPane("shows");
key.stopPropagation() key.stopPropagation();
} }
} }
}) });
return { return {
showsPanel: () => ( showsPanel: () => (
@@ -223,7 +233,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box> </box>
} }
> >
<scrollbox height="100%" focused={props.focused && focusPane() === "shows"}> <scrollbox
height="100%"
focused={props.focused && focusPane() === "shows"}
>
<For each={shows()}> <For each={shows()}>
{(feed, index) => ( {(feed, index) => (
<box <box
@@ -233,8 +246,8 @@ export function MyShowsPage(props: MyShowsPageProps) {
paddingRight={1} paddingRight={1}
backgroundColor={index() === showIndex() ? "#333" : undefined} backgroundColor={index() === showIndex() ? "#333" : undefined}
onMouseDown={() => { onMouseDown={() => {
setShowIndex(index()) setShowIndex(index());
setEpisodeIndex(0) setEpisodeIndex(0);
}} }}
> >
<text fg={index() === showIndex() ? "cyan" : "gray"}> <text fg={index() === showIndex() ? "cyan" : "gray"}>
@@ -270,7 +283,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box> </box>
} }
> >
<scrollbox height="100%" focused={props.focused && focusPane() === "episodes"}> <scrollbox
height="100%"
focused={props.focused && focusPane() === "episodes"}
>
<For each={episodes()}> <For each={episodes()}>
{(episode, index) => ( {(episode, index) => (
<box <box
@@ -278,15 +294,21 @@ export function MyShowsPage(props: MyShowsPageProps) {
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={index() === episodeIndex() ? "#333" : undefined} backgroundColor={
index() === episodeIndex() ? "#333" : undefined
}
onMouseDown={() => setEpisodeIndex(index())} onMouseDown={() => setEpisodeIndex(index())}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={index() === episodeIndex() ? "cyan" : "gray"}> <text fg={index() === episodeIndex() ? "cyan" : "gray"}>
{index() === episodeIndex() ? ">" : " "} {index() === episodeIndex() ? ">" : " "}
</text> </text>
<text fg={index() === episodeIndex() ? "white" : undefined}> <text
{episode.episodeNumber ? `#${episode.episodeNumber} ` : ""} fg={index() === episodeIndex() ? "white" : undefined}
>
{episode.episodeNumber
? `#${episode.episodeNumber} `
: ""}
{episode.title} {episode.title}
</text> </text>
</box> </box>
@@ -294,7 +316,9 @@ export function MyShowsPage(props: MyShowsPageProps) {
<text fg="gray">{formatDate(episode.pubDate)}</text> <text fg="gray">{formatDate(episode.pubDate)}</text>
<text fg="gray">{formatDuration(episode.duration)}</text> <text fg="gray">{formatDuration(episode.duration)}</text>
<Show when={downloadLabel(episode.id)}> <Show when={downloadLabel(episode.id)}>
<text fg={downloadColor(episode.id)}>{downloadLabel(episode.id)}</text> <text fg={downloadColor(episode.id)}>
{downloadLabel(episode.id)}
</text>
</Show> </Show>
</box> </box>
</box> </box>
@@ -305,7 +329,13 @@ export function MyShowsPage(props: MyShowsPageProps) {
<text fg="yellow">Loading more episodes...</text> <text fg="yellow">Loading more episodes...</text>
</box> </box>
</Show> </Show>
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}> <Show
when={
!feedStore.isLoadingMore() &&
selectedShow() &&
feedStore.hasMoreEpisodes(selectedShow()!.id)
}
>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
<text fg="gray">Scroll down for more episodes</text> <text fg="gray">Scroll down for more episodes</text>
</box> </box>
@@ -318,5 +348,5 @@ export function MyShowsPage(props: MyShowsPageProps) {
focusPane, focusPane,
selectedShow, selectedShow,
} };
} }

147
src/tabs/Player/Player.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useKeyboard } from "@opentui/solid";
import { PlaybackControls } from "./PlaybackControls";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
import type { Episode } from "@/types/episode";
type PlayerProps = {
focused: boolean;
episode?: Episode | null;
onExit?: () => void;
};
const SAMPLE_EPISODE: Episode = {
id: "sample-ep",
podcastId: "sample-podcast",
title: "A Tour of the Productive Mind",
description: "A short guided session on building creative focus.",
audioUrl: "",
duration: 2780,
pubDate: new Date(),
};
export function Player(props: PlayerProps) {
const audio = useAudio();
// The episode to display — prefer a passed-in episode, then the
// currently-playing episode, then fall back to the sample.
const episode = () =>
props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE;
const dur = () => audio.duration() || episode().duration || 1;
useKeyboard((key: { name: string }) => {
if (!props.focused) return;
if (key.name === "space") {
if (audio.currentEpisode()) {
audio.togglePlayback();
} else {
// Nothing loaded yet — start playing the displayed episode
const ep = episode();
if (ep.audioUrl) {
audio.play(ep);
}
}
return;
}
if (key.name === "escape") {
props.onExit?.();
return;
}
if (key.name === "left") {
audio.seekRelative(-10);
}
if (key.name === "right") {
audio.seekRelative(10);
}
if (key.name === "up") {
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))));
}
if (key.name === "down") {
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))));
}
if (key.name === "s") {
const next =
audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2));
audio.setSpeed(next);
}
});
const progressPercent = () => {
const d = dur();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Now Playing</strong>
</text>
<text fg="gray">
{formatTime(audio.position())} / {formatTime(dur())} (
{progressPercent()}%)
</text>
</box>
{audio.error() && <text fg="red">{audio.error()}</text>}
<box border padding={1} flexDirection="column" gap={1}>
<text fg="white">
<strong>{episode().title}</strong>
</text>
<text fg="gray">{episode().description}</text>
<RealtimeWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
speed={audio.speed()}
onSeek={(next: number) => audio.seek(next)}
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!episode().audioUrl}
onToggle={() => {
if (audio.currentEpisode()) {
audio.togglePlayback();
} else {
const ep = episode();
if (ep.audioUrl) audio.play(ep);
}
}}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(dur())}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<text fg="gray">
Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc
back
</text>
</box>
);
}

View File

@@ -1,86 +1,97 @@
/** /**
* RealtimeWaveform live audio frequency visualization using cavacore. * RealtimeWaveform live audio frequency visualization using cavacore.
* *
* Replaces MergedWaveform during playback. Spawns an independent ffmpeg * Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore * process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal * for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps. * characters at ~30fps.
*
* Falls back gracefully if cavacore is unavailable (loadCavaCore returns null).
* Same prop interface as MergedWaveform for drop-in replacement.
*/ */
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js" import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import { loadCavaCore, type CavaCore, type CavaCoreConfig } from "../utils/cavacore" import {
import { AudioStreamReader } from "../utils/audio-stream-reader" loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = { export type RealtimeWaveformProps = {
/** Audio URL — used to start the ffmpeg decode stream */ /** Audio URL — used to start the ffmpeg decode stream */
audioUrl: string audioUrl: string;
/** Current playback position in seconds */ /** Current playback position in seconds */
position: number position: number;
/** Total duration in seconds */ /** Total duration in seconds */
duration: number duration: number;
/** Whether audio is currently playing */ /** Whether audio is currently playing */
isPlaying: boolean isPlaying: boolean;
/** Playback speed multiplier (default: 1) */ /** Playback speed multiplier (default: 1) */
speed?: number speed?: number;
/** Number of frequency bars / columns */ /** Number of frequency bars / columns */
resolution?: number resolution?: number;
/** Callback when user clicks to seek */ /** Callback when user clicks to seek */
onSeek?: (seconds: number) => void onSeek?: (seconds: number) => void;
/** Visualizer configuration overrides */ /** Visualizer configuration overrides */
visualizerConfig?: Partial<CavaCoreConfig> visualizerConfig?: Partial<CavaCoreConfig>;
} };
/** Unicode lower block elements: space (silence) through full block (max) */ /** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"] const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */ /** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33 const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */ /** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512 const SAMPLES_PER_FRAME = 512;
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) { export function RealtimeWaveform(props: RealtimeWaveformProps) {
const resolution = () => props.resolution ?? 32 const resolution = () => props.resolution ?? 32;
// Frequency bar values (0.01.0 per bar) // Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]) const [barData, setBarData] = createSignal<number[]>([]);
// Track whether cavacore is available // Track whether cavacore is available
const [available, setAvailable] = createSignal(false) const [available, setAvailable] = createSignal(false);
let cava: CavaCore | null = null let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null let sampleBuffer: Float64Array | null = null;
// ── Lifecycle: init cavacore once ────────────────────────────────── // ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => { const initCava = () => {
if (cava) return true if (cava) return true;
cava = loadCavaCore() cava = loadCavaCore();
if (!cava) { if (!cava) {
setAvailable(false) setAvailable(false);
return false return false;
} }
setAvailable(true) setAvailable(true);
return true return true;
} };
// ── Start/stop the visualization pipeline ────────────────────────── // ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => { const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization() stopVisualization();
if (!url || !initCava() || !cava) return if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides // Initialize cavacore with current resolution + any overrides
const config: CavaCoreConfig = { const config: CavaCoreConfig = {
@@ -88,56 +99,57 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
sampleRate: 44100, sampleRate: 44100,
channels: 1, channels: 1,
...props.visualizerConfig, ...props.visualizerConfig,
} };
cava.init(config) cava.init(config);
// Pre-allocate sample read buffer // Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME) sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new) // Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) { if (!reader || reader.url !== url) {
if (reader) reader.stop() if (reader) reader.stop();
reader = new AudioStreamReader({ url }) reader = new AudioStreamReader({ url });
} }
reader.start(position, speed) reader.start(position, speed);
// Start render loop // Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL) frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
} };
const stopVisualization = () => { const stopVisualization = () => {
if (frameTimer) { if (frameTimer) {
clearInterval(frameTimer) clearInterval(frameTimer);
frameTimer = null frameTimer = null;
} }
if (reader) { if (reader) {
reader.stop() reader.stop();
// Don't null reader — we reuse it across start/stop cycles // Don't null reader — we reuse it across start/stop cycles
} }
if (cava?.isReady) { if (cava?.isReady) {
cava.destroy() cava.destroy();
} }
sampleBuffer = null sampleBuffer = null;
} };
// ── Render loop (called at ~30fps) ───────────────────────────────── // ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => { const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream // Read available PCM samples from the stream
const count = reader.read(sampleBuffer) const count = reader.read(sampleBuffer);
if (count === 0) return if (count === 0) return;
// Feed samples to cavacore → get frequency bars // Feed samples to cavacore → get frequency bars
const input = count < sampleBuffer.length const input =
? sampleBuffer.subarray(0, count) count < sampleBuffer.length
: sampleBuffer ? sampleBuffer.subarray(0, count)
const output = cava.execute(input) : sampleBuffer;
const output = cava.execute(input);
// Copy bar values to a new array for the signal // Copy bar values to a new array for the signal
setBarData(Array.from(output)) setBarData(Array.from(output));
} };
// ── Single unified effect: respond to all prop changes ───────────── // ── Single unified effect: respond to all prop changes ─────────────
// //
@@ -159,14 +171,14 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
], ],
([playing, url, speed]) => { ([playing, url, speed]) => {
if (playing && url) { if (playing && url) {
const pos = untrack(() => props.position) const pos = untrack(() => props.position);
startVisualization(url, pos, speed) startVisualization(url, pos, speed);
} else { } else {
stopVisualization() stopVisualization();
} }
}, },
), ),
) );
// ── Seek detection: lightweight effect for position jumps ────────── // ── Seek detection: lightweight effect for position jumps ──────────
// //
@@ -175,107 +187,94 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// This is intentionally a separate effect — it should NOT trigger a // This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos. // full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0 let lastSyncPosition = 0;
createEffect( createEffect(
on( on(
() => props.position, () => props.position,
(pos) => { (pos) => {
if (!props.isPlaying || !reader?.running) { if (!props.isPlaying || !reader?.running) {
lastSyncPosition = pos lastSyncPosition = pos;
return return;
} }
const delta = Math.abs(pos - lastSyncPosition) const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos lastSyncPosition = pos;
if (delta > 2) { if (delta > 2) {
const speed = props.speed ?? 1 const speed = props.speed ?? 1;
reader.restart(pos, speed) reader.restart(pos, speed);
} }
}, },
), ),
) );
// Cleanup on unmount // Cleanup on unmount
onCleanup(() => { onCleanup(() => {
stopVisualization() stopVisualization();
if (reader) { if (reader) {
reader.stop() reader.stop();
reader = null reader = null;
} }
// Don't null cava itself — it can be reused. But do destroy its plan. // Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) { if (cava?.isReady) {
cava.destroy() cava.destroy();
} }
}) });
// ── Rendering ────────────────────────────────────────────────────── // ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () => const playedRatio = () =>
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration) props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
const renderLine = () => { const renderLine = () => {
const bars = barData() const bars = barData();
const numBars = resolution() const numBars = resolution();
// If no data yet, show empty placeholder // If no data yet, show empty placeholder
if (bars.length === 0) { if (bars.length === 0) {
const placeholder = ".".repeat(numBars) const placeholder = ".".repeat(numBars);
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text> <text fg="#3b4252">{placeholder}</text>
</box> </box>
) );
} }
const played = Math.floor(numBars * playedRatio()) const played = Math.floor(numBars * playedRatio());
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252" const futureColor = "#3b4252";
const playedChars = bars const playedChars = bars
.slice(0, played) .slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("") .join("");
const futureChars = bars const futureChars = bars
.slice(played) .slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("") .join("");
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text> <text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text> <text fg={futureColor}>{futureChars || " "}</text>
</box> </box>
) );
} };
const handleClick = (event: { x: number }) => { const handleClick = (event: { x: number }) => {
const numBars = resolution() const numBars = resolution();
const ratio = numBars === 0 ? 0 : event.x / numBars const ratio = numBars === 0 ? 0 : event.x / numBars;
const next = Math.max( const next = Math.max(
0, 0,
Math.min(props.duration, Math.round(props.duration * ratio)), Math.min(props.duration, Math.round(props.duration * ratio)),
) );
props.onSeek?.(next) props.onSeek?.(next);
} };
return ( return (
<box border padding={1} onMouseDown={handleClick}> <box border padding={1} onMouseDown={handleClick}>
{renderLine()} {renderLine()}
</box> </box>
) );
}
/**
* Check if cavacore is available on this system.
* Useful for deciding whether to show RealtimeWaveform or MergedWaveform.
*/
let _cavacoreAvailable: boolean | null = null
export function isCavacoreAvailable(): boolean {
if (_cavacoreAvailable === null) {
const cava = loadCavaCore()
_cavacoreAvailable = cava !== null
}
return _cavacoreAvailable
} }

View File

@@ -1,16 +1,16 @@
import { Show } from "solid-js" import { Show } from "solid-js";
import type { SearchResult } from "../types/source" import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge" import { SourceBadge } from "./SourceBadge";
type ResultCardProps = { type ResultCardProps = {
result: SearchResult result: SearchResult;
selected: boolean selected: boolean;
onSelect: () => void onSelect: () => void;
onSubscribe?: () => void onSubscribe?: () => void;
} };
export function ResultCard(props: ResultCardProps) { export function ResultCard(props: ResultCardProps) {
const podcast = () => props.result.podcast const podcast = () => props.result.podcast;
return ( return (
<box <box
@@ -21,7 +21,11 @@ export function ResultCard(props: ResultCardProps) {
backgroundColor={props.selected ? "#222" : undefined} backgroundColor={props.selected ? "#222" : undefined}
onMouseDown={props.onSelect} onMouseDown={props.onSelect}
> >
<box flexDirection="row" justifyContent="space-between" alignItems="center"> <box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center"> <box flexDirection="row" gap={2} alignItems="center">
<text fg={props.selected ? "cyan" : "white"}> <text fg={props.selected ? "cyan" : "white"}>
<strong>{podcast().title}</strong> <strong>{podcast().title}</strong>
@@ -67,13 +71,13 @@ export function ResultCard(props: ResultCardProps) {
paddingRight={1} paddingRight={1}
width={18} width={18}
onMouseDown={(event) => { onMouseDown={(event) => {
event.stopPropagation?.() event.stopPropagation?.();
props.onSubscribe?.() props.onSubscribe?.();
}} }}
> >
<text fg="cyan">[+] Add to Feeds</text> <text fg="cyan">[+] Add to Feeds</text>
</box> </box>
</Show> </Show>
</box> </box>
) );
} }

View File

@@ -0,0 +1,73 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
return (
<box flexDirection="column" border padding={1} gap={1} height="100%">
<Show
when={props.result}
fallback={<text fg="gray">Select a result to see details.</text>}
>
{(result) => (
<>
<text fg="white">
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg="gray">by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg="gray">{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg="yellow">[{category}]</text>
))}
</box>
</Show>
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
<text fg="gray">
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="cyan">[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg="green">Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -2,171 +2,171 @@
* SearchPage component - Main search interface for PodTUI * SearchPage component - Main search interface for PodTUI
*/ */
import { createSignal, createEffect, Show } from "solid-js" import { createSignal, createEffect, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { useSearchStore } from "../stores/search" import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults" import { SearchResults } from "./SearchResults";
import { SearchHistory } from "./SearchHistory" import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "../types/source" import type { SearchResult } from "@/types/source";
type SearchPageProps = { type SearchPageProps = {
focused: boolean focused: boolean;
onSubscribe?: (result: SearchResult) => void onSubscribe?: (result: SearchResult) => void;
onInputFocusChange?: (focused: boolean) => void onInputFocusChange?: (focused: boolean) => void;
onExit?: () => void onExit?: () => void;
} };
type FocusArea = "input" | "results" | "history" type FocusArea = "input" | "results" | "history";
export function SearchPage(props: SearchPageProps) { export function SearchPage(props: SearchPageProps) {
const searchStore = useSearchStore() const searchStore = useSearchStore();
const [focusArea, setFocusArea] = createSignal<FocusArea>("input") const [focusArea, setFocusArea] = createSignal<FocusArea>("input");
const [inputValue, setInputValue] = createSignal("") const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0) const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0) const [historyIndex, setHistoryIndex] = createSignal(0);
// Keep parent informed about input focus state // Keep parent informed about input focus state
createEffect(() => { createEffect(() => {
const isInputFocused = props.focused && focusArea() === "input" const isInputFocused = props.focused && focusArea() === "input";
props.onInputFocusChange?.(isInputFocused) props.onInputFocusChange?.(isInputFocused);
}) });
const handleSearch = async () => { const handleSearch = async () => {
const query = inputValue().trim() const query = inputValue().trim();
if (query) { if (query) {
await searchStore.search(query) await searchStore.search(query);
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results");
setResultIndex(0) setResultIndex(0);
} }
} }
} };
const handleHistorySelect = async (query: string) => { const handleHistorySelect = async (query: string) => {
setInputValue(query) setInputValue(query);
await searchStore.search(query) await searchStore.search(query);
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results");
setResultIndex(0) setResultIndex(0);
} }
} };
const handleResultSelect = (result: SearchResult) => { const handleResultSelect = (result: SearchResult) => {
props.onSubscribe?.(result) props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id) searchStore.markSubscribed(result.podcast.id);
} };
// Keyboard navigation // Keyboard navigation
useKeyboard((key) => { useKeyboard((key) => {
if (!props.focused) return if (!props.focused) return;
const area = focusArea() const area = focusArea();
// Enter to search from input // Enter to search from input
if ((key.name === "return" || key.name === "enter") && area === "input") { if ((key.name === "return" || key.name === "enter") && area === "input") {
handleSearch() handleSearch();
return return;
} }
// Tab to cycle focus areas // Tab to cycle focus areas
if (key.name === "tab" && !key.shift) { if (key.name === "tab" && !key.shift) {
if (area === "input") { if (area === "input") {
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results");
} else if (searchStore.history().length > 0) { } else if (searchStore.history().length > 0) {
setFocusArea("history") setFocusArea("history");
} }
} else if (area === "results") { } else if (area === "results") {
if (searchStore.history().length > 0) { if (searchStore.history().length > 0) {
setFocusArea("history") setFocusArea("history");
} else { } else {
setFocusArea("input") setFocusArea("input");
} }
} else { } else {
setFocusArea("input") setFocusArea("input");
} }
return return;
} }
if (key.name === "tab" && key.shift) { if (key.name === "tab" && key.shift) {
if (area === "input") { if (area === "input") {
if (searchStore.history().length > 0) { if (searchStore.history().length > 0) {
setFocusArea("history") setFocusArea("history");
} else if (searchStore.results().length > 0) { } else if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results");
} }
} else if (area === "history") { } else if (area === "history") {
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results");
} else { } else {
setFocusArea("input") setFocusArea("input");
} }
} else { } else {
setFocusArea("input") setFocusArea("input");
} }
return return;
} }
// Up/Down for results and history // Up/Down for results and history
if (area === "results") { if (area === "results") {
const results = searchStore.results() const results = searchStore.results();
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
setResultIndex((i) => Math.min(i + 1, results.length - 1)) setResultIndex((i) => Math.min(i + 1, results.length - 1));
return return;
} }
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
setResultIndex((i) => Math.max(i - 1, 0)) setResultIndex((i) => Math.max(i - 1, 0));
return return;
} }
if (key.name === "return" || key.name === "enter") { if (key.name === "return" || key.name === "enter") {
const result = results[resultIndex()] const result = results[resultIndex()];
if (result) handleResultSelect(result) if (result) handleResultSelect(result);
return return;
} }
} }
if (area === "history") { if (area === "history") {
const history = searchStore.history() const history = searchStore.history();
if (key.name === "down" || key.name === "j") { if (key.name === "down" || key.name === "j") {
setHistoryIndex((i) => Math.min(i + 1, history.length - 1)) setHistoryIndex((i) => Math.min(i + 1, history.length - 1));
return return;
} }
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
setHistoryIndex((i) => Math.max(i - 1, 0)) setHistoryIndex((i) => Math.max(i - 1, 0));
return return;
} }
if (key.name === "return" || key.name === "enter") { if (key.name === "return" || key.name === "enter") {
const query = history[historyIndex()] const query = history[historyIndex()];
if (query) handleHistorySelect(query) if (query) handleHistorySelect(query);
return return;
} }
} }
// Escape goes back to input or up one level // Escape goes back to input or up one level
if (key.name === "escape") { if (key.name === "escape") {
if (area === "input") { if (area === "input") {
props.onExit?.() props.onExit?.();
} else { } else {
setFocusArea("input") setFocusArea("input");
key.stopPropagation() key.stopPropagation();
} }
return return;
} }
// "/" focuses search input // "/" focuses search input
if (key.name === "/" && area !== "input") { if (key.name === "/" && area !== "input") {
setFocusArea("input") setFocusArea("input");
return return;
} }
}) });
return ( return (
<box flexDirection="column" height="100%" gap={1}> <box flexDirection="column" height="100%" gap={1}>
{/* Search Header */} {/* Search Header */}
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text> <text>
<strong>Search Podcasts</strong> <strong>Search Podcasts</strong>
</text> </text>
{/* Search Input */} {/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
@@ -174,7 +174,7 @@ export function SearchPage(props: SearchPageProps) {
<input <input
value={inputValue()} value={inputValue()}
onInput={(value) => { onInput={(value) => {
setInputValue(value) setInputValue(value);
}} }}
placeholder="Enter podcast name, topic, or author..." placeholder="Enter podcast name, topic, or author..."
focused={props.focused && focusArea() === "input"} focused={props.focused && focusArea() === "input"}
@@ -234,17 +234,17 @@ export function SearchPage(props: SearchPageProps) {
</box> </box>
{/* History Sidebar */} {/* History Sidebar */}
<box width={30} border> <box width={30} border>
<box padding={1} flexDirection="column"> <box padding={1} flexDirection="column">
<box paddingBottom={1}> <box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}> <text fg={focusArea() === "history" ? "cyan" : "gray"}>
History History
</text> </text>
</box> </box>
<SearchHistory <SearchHistory
history={searchStore.history()} history={searchStore.history()}
selectedIndex={historyIndex()} selectedIndex={historyIndex()}
focused={focusArea() === "history"} focused={focusArea() === "history"}
onSelect={handleHistorySelect} onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory} onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory} onClear={searchStore.clearHistory}
@@ -262,5 +262,5 @@ export function SearchPage(props: SearchPageProps) {
<text fg="gray">[Esc] Up</text> <text fg="gray">[Esc] Up</text>
</box> </box>
</box> </box>
) );
} }

View File

@@ -2,32 +2,35 @@
* SearchResults component for displaying podcast search results * SearchResults component for displaying podcast search results
*/ */
import { For, Show } from "solid-js" import { For, Show } from "solid-js";
import type { SearchResult } from "../types/source" import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard" import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail" import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = { type SearchResultsProps = {
results: SearchResult[] results: SearchResult[];
selectedIndex: number selectedIndex: number;
focused: boolean focused: boolean;
onSelect?: (result: SearchResult) => void onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void onChange?: (index: number) => void;
isSearching?: boolean isSearching?: boolean;
error?: string | null error?: string | null;
} };
export function SearchResults(props: SearchResultsProps) { export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => { const handleSelect = (index: number) => {
props.onChange?.(index) props.onChange?.(index);
} };
return ( return (
<Show when={!props.isSearching} fallback={ <Show
<box padding={1}> when={!props.isSearching}
<text fg="yellow">Searching...</text> fallback={
</box> <box padding={1}>
}> <text fg="yellow">Searching...</text>
</box>
}
>
<Show <Show
when={!props.error} when={!props.error}
fallback={ fallback={
@@ -40,7 +43,9 @@ export function SearchResults(props: SearchResultsProps) {
when={props.results.length > 0} when={props.results.length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1}>
<text fg="gray">No results found. Try a different search term.</text> <text fg="gray">
No results found. Try a different search term.
</text>
</box> </box>
} }
> >
@@ -71,5 +76,5 @@ export function SearchResults(props: SearchResultsProps) {
</Show> </Show>
</Show> </Show>
</Show> </Show>
) );
} }

View File

@@ -0,0 +1,34 @@
import { SourceType } from "@/types/source";
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";
};
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "cyan";
if (sourceType === SourceType.RSS) return "green";
if (sourceType === SourceType.CUSTOM) return "yellow";
return "gray";
};
export function SourceBadge(props: SourceBadgeProps) {
const label = () => props.sourceName || props.sourceId;
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg="gray">{label()}</text>
</box>
);
}

View File

@@ -1,12 +1,12 @@
import { detectFormat } from "../utils/file-detector" import { detectFormat } from "@/utils/file-detector";
type FilePickerProps = { type FilePickerProps = {
value: string value: string;
onChange: (value: string) => void onChange: (value: string) => void;
} };
export function FilePicker(props: FilePickerProps) { export function FilePicker(props: FilePickerProps) {
const format = detectFormat(props.value) const format = detectFormat(props.value);
return ( return (
<box style={{ flexDirection: "column", gap: 1 }}> <box style={{ flexDirection: "column", gap: 1 }}>
@@ -18,5 +18,5 @@ export function FilePicker(props: FilePickerProps) {
/> />
<text>Format: {format}</text> <text>Format: {format}</text>
</box> </box>
) );
} }

View File

@@ -3,84 +3,84 @@
* Email/password login with links to code validation and OAuth * Email/password login with links to code validation and OAuth
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { useAuthStore } from "../stores/auth" import { useAuthStore } from "@/stores/auth";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { AUTH_CONFIG } from "../config/auth" import { AUTH_CONFIG } from "@/config/auth";
interface LoginScreenProps { interface LoginScreenProps {
focused?: boolean focused?: boolean;
onNavigateToCode?: () => void onNavigateToCode?: () => void;
onNavigateToOAuth?: () => void onNavigateToOAuth?: () => void;
} }
type FocusField = "email" | "password" | "submit" | "code" | "oauth" type FocusField = "email" | "password" | "submit" | "code" | "oauth";
export function LoginScreen(props: LoginScreenProps) { export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore() const auth = useAuthStore();
const { theme } = useTheme() const { theme } = useTheme();
const [email, setEmail] = createSignal("") const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("") const [password, setPassword] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("email") const [focusField, setFocusField] = createSignal<FocusField>("email");
const [emailError, setEmailError] = createSignal<string | null>(null) const [emailError, setEmailError] = createSignal<string | null>(null);
const [passwordError, setPasswordError] = createSignal<string | null>(null) const [passwordError, setPasswordError] = createSignal<string | null>(null);
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"] const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
const validateEmail = (value: string): boolean => { const validateEmail = (value: string): boolean => {
if (!value) { if (!value) {
setEmailError("Email is required") setEmailError("Email is required");
return false return false;
} }
if (!AUTH_CONFIG.email.pattern.test(value)) { if (!AUTH_CONFIG.email.pattern.test(value)) {
setEmailError("Invalid email format") setEmailError("Invalid email format");
return false return false;
} }
setEmailError(null) setEmailError(null);
return true return true;
} };
const validatePassword = (value: string): boolean => { const validatePassword = (value: string): boolean => {
if (!value) { if (!value) {
setPasswordError("Password is required") setPasswordError("Password is required");
return false return false;
} }
if (value.length < AUTH_CONFIG.password.minLength) { if (value.length < AUTH_CONFIG.password.minLength) {
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`) setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
return false return false;
} }
setPasswordError(null) setPasswordError(null);
return true return true;
} };
const handleSubmit = async () => { const handleSubmit = async () => {
const isEmailValid = validateEmail(email()) const isEmailValid = validateEmail(email());
const isPasswordValid = validatePassword(password()) const isPasswordValid = validatePassword(password());
if (!isEmailValid || !isPasswordValid) { if (!isEmailValid || !isPasswordValid) {
return return;
} }
await auth.login({ email: email(), password: password() }) await auth.login({ email: email(), password: password() });
} };
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField()) const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length ? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length : (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]) setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
if (focusField() === "submit") { if (focusField() === "submit") {
handleSubmit() handleSubmit();
} else if (focusField() === "code" && props.onNavigateToCode) { } else if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode() props.onNavigateToCode();
} else if (focusField() === "oauth" && props.onNavigateToOAuth) { } else if (focusField() === "oauth" && props.onNavigateToOAuth) {
props.onNavigateToOAuth() props.onNavigateToOAuth();
} }
} }
} };
return ( return (
<box flexDirection="column" border padding={2} gap={1}> <box flexDirection="column" border padding={2} gap={1}>
@@ -92,7 +92,9 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Email field */} {/* Email field */}
<box flexDirection="column" gap={0}> <box flexDirection="column" gap={0}>
<text fg={focusField() === "email" ? theme.primary : undefined}>Email:</text> <text fg={focusField() === "email" ? theme.primary : undefined}>
Email:
</text>
<input <input
value={email()} value={email()}
onInput={setEmail} onInput={setEmail}
@@ -100,9 +102,7 @@ export function LoginScreen(props: LoginScreenProps) {
focused={props.focused && focusField() === "email"} focused={props.focused && focusField() === "email"}
width={30} width={30}
/> />
{emailError() && ( {emailError() && <text fg={theme.error}>{emailError()}</text>}
<text fg={theme.error}>{emailError()}</text>
)}
</box> </box>
{/* Password field */} {/* Password field */}
@@ -117,9 +117,7 @@ export function LoginScreen(props: LoginScreenProps) {
focused={props.focused && focusField() === "password"} focused={props.focused && focusField() === "password"}
width={30} width={30}
/> />
{passwordError() && ( {passwordError() && <text fg={theme.error}>{passwordError()}</text>}
<text fg={theme.error}>{passwordError()}</text>
)}
</box> </box>
<box height={1} /> <box height={1} />
@@ -129,7 +127,9 @@ export function LoginScreen(props: LoginScreenProps) {
<box <box
border border
padding={1} padding={1}
backgroundColor={focusField() === "submit" ? theme.primary : undefined} backgroundColor={
focusField() === "submit" ? theme.primary : undefined
}
> >
<text fg={focusField() === "submit" ? theme.text : undefined}> <text fg={focusField() === "submit" ? theme.text : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"} {auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
@@ -138,9 +138,7 @@ export function LoginScreen(props: LoginScreenProps) {
</box> </box>
{/* Auth error message */} {/* Auth error message */}
{auth.error && ( {auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<text fg={theme.error}>{auth.error.message}</text>
)}
<box height={1} /> <box height={1} />
@@ -173,5 +171,5 @@ export function LoginScreen(props: LoginScreenProps) {
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text> <text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box> </box>
) );
} }

View File

@@ -3,39 +3,39 @@
* Displays OAuth limitations and alternative authentication methods * Displays OAuth limitations and alternative authentication methods
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth" import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
interface OAuthPlaceholderProps { interface OAuthPlaceholderProps {
focused?: boolean focused?: boolean;
onBack?: () => void onBack?: () => void;
onNavigateToCode?: () => void onNavigateToCode?: () => void;
} }
type FocusField = "code" | "back" type FocusField = "code" | "back";
export function OAuthPlaceholder(props: OAuthPlaceholderProps) { export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
const [focusField, setFocusField] = createSignal<FocusField>("code") const [focusField, setFocusField] = createSignal<FocusField>("code");
const fields: FocusField[] = ["code", "back"] const fields: FocusField[] = ["code", "back"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField()) const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length ? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length : (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]) setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
if (focusField() === "code" && props.onNavigateToCode) { if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode() props.onNavigateToCode();
} else if (focusField() === "back" && props.onBack) { } else if (focusField() === "back" && props.onBack) {
props.onBack() props.onBack();
} }
} else if (key.name === "escape" && props.onBack) { } else if (key.name === "escape" && props.onBack) {
props.onBack() props.onBack();
} }
} };
return ( return (
<box flexDirection="column" border padding={2} gap={1}> <box flexDirection="column" border padding={2} gap={1}>
@@ -121,5 +121,5 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text> <text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</box> </box>
) );
} }

View File

@@ -1,10 +1,10 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { useAppStore } from "../stores/app" import { useAppStore } from "@/stores/app";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import type { ThemeName } from "../types/settings" import type { ThemeName } from "@/types/settings";
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto" type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "system", label: "System" }, { value: "system", label: "System" },
@@ -13,68 +13,77 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "tokyo", label: "Tokyo" }, { value: "tokyo", label: "Tokyo" },
{ value: "nord", label: "Nord" }, { value: "nord", label: "Nord" },
{ value: "custom", label: "Custom" }, { value: "custom", label: "Custom" },
] ];
export function PreferencesPanel() { export function PreferencesPanel() {
const appStore = useAppStore() const appStore = useAppStore();
const { theme } = useTheme() const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("theme") const [focusField, setFocusField] = createSignal<FocusField>("theme");
const settings = () => appStore.state().settings const settings = () => appStore.state().settings;
const preferences = () => appStore.state().preferences const preferences = () => appStore.state().preferences;
const handleKey = (key: { name: string; shift?: boolean }) => { const handleKey = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const fields: FocusField[] = ["theme", "font", "speed", "explicit", "auto"] const fields: FocusField[] = [
const idx = fields.indexOf(focusField()) "theme",
"font",
"speed",
"explicit",
"auto",
];
const idx = fields.indexOf(focusField());
const next = key.shift const next = key.shift
? (idx - 1 + fields.length) % fields.length ? (idx - 1 + fields.length) % fields.length
: (idx + 1) % fields.length : (idx + 1) % fields.length;
setFocusField(fields[next]) setFocusField(fields[next]);
return return;
} }
if (key.name === "left" || key.name === "h") { if (key.name === "left" || key.name === "h") {
stepValue(-1) stepValue(-1);
} }
if (key.name === "right" || key.name === "l") { if (key.name === "right" || key.name === "l") {
stepValue(1) stepValue(1);
} }
if (key.name === "space" || key.name === "return" || key.name === "enter") { if (key.name === "space" || key.name === "return" || key.name === "enter") {
toggleValue() toggleValue();
} }
} };
const stepValue = (delta: number) => { const stepValue = (delta: number) => {
const field = focusField() const field = focusField();
if (field === "theme") { if (field === "theme") {
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme) const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length;
appStore.setTheme(THEME_LABELS[next].value) appStore.setTheme(THEME_LABELS[next].value);
return return;
} }
if (field === "font") { if (field === "font") {
const next = Math.min(20, Math.max(10, settings().fontSize + delta)) const next = Math.min(20, Math.max(10, settings().fontSize + delta));
appStore.updateSettings({ fontSize: next }) appStore.updateSettings({ fontSize: next });
return return;
} }
if (field === "speed") { if (field === "speed") {
const next = Math.min(2, Math.max(0.5, settings().playbackSpeed + delta * 0.1)) const next = Math.min(
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) }) 2,
Math.max(0.5, settings().playbackSpeed + delta * 0.1),
);
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
} }
} };
const toggleValue = () => { const toggleValue = () => {
const field = focusField() const field = focusField();
if (field === "explicit") { if (field === "explicit") {
appStore.updatePreferences({ showExplicit: !preferences().showExplicit }) appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
} }
if (field === "auto") { if (field === "auto") {
appStore.updatePreferences({ autoDownload: !preferences().autoDownload }) appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
} }
} };
useKeyboard(handleKey) useKeyboard(handleKey);
return ( return (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
@@ -82,15 +91,21 @@ export function PreferencesPanel() {
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>Theme:</text> <text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
Theme:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text> <text fg={theme.text}>
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
</text>
</box> </box>
<text fg={theme.textMuted}>[Left/Right]</text> <text fg={theme.textMuted}>[Left/Right]</text>
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>Font Size:</text> <text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
Font Size:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{settings().fontSize}px</text> <text fg={theme.text}>{settings().fontSize}px</text>
</box> </box>
@@ -98,7 +113,9 @@ export function PreferencesPanel() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>Playback:</text> <text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
Playback:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{settings().playbackSpeed}x</text> <text fg={theme.text}>{settings().playbackSpeed}x</text>
</box> </box>
@@ -106,9 +123,15 @@ export function PreferencesPanel() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "explicit" ? theme.primary : theme.textMuted}>Show Explicit:</text> <text
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
>
Show Explicit:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={preferences().showExplicit ? theme.success : theme.textMuted}> <text
fg={preferences().showExplicit ? theme.success : theme.textMuted}
>
{preferences().showExplicit ? "On" : "Off"} {preferences().showExplicit ? "On" : "Off"}
</text> </text>
</box> </box>
@@ -116,9 +139,13 @@ export function PreferencesPanel() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>Auto Download:</text> <text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
Auto Download:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={preferences().autoDownload ? theme.success : theme.textMuted}> <text
fg={preferences().autoDownload ? theme.success : theme.textMuted}
>
{preferences().autoDownload ? "On" : "Off"} {preferences().autoDownload ? "On" : "Off"}
</text> </text>
</box> </box>
@@ -128,5 +155,5 @@ export function PreferencesPanel() {
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text> <text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box> </box>
) );
} }

View File

@@ -1,19 +1,19 @@
import { createSignal, For } from "solid-js" import { createSignal, For } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { SourceManager } from "./SourceManager" import { SourceManager } from "./SourceManager";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { PreferencesPanel } from "./PreferencesPanel" import { PreferencesPanel } from "./PreferencesPanel";
import { SyncPanel } from "./SyncPanel" import { SyncPanel } from "./SyncPanel";
import { VisualizerSettings } from "./VisualizerSettings" import { VisualizerSettings } from "./VisualizerSettings";
type SettingsScreenProps = { type SettingsScreenProps = {
accountLabel: string accountLabel: string;
accountStatus: "signed-in" | "signed-out" accountStatus: "signed-in" | "signed-out";
onOpenAccount?: () => void onOpenAccount?: () => void;
onExit?: () => void onExit?: () => void;
} };
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account" type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account";
const SECTIONS: Array<{ id: SectionId; label: string }> = [ const SECTIONS: Array<{ id: SectionId; label: string }> = [
{ id: "sync", label: "Sync" }, { id: "sync", label: "Sync" },
@@ -21,41 +21,47 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [
{ id: "preferences", label: "Preferences" }, { id: "preferences", label: "Preferences" },
{ id: "visualizer", label: "Visualizer" }, { id: "visualizer", label: "Visualizer" },
{ id: "account", label: "Account" }, { id: "account", label: "Account" },
] ];
export function SettingsScreen(props: SettingsScreenProps) { export function SettingsScreen(props: SettingsScreenProps) {
const { theme } = useTheme() const { theme } = useTheme();
const [activeSection, setActiveSection] = createSignal<SectionId>("sync") const [activeSection, setActiveSection] = createSignal<SectionId>("sync");
useKeyboard((key) => { useKeyboard((key) => {
if (key.name === "escape") { if (key.name === "escape") {
props.onExit?.() props.onExit?.();
return return;
} }
if (key.name === "tab") { if (key.name === "tab") {
const idx = SECTIONS.findIndex((s) => s.id === activeSection()) const idx = SECTIONS.findIndex((s) => s.id === activeSection());
const next = key.shift const next = key.shift
? (idx - 1 + SECTIONS.length) % SECTIONS.length ? (idx - 1 + SECTIONS.length) % SECTIONS.length
: (idx + 1) % SECTIONS.length : (idx + 1) % SECTIONS.length;
setActiveSection(SECTIONS[next].id) setActiveSection(SECTIONS[next].id);
return return;
} }
if (key.name === "1") setActiveSection("sync") if (key.name === "1") setActiveSection("sync");
if (key.name === "2") setActiveSection("sources") if (key.name === "2") setActiveSection("sources");
if (key.name === "3") setActiveSection("preferences") if (key.name === "3") setActiveSection("preferences");
if (key.name === "4") setActiveSection("visualizer") if (key.name === "4") setActiveSection("visualizer");
if (key.name === "5") setActiveSection("account") if (key.name === "5") setActiveSection("account");
}) });
return ( return (
<box flexDirection="column" gap={1} height="100%"> <box flexDirection="column" gap={1} height="100%">
<box flexDirection="row" justifyContent="space-between" alignItems="center"> <box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<text> <text>
<strong>Settings</strong> <strong>Settings</strong>
</text> </text>
<text fg={theme.textMuted}>[Tab] Switch section | 1-5 jump | Esc up</text> <text fg={theme.textMuted}>
[Tab] Switch section | 1-5 jump | Esc up
</text>
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -64,10 +70,16 @@ export function SettingsScreen(props: SettingsScreenProps) {
<box <box
border border
padding={0} padding={0}
backgroundColor={activeSection() === section.id ? theme.primary : undefined} backgroundColor={
activeSection() === section.id ? theme.primary : undefined
}
onMouseDown={() => setActiveSection(section.id)} onMouseDown={() => setActiveSection(section.id)}
> >
<text fg={activeSection() === section.id ? theme.text : theme.textMuted}> <text
fg={
activeSection() === section.id ? theme.text : theme.textMuted
}
>
[{index() + 1}] {section.label} [{index() + 1}] {section.label}
</text> </text>
</box> </box>
@@ -85,7 +97,13 @@ export function SettingsScreen(props: SettingsScreenProps) {
<text fg={theme.textMuted}>Account</text> <text fg={theme.textMuted}>Account</text>
<box flexDirection="row" gap={2} alignItems="center"> <box flexDirection="row" gap={2} alignItems="center">
<text fg={theme.textMuted}>Status:</text> <text fg={theme.textMuted}>Status:</text>
<text fg={props.accountStatus === "signed-in" ? theme.success : theme.warning}> <text
fg={
props.accountStatus === "signed-in"
? theme.success
: theme.warning
}
>
{props.accountLabel} {props.accountLabel}
</text> </text>
</box> </box>
@@ -98,5 +116,5 @@ export function SettingsScreen(props: SettingsScreenProps) {
<text fg={theme.textMuted}>Enter to dive | Esc up</text> <text fg={theme.textMuted}>Enter to dive | Esc up</text>
</box> </box>
) );
} }

View File

@@ -3,39 +3,39 @@
* Add, remove, and configure podcast sources * Add, remove, and configure podcast sources
*/ */
import { createSignal, For } from "solid-js" import { createSignal, For } from "solid-js";
import { useFeedStore } from "../stores/feed" import { useFeedStore } from "@/stores/feed";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { SourceType } from "../types/source" import { SourceType } from "@/types/source";
import type { PodcastSource } from "../types/source" import type { PodcastSource } from "@/types/source";
interface SourceManagerProps { interface SourceManagerProps {
focused?: boolean focused?: boolean;
onClose?: () => void onClose?: () => void;
} }
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language" type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
export function SourceManager(props: SourceManagerProps) { export function SourceManager(props: SourceManagerProps) {
const feedStore = useFeedStore() const feedStore = useFeedStore();
const { theme } = useTheme() const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0);
const [focusArea, setFocusArea] = createSignal<FocusArea>("list") const [focusArea, setFocusArea] = createSignal<FocusArea>("list");
const [newSourceUrl, setNewSourceUrl] = createSignal("") const [newSourceUrl, setNewSourceUrl] = createSignal("");
const [newSourceName, setNewSourceName] = createSignal("") const [newSourceName, setNewSourceName] = createSignal("");
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null);
const sources = () => feedStore.sources() const sources = () => feedStore.sources();
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "escape") { if (key.name === "escape") {
if (focusArea() !== "list") { if (focusArea() !== "list") {
setFocusArea("list") setFocusArea("list");
setError(null) setError(null);
} else if (props.onClose) { } else if (props.onClose) {
props.onClose() props.onClose();
} }
return return;
} }
if (key.name === "tab") { if (key.name === "tab") {
@@ -46,82 +46,100 @@ export function SourceManager(props: SourceManagerProps) {
"explicit", "explicit",
"add", "add",
"url", "url",
] ];
const idx = areas.indexOf(focusArea()) const idx = areas.indexOf(focusArea());
const nextIdx = key.shift const nextIdx = key.shift
? (idx - 1 + areas.length) % areas.length ? (idx - 1 + areas.length) % areas.length
: (idx + 1) % areas.length : (idx + 1) % areas.length;
setFocusArea(areas[nextIdx]) setFocusArea(areas[nextIdx]);
return return;
} }
if (focusArea() === "list") { if (focusArea() === "list") {
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1)) setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") { } else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1)) setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
} else if (key.name === "return" || key.name === "enter" || key.name === "space") { } else if (
const source = sources()[selectedIndex()] key.name === "return" ||
key.name === "enter" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source) { if (source) {
feedStore.toggleSource(source.id) feedStore.toggleSource(source.id);
} }
} else if (key.name === "d" || key.name === "delete") { } else if (key.name === "d" || key.name === "delete") {
const source = sources()[selectedIndex()] const source = sources()[selectedIndex()];
if (source) { if (source) {
const removed = feedStore.removeSource(source.id) const removed = feedStore.removeSource(source.id);
if (!removed) { if (!removed) {
setError("Cannot remove default sources") setError("Cannot remove default sources");
} }
} }
} else if (key.name === "a") { } else if (key.name === "a") {
setFocusArea("add") setFocusArea("add");
} }
} }
if (focusArea() === "country") { if (focusArea() === "country") {
if (key.name === "enter" || key.name === "return" || key.name === "space") { if (
const source = sources()[selectedIndex()] key.name === "enter" ||
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) { if (source && source.type === SourceType.API) {
const next = source.country === "US" ? "GB" : "US" const next = source.country === "US" ? "GB" : "US";
feedStore.updateSource(source.id, { country: next }) feedStore.updateSource(source.id, { country: next });
} }
} }
} }
if (focusArea() === "explicit") { if (focusArea() === "explicit") {
if (key.name === "enter" || key.name === "return" || key.name === "space") { if (
const source = sources()[selectedIndex()] key.name === "enter" ||
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) { if (source && source.type === SourceType.API) {
feedStore.updateSource(source.id, { allowExplicit: !source.allowExplicit }) feedStore.updateSource(source.id, {
allowExplicit: !source.allowExplicit,
});
} }
} }
} }
if (focusArea() === "language") { if (focusArea() === "language") {
if (key.name === "enter" || key.name === "return" || key.name === "space") { if (
const source = sources()[selectedIndex()] key.name === "enter" ||
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) { if (source && source.type === SourceType.API) {
const next = source.language === "ja_jp" ? "en_us" : "ja_jp" const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
feedStore.updateSource(source.id, { language: next }) feedStore.updateSource(source.id, { language: next });
} }
} }
} }
} };
const handleAddSource = () => { const handleAddSource = () => {
const url = newSourceUrl().trim() const url = newSourceUrl().trim();
const name = newSourceName().trim() || `Custom Source` const name = newSourceName().trim() || `Custom Source`;
if (!url) { if (!url) {
setError("URL is required") setError("URL is required");
return return;
} }
try { try {
new URL(url) new URL(url);
} catch { } catch {
setError("Invalid URL format") setError("Invalid URL format");
return return;
} }
feedStore.addSource({ feedStore.addSource({
@@ -130,25 +148,25 @@ export function SourceManager(props: SourceManagerProps) {
baseUrl: url, baseUrl: url,
enabled: true, enabled: true,
description: `Custom RSS feed: ${url}`, description: `Custom RSS feed: ${url}`,
}) });
setNewSourceUrl("") setNewSourceUrl("");
setNewSourceName("") setNewSourceName("");
setFocusArea("list") setFocusArea("list");
setError(null) setError(null);
} };
const getSourceIcon = (source: PodcastSource) => { const getSourceIcon = (source: PodcastSource) => {
if (source.type === SourceType.API) return "[API]" if (source.type === SourceType.API) return "[API]";
if (source.type === SourceType.RSS) return "[RSS]" if (source.type === SourceType.RSS) return "[RSS]";
return "[?]" return "[?]";
} };
const selectedSource = () => sources()[selectedIndex()] const selectedSource = () => sources()[selectedIndex()];
const isApiSource = () => selectedSource()?.type === SourceType.API const isApiSource = () => selectedSource()?.type === SourceType.API;
const sourceCountry = () => selectedSource()?.country || "US" const sourceCountry = () => selectedSource()?.country || "US";
const sourceExplicit = () => selectedSource()?.allowExplicit !== false const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
const sourceLanguage = () => selectedSource()?.language || "en_us" const sourceLanguage = () => selectedSource()?.language || "en_us";
return ( return (
<box flexDirection="column" border padding={1} gap={1}> <box flexDirection="column" border padding={1} gap={1}>
@@ -161,11 +179,13 @@ export function SourceManager(props: SourceManagerProps) {
</box> </box>
</box> </box>
<text fg={theme.textMuted}>Manage where to search for podcasts</text> <text fg={theme.textMuted}>Manage where to search for podcasts</text>
{/* Source list */} {/* Source list */}
<box border padding={1} flexDirection="column" gap={1}> <box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>Sources:</text> <text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
Sources:
</text>
<scrollbox height={6}> <scrollbox height={6}>
<For each={sources()}> <For each={sources()}>
{(source, index) => ( {(source, index) => (
@@ -179,16 +199,18 @@ export function SourceManager(props: SourceManagerProps) {
: undefined : undefined
} }
onMouseDown={() => { onMouseDown={() => {
setSelectedIndex(index()) setSelectedIndex(index());
setFocusArea("list") setFocusArea("list");
feedStore.toggleSource(source.id) feedStore.toggleSource(source.id);
}} }}
> >
<text fg={ <text
focusArea() === "list" && index() === selectedIndex() fg={
? theme.primary focusArea() === "list" && index() === selectedIndex()
: theme.textMuted ? theme.primary
}> : theme.textMuted
}
>
{focusArea() === "list" && index() === selectedIndex() {focusArea() === "list" && index() === selectedIndex()
? ">" ? ">"
: " "} : " "}
@@ -210,49 +232,78 @@ export function SourceManager(props: SourceManagerProps) {
)} )}
</For> </For>
</scrollbox> </scrollbox>
<text fg={theme.textMuted}>Space/Enter to toggle, d to delete, a to add</text> <text fg={theme.textMuted}>
Space/Enter to toggle, d to delete, a to add
</text>
{/* API settings */} {/* API settings */}
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={isApiSource() ? theme.textMuted : theme.accent}> <text fg={isApiSource() ? theme.textMuted : theme.accent}>
{isApiSource() ? "API Settings" : "API Settings (select an API source)"} {isApiSource()
? "API Settings"
: "API Settings (select an API source)"}
</text> </text>
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
<box <box
border border
padding={0} padding={0}
backgroundColor={focusArea() === "country" ? theme.primary : undefined} backgroundColor={
focusArea() === "country" ? theme.primary : undefined
}
> >
<text fg={focusArea() === "country" ? theme.primary : theme.textMuted}> <text
fg={focusArea() === "country" ? theme.primary : theme.textMuted}
>
Country: {sourceCountry()} Country: {sourceCountry()}
</text> </text>
</box> </box>
<box <box
border border
padding={0} padding={0}
backgroundColor={focusArea() === "language" ? theme.primary : undefined} backgroundColor={
focusArea() === "language" ? theme.primary : undefined
}
> >
<text fg={focusArea() === "language" ? theme.primary : theme.textMuted}> <text
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} fg={
focusArea() === "language" ? theme.primary : theme.textMuted
}
>
Language:{" "}
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
</text> </text>
</box> </box>
<box <box
border border
padding={0} padding={0}
backgroundColor={focusArea() === "explicit" ? theme.primary : undefined} backgroundColor={
focusArea() === "explicit" ? theme.primary : undefined
}
> >
<text fg={focusArea() === "explicit" ? theme.primary : theme.textMuted}> <text
fg={
focusArea() === "explicit" ? theme.primary : theme.textMuted
}
>
Explicit: {sourceExplicit() ? "Yes" : "No"} Explicit: {sourceExplicit() ? "Yes" : "No"}
</text> </text>
</box> </box>
</box> </box>
<text fg={theme.textMuted}>Enter/Space to toggle focused setting</text> <text fg={theme.textMuted}>
Enter/Space to toggle focused setting
</text>
</box> </box>
</box> </box>
{/* Add new source form */} {/* Add new source form */}
<box border padding={1} flexDirection="column" gap={1}> <box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "add" || focusArea() === "url" ? theme.primary : theme.textMuted}> <text
fg={
focusArea() === "add" || focusArea() === "url"
? theme.primary
: theme.textMuted
}
>
Add New Source: Add New Source:
</text> </text>
@@ -272,8 +323,8 @@ export function SourceManager(props: SourceManagerProps) {
<input <input
value={newSourceUrl()} value={newSourceUrl()}
onInput={(v) => { onInput={(v) => {
setNewSourceUrl(v) setNewSourceUrl(v);
setError(null) setError(null);
}} }}
placeholder="https://example.com/feed.rss" placeholder="https://example.com/feed.rss"
focused={props.focused && focusArea() === "url"} focused={props.focused && focusArea() === "url"}
@@ -281,22 +332,15 @@ export function SourceManager(props: SourceManagerProps) {
/> />
</box> </box>
<box <box border padding={0} width={15} onMouseDown={handleAddSource}>
border
padding={0}
width={15}
onMouseDown={handleAddSource}
>
<text fg={theme.success}>[+] Add Source</text> <text fg={theme.success}>[+] Add Source</text>
</box> </box>
</box> </box>
{/* Error message */} {/* Error message */}
{error() && ( {error() && <text fg={theme.error}>{error()}</text>}
<text fg={theme.error}>{error()}</text>
)}
<text fg={theme.textMuted}>Tab to switch sections, Esc to close</text> <text fg={theme.textMuted}>Tab to switch sections, Esc to close</text>
</box> </box>
) );
} }

View File

@@ -3,60 +3,60 @@
* Displays user profile information and sync status * Displays user profile information and sync status
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { useAuthStore } from "../stores/auth" import { useAuthStore } from "@/stores/auth";
import { format } from "date-fns" import { format } from "date-fns";
interface SyncProfileProps { interface SyncProfileProps {
focused?: boolean focused?: boolean;
onLogout?: () => void onLogout?: () => void;
onManageSync?: () => void onManageSync?: () => void;
} }
type FocusField = "sync" | "export" | "logout" type FocusField = "sync" | "export" | "logout";
export function SyncProfile(props: SyncProfileProps) { export function SyncProfile(props: SyncProfileProps) {
const auth = useAuthStore() const auth = useAuthStore();
const [focusField, setFocusField] = createSignal<FocusField>("sync") const [focusField, setFocusField] = createSignal<FocusField>("sync");
const [lastSyncTime] = createSignal<Date | null>(new Date()) const [lastSyncTime] = createSignal<Date | null>(new Date());
const fields: FocusField[] = ["sync", "export", "logout"] const fields: FocusField[] = ["sync", "export", "logout"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField()) const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length ? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length : (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]) setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") { } else if (key.name === "return" || key.name === "enter") {
if (focusField() === "sync" && props.onManageSync) { if (focusField() === "sync" && props.onManageSync) {
props.onManageSync() props.onManageSync();
} else if (focusField() === "logout" && props.onLogout) { } else if (focusField() === "logout" && props.onLogout) {
handleLogout() handleLogout();
} }
} }
} };
const handleLogout = () => { const handleLogout = () => {
auth.logout() auth.logout();
if (props.onLogout) { if (props.onLogout) {
props.onLogout() props.onLogout();
} }
} };
const formatDate = (date: Date | null | undefined): string => { const formatDate = (date: Date | null | undefined): string => {
if (!date) return "Never" if (!date) return "Never";
return format(date, "MMM d, yyyy HH:mm") return format(date, "MMM d, yyyy HH:mm");
} };
const user = () => auth.state().user const user = () => auth.state().user;
// Get user initials for avatar // Get user initials for avatar
const userInitials = () => { const userInitials = () => {
const name = user()?.name || "?" const name = user()?.name || "?";
return name.slice(0, 2).toUpperCase() return name.slice(0, 2).toUpperCase();
} };
return ( return (
<box flexDirection="column" border padding={2} gap={1}> <box flexDirection="column" border padding={2} gap={1}>
@@ -69,7 +69,14 @@ export function SyncProfile(props: SyncProfileProps) {
{/* User avatar and info */} {/* User avatar and info */}
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
{/* ASCII avatar */} {/* ASCII avatar */}
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center"> <box
border
padding={1}
width={8}
height={4}
justifyContent="center"
alignItems="center"
>
<text fg="cyan">{userInitials()}</text> <text fg="cyan">{userInitials()}</text>
</box> </box>
@@ -144,5 +151,5 @@ export function SyncProfile(props: SyncProfileProps) {
<text fg="gray">Tab to navigate, Enter to select</text> <text fg="gray">Tab to navigate, Enter to select</text>
</box> </box>
) );
} }

View File

@@ -5,95 +5,100 @@
* frequency cutoffs. All changes persist via the app store. * frequency cutoffs. All changes persist via the app store.
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid";
import { useAppStore } from "../stores/app" import { useAppStore } from "@/stores/app";
import { useTheme } from "../context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { isCavacoreAvailable } from "./RealtimeWaveform"
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut" type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
const FIELDS: FocusField[] = ["bars", "sensitivity", "noise", "lowCut", "highCut"] const FIELDS: FocusField[] = [
"bars",
"sensitivity",
"noise",
"lowCut",
"highCut",
];
export function VisualizerSettings() { export function VisualizerSettings() {
const appStore = useAppStore() const appStore = useAppStore();
const { theme } = useTheme() const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("bars") const [focusField, setFocusField] = createSignal<FocusField>("bars");
const viz = () => appStore.state().settings.visualizer const viz = () => appStore.state().settings.visualizer;
const handleKey = (key: { name: string; shift?: boolean }) => { const handleKey = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") { if (key.name === "tab") {
const idx = FIELDS.indexOf(focusField()) const idx = FIELDS.indexOf(focusField());
const next = key.shift const next = key.shift
? (idx - 1 + FIELDS.length) % FIELDS.length ? (idx - 1 + FIELDS.length) % FIELDS.length
: (idx + 1) % FIELDS.length : (idx + 1) % FIELDS.length;
setFocusField(FIELDS[next]) setFocusField(FIELDS[next]);
return return;
} }
if (key.name === "left" || key.name === "h") { if (key.name === "left" || key.name === "h") {
stepValue(-1) stepValue(-1);
} }
if (key.name === "right" || key.name === "l") { if (key.name === "right" || key.name === "l") {
stepValue(1) stepValue(1);
} }
} };
const stepValue = (delta: number) => { const stepValue = (delta: number) => {
const field = focusField() const field = focusField();
const v = viz() const v = viz();
switch (field) { switch (field) {
case "bars": { case "bars": {
// Step by 8: 8, 16, 24, 32, ..., 128 // Step by 8: 8, 16, 24, 32, ..., 128
const next = Math.min(128, Math.max(8, v.bars + delta * 8)) const next = Math.min(128, Math.max(8, v.bars + delta * 8));
appStore.updateVisualizer({ bars: next }) appStore.updateVisualizer({ bars: next });
break break;
} }
case "sensitivity": { case "sensitivity": {
// Toggle: 0 (manual) or 1 (auto) // Toggle: 0 (manual) or 1 (auto)
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 }) appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 });
break break;
} }
case "noise": { case "noise": {
// Step by 0.05: 0.0 1.0 // Step by 0.05: 0.0 1.0
const next = Math.min(1, Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2)))) const next = Math.min(
appStore.updateVisualizer({ noiseReduction: next }) 1,
break Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))),
);
appStore.updateVisualizer({ noiseReduction: next });
break;
} }
case "lowCut": { case "lowCut": {
// Step by 10: 20 500 Hz // Step by 10: 20 500 Hz
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10)) const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10));
appStore.updateVisualizer({ lowCutOff: next }) appStore.updateVisualizer({ lowCutOff: next });
break break;
} }
case "highCut": { case "highCut": {
// Step by 500: 1000 20000 Hz // Step by 500: 1000 20000 Hz
const next = Math.min(20000, Math.max(1000, v.highCutOff + delta * 500)) const next = Math.min(
appStore.updateVisualizer({ highCutOff: next }) 20000,
break Math.max(1000, v.highCutOff + delta * 500),
);
appStore.updateVisualizer({ highCutOff: next });
break;
} }
} }
} };
useKeyboard(handleKey) useKeyboard(handleKey);
const cavacoreStatus = isCavacoreAvailable()
return ( return (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Visualizer</text> <text fg={theme.textMuted}>Visualizer</text>
{!cavacoreStatus && (
<text fg={theme.warning}>
cavacore not available using static waveform
</text>
)}
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>Bars:</text> <text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
Bars:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{viz().bars}</text> <text fg={theme.text}>{viz().bars}</text>
</box> </box>
@@ -101,9 +106,17 @@ export function VisualizerSettings() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text> <text
fg={
focusField() === "sensitivity" ? theme.primary : theme.textMuted
}
>
Auto Sensitivity:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}> <text
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
>
{viz().sensitivity === 1 ? "On" : "Off"} {viz().sensitivity === 1 ? "On" : "Off"}
</text> </text>
</box> </box>
@@ -111,7 +124,9 @@ export function VisualizerSettings() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text> <text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
Noise Reduction:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text> <text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
</box> </box>
@@ -119,7 +134,11 @@ export function VisualizerSettings() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text> <text
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
>
Low Cutoff:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{viz().lowCutOff} Hz</text> <text fg={theme.text}>{viz().lowCutOff} Hz</text>
</box> </box>
@@ -127,7 +146,11 @@ export function VisualizerSettings() {
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text> <text
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
>
High Cutoff:
</text>
<box border padding={0}> <box border padding={0}>
<text fg={theme.text}>{viz().highCutOff} Hz</text> <text fg={theme.text}>{viz().highCutOff} Hz</text>
</box> </box>
@@ -137,5 +160,5 @@ export function VisualizerSettings() {
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text> <text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box> </box>
) );
} }

View File

@@ -2,19 +2,20 @@
* App state persistence via JSON file in XDG_CONFIG_HOME * App state persistence via JSON file in XDG_CONFIG_HOME
* *
* Reads and writes app settings, preferences, and custom theme to a JSON file * Reads and writes app settings, preferences, and custom theme to a JSON file
* instead of localStorage. Provides migration from localStorage on first run.
*/ */
import { ensureConfigDir, getConfigFilePath } from "./config-dir" import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup" import { backupConfigFile } from "./config-backup";
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings" import type {
import { DEFAULT_THEME } from "../constants/themes" AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes";
const APP_STATE_FILE = "app-state.json" const APP_STATE_FILE = "app-state.json";
const PROGRESS_FILE = "progress.json" const PROGRESS_FILE = "progress.json";
const LEGACY_APP_STATE_KEY = "podtui_app_state"
const LEGACY_PROGRESS_KEY = "podtui_progress"
// --- Defaults --- // --- Defaults ---
@@ -24,7 +25,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
} };
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
@@ -32,141 +33,89 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
} };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
} };
const defaultState: AppState = { const defaultState: AppState = {
settings: defaultSettings, settings: defaultSettings,
preferences: defaultPreferences, preferences: defaultPreferences,
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
} };
// --- App State --- // --- App State ---
/** Load app state from JSON file */ /** Load app state from JSON file */
export async function loadAppStateFromFile(): Promise<AppState> { export async function loadAppStateFromFile(): Promise<AppState> {
try { try {
const filePath = getConfigFilePath(APP_STATE_FILE) const filePath = getConfigFilePath(APP_STATE_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return defaultState if (!(await file.exists())) return defaultState;
const raw = await file.json() const raw = await file.json();
if (!raw || typeof raw !== "object") return defaultState if (!raw || typeof raw !== "object") return defaultState;
const parsed = raw as Partial<AppState> const parsed = raw as Partial<AppState>;
return { return {
settings: { ...defaultSettings, ...parsed.settings }, settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences }, preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme }, customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
} };
} catch { } catch {
return defaultState return defaultState;
} }
} }
/** Save app state to JSON file */ /** Save app state to JSON file */
export async function saveAppStateToFile(state: AppState): Promise<void> { export async function saveAppStateToFile(state: AppState): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(APP_STATE_FILE) await backupConfigFile(APP_STATE_FILE);
const filePath = getConfigFilePath(APP_STATE_FILE) const filePath = getConfigFilePath(APP_STATE_FILE);
await Bun.write(filePath, JSON.stringify(state, null, 2)) await Bun.write(filePath, JSON.stringify(state, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }
/**
* Migrate app state from localStorage to file.
* Only runs once — if the state file already exists, it's a no-op.
*/
export async function migrateAppStateFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(APP_STATE_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem(LEGACY_APP_STATE_KEY)
if (!raw) return false
const parsed = JSON.parse(raw) as Partial<AppState>
const state: AppState = {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
await saveAppStateToFile(state)
return true
} catch {
return false
}
}
// --- Progress ---
interface ProgressEntry { interface ProgressEntry {
episodeId: string episodeId: string;
position: number position: number;
duration: number duration: number;
timestamp: string | Date timestamp: string | Date;
playbackSpeed?: number playbackSpeed?: number;
} }
/** Load progress map from JSON file */ /** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> { export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
try { try {
const filePath = getConfigFilePath(PROGRESS_FILE) const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return {} if (!(await file.exists())) return {};
const raw = await file.json() const raw = await file.json();
if (!raw || typeof raw !== "object") return {} if (!raw || typeof raw !== "object") return {};
return raw as Record<string, ProgressEntry> return raw as Record<string, ProgressEntry>;
} catch { } catch {
return {} return {};
} }
} }
/** Save progress map to JSON file */ /** Save progress map to JSON file */
export async function saveProgressToFile(data: Record<string, unknown>): Promise<void> { export async function saveProgressToFile(
data: Record<string, unknown>,
): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(PROGRESS_FILE) await backupConfigFile(PROGRESS_FILE);
const filePath = getConfigFilePath(PROGRESS_FILE) const filePath = getConfigFilePath(PROGRESS_FILE);
await Bun.write(filePath, JSON.stringify(data, null, 2)) await Bun.write(filePath, JSON.stringify(data, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }
/**
* Migrate progress from localStorage to file.
* Only runs once — if the progress file already exists, it's a no-op.
*/
export async function migrateProgressFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem(LEGACY_PROGRESS_KEY)
if (!raw) return false
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") return false
await saveProgressToFile(parsed as Record<string, unknown>)
return true
} catch {
return false
}
}

View File

@@ -1,107 +1,116 @@
/** /**
* Config file validation and migration for PodTUI
*
* Validates JSON structure of config files, handles corrupted files * Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single * gracefully (falling back to defaults), and provides a single
* entry-point to migrate all localStorage data to XDG config files.
*/ */
import { getConfigFilePath } from "./config-dir" import { getConfigFilePath } from "./config-dir";
import {
migrateAppStateFromLocalStorage,
migrateProgressFromLocalStorage,
} from "./app-persistence"
import {
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "./feeds-persistence"
// --- Validation helpers --- // --- Validation helpers ---
/** Check that a value is a non-null object */ /** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> { function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v) return v !== null && typeof v === "object" && !Array.isArray(v);
} }
/** Validate AppState JSON structure */ /** Validate AppState JSON structure */
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } { export function validateAppState(data: unknown): {
const errors: string[] = [] valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) { if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] } return { valid: false, errors: ["app-state.json is not an object"] };
} }
// settings // settings
if (data.settings !== undefined) { if (data.settings !== undefined) {
if (!isObject(data.settings)) { if (!isObject(data.settings)) {
errors.push("settings must be an object") errors.push("settings must be an object");
} else { } else {
const s = data.settings as Record<string, unknown> 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.theme !== undefined && typeof s.theme !== "string")
if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number") errors.push("settings.theme must be a string");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number") if (s.fontSize !== undefined && typeof s.fontSize !== "number")
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string") 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 // preferences
if (data.preferences !== undefined) { if (data.preferences !== undefined) {
if (!isObject(data.preferences)) { if (!isObject(data.preferences)) {
errors.push("preferences must be an object") errors.push("preferences must be an object");
} else { } else {
const p = data.preferences as Record<string, unknown> 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.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a 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 // customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) { if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object") errors.push("customTheme must be an object");
} }
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors };
} }
/** Validate feeds JSON structure */ /** Validate feeds JSON structure */
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } { export function validateFeeds(data: unknown): {
const errors: string[] = [] valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] } return { valid: false, errors: ["feeds.json is not an array"] };
} }
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const feed = data[i] const feed = data[i];
if (!isObject(feed)) { if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`) errors.push(`feeds[${i}] is not an object`);
continue continue;
} }
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`) if (typeof feed.id !== "string")
if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`) errors.push(`feeds[${i}].id must be a string`);
if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`) 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 } return { valid: errors.length === 0, errors };
} }
/** Validate progress JSON structure */ /** Validate progress JSON structure */
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } { export function validateProgress(data: unknown): {
const errors: string[] = [] valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) { if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] } return { valid: false, errors: ["progress.json is not an object"] };
} }
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) { if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`) errors.push(`progress["${key}"] is not an object`);
continue continue;
} }
const p = value as Record<string, unknown> const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`) if (typeof p.episodeId !== "string")
if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`) errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`) 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 } return { valid: errors.length === 0, errors };
} }
// --- Safe config file reading --- // --- Safe config file reading ---
@@ -115,52 +124,27 @@ export async function safeReadConfigFile<T>(
validator: (data: unknown) => { valid: boolean; errors: string[] }, validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> { ): Promise<{ data: T | null; errors: string[] }> {
try { try {
const filePath = getConfigFilePath(filename) const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) { if (!(await file.exists())) {
return { data: null, errors: [] } return { data: null, errors: [] };
} }
const text = await file.text() const text = await file.text();
let parsed: unknown let parsed: unknown;
try { try {
parsed = JSON.parse(text) parsed = JSON.parse(text);
} catch { } catch {
return { data: null, errors: [`${filename}: invalid JSON`] } return { data: null, errors: [`${filename}: invalid JSON`] };
} }
const result = validator(parsed) const result = validator(parsed);
if (!result.valid) { if (!result.valid) {
return { data: null, errors: result.errors } return { data: null, errors: result.errors };
} }
return { data: parsed as T, errors: [] } return { data: parsed as T, errors: [] };
} catch (err) { } catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] } return { data: null, errors: [`${filename}: ${String(err)}`] };
} }
} }
// --- Unified migration ---
/**
* Run all localStorage -> file migrations.
* Safe to call multiple times; each migration is a no-op if the target
* file already exists.
*
* Returns a summary of what was migrated.
*/
export async function migrateAllFromLocalStorage(): Promise<{
appState: boolean
progress: boolean
feeds: boolean
sources: boolean
}> {
const [appState, progress, feeds, sources] = await Promise.all([
migrateAppStateFromLocalStorage(),
migrateProgressFromLocalStorage(),
migrateFeedsFromLocalStorage(),
migrateSourcesFromLocalStorage(),
])
return { appState, progress, feeds, sources }
}

View File

@@ -2,15 +2,14 @@
* Feeds persistence via JSON file in XDG_CONFIG_HOME * Feeds persistence via JSON file in XDG_CONFIG_HOME
* *
* Reads and writes feeds to a JSON file instead of localStorage. * Reads and writes feeds to a JSON file instead of localStorage.
* Provides migration from localStorage on first run.
*/ */
import { ensureConfigDir, getConfigFilePath } from "./config-dir" import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup" import { backupConfigFile } from "./config-backup";
import type { Feed } from "../types/feed" import type { Feed } from "../types/feed";
const FEEDS_FILE = "feeds.json" const FEEDS_FILE = "feeds.json";
const SOURCES_FILE = "sources.json" const SOURCES_FILE = "sources.json";
/** Deserialize date strings back to Date objects in feed data */ /** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed { function reviveDates(feed: Feed): Feed {
@@ -25,31 +24,31 @@ function reviveDates(feed: Feed): Feed {
...ep, ...ep,
pubDate: new Date(ep.pubDate), pubDate: new Date(ep.pubDate),
})), })),
} };
} }
/** Load feeds from JSON file */ /** Load feeds from JSON file */
export async function loadFeedsFromFile(): Promise<Feed[]> { export async function loadFeedsFromFile(): Promise<Feed[]> {
try { try {
const filePath = getConfigFilePath(FEEDS_FILE) const filePath = getConfigFilePath(FEEDS_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return [] if (!(await file.exists())) return [];
const raw = await file.json() const raw = await file.json();
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return [];
return raw.map(reviveDates) return raw.map(reviveDates);
} catch { } catch {
return [] return [];
} }
} }
/** Save feeds to JSON file */ /** Save feeds to JSON file */
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> { export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(FEEDS_FILE) await backupConfigFile(FEEDS_FILE);
const filePath = getConfigFilePath(FEEDS_FILE) const filePath = getConfigFilePath(FEEDS_FILE);
await Bun.write(filePath, JSON.stringify(feeds, null, 2)) await Bun.write(filePath, JSON.stringify(feeds, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
@@ -58,75 +57,26 @@ export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
/** Load sources from JSON file */ /** Load sources from JSON file */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> { export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try { try {
const filePath = getConfigFilePath(SOURCES_FILE) const filePath = getConfigFilePath(SOURCES_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return null if (!(await file.exists())) return null;
const raw = await file.json() const raw = await file.json();
if (!Array.isArray(raw)) return null if (!Array.isArray(raw)) return null;
return raw as T[] return raw as T[];
} catch { } catch {
return null return null;
} }
} }
/** Save sources to JSON file */ /** Save sources to JSON file */
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> { export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(SOURCES_FILE) await backupConfigFile(SOURCES_FILE);
const filePath = getConfigFilePath(SOURCES_FILE) const filePath = getConfigFilePath(SOURCES_FILE);
await Bun.write(filePath, JSON.stringify(sources, null, 2)) await Bun.write(filePath, JSON.stringify(sources, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }
/**
* Migrate feeds from localStorage to file.
* Only runs once — if the feeds file already exists, it's a no-op.
*/
export async function migrateFeedsFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(FEEDS_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false // Already migrated
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem("podtui_feeds")
if (!raw) return false
const feeds = JSON.parse(raw) as Feed[]
if (!Array.isArray(feeds) || feeds.length === 0) return false
await saveFeedsToFile(feeds)
return true
} catch {
return false
}
}
/**
* Migrate sources from localStorage to file.
*/
export async function migrateSourcesFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(SOURCES_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem("podtui_sources")
if (!raw) return false
const sources = JSON.parse(raw)
if (!Array.isArray(sources) || sources.length === 0) return false
await saveSourcesToFile(sources)
return true
} catch {
return false
}
}

View File

@@ -1,36 +1,49 @@
import { RGBA, type TerminalColors } from "@opentui/core" import { RGBA, type TerminalColors } from "@opentui/core";
import { ansiToRgba } from "./ansi-to-rgba" import { ansiToRgba } from "./ansi-to-rgba";
import { generateGrayScale, generateMutedTextColor, tint } from "./color-generation" import {
import type { ThemeJson } from "../types/theme-schema" generateGrayScale,
generateMutedTextColor,
tint,
} from "./color-generation";
import type { ThemeJson } from "../types/theme-schema";
let cached: TerminalColors | null = null let cached: TerminalColors | null = null;
export function clearPaletteCache() { export function clearPaletteCache() {
cached = null cached = null;
} }
export function detectSystemTheme(colors: TerminalColors) { export function detectSystemTheme(colors: TerminalColors) {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000") const bg = RGBA.fromHex(
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b colors.defaultBackground ?? colors.palette[0] ?? "#000000",
const mode = luminance > 0.5 ? "light" : "dark" );
return { mode, background: bg } 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(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { export function generateSystemTheme(
cached = colors colors: TerminalColors,
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000") mode: "dark" | "light",
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7] ?? "#ffffff") ): ThemeJson {
const transparent = RGBA.fromInts(0, 0, 0, 0) cached = colors;
const isDark = mode === "dark" const bg = RGBA.fromHex(
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
);
const fg = RGBA.fromHex(
colors.defaultForeground ?? colors.palette[7] ?? "#ffffff",
);
const transparent = RGBA.fromInts(0, 0, 0, 0);
const isDark = mode === "dark";
const col = (i: number) => { const col = (i: number) => {
const value = colors.palette[i] const value = colors.palette[i];
if (value) return RGBA.fromHex(value) if (value) return RGBA.fromHex(value);
return ansiToRgba(i) return ansiToRgba(i);
} };
const grays = generateGrayScale(bg, isDark) const grays = generateGrayScale(bg, isDark);
const textMuted = generateMutedTextColor(bg, isDark) const textMuted = generateMutedTextColor(bg, isDark);
const ansi = { const ansi = {
black: col(0), black: col(0),
@@ -43,13 +56,13 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
white: col(7), white: col(7),
redBright: col(9), redBright: col(9),
greenBright: col(10), greenBright: col(10),
} };
const diffAlpha = isDark ? 0.22 : 0.14 const diffAlpha = isDark ? 0.22 : 0.14;
const diffAddedBg = tint(bg, ansi.green, diffAlpha) const diffAddedBg = tint(bg, ansi.green, diffAlpha);
const diffRemovedBg = tint(bg, ansi.red, diffAlpha) const diffRemovedBg = tint(bg, ansi.red, diffAlpha);
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha) const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha);
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha) const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha);
return { return {
theme: { theme: {
@@ -68,7 +81,7 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
backgroundElement: grays[3], backgroundElement: grays[3],
backgroundMenu: grays[3], backgroundMenu: grays[3],
borderSubtle: grays[6], borderSubtle: grays[6],
border: grays[7], border: fg,
borderActive: grays[8], borderActive: grays[8],
diffAdded: ansi.green, diffAdded: ansi.green,
diffRemoved: ansi.red, diffRemoved: ansi.red,
@@ -106,5 +119,5 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
syntaxOperator: ansi.cyan, syntaxOperator: ansi.cyan,
syntaxPunctuation: fg, syntaxPunctuation: fg,
}, },
} };
} }

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from "bun:test"
import { ansiToRgba } from "./ansi-to-rgba"
import { resolveTheme } from "./theme-resolver"
import type { ThemeJson } from "../types/theme-schema"
describe("theme utils", () => {
it("converts ansi codes", () => {
const color = ansiToRgba(1)
expect(color).toBeTruthy()
})
it("resolves simple theme", () => {
const json: ThemeJson = {
theme: {
primary: "#ffffff",
secondary: "#000000",
accent: "#000000",
error: "#000000",
warning: "#000000",
success: "#000000",
info: "#000000",
text: "#000000",
textMuted: "#000000",
background: "#000000",
backgroundPanel: "#000000",
backgroundElement: "#000000",
border: "#000000",
borderActive: "#000000",
borderSubtle: "#000000",
diffAdded: "#000000",
diffRemoved: "#000000",
diffContext: "#000000",
diffHunkHeader: "#000000",
diffHighlightAdded: "#000000",
diffHighlightRemoved: "#000000",
diffAddedBg: "#000000",
diffRemovedBg: "#000000",
diffContextBg: "#000000",
diffLineNumber: "#000000",
diffAddedLineNumberBg: "#000000",
diffRemovedLineNumberBg: "#000000",
markdownText: "#000000",
markdownHeading: "#000000",
markdownLink: "#000000",
markdownLinkText: "#000000",
markdownCode: "#000000",
markdownBlockQuote: "#000000",
markdownEmph: "#000000",
markdownStrong: "#000000",
markdownHorizontalRule: "#000000",
markdownListItem: "#000000",
markdownListEnumeration: "#000000",
markdownImage: "#000000",
markdownImageText: "#000000",
markdownCodeBlock: "#000000",
syntaxComment: "#000000",
syntaxKeyword: "#000000",
syntaxFunction: "#000000",
syntaxVariable: "#000000",
syntaxString: "#000000",
syntaxNumber: "#000000",
syntaxType: "#000000",
syntaxOperator: "#000000",
syntaxPunctuation: "#000000",
},
}
const resolved = resolveTheme(json, "dark") as unknown as { primary: unknown }
expect(resolved.primary).toBeTruthy()
})
})

View File

@@ -1,8 +0,0 @@
export const createWaveform = (width: number): number[] => {
const data: number[] = []
for (let i = 0; i < width; i += 1) {
const value = 0.2 + Math.abs(Math.sin(i / 3)) * 0.8
data.push(Number(value.toFixed(2)))
}
return data
}

View File

@@ -1,128 +0,0 @@
# PodTUI Task Index
This directory contains all task files for the PodTUI project feature implementation.
## Task Structure
Each feature has its own directory with:
- `README.md` - Feature overview and task list
- `{seq}-{task-description}.md` - Individual task files
## Feature Overview
### 1. Text Selection Copy to Clipboard
**Feature:** Text selection copy to clipboard
**Tasks:** 2 tasks
**Directory:** `tasks/text-selection-copy/`
### 2. HTML vs Plain Text RSS Parsing
**Feature:** Detect and handle both HTML and plain text content in RSS feeds
**Tasks:** 3 tasks
**Directory:** `tasks/rss-content-parsing/`
### 3. Merged Waveform Progress Bar
**Feature:** Create a real-time waveform visualization that expands from a progress bar during playback
**Tasks:** 4 tasks
**Directory:** `tasks/merged-waveform/`
### 4. Episode List Infinite Scroll
**Feature:** Implement scroll-to-bottom loading for episode lists with MAX_EPISODES_REFRESH limit
**Tasks:** 4 tasks
**Directory:** `tasks/episode-infinite-scroll/`
### 5. Episode Downloads
**Feature:** Add per-episode download and per-feed auto-download settings
**Tasks:** 6 tasks
**Directory:** `tasks/episode-downloads/`
### 6. Discover Categories Shortcuts Fix
**Feature:** Fix broken discover category filter functionality
**Tasks:** 3 tasks
**Directory:** `tasks/discover-categories-fix/`
### 7. Config Persistence to XDG_CONFIG_HOME
**Feature:** Move feeds and themes persistence from localStorage to XDG_CONFIG_HOME directory
**Tasks:** 5 tasks
**Directory:** `tasks/config-persistence/`
### 8. Audio Playback Fix
**Feature:** Fix non-functional volume/speed controls and add multimedia key support
**Tasks:** 5 tasks
**Directory:** `tasks/audio-playback-fix/`
## Task Summary
**Total Features:** 8
**Total Tasks:** 32
**Critical Path:** Feature 7 (Config Persistence) - 5 tasks, Feature 8 (Audio Playback Fix) - 5 tasks
## Task Dependencies
### Feature 1: Text Selection Copy to Clipboard
- 01 → 02
### Feature 2: HTML vs Plain Text RSS Parsing
- 03 → 04
- 03 → 05
### Feature 3: Merged Waveform Progress Bar
- 06 → 07
- 07 → 08
- 08 → 09
### Feature 4: Episode List Infinite Scroll
- 10 → 11
- 11 → 12
- 12 → 13
### Feature 5: Episode Downloads
- 14 → 15
- 15 → 16
- 16 → 17
- 17 → 18
- 18 → 19
### Feature 6: Discover Categories Shortcuts Fix
- 20 → 21
- 21 → 22
### Feature 7: Config Persistence to XDG_CONFIG_HOME
- 23 -> 24
- 23 -> 25
- 24 -> 26
- 25 -> 26
- 26 -> 27
### Feature 8: Audio Playback Fix
- 28 -> 29
- 29 -> 30
- 30 -> 31
- 31 -> 32
## Priority Overview
**P1 (Critical):**
- 23: Implement XDG_CONFIG_HOME directory setup
- 24: Refactor feeds persistence to JSON file
- 25: Refactor theme persistence to JSON file
- 26: Add config file validation and migration
- 28: Fix volume and speed controls in audio backends
- 32: Test multimedia controls across platforms
**P2 (High):**
- All other tasks (01-22, 27, 29-31)
**P3 (Medium):**
- 09: Optimize waveform rendering performance
- 13: Add loading indicator for pagination
- 19: Create download queue management
- 30: Add multimedia key detection and handling
- 31: Implement platform-specific media stream integration
## Next Steps
1. Review all task files for accuracy
2. Confirm task dependencies
3. Start with P1 tasks (Feature 7 or Feature 8)
4. Follow dependency order within each feature
5. Mark tasks complete as they're finished

View File

@@ -1,65 +0,0 @@
# 01. Fix volume and speed controls in audio backends [x]
meta:
id: audio-playback-fix-01
feature: audio-playback-fix
priority: P1
depends_on: []
tags: [implementation, backend-fix, testing-required]
objective:
- Fix non-functional volume and speed controls in audio player backends (mpv, ffplay, afplay)
- Implement proper error handling and validation for volume/speed commands
- Ensure commands are successfully received and applied by the audio player
deliverables:
- Fixed `MpvBackend.setVolume()` and `MpvBackend.setSpeed()` methods with proper IPC command validation
- Enhanced `AfplayBackend.setVolume()` and `AfplayBackend.setSpeed()` for runtime changes
- Added command response validation in all backends
- Unit tests for volume and speed control methods
steps:
- Step 1: Analyze current IPC implementation in MpvBackend (lines 206-223)
- Step 2: Implement proper response validation for setVolume and setSpeed IPC commands
- Step 3: Fix afplay backend to apply volume/speed changes at runtime (currently only on next play)
- Step 4: Add error handling and logging for failed volume/speed commands
- Step 5: Add unit tests in `src/utils/audio-player.test.ts` for volume/speed methods
- Step 6: Verify volume changes apply immediately and persist across playback
- Step 7: Verify speed changes apply immediately and persist across playback
tests:
- Unit:
- Test MpvBackend.setVolume() sends correct IPC command and receives valid response
- Test MpvBackend.setSpeed() sends correct IPC command and receives valid response
- Test AfplayBackend.setVolume() applies volume immediately
- Test AfplayBackend.setSpeed() applies speed immediately
- Test volume clamp values (0-1 range)
- Test speed clamp values (0.25-3 range)
- Integration:
- Test volume control through Player component UI
- Test speed control through Player component UI
- Test volume/speed changes persist across pause/resume cycles
- Test volume/speed changes persist across track changes
acceptance_criteria:
- Volume slider in Player component changes volume in real-time
- Speed controls in Player component change playback speed in real-time
- Volume changes are visible in system audio output
- Speed changes are immediately reflected in playback rate
- No errors logged when changing volume or speed
- Volume/speed settings persist when restarting the app
validation:
- Run `bun test src/utils/audio-player.test.ts` to verify unit tests pass
- Test volume control using Up/Down arrow keys in Player
- Test speed control using 'S' key in Player
- Verify volume level is visible in PlaybackControls component
- Verify speed level is visible in PlaybackControls component
- Check console logs for any IPC errors
notes:
- mpv backend uses JSON IPC over Unix socket - need to validate response format
- afplay backend needs to restart process for volume/speed changes (current behavior)
- ffplay backend doesn't support runtime volume/speed changes (document limitation)
- Volume and speed state is stored in backend class properties and should be updated on successful commands
- Reference: src/utils/audio-player.ts lines 206-223 (mpv send method), lines 789-791 (afplay setVolume), lines 793-795 (afplay setSpeed)

View File

@@ -1,61 +0,0 @@
# 02. Add multimedia key detection and handling [x]
meta:
id: audio-playback-fix-02
feature: audio-playback-fix
priority: P2
depends_on: []
tags: [implementation, keyboard, multimedia]
objective:
- Implement detection and handling of multimedia keys (Play/Pause, Next/Previous, Volume Up/Down)
- Create reusable multimedia key handler hook
- Map multimedia keys to audio playback actions
deliverables:
- New `useMultimediaKeys()` hook in `src/hooks/useMultimediaKeys.ts`
- Integration with existing audio hook to handle multimedia key events
- Documentation of supported multimedia keys and their mappings
steps:
- Step 1: Research @opentui/solid keyboard event types for multimedia key detection
- Step 2: Create `useMultimediaKeys()` hook with event listener for multimedia keys
- Step 3: Define multimedia key mappings (Play/Pause, Next, Previous, Volume Up, Volume Down)
- Step 4: Integrate hook with audio hook to trigger playback actions
- Step 5: Add keyboard event filtering to prevent conflicts with other shortcuts
- Step 6: Test multimedia key detection across different platforms
- Step 7: Add help text to Player component showing multimedia key bindings
tests:
- Unit:
- Test multimedia key events are detected correctly
- Test key mapping functions return correct audio actions
- Test hook cleanup removes event listeners
- Integration:
- Test Play/Pause key toggles playback
- Test Next/Previous keys skip tracks (placeholder for future)
- Test Volume Up/Down keys adjust volume
- Test keys don't trigger when input is focused
- Test keys don't trigger when player is not focused
acceptance_criteria:
- Multimedia keys are detected and logged when pressed
- Play/Pause key toggles audio playback
- Volume Up/Down keys adjust volume level
- Keys work when Player component is focused
- Keys don't interfere with other keyboard shortcuts
- Help text displays multimedia key bindings
validation:
- Press multimedia keys while Player is focused and verify playback responds
- Check console logs for detected multimedia key events
- Verify Up/Down keys adjust volume display in Player component
- Verify Space key still works for play/pause
- Test in different terminal emulators (iTerm2, Terminal.app, etc.)
notes:
- Multimedia key detection may vary by platform and terminal emulator
- Common multimedia keys: Space (Play/Pause), ArrowUp (Volume Up), ArrowDown (Volume Down)
- Some terminals don't pass multimedia keys to application
- May need to use platform-specific APIs or terminal emulator-specific key codes
- Reference: @opentui/solid keyboard event types and existing useKeyboard hook patterns

View File

@@ -1,66 +0,0 @@
# 03. Implement platform-specific media stream integration [x]
meta:
id: audio-playback-fix-03
feature: audio-playback-fix
priority: P2
depends_on: []
tags: [implementation, platform-integration, media-apis]
objective:
- Register audio player with platform-specific media frameworks
- Enable OS media controls (notification center, lock screen, multimedia keys)
- Support macOS AVFoundation, Windows Media Foundation, and Linux PulseAudio/GStreamer
deliverables:
- Platform-specific media registration module in `src/utils/media-registry.ts`
- Integration with audio hook to register/unregister media streams
- Platform detection and conditional registration logic
- Documentation of supported platforms and media APIs
steps:
- Step 1: Research platform-specific media API integration options
- Step 2: Create `MediaRegistry` class with platform detection
- Step 3: Implement macOS AVFoundation integration (AVPlayer + AVAudioSession)
- Step 4: Implement Windows Media Foundation integration (MediaSession + PlaybackInfo)
- Step 5: Implement Linux PulseAudio/GStreamer integration (Mpris or libpulse)
- Step 6: Integrate with audio hook to register media stream on play
- Step 7: Unregister media stream on stop or dispose
- Step 8: Handle platform-specific limitations and fallbacks
- Step 9: Test media registration across platforms
tests:
- Unit:
- Test platform detection returns correct platform name
- Test MediaRegistry.register() calls platform-specific APIs
- Test MediaRegistry.unregister() cleans up platform resources
- Integration:
- Test audio player appears in macOS notification center
- Test audio player appears in Windows media controls
- Test audio player appears in Linux media player notifications
- Test media controls update with playback position
- Test multimedia keys control playback through media APIs
acceptance_criteria:
- Audio player appears in platform media controls (notification center, lock screen)
- Media controls update with current track info and playback position
- Multimedia keys work through media APIs (not just terminal)
- Media registration works on macOS, Windows, and Linux
- Media unregistration properly cleans up resources
- No memory leaks from media stream registration
validation:
- On macOS: Check notification center for audio player notification
- On Windows: Check media controls in taskbar/notification area
- On Linux: Check media player notifications in desktop environment
- Test multimedia keys work with system media player (not just terminal)
- Monitor memory usage for leaks
notes:
- Platform-specific media APIs are complex and may have limitations
- macOS AVFoundation: Use AVPlayer with AVAudioSession for media registration
- Windows Media Foundation: Use MediaSession API and PlaybackInfo for media controls
- Linux: Use Mpris (Media Player Remote Interface Specification) or libpulse
- May need additional platform-specific dependencies or native code
- Fallback to terminal multimedia key handling if platform APIs unavailable
- Reference: Platform-specific media API documentation and examples

View File

@@ -1,63 +0,0 @@
# 04. Add media key listeners to audio hook [x]
meta:
id: audio-playback-fix-04
feature: audio-playback-fix
priority: P2
depends_on: []
tags: [implementation, integration, event-handling]
objective:
- Integrate multimedia key handling with existing audio hook
- Route multimedia key events to appropriate audio control actions
- Ensure proper cleanup of event listeners
deliverables:
- Updated `useAudio()` hook with multimedia key event handling
- Media key event listener registration in audio hook
- Integration with multimedia key detection hook
- Proper cleanup of event listeners on component unmount
steps:
- Step 1: Import multimedia key detection hook into audio hook
- Step 2: Register multimedia key event listener in audio hook
- Step 3: Map multimedia key events to audio control actions (play/pause, seek, volume)
- Step 4: Add event listener cleanup on hook dispose
- Step 5: Test event listener cleanup with multiple component instances
- Step 6: Add error handling for failed multimedia key events
- Step 7: Test multimedia key events trigger correct audio actions
tests:
- Unit:
- Test multimedia key events are captured in audio hook
- Test events are mapped to correct audio control actions
- Test event listeners are properly cleaned up
- Test multiple audio hook instances don't conflict
- Integration:
- Test multimedia keys control playback from any component
- Test multimedia keys work when player is not focused
- Test multimedia keys don't interfere with other keyboard shortcuts
- Test event listeners are removed when audio hook is disposed
acceptance_criteria:
- Multimedia key events are captured by audio hook
- Multimedia keys trigger correct audio control actions
- Event listeners are properly cleaned up on unmount
- No duplicate event listeners when components re-render
- No memory leaks from event listeners
- Error handling prevents crashes from invalid events
validation:
- Use multimedia keys and verify audio responds correctly
- Unmount and remount audio hook to test cleanup
- Check for memory leaks with browser dev tools or system monitoring
- Verify event listener count is correct after cleanup
- Test with multiple Player components to ensure no conflicts
notes:
- Audio hook is a singleton, so event listeners should be registered once
- Multimedia key detection hook should be reused to avoid duplicate listeners
- Event listener cleanup should use onCleanup from solid-js
- Reference: src/hooks/useAudio.ts for event listener patterns
- Multimedia keys may only work when terminal is focused (platform limitation)
- Consider adding platform-specific key codes for better compatibility

View File

@@ -1,138 +0,0 @@
# 05. Test multimedia controls across platforms [x]
meta:
id: audio-playback-fix-05
feature: audio-playback-fix
priority: P1
depends_on: []
tags: [testing, integration, cross-platform]
objective:
- Comprehensive testing of volume/speed controls and multimedia key support
- Verify platform-specific media integration works correctly
- Validate all controls across different audio backends
deliverables:
- Test suite for volume/speed controls in `src/utils/audio-player.test.ts`
- Integration tests for multimedia key handling in `src/hooks/useMultimediaKeys.test.ts`
- Platform-specific integration tests in `src/utils/media-registry.test.ts`
- Test coverage report showing all features tested
steps:
- Step 1: Run existing unit tests for audio player backends
- Step 2: Add volume control tests (setVolume, volume clamp, persistence)
- Step 3: Add speed control tests (setSpeed, speed clamp, persistence)
- Step 4: Create integration test for multimedia key handling
- Step 5: Test volume/speed controls with Player component UI
- Step 6: Test multimedia keys with Player component UI
- Step 7: Test platform-specific media integration on each platform
- Step 8: Test all controls across mpv, ffplay, and afplay backends
- Step 9: Document any platform-specific limitations or workarounds
tests:
- Unit:
- Test volume control methods in all backends
- Test speed control methods in all backends
- Test volume clamp logic (0-1 range)
- Test speed clamp logic (0.25-3 range)
- Test multimedia key detection
- Test event listener cleanup
- Integration:
- Test volume control via Player component UI
- Test speed control via Player component UI
- Test multimedia keys via keyboard
- Test volume/speed persistence across pause/resume
- Test volume/speed persistence across track changes
- Cross-platform:
- Test volume/speed controls on macOS
- Test volume/speed controls on Linux
- Test volume/speed controls on Windows
- Test multimedia keys on each platform
- Test media registration on each platform
acceptance_criteria:
- All unit tests pass with >90% code coverage
- All integration tests pass
- Volume controls work correctly on all platforms
- Speed controls work correctly on all platforms
- Multimedia keys work on all platforms
- Media controls appear on all supported platforms
- All audio backends (mpv, ffplay, afplay) work correctly
- No regressions in existing audio functionality
validation:
- Run full test suite: `bun test`
- Check test coverage: `bun test --coverage`
- Manually test volume controls on each platform
- Manually test speed controls on each platform
- Manually test multimedia keys on each platform
- Verify media controls appear on each platform
- Check for any console errors or warnings
notes:
- Test suite should cover all audio backend implementations
- Integration tests should verify UI controls work correctly
- Platform-specific tests should run on actual platform if possible
- Consider using test doubles for platform-specific APIs
- Document any platform-specific issues or limitations found
- Reference: Test patterns from existing test files in src/utils/
## Implementation Notes (Completed)
### Manual Validation Steps
1. **Volume controls (all backends)**
- Launch app, load an episode, press Up/Down arrows on Player tab
- Volume indicator in PlaybackControls should update (0.00 - 1.00)
- Audio output volume should change audibly
- Test on non-Player tabs: Up/Down should still adjust volume via global media keys
2. **Speed controls (mpv, afplay)**
- Press `S` to cycle speed: 1.0 -> 1.25 -> 1.5 -> 1.75 -> 2.0 -> 0.5
- Speed indicator should update in PlaybackControls
- Audible pitch/speed change on mpv and afplay
- ffplay: speed changes require track restart (documented limitation)
3. **Seek controls**
- Press Left/Right arrows to seek -10s / +10s
- Position indicator should update
- Works on Player tab (local) and other tabs (global media keys)
4. **Global media keys (non-Player tabs)**
- Navigate to Feed, Shows, or Discover tab
- Start playing an episode from Player tab first
- Switch to another tab
- Press Space to toggle play/pause
- Press Up/Down to adjust volume
- Press Left/Right to seek
- Press S to cycle speed
5. **Platform media integration (macOS)**
- Install `nowplaying-cli`: `brew install nowplaying-cli`
- Track info should appear in macOS Now Playing widget
- If `nowplaying-cli` is not installed, graceful no-op (no errors)
### Platform Limitations
| Backend | Volume | Speed | Seek | Notes |
|---------|--------|-------|------|-------|
| **mpv** | Runtime (IPC) | Runtime (IPC) | Runtime (IPC) | Best support, uses Unix socket |
| **afplay** | Restart required | Restart required | Not supported | Process restarts with new args |
| **ffplay** | Restart required | Not supported | Not supported | No runtime speed flag |
| **system** | Depends on OS | Depends on OS | Depends on OS | Uses `open`/`xdg-open` |
| **noop** | No-op | No-op | No-op | Silent fallback |
### Media Registry Platform Support
| Platform | Integration | Status |
|----------|------------|--------|
| **macOS** | `nowplaying-cli` | Works if binary installed |
| **Linux** | MPRIS D-Bus | Stub (no-op), upgradable |
| **Windows** | None | No-op stub |
### Key Architecture Decisions
- Global media keys use event bus (`media.*` events) for decoupling
- `useMultimediaKeys` hook is called once in App.tsx
- Guards prevent double-handling when Player tab is focused (Player.tsx handles locally)
- Guards prevent interference when text input is focused
- MediaRegistry is a singleton, fire-and-forget, never throws

View File

@@ -1,26 +0,0 @@
# Audio Playback Fix
Objective: Fix volume and speed controls and add multimedia key support with platform media stream integration
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — Fix volume and speed controls in audio backends → `01-fix-volume-speed-controls.md`
- [x] 02 — Add multimedia key detection and handling → `02-add-multimedia-key-detection.md`
- [x] 03 — Implement platform-specific media stream integration → `03-implement-platform-media-integration.md`
- [x] 04 — Add media key listeners to audio hook → `04-add-media-key-listeners.md`
- [x] 05 — Test multimedia controls across platforms → `05-test-multimedia-controls.md`
Dependencies
- 01 depends on 02
- 02 depends on 03
- 03 depends on 04
- 04 depends on 05
Exit criteria
- Volume controls change playback volume in real-time
- Speed controls change playback speed in real-time
- Multimedia keys (Space, Arrow keys, Volume keys, Media keys) control playback
- Audio player appears in system media controls
- System multimedia keys trigger appropriate playback actions
- All controls work across mpv, ffplay, and afplay backends

View File

@@ -1,50 +0,0 @@
# 23. Implement XDG_CONFIG_HOME Directory Setup
meta:
id: config-persistence-23
feature: config-persistence
priority: P1
depends_on: []
tags: [configuration, file-system, directory-setup]
objective:
- Implement XDG_CONFIG_HOME directory detection and creation
- Create application-specific config directory
- Handle XDG_CONFIG_HOME environment variable
- Provide fallback to ~/.config if XDG_CONFIG_HOME not set
deliverables:
- Config directory detection utility
- Directory creation logic
- Environment variable handling
steps:
1. Create `src/utils/config-dir.ts`
2. Implement XDG_CONFIG_HOME detection
3. Create fallback to HOME/.config
4. Create application-specific directory (podcast-tui-app)
5. Add directory creation with error handling
tests:
- Unit: Test XDG_CONFIG_HOME detection
- Unit: Test config directory creation
- Manual: Verify directory exists at expected path
acceptance_criteria:
- Config directory is created at correct path
- XDG_CONFIG_HOME is respected if set
- Falls back to ~/.config if XDG_CONFIG_HOME not set
- Directory is created with correct permissions
validation:
- Run app and check config directory exists
- Test with XDG_CONFIG_HOME=/custom/path
- Test with XDG_CONFIG_HOME not set
- Verify directory is created in both cases
notes:
- XDG_CONFIG_HOME default: ~/.config
- App name from package.json: podcast-tui-app
- Use Bun.file() and file operations for directory creation
- Handle permission errors gracefully
- Use mkdir -p for recursive creation

View File

@@ -1,51 +0,0 @@
# 24. Refactor Feeds Persistence to JSON File
meta:
id: config-persistence-24
feature: config-persistence
priority: P1
depends_on: [config-persistence-23]
tags: [persistence, feeds, file-io]
objective:
- Move feeds persistence from localStorage to JSON file
- Load feeds from XDG_CONFIG_HOME directory
- Save feeds to JSON file
- Maintain backward compatibility
deliverables:
- Feeds JSON file I/O functions
- Updated feed store persistence
- Migration from localStorage
steps:
1. Create `src/utils/feeds-persistence.ts`
2. Implement loadFeedsFromFile() function
3. Implement saveFeedsToFile() function
4. Update feed store to use file-based persistence
5. Add migration from localStorage to file
tests:
- Unit: Test file I/O functions
- Integration: Test feed persistence with file
- Migration: Test migration from localStorage
acceptance_criteria:
- Feeds are loaded from JSON file
- Feeds are saved to JSON file
- Backward compatibility maintained
validation:
- Start app with no config file
- Subscribe to feeds
- Verify feeds saved to file
- Restart app and verify feeds loaded
- Test migration from localStorage
notes:
- File path: XDG_CONFIG_HOME/podcast-tui-app/feeds.json
- Use JSON.stringify/parse for serialization
- Handle file not found (empty initial load)
- Handle file write errors
- Add timestamp to file for versioning
- Maintain Feed type structure

View File

@@ -1,52 +0,0 @@
# 25. Refactor Theme Persistence to JSON File
meta:
id: config-persistence-25
feature: config-persistence
priority: P1
depends_on: [config-persistence-23]
tags: [persistence, themes, file-io]
objective:
- Move theme persistence from localStorage to JSON file
- Load custom themes from XDG_CONFIG_HOME directory
- Save custom themes to JSON file
- Maintain backward compatibility
deliverables:
- Themes JSON file I/O functions
- Updated theme persistence
- Migration from localStorage
steps:
1. Create `src/utils/themes-persistence.ts`
2. Implement loadThemesFromFile() function
3. Implement saveThemesToFile() function
4. Update theme store to use file-based persistence
5. Add migration from localStorage to file
tests:
- Unit: Test file I/O functions
- Integration: Test theme persistence with file
- Migration: Test migration from localStorage
acceptance_criteria:
- Custom themes are loaded from JSON file
- Custom themes are saved to JSON file
- Backward compatibility maintained
validation:
- Start app with no theme file
- Load custom theme
- Verify theme saved to file
- Restart app and verify theme loaded
- Test migration from localStorage
notes:
- File path: XDG_CONFIG_HOME/podcast-tui-app/themes.json
- Use JSON.stringify/parse for serialization
- Handle file not found (use default themes)
- Handle file write errors
- Add timestamp to file for versioning
- Maintain theme type structure
- Include all theme files in directory

View File

@@ -1,51 +0,0 @@
# 26. Add Config File Validation and Migration
meta:
id: config-persistence-26
feature: config-persistence
priority: P1
depends_on: [config-persistence-24, config-persistence-25]
tags: [validation, migration, data-integrity]
objective:
- Validate config file structure and data integrity
- Migrate data from localStorage to file
- Provide migration on first run
- Handle config file corruption
deliverables:
- Config file validation function
- Migration utility from localStorage
- Error handling for corrupted files
steps:
1. Create config file schema validation
2. Implement migration from localStorage to file
3. Add config file backup before migration
4. Handle corrupted JSON files
5. Test migration scenarios
tests:
- Unit: Test validation function
- Integration: Test migration from localStorage
- Error: Test corrupted file handling
acceptance_criteria:
- Config files are validated before use
- Migration from localStorage works seamlessly
- Corrupted files are handled gracefully
validation:
- Start app with localStorage data
- Verify migration to file
- Corrupt file and verify handling
- Test migration on app restart
notes:
- Validate Feed type structure
- Validate theme structure
- Create backup before migration
- Log migration events
- Provide error messages for corrupted files
- Add config file versioning
- Test with both new and old data formats

View File

@@ -1,50 +0,0 @@
# 27. Implement Config File Backup on Update
meta:
id: config-persistence-27
feature: config-persistence
priority: P2
depends_on: [config-persistence-26]
tags: [backup, data-safety, migration]
objective:
- Create backups of config files before updates
- Handle config file changes during app updates
- Provide rollback capability if needed
deliverables:
- Config backup utility
- Backup on config changes
- Config version history
steps:
1. Create config backup function
2. Implement backup on config save
3. Add config version history management
4. Test backup and restore scenarios
5. Add config file version display
tests:
- Unit: Test backup function
- Integration: Test backup on config save
- Manual: Test restore from backup
acceptance_criteria:
- Config files are backed up before updates
- Backup preserves data integrity
- Config version history is maintained
validation:
- Make config changes
- Verify backup created
- Restart app and check backup
- Test restore from backup
notes:
- Backup file naming: feeds.json.backup, themes.json.backup
- Keep last N backups (e.g., 5)
- Backup timestamp in filename
- Use atomic file operations
- Test with large config files
- Add config file size tracking
- Consider automatic cleanup of old backups

View File

@@ -1,25 +0,0 @@
# Config Persistence to XDG_CONFIG_HOME
Objective: Move feeds and themes persistence from localStorage to XDG_CONFIG_HOME directory
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 23 — Implement XDG_CONFIG_HOME directory setup → `23-config-directory-setup.md`
- [ ] 24 — Refactor feeds persistence to JSON file → `24-feeds-persistence-refactor.md`
- [ ] 25 — Refactor theme persistence to JSON file → `25-theme-persistence-refactor.md`
- [ ] 26 — Add config file validation and migration → `26-config-file-validation.md`
- [ ] 27 — Implement config file backup on update → `27-config-file-backup.md`
Dependencies
- 23 -> 24
- 23 -> 25
- 24 -> 26
- 25 -> 26
- 26 -> 27
Exit criteria
- Feeds are persisted to XDG_CONFIG_HOME/podcast-tui-app/feeds.json
- Themes are persisted to XDG_CONFIG_HOME/podcast-tui-app/themes.json
- Config file validation ensures data integrity
- Migration from localStorage works seamlessly

View File

@@ -1,47 +0,0 @@
# 20. Debug Category Filter Implementation [x]
meta:
id: discover-categories-fix-20
feature: discover-categories-fix
priority: P2
depends_on: []
tags: [debugging, discover, categories]
objective:
- Identify why category filter is not working
- Analyze CategoryFilter component behavior
- Trace state flow from category selection to show filtering
deliverables:
- Debugged category filter logic
- Identified root cause of issue
- Test cases to verify fix
steps:
1. Review CategoryFilter component implementation
2. Review DiscoverPage category selection handler
3. Review discover store category filtering logic
4. Add console logging to trace state changes
5. Test with various category selections
tests:
- Debug: Test category selection in UI
- Debug: Verify state updates in console
- Manual: Select different categories and observe behavior
acceptance_criteria:
- Root cause of category filter issue identified
- State flow from category to shows is traced
- Specific code causing issue identified
validation:
- Run app and select categories
- Check console for state updates
- Verify which component is not responding correctly
notes:
- Check if categoryIndex signal is updated
- Verify discoverStore.setSelectedCategory() is called
- Check if filteredPodcasts() is recalculated
- Look for race conditions or state sync issues
- Add temporary logging to trace state changes

View File

@@ -1,47 +0,0 @@
# 21. Fix Category State Synchronization [x]
meta:
id: discover-categories-fix-21
feature: discover-categories-fix
priority: P2
depends_on: [discover-categories-fix-20]
tags: [state-management, discover, categories]
objective:
- Ensure category state is properly synchronized across components
- Fix state updates not triggering re-renders
- Ensure category selection persists correctly
deliverables:
- Fixed state synchronization logic
- Updated category selection handlers
- Verified state propagation
steps:
1. Fix category state update handlers in DiscoverPage
2. Ensure discoverStore.setSelectedCategory() is called correctly
3. Fix signal updates to trigger component re-renders
4. Test state synchronization across component updates
5. Verify category state persists on navigation
tests:
- Unit: Test state update handlers
- Integration: Test category selection and state updates
- Manual: Navigate between tabs and verify category state
acceptance_criteria:
- Category state updates propagate correctly
- Component re-renders when category changes
- Category selection persists across navigation
validation:
- Select category and verify show list updates
- Switch tabs and back, verify category still selected
- Test category navigation with keyboard
notes:
- Check if signals are properly created and updated
- Verify discoverStore state is reactive
- Ensure CategoryFilter and TrendingShows receive updated data
- Test with multiple category selections
- Add state persistence if needed

View File

@@ -1,47 +0,0 @@
# 22. Fix Category Keyboard Navigation [x]
meta:
id: discover-categories-fix-22
feature: discover-categories-fix
priority: P2
depends_on: [discover-categories-fix-21]
tags: [keyboard, navigation, discover]
objective:
- Fix keyboard navigation for categories
- Ensure category selection works with arrow keys
- Fix category index tracking during navigation
deliverables:
- Fixed keyboard navigation handlers
- Updated category index tracking
- Verified navigation works correctly
steps:
1. Review keyboard navigation in DiscoverPage
2. Fix category index signal updates
3. Ensure categoryIndex signal is updated on arrow key presses
4. Test category navigation with arrow keys
5. Fix category selection on Enter key
tests:
- Integration: Test category navigation with keyboard
- Manual: Navigate categories with arrow keys
- Edge case: Test category navigation from shows list
acceptance_criteria:
- Arrow keys navigate categories correctly
- Category index updates on navigation
- Enter key selects category and updates shows list
validation:
- Use arrow keys to navigate categories
- Verify category highlight moves correctly
- Press Enter to select category and verify show list updates
notes:
- Check if categoryIndex signal is bound correctly
- Ensure arrow keys update categoryIndex signal
- Verify categoryIndex is used in filteredPodcasts()
- Test category navigation from shows list back to categories
- Add keyboard hints in UI

View File

@@ -1,19 +0,0 @@
# Discover Categories Shortcuts Fix
Objective: Fix broken discover category filter functionality
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 20 — Debug category filter implementation → `20-category-filter-debug.md`
- [ ] 21 — Fix category state synchronization → `21-category-state-sync.md`
- [ ] 22 — Fix category keyboard navigation → `22-category-navigation-fix.md`
Dependencies
- 20 -> 21
- 21 -> 22
Exit criteria
- Category filter correctly updates show list
- Keyboard navigation works for categories
- Category selection persists during navigation

View File

@@ -1,46 +0,0 @@
# 14. Define Download Storage Structure [x]
meta:
id: episode-downloads-14
feature: episode-downloads
priority: P2
depends_on: []
tags: [storage, types, data-model]
objective:
- Define data structures for downloaded episodes
- Create download state tracking
- Design download history and metadata storage
deliverables:
- DownloadedEpisode type definition
- Download state interface
- Storage schema for download metadata
steps:
1. Add DownloadedEpisode type to types/episode.ts
2. Define download state structure (status, progress, timestamp)
3. Create download metadata interface
4. Add download-related fields to Feed type
5. Design database-like storage structure
tests:
- Unit: Test type definitions
- Integration: Test storage schema
- Validation: Verify structure supports all download scenarios
acceptance_criteria:
- DownloadedEpisode type properly defines download metadata
- Download state interface tracks all necessary information
- Storage schema supports history and progress tracking
validation:
- Review type definitions for completeness
- Verify storage structure can hold all download data
- Test with mock download scenarios
notes:
- Add fields: status (downloading, completed, failed), progress (0-100), filePath, downloadedAt
- Include download speed and estimated time remaining
- Store download history with timestamps
- Consider adding resume capability

View File

@@ -1,47 +0,0 @@
# 15. Create Episode Download Utility [x]
meta:
id: episode-downloads-15
feature: episode-downloads
priority: P2
depends_on: [episode-downloads-14]
tags: [downloads, utilities, file-io]
objective:
- Implement episode download functionality
- Download audio files from episode URLs
- Handle download errors and edge cases
deliverables:
- Download utility function
- File download handler
- Error handling for download failures
steps:
1. Create `src/utils/episode-downloader.ts`
2. Implement download function using Bun.file() or fetch
3. Add progress tracking during download
4. Handle download cancellation
5. Add error handling for network and file system errors
tests:
- Unit: Test download function with mock URLs
- Integration: Test with real audio file URLs
- Error handling: Test download failure scenarios
acceptance_criteria:
- Episodes can be downloaded successfully
- Download progress is tracked
- Errors are handled gracefully
validation:
- Download test episode from real podcast
- Verify file is saved correctly
- Check download progress tracking
notes:
- Use Bun's built-in file download capabilities
- Support resuming interrupted downloads
- Handle large files with streaming
- Add download speed tracking
- Consider download location in downloadPath setting

View File

@@ -1,47 +0,0 @@
# 16. Implement Download Progress Tracking [x]
meta:
id: episode-downloads-16
feature: episode-downloads
priority: P2
depends_on: [episode-downloads-15]
tags: [progress, state-management, downloads]
objective:
- Track download progress for each episode
- Update download state in real-time
- Store download progress in persistent storage
deliverables:
- Download progress state in app store
- Progress update utility
- Integration with download utility
steps:
1. Add download state to app store
2. Update progress during download
3. Save progress to persistent storage
4. Handle download completion
5. Test progress tracking accuracy
tests:
- Unit: Test progress update logic
- Integration: Test progress tracking with download
- Persistence: Verify progress saved and restored
acceptance_criteria:
- Download progress is tracked accurately
- Progress updates in real-time
- Progress persists across app restarts
validation:
- Download a large file and watch progress
- Verify progress updates at intervals
- Restart app and verify progress restored
notes:
- Use existing progress store for episode playback
- Create separate download progress store
- Update progress every 1-2 seconds
- Handle download cancellation by resetting progress
- Store progress in XDG_CONFIG_HOME directory

View File

@@ -1,47 +0,0 @@
# 17. Add Download Status in Episode List [x]
meta:
id: episode-downloads-17
feature: episode-downloads
priority: P2
depends_on: [episode-downloads-16]
tags: [ui, downloads, display]
objective:
- Display download status for episodes
- Add download button to episode list
- Show download progress visually
deliverables:
- Download status indicator component
- Download button in episode list
- Progress bar for downloading episodes
steps:
1. Add download status field to EpisodeListItem
2. Create download button in MyShowsPage episodes panel
3. Display download status (none, queued, downloading, completed, failed)
4. Add download progress bar for downloading episodes
5. Test download status display
tests:
- Integration: Test download status display
- Visual: Verify download button and progress bar
- UX: Test download status changes
acceptance_criteria:
- Download status is visible in episode list
- Download button is accessible
- Progress bar shows download progress
validation:
- View episode list with download button
- Start download and watch status change
- Verify progress bar updates
notes:
- Reuse existing episode list UI from MyShowsPage
- Add download icon button next to episode title
- Show status text: "DL", "DWN", "DONE", "ERR"
- Use existing progress bar component for download progress
- Position download button in episode header

View File

@@ -1,48 +0,0 @@
# 18. Implement Per-Feed Auto-Download Settings [x]
meta:
id: episode-downloads-18
feature: episode-downloads
priority: P2
depends_on: [episode-downloads-17]
tags: [settings, automation, downloads]
objective:
- Add per-feed auto-download settings
- Configure number of episodes to auto-download per feed
- Enable/disable auto-download per feed
deliverables:
- Auto-download settings in feed store
- Settings UI for per-feed configuration
- Auto-download trigger logic
steps:
1. Add autoDownload field to Feed type
2. Add autoDownloadCount field to Feed type
3. Add settings UI in FeedPage or MyShowsPage
4. Implement auto-download trigger logic
5. Test auto-download functionality
tests:
- Unit: Test auto-download trigger logic
- Integration: Test with multiple feeds
- Edge case: Test with feeds having fewer episodes
acceptance_criteria:
- Auto-download settings are configurable per feed
- Settings are saved to persistent storage
- Auto-download works correctly when enabled
validation:
- Configure auto-download for a feed
- Subscribe to new episodes and verify auto-download
- Test with multiple feeds
notes:
- Add settings in FeedPage or MyShowsPage
- Default: autoDownload = false, autoDownloadCount = 0
- Only download newest episodes (by pubDate)
- Respect MAX_EPISODES_REFRESH limit
- Add settings in feed detail or feed list
- Consider adding "auto-download all new episodes" setting

View File

@@ -1,48 +0,0 @@
# 19. Create Download Queue Management [x]
meta:
id: episode-downloads-19
feature: episode-downloads
priority: P3
depends_on: [episode-downloads-18]
tags: [queue, downloads, management]
objective:
- Manage download queue for multiple episodes
- Handle concurrent downloads
- Provide queue UI for managing downloads
deliverables:
- Download queue data structure
- Download queue manager
- Download queue UI
steps:
1. Create download queue data structure
2. Implement download queue manager (add, remove, process)
3. Handle concurrent downloads (limit to 1-2 at a time)
4. Create download queue UI component
5. Test queue management
tests:
- Unit: Test queue management logic
- Integration: Test with multiple downloads
- Edge case: Test queue with 50+ episodes
acceptance_criteria:
- Download queue manages multiple downloads
- Concurrent downloads are limited
- Queue UI shows download status
validation:
- Add 10 episodes to download queue
- Verify queue processes sequentially
- Check queue UI displays correctly
notes:
- Use queue data structure (array of episodes)
- Limit concurrent downloads to 2 for performance
- Add queue UI in Settings or separate tab
- Show queue in SettingsScreen or new Downloads tab
- Allow removing items from queue
- Add pause/resume for downloads

View File

@@ -1,26 +0,0 @@
# Episode Downloads
Objective: Add per-episode download and per-feed auto-download settings
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 14 — Define download storage structure → `14-download-storage-structure.md`
- [ ] 15 — Create episode download utility → `15-episode-download-utility.md`
- [ ] 16 — Implement download progress tracking → `16-download-progress-tracking.md`
- [ ] 17 — Add download status in episode list → `17-download-ui-component.md`
- [ ] 18 — Implement per-feed auto-download settings → `18-auto-download-settings.md`
- [ ] 19 — Create download queue management → `19-download-queue-management.md`
Dependencies
- 14 -> 15
- 15 -> 16
- 16 -> 17
- 17 -> 18
- 18 -> 19
Exit criteria
- Episodes can be downloaded individually
- Per-feed auto-download settings are configurable
- Download progress is tracked and displayed
- Download queue can be managed

View File

@@ -1,46 +0,0 @@
# 10. Add Scroll Event Listener to Episodes Panel
meta:
id: episode-infinite-scroll-10
feature: episode-infinite-scroll
priority: P2
depends_on: []
tags: [ui, events, scroll]
objective:
- Detect when user scrolls to bottom of episodes list
- Add scroll event listener to episodes panel
- Track scroll position and trigger pagination when needed
deliverables:
- Scroll event handler function
- Scroll position tracking
- Integration with episodes panel
steps:
1. Modify MyShowsPage to add scroll event listener
2. Detect scroll-to-bottom event (when scrollHeight - scrollTop <= clientHeight)
3. Track current scroll position
4. Add debouncing for scroll events
5. Test scroll detection accuracy
tests:
- Unit: Test scroll detection logic
- Integration: Test scroll events in episodes panel
- Manual: Scroll to bottom and verify detection
acceptance_criteria:
- Scroll-to-bottom is detected accurately
- Debouncing prevents excessive event firing
- Scroll position is tracked correctly
validation:
- Scroll through episodes list
- Verify bottom detection works
- Test with different terminal sizes
notes:
- Use scrollbox component's scroll event if available
- Debounce scroll events to 100ms
- Handle both manual scroll and programmatic scroll
- Consider virtual scrolling if episode count is large

View File

@@ -1,46 +0,0 @@
# 11. Implement Paginated Episode Fetching
meta:
id: episode-infinite-scroll-11
feature: episode-infinite-scroll
priority: P2
depends_on: [episode-infinite-scroll-10]
tags: [rss, pagination, data-fetching]
objective:
- Fetch episodes in chunks with MAX_EPISODES_REFRESH limit
- Merge new episodes with existing list
- Maintain episode ordering (newest first)
deliverables:
- Paginated episode fetch function
- Episode list merging logic
- Integration with feed store
steps:
1. Create paginated fetch function in feed store
2. Implement chunk-based episode fetching (50 episodes at a time)
3. Add logic to merge new episodes with existing list
4. Maintain reverse chronological order (newest first)
5. Deduplicate episodes by title or URL
tests:
- Unit: Test paginated fetch logic
- Integration: Test with real RSS feeds
- Edge case: Test with feeds having < 50 episodes
acceptance_criteria:
- Episodes fetched in chunks of MAX_EPISODES_REFRESH
- New episodes merged correctly with existing list
- Episode ordering maintained (newest first)
validation:
- Test with RSS feed having 100+ episodes
- Verify pagination works correctly
- Check episode ordering after merge
notes:
- Use existing `MAX_EPISODES_REFRESH = 50` constant
- Add episode deduplication logic
- Preserve episode metadata during merge
- Handle cases where feed has fewer episodes

View File

@@ -1,46 +0,0 @@
# 12. Manage Episode List Pagination State
meta:
id: episode-infinite-scroll-12
feature: episode-infinite-scroll
priority: P2
depends_on: [episode-infinite-scroll-11]
tags: [state-management, pagination]
objective:
- Track pagination state (current page, loaded count, has more episodes)
- Manage episode list state changes
- Handle pagination state across component renders
deliverables:
- Pagination state in feed store
- Episode list state management
- Integration with scroll events
steps:
1. Add pagination state to feed store (currentPage, loadedCount, hasMore)
2. Update episode list when new episodes are loaded
3. Manage loading state for pagination
4. Handle empty episode list case
5. Test pagination state transitions
tests:
- Unit: Test pagination state updates
- Integration: Test state transitions with scroll
- Edge case: Test with no episodes in feed
acceptance_criteria:
- Pagination state accurately tracks loaded episodes
- Episode list updates correctly with new episodes
- Loading state properly managed
validation:
- Load episodes and verify state updates
- Scroll to bottom and verify pagination triggers
- Test with feed having many episodes
notes:
- Use existing feed store from `src/stores/feed.ts`
- Add pagination state to Feed interface
- Consider loading indicator visibility
- Handle rapid scroll events gracefully

View File

@@ -1,46 +0,0 @@
# 13. Add Loading Indicator for Pagination
meta:
id: episode-infinite-scroll-13
feature: episode-infinite-scroll
priority: P3
depends_on: [episode-infinite-scroll-12]
tags: [ui, feedback, loading]
objective:
- Display loading indicator when fetching more episodes
- Show loading state in episodes panel
- Hide indicator when pagination complete
deliverables:
- Loading indicator component
- Loading state display logic
- Integration with pagination events
steps:
1. Add loading state to episodes panel state
2. Create loading indicator UI (spinner or text)
3. Display indicator when fetching episodes
4. Hide indicator when pagination complete
5. Test loading state visibility
tests:
- Integration: Test loading indicator during fetch
- Visual: Verify loading state doesn't block interaction
- UX: Test loading state disappears when done
acceptance_criteria:
- Loading indicator displays during fetch
- Indicator is visible but doesn't block scrolling
- Indicator disappears when pagination complete
validation:
- Scroll to bottom and watch loading indicator
- Verify indicator shows/hides correctly
- Test with slow RSS feeds
notes:
- Reuse existing loading indicator pattern from MyShowsPage
- Use spinner or "Loading..." text
- Position indicator at bottom of scrollbox
- Don't block user interaction while loading

View File

@@ -1,21 +0,0 @@
# Episode List Infinite Scroll
Objective: Implement scroll-to-bottom loading for episode lists with MAX_EPISODES_REFRESH limit
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 10 — Add scroll event listener to episodes panel → `10-episode-list-scroll-handler.md`
- [ ] 11 — Implement paginated episode fetching → `11-paginated-episode-loading.md`
- [ ] 12 — Manage episode list pagination state → `12-episode-list-state-management.md`
- [ ] 13 — Add loading indicator for pagination → `13-load-more-indicator.md`
Dependencies
- 10 -> 11
- 11 -> 12
- 12 -> 13
Exit criteria
- Episode list automatically loads more episodes when scrolling to bottom
- MAX_EPISODES_REFRESH is respected per fetch
- Loading state is properly displayed during pagination

View File

@@ -1,46 +0,0 @@
# 06. Implement Audio Waveform Analysis
meta:
id: merged-waveform-06
feature: merged-waveform
priority: P2
depends_on: []
tags: [audio, waveform, analysis]
objective:
- Analyze audio data to extract waveform information
- Create real-time waveform data from audio streams
- Generate waveform data points for visualization
deliverables:
- Audio analysis utility
- Waveform data extraction function
- Integration with audio backend
steps:
1. Research and select audio waveform analysis library (e.g., `audiowaveform`)
2. Create `src/utils/audio-waveform.ts`
3. Implement audio data extraction from backend
4. Generate waveform data points (amplitude values)
5. Add sample rate and duration normalization
tests:
- Unit: Test waveform generation from sample audio
- Integration: Test with real audio playback
- Performance: Measure waveform generation overhead
acceptance_criteria:
- Waveform data is generated from audio content
- Data points represent audio amplitude accurately
- Generation works with real-time audio streams
validation:
- Generate waveform from sample MP3 file
- Verify amplitude data matches audio peaks
- Test with different audio formats
notes:
- Consider using `ffmpeg` or `sox` for offline analysis
- For real-time: analyze audio chunks during playback
- Waveform resolution: 64-256 data points for TUI display
- Normalize amplitude to 0-1 range

View File

@@ -1,46 +0,0 @@
# 07. Create Merged Progress-Waveform Component
meta:
id: merged-waveform-07
feature: merged-waveform
priority: P2
depends_on: [merged-waveform-06]
tags: [ui, waveform, component]
objective:
- Design and implement a single component that shows progress bar and waveform
- Component starts as progress bar, expands to waveform when playing
- Provide smooth transitions between states
deliverables:
- MergedWaveform component
- State management for progress vs waveform display
- Visual styling for progress bar and waveform
steps:
1. Create `src/components/MergedWaveform.tsx`
2. Design component state machine (progress bar → waveform)
3. Implement progress bar visualization
4. Add waveform expansion animation
5. Style progress bar and waveform with theme colors
tests:
- Unit: Test component state transitions
- Integration: Test component in Player
- Visual: Verify smooth expansion animation
acceptance_criteria:
- Component displays progress bar when paused
- Component smoothly expands to waveform when playing
- Visual styles match theme and existing UI
validation:
- Test with paused and playing states
- Verify expansion is smooth and visually appealing
- Check theme color integration
notes:
- Use existing Waveform component as base
- Add CSS transitions for smooth expansion
- Keep component size manageable (fit in progress bar area)
- Consider responsive to terminal width changes

View File

@@ -1,46 +0,0 @@
# 08. Implement Real-Time Waveform Rendering During Playback
meta:
id: merged-waveform-08
feature: merged-waveform
priority: P2
depends_on: [merged-waveform-07]
tags: [audio, realtime, rendering]
objective:
- Update waveform in real-time during audio playback
- Highlight waveform based on current playback position
- Sync waveform with audio backend position updates
deliverables:
- Real-time waveform update logic
- Playback position highlighting
- Integration with audio backend position tracking
steps:
1. Subscribe to audio backend position updates
2. Update waveform data points based on playback position
3. Implement playback position highlighting
4. Add animation for progress indicator
5. Test synchronization with audio playback
tests:
- Integration: Test waveform sync with audio playback
- Performance: Measure real-time update overhead
- Visual: Verify progress highlighting matches audio position
acceptance_criteria:
- Waveform updates in real-time during playback
- Playback position is accurately highlighted
- No lag or desynchronization with audio
validation:
- Play audio and watch waveform update
- Verify progress bar matches audio position
- Test with different playback speeds
notes:
- Use existing audio position polling in `useAudio.ts`
- Update waveform every ~100ms for smooth visuals
- Consider reducing waveform resolution during playback for performance
- Ensure highlighting doesn't flicker

View File

@@ -1,46 +0,0 @@
# 09. Optimize Waveform Rendering Performance
meta:
id: merged-waveform-09
feature: merged-waveform
priority: P3
depends_on: [merged-waveform-08]
tags: [performance, optimization]
objective:
- Ensure waveform rendering doesn't cause performance issues
- Optimize for terminal TUI environment
- Minimize CPU and memory usage
deliverables:
- Performance optimizations
- Memory management for waveform data
- Performance monitoring and testing
steps:
1. Profile waveform rendering performance
2. Optimize data point generation and updates
3. Implement waveform data caching
4. Add performance monitoring
5. Test with long audio files
tests:
- Performance: Measure CPU usage during playback
- Performance: Measure memory usage over time
- Load test: Test with 30+ minute audio files
acceptance_criteria:
- Waveform rendering < 16ms per frame
- No memory leaks during extended playback
- Smooth playback even with waveform rendering
validation:
- Profile CPU usage during playback
- Monitor memory over 30-minute playback session
- Test with multiple simultaneous audio files
notes:
- Consider reducing waveform resolution during playback
- Cache waveform data to avoid regeneration
- Use efficient data structures for waveform points
- Test on slower terminals (e.g., tmux)

View File

@@ -1,21 +0,0 @@
# Merged Waveform Progress Bar
Objective: Create a real-time waveform visualization that expands from a progress bar during playback
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 06 — Implement audio waveform analysis → `06-waveform-audio-analysis.md`
- [ ] 07 — Create merged progress-waveform component → `07-merged-waveform-component.md`
- [ ] 08 — Implement real-time waveform rendering during playback → `08-realtime-waveform-rendering.md`
- [ ] 09 — Optimize waveform rendering performance → `09-waveform-performance-optimization.md`
Dependencies
- 06 -> 07
- 07 -> 08
- 08 -> 09
Exit criteria
- Waveform smoothly expands from progress bar during playback
- Waveform is highlighted based on current playback position
- No performance degradation during playback

View File

@@ -1,57 +0,0 @@
# 01. Copy cavacore library files to project
meta:
id: real-time-audio-visualization-01
feature: real-time-audio-visualization
priority: P0
depends_on: []
tags: [setup, build]
objective:
- Copy necessary cava library files from cava/ directory to src/utils/ for integration
deliverables:
- src/utils/cavacore.h - Header file with cavacore API
- src/utils/cavacore.c - Implementation of cavacore library
- src/utils/audio-stream.h - Audio stream reader header
- src/utils/audio-stream.c - Audio stream reader implementation
- src/utils/audio-input.h - Common audio input types
- src/utils/audio-input.c - Audio input buffer management
steps:
- Identify necessary files from cava/ directory:
- cavacore.h (API definition)
- cavacore.c (FFT processing implementation)
- input/common.h (common audio data structures)
- input/common.c (input buffer handling)
- input/fifo.h (FIFO input support - optional, for testing)
- input/fifo.c (FIFO input implementation - optional)
- Copy cavacore.h to src/utils/
- Copy cavacore.c to src/utils/
- Copy input/common.h to src/utils/
- Copy input/common.c to src/utils/
- Copy input/fifo.h to src/utils/ (optional)
- Copy input/fifo.c to src/utils/ (optional)
- Update file headers to indicate origin and licensing
- Note: Files from cava/ directory will be removed after integration
tests:
- Unit: Verify all files compile successfully
- Integration: Ensure no import errors in TypeScript/JavaScript files
- Manual: Check that files are accessible from src/utils/
acceptance_criteria:
- All required cava files are copied to src/utils/
- File headers include proper copyright and license information
- No compilation errors from missing dependencies
- Files are properly formatted for TypeScript/JavaScript integration
validation:
- Run: `bun run build` to verify compilation
- Check: `ls src/utils/*.c src/utils/*.h` to confirm file presence
notes:
- Only need cavacore.c, cavacore.h, and common.c/common.h for basic functionality
- input/fifo.c is optional - can be added later if needed
- FFTW library will need to be installed and linked separately
- The files will be integrated into the audio-waveform utility

View File

@@ -1,61 +0,0 @@
# 02. Integrate cavacore library for audio analysis
meta:
id: real-time-audio-visualization-02
feature: real-time-audio-visualization
priority: P0
depends_on: [real-time-audio-visualization-01]
tags: [integration, audio-processing]
objective:
- Create a TypeScript binding for the cavacore C library
- Provide async API for real-time audio frequency analysis
deliverables:
- src/utils/cavacore.ts - TypeScript bindings for cavacore API
- src/utils/audio-visualizer.ts - High-level audio visualizer class
- Updated package.json with FFTW dependency
steps:
- Review cavacore.h API and understand the interface:
- cava_init() - Initialize with parameters
- cava_execute() - Process samples and return frequencies
- cava_destroy() - Clean up
- Create cavacore.ts wrapper with TypeScript types:
- Define C-style structs as TypeScript interfaces
- Create bind() function to load shared library
- Implement async wrappers for init, execute, destroy
- Create audio-visualizer.ts class:
- Handle initialization with configurable parameters (bars, sensitivity, noise reduction)
- Provide execute() method that accepts audio samples and returns frequency data
- Manage cleanup and error handling
- Update package.json:
- Add @types/fftw3 dependency (if available) or document manual installation
- Add build instructions for linking FFTW library
- Test basic initialization and execution with dummy data
tests:
- Unit: Test cavacore initialization with valid parameters
- Unit: Test cavacore execution with sample audio data
- Unit: Test cleanup and memory management
- Integration: Verify no memory leaks after multiple init/destroy cycles
- Integration: Test with actual audio data from ffmpeg
acceptance_criteria:
- cavacore.ts compiles without TypeScript errors
- audio-visualizer.ts can be imported and initialized
- execute() method returns frequency data array
- Proper error handling for missing FFTW library
- No memory leaks in long-running tests
validation:
- Run: `bun run build` to verify TypeScript compilation
- Run: `bun test` for unit tests
- Manual: Test with sample audio file and verify output
notes:
- FFTW library needs to be installed separately on the system
- On macOS: brew install fftw
- On Linux: apt install libfftw3-dev
- The C code will need to be compiled into a shared library (.so/.dylib/.dll)
- For Bun, we can use `Bun.native()` or `Bun.ffi` to call C functions

View File

@@ -1,72 +0,0 @@
# 03. Create audio stream reader for real-time data
meta:
id: real-time-audio-visualization-03
feature: real-time-audio-visualization
priority: P1
depends_on: [real-time-audio-visualization-02]
tags: [audio-stream, real-time]
objective:
- Create a mechanism to read audio stream from mpv backend
- Convert audio data to format suitable for cavacore processing
- Implement efficient buffer management
deliverables:
- src/utils/audio-stream-reader.ts - Audio stream reader class
- src/utils/audio-stream-reader.test.ts - Unit tests
steps:
- Design audio stream reader interface:
- Constructor accepts audio URL and backend (mpv)
- Start() method initiates audio playback and stream capture
- readSamples() method returns next batch of audio samples
- stop() method terminates stream capture
- Implement stream reading for mpv backend:
- Use mpv IPC to query audio device parameters (sample rate, channels)
- Use ffmpeg or similar to pipe audio output to stdin
- Read PCM samples from the stream
- Convert audio samples to appropriate format:
- Handle different bit depths (16-bit, 32-bit)
- Handle different sample rates (44100, 48000, etc.)
- Interleave stereo channels if needed
- Implement buffer management:
- Circular buffer for efficient sample storage
- Non-blocking read with timeout
- Sample rate conversion if needed
- Handle errors:
- Invalid audio URL
- Backend connection failure
- Sample format mismatch
- Create unit tests:
- Mock mpv backend
- Test sample reading
- Test buffer management
- Test error conditions
tests:
- Unit: Test sample rate detection
- Unit: Test channel detection
- Unit: Test sample reading with valid data
- Unit: Test buffer overflow handling
- Unit: Test error handling for invalid audio
- Integration: Test with actual audio file and mpv
- Integration: Test with ffplay backend
acceptance_criteria:
- Audio stream reader successfully reads audio data from mpv
- Samples are converted to 16-bit PCM format
- Buffer management prevents overflow
- Error handling works for invalid audio
- No memory leaks in long-running tests
validation:
- Run: `bun test` for unit tests
- Manual: Play audio and verify stream reader captures data
- Manual: Test with different audio formats (mp3, wav, m4a)
notes:
- mpv can output audio via pipe to stdin using --audio-file-pipe
- Alternative: Use ffmpeg to re-encode audio to standard format
- Sample rate conversion may be needed for cavacore compatibility
- For simplicity, start with 16-bit PCM, single channel (mono)

View File

@@ -1,75 +0,0 @@
# 04. Create realtime waveform component
meta:
id: real-time-audio-visualization-04
feature: real-time-audio-visualization
priority: P1
depends_on: [real-time-audio-visualization-03]
tags: [component, ui]
objective:
- Create a SolidJS component that displays real-time audio visualization
- Integrate audio-visualizer and audio-stream-reader
- Display frequency data as visual waveform bars
deliverables:
- src/components/RealtimeWaveform.tsx - Real-time waveform component
- src/components/RealtimeWaveform.test.tsx - Component tests
steps:
- Create RealtimeWaveform component:
- Accept props: audioUrl, position, duration, isPlaying, onSeek, resolution
- Initialize audio-visualizer with cavacore
- Initialize audio-stream-reader for mpv backend
- Create render loop that:
- Reads audio samples from stream reader
- Passes samples to cavacore execute()
- Gets frequency data back
- Maps frequency data to visual bars
- Renders bars with appropriate colors
- Implement rendering logic:
- Map frequency values to bar heights
- Color-code bars based on intensity
- Handle played vs unplayed portions
- Support click-to-seek
- Create visual style:
- Use terminal block characters for bars
- Apply colors based on frequency bands (bass, mid, treble)
- Add visual flair (gradients, glow effects if possible)
- Implement state management:
- Track current frequency data
- Track playback position
- Handle component lifecycle (cleanup)
- Create unit tests:
- Test component initialization
- Test render loop
- Test click-to-seek
- Test cleanup
tests:
- Unit: Test component props
- Unit: Test frequency data mapping
- Unit: Test visual bar rendering
- Integration: Test with mock audio data
- Integration: Test with actual audio playback
acceptance_criteria:
- Component renders without errors
- Visual bars update in real-time during playback
- Frequency data is correctly calculated from audio samples
- Click-to-seek works
- Component cleans up resources properly
- Visual style matches design requirements
validation:
- Run: `bun test` for unit tests
- Manual: Play audio and verify visualization updates
- Manual: Test seeking and verify visualization follows
- Performance: Monitor frame rate and CPU usage
notes:
- Use SolidJS createEffect for reactive updates
- Keep render loop efficient to maintain 60fps
- Consider debouncing if processing is too heavy
- May need to adjust sample rate for performance
- Visual style should complement existing MergedWaveform design

Some files were not shown because too many files have changed in this diff Show More