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 { useSelectionHandler } from "@opentui/solid";
import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation";
import { TabNavigation } from "./components/TabNavigation";
import { FeedPage } from "./components/FeedPage";
import { MyShowsPage } from "./components/MyShowsPage";
import { LoginScreen } from "./components/LoginScreen";
import { CodeValidation } from "./components/CodeValidation";
import { OAuthPlaceholder } from "./components/OAuthPlaceholder";
import { SyncProfile } from "./components/SyncProfile";
import { SearchPage } from "./components/SearchPage";
import { DiscoverPage } from "./components/DiscoverPage";
import { Player } from "./components/Player";
import { SettingsScreen } from "./components/SettingsScreen";
import { useAuthStore } from "./stores/auth";
import { useFeedStore } from "./stores/feed";
import { useAppStore } from "./stores/app";
import { useAudio } from "./hooks/useAudio";
import { useMultimediaKeys } from "./hooks/useMultimediaKeys";
import { FeedVisibility } from "./types/feed";
import { useAppKeyboard } from "./hooks/useAppKeyboard";
import { Clipboard } from "./utils/clipboard";
import { emit } from "./utils/event-bus";
import type { TabId } from "./components/Tab";
import type { AuthScreen } from "./types/auth";
import type { Episode } from "./types/episode";
import { FeedPage } from "@/tabs/Feed/FeedPage";
import { MyShowsPage } from "@/tabs/MyShows/MyShowsPage";
import { LoginScreen } from "@/tabs/Settings/LoginScreen";
import { CodeValidation } from "@/components/CodeValidation";
import { OAuthPlaceholder } from "@/tabs/Settings/OAuthPlaceholder";
import { SyncProfile } from "@/tabs/Settings/SyncProfile";
import { SearchPage } from "@/tabs/Search/SearchPage";
import { DiscoverPage } from "@/tabs/Discover/DiscoverPage";
import { Player } from "@/tabs/Player/Player";
import { SettingsScreen } from "@/tabs/Settings/SettingsScreen";
import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "@/types/feed";
import { useAppKeyboard } from "@/hooks/useAppKeyboard";
import { Clipboard } from "@/utils/clipboard";
import { emit } from "@/utils/event-bus";
import type { TabId } from "@/components/Tab";
import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode";
export function App() {
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
@@ -53,7 +52,9 @@ export function App() {
// My Shows page returns panel renderers
const myShows = MyShowsPage({
get focused() { return activeTab() === "shows" && layerDepth() > 0 },
get focused() {
return activeTab() === "shows" && layerDepth() > 0;
},
onPlayEpisode: (episode, feed) => {
handlePlayEpisode(episode);
},
@@ -69,8 +70,12 @@ export function App() {
setActiveTab(tab);
setInputFocused(false);
},
get inputFocused() { return inputFocused() },
get navigationEnabled() { return layerDepth() === 0 },
get inputFocused() {
return inputFocused();
},
get navigationEnabled() {
return layerDepth() === 0;
},
layerDepth,
onLayerChange: (newDepth) => {
setLayerDepth(newDepth);
@@ -94,18 +99,20 @@ export function App() {
// Copy selected text to clipboard when selection ends (mouse release)
useSelectionHandler((selection: any) => {
if (!selection) return
const text = selection.getSelectedText?.()
if (!text || text.trim().length === 0) return
if (!selection) return;
const text = selection.getSelectedText?.();
if (!text || text.trim().length === 0) return;
Clipboard.copy(text).then(() => {
emit("toast.show", {
message: "Copied to clipboard",
variant: "info",
duration: 1500,
Clipboard.copy(text)
.then(() => {
emit("toast.show", {
message: "Copied to clipboard",
variant: "info",
duration: 1500,
});
})
}).catch(() => {})
})
.catch(() => {});
});
const getPanels = createMemo(() => {
const tab = activeTab();
@@ -156,19 +163,21 @@ export function App() {
if (showAuthPanel()) {
if (auth.isAuthenticated) {
return {
panels: [{
title: "Account",
content: (
<SyncProfile
focused={layerDepth() > 0}
onLogout={() => {
auth.logout();
setShowAuthPanel(false);
}}
onManageSync={() => setShowAuthPanel(false)}
/>
),
}],
panels: [
{
title: "Account",
content: (
<SyncProfile
focused={layerDepth() > 0}
onLogout={() => {
auth.logout();
setShowAuthPanel(false);
}}
onManageSync={() => setShowAuthPanel(false)}
/>
),
},
],
activePanelIndex: 0,
hint: "Esc back",
};
@@ -203,104 +212,121 @@ export function App() {
};
return {
panels: [{
title: "Sign In",
content: authContent(),
}],
panels: [
{
title: "Sign In",
content: authContent(),
},
],
activePanelIndex: 0,
hint: "Esc back",
};
}
return {
panels: [{
title: "Settings",
content: (
<SettingsScreen
onOpenAccount={() => setShowAuthPanel(true)}
accountLabel={
auth.isAuthenticated
? `Signed in as ${auth.user?.email}`
: "Not signed in"
}
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
onExit={() => setLayerDepth(0)}
/>
),
}],
panels: [
{
title: "Settings",
content: (
<SettingsScreen
onOpenAccount={() => setShowAuthPanel(true)}
accountLabel={
auth.isAuthenticated
? `Signed in as ${auth.user?.email}`
: "Not signed in"
}
accountStatus={
auth.isAuthenticated ? "signed-in" : "signed-out"
}
onExit={() => setLayerDepth(0)}
/>
),
},
],
activePanelIndex: 0,
hint: "j/k navigate | Enter select | Esc back",
};
case "discover":
return {
panels: [{
title: "Discover",
content: (
<DiscoverPage
focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)}
/>
),
}],
panels: [
{
title: "Discover",
content: (
<DiscoverPage
focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)}
/>
),
},
],
activePanelIndex: 0,
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
};
case "search":
return {
panels: [{
title: "Search",
content: (
<SearchPage
focused={layerDepth() > 0}
onInputFocusChange={setInputFocused}
onExit={() => setLayerDepth(0)}
onSubscribe={(result) => {
const feeds = feedStore.feeds();
const alreadySubscribed = feeds.some(
(feed) =>
feed.podcast.id === result.podcast.id ||
feed.podcast.feedUrl === result.podcast.feedUrl,
);
if (!alreadySubscribed) {
feedStore.addFeed(
{ ...result.podcast, isSubscribed: true },
result.sourceId,
FeedVisibility.PUBLIC,
panels: [
{
title: "Search",
content: (
<SearchPage
focused={layerDepth() > 0}
onInputFocusChange={setInputFocused}
onExit={() => setLayerDepth(0)}
onSubscribe={(result) => {
const feeds = feedStore.feeds();
const alreadySubscribed = feeds.some(
(feed) =>
feed.podcast.id === result.podcast.id ||
feed.podcast.feedUrl === result.podcast.feedUrl,
);
}
}}
/>
),
}],
if (!alreadySubscribed) {
feedStore.addFeed(
{ ...result.podcast, isSubscribed: true },
result.sourceId,
FeedVisibility.PUBLIC,
);
}
}}
/>
),
},
],
activePanelIndex: 0,
hint: "Tab switch focus | / search | Enter select | Esc back",
};
case "player":
return {
panels: [{
title: "Player",
content: (
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
),
}],
panels: [
{
title: "Player",
content: (
<Player
focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)}
/>
),
},
],
activePanelIndex: 0,
hint: "Space play/pause | Esc back",
};
default:
return {
panels: [{
title: tab,
content: (
<box padding={2}>
<text>Coming soon</text>
</box>
),
}],
panels: [
{
title: tab,
content: (
<box padding={2}>
<text>Coming soon</text>
</box>
),
},
],
activePanelIndex: 0,
hint: "",
};
@@ -308,24 +334,21 @@ export function App() {
});
return (
<ErrorBoundary fallback={(err) => (
<box border padding={2}>
<text fg="red">
Error: {err?.message ?? String(err)}{"\n"}
Press a number key (1-6) to switch tabs.
</text>
</box>
)}>
<ErrorBoundary
fallback={(err) => (
<box border padding={2}>
<text fg="red">
Error: {err?.message ?? String(err)}
{"\n"}
Press a number key (1-6) to switch tabs.
</text>
</box>
)}
>
<Layout
header={
<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}
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
*/
import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth"
import { AUTH_CONFIG } from "../config/auth"
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { AUTH_CONFIG } from "@/config/auth";
interface CodeValidationProps {
focused?: boolean
onBack?: () => void
focused?: boolean;
onBack?: () => void;
}
type FocusField = "code" | "submit" | "back"
type FocusField = "code" | "submit" | "back";
export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore()
const [code, setCode] = createSignal("")
const [focusField, setFocusField] = createSignal<FocusField>("code")
const [codeError, setCodeError] = createSignal<string | null>(null)
const auth = useAuthStore();
const [code, setCode] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("code");
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) */
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
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength)
setCode(limited)
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
setCode(limited);
// Clear error when typing
if (codeError()) {
setCodeError(null)
setCodeError(null);
}
}
};
const validateCode = (value: string): boolean => {
if (!value) {
setCodeError("Code is required")
return false
setCodeError("Code is required");
return false;
}
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
setCodeError(`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`)
return false
setCodeError(
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
);
return false;
}
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
setCodeError("Code must contain only letters and numbers")
return false
setCodeError("Code must contain only letters and numbers");
return false;
}
setCodeError(null)
return true
}
setCodeError(null);
return true;
};
const handleSubmit = async () => {
if (!validateCode(code())) {
return
return;
}
const success = await auth.validateCode(code())
const success = await auth.validateCode(code());
if (!success && auth.error) {
setCodeError(auth.error.message)
setCodeError(auth.error.message);
}
}
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "submit") {
handleSubmit()
handleSubmit();
} else if (focusField() === "back" && props.onBack) {
props.onBack()
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack()
props.onBack();
}
}
};
const codeProgress = () => {
const len = code().length
const max = AUTH_CONFIG.codeValidation.codeLength
return `${len}/${max}`
}
const len = code().length;
const max = AUTH_CONFIG.codeValidation.codeLength;
return `${len}/${max}`;
};
const codeDisplay = () => {
const current = code()
const max = AUTH_CONFIG.codeValidation.codeLength
const filled = current.split("")
const empty = Array(max - filled.length).fill("_")
return [...filled, ...empty].join(" ")
}
const current = code();
const max = AUTH_CONFIG.codeValidation.codeLength;
const filled = current.split("");
const empty = Array(max - filled.length).fill("_");
return [...filled, ...empty].join(" ");
};
return (
<box flexDirection="column" border padding={2} gap={1}>
@@ -103,7 +105,9 @@ export function CodeValidation(props: CodeValidationProps) {
<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>
<box height={1} />
@@ -115,7 +119,13 @@ export function CodeValidation(props: CodeValidationProps) {
</text>
<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()}
</text>
</box>
@@ -129,9 +139,7 @@ export function CodeValidation(props: CodeValidationProps) {
width={30}
/>
{codeError() && (
<text fg="red">{codeError()}</text>
)}
{codeError() && <text fg="red">{codeError()}</text>}
</box>
<box height={1} />
@@ -160,13 +168,11 @@ export function CodeValidation(props: CodeValidationProps) {
</box>
{/* Auth error message */}
{auth.error && (
<text fg="red">{auth.error.message}</text>
)}
{auth.error && <text fg="red">{auth.error.message}</text>}
<box height={1} />
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</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 { RGBA } from "@opentui/core"
import { Show, For, createMemo } from "solid-js"
import { useTheme } from "../context/ThemeContext"
import type { JSX } from "solid-js";
import type { RGBA } from "@opentui/core";
import { Show, For } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
type PanelConfig = {
/** Panel content */
content: JSX.Element
content: JSX.Element;
/** Panel title shown in header */
title?: string
title?: string;
/** Fixed width (leave undefined for flex) */
width?: number
width?: number;
/** Whether this panel is currently focused */
focused?: boolean
}
focused?: boolean;
};
type LayoutProps = {
/** Top tab bar */
header?: JSX.Element
header?: JSX.Element;
/** Bottom status bar */
footer?: JSX.Element
footer?: JSX.Element;
/** Panels to display left-to-right like a file explorer */
panels: PanelConfig[]
panels: PanelConfig[];
/** Index of the currently active/focused panel */
activePanelIndex?: number
}
activePanelIndex?: number;
};
export function Layout(props: LayoutProps) {
const context = useTheme()
const panelBg = (index: number): RGBA => {
const backgrounds = context.theme.layerBackgrounds
const backgrounds = theme.layerBackgrounds;
const layers = [
backgrounds?.layer0 ?? context.theme.background,
backgrounds?.layer1 ?? context.theme.backgroundPanel,
backgrounds?.layer2 ?? context.theme.backgroundElement,
backgrounds?.layer3 ?? context.theme.backgroundMenu,
]
return layers[Math.min(index, layers.length - 1)]
}
backgrounds?.layer0 ?? theme.background,
backgrounds?.layer1 ?? theme.backgroundPanel,
backgrounds?.layer2 ?? theme.backgroundElement,
backgrounds?.layer3 ?? theme.backgroundMenu,
];
return layers[Math.min(index, layers.length - 1)];
};
const borderColor = (index: number): RGBA | string => {
const isActive = index === (props.activePanelIndex ?? 0)
const isActive = index === (props.activePanelIndex ?? 0);
return isActive
? (context.theme.accent ?? context.theme.primary)
: (context.theme.border ?? context.theme.textMuted)
}
? (theme.accent ?? theme.primary)
: (theme.border ?? theme.textMuted);
};
const { theme } = useTheme();
return (
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={context.theme.background}
backgroundColor={theme.background}
>
{/* Header - tab bar */}
<Show when={props.header}>
<box
style={{
height: 3,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
backgroundColor: theme.surface ?? theme.backgroundPanel,
}}
>
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
@@ -68,16 +67,13 @@ export function Layout(props: LayoutProps) {
</Show>
{/* Main content: side-by-side panels */}
<box
flexDirection="row"
style={{ flexGrow: 1 }}
>
<box flexDirection="row" style={{ flexGrow: 1 }}>
<For each={props.panels}>
{(panel, index) => (
<box
flexDirection="column"
border
borderColor={borderColor(index())}
borderColor={theme.border}
backgroundColor={panelBg(index())}
style={{
flexGrow: panel.width ? 0 : 1,
@@ -92,13 +88,18 @@ export function Layout(props: LayoutProps) {
height: 1,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: index() === (props.activePanelIndex ?? 0)
? (context.theme.accent ?? context.theme.primary)
: (context.theme.surface ?? context.theme.backgroundPanel),
backgroundColor:
index() === (props.activePanelIndex ?? 0)
? (theme.accent ?? theme.primary)
: (theme.surface ?? theme.backgroundPanel),
}}
>
<text
fg={index() === (props.activePanelIndex ?? 0) ? "black" : undefined}
fg={
index() === (props.activePanelIndex ?? 0)
? "black"
: undefined
}
>
<strong>{panel.title}</strong>
</text>
@@ -124,14 +125,12 @@ export function Layout(props: LayoutProps) {
<box
style={{
height: 2,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
backgroundColor: theme.surface ?? theme.backgroundPanel,
}}
>
<box style={{ padding: 1 }}>
{props.footer}
</box>
<box style={{ padding: 1 }}>{props.footer}</box>
</box>
</Show>
</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() {
return (
@@ -22,5 +22,5 @@ export function ShortcutHelp() {
</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 = {
id: TabId
label: string
}
id: TabId;
label: string;
};
export const tabs: TabDefinition[] = [
{ id: "feed", label: "Feed" },
@@ -14,27 +20,31 @@ export const tabs: TabDefinition[] = [
{ id: "search", label: "Search" },
{ id: "player", label: "Player" },
{ id: "settings", label: "Settings" },
]
];
type TabProps = {
tab: TabDefinition
active: boolean
onSelect: (tab: TabId) => void
}
tab: TabDefinition;
active: boolean;
onSelect: (tab: TabId) => void;
};
export function Tab(props: TabProps) {
const { theme } = useTheme()
const { theme } = useTheme();
return (
<box
border
borderColor={theme.border}
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.tab.label}
{props.active ? "]" : " "}
</text>
</box>
)
);
}

View File

@@ -1,19 +1,43 @@
import { Tab, type TabId } from "./Tab"
import { Tab, type TabId } from "./Tab";
type TabNavigationProps = {
activeTab: TabId
onTabSelect: (tab: TabId) => void
}
activeTab: TabId;
onTabSelect: (tab: TabId) => void;
};
export function TabNavigation(props: TabNavigationProps) {
return (
<box style={{ flexDirection: "row", gap: 1 }}>
<Tab tab={{ id: "feed", label: "Feed" }} active={props.activeTab === "feed"} 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} />
<Tab
tab={{ id: "feed", label: "Feed" }}
active={props.activeTab === "feed"}
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>
)
);
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,208 +4,218 @@
* Right panel: episodes for the selected show
*/
import { createSignal, For, Show, createMemo, createEffect } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useFeedStore } from "../stores/feed"
import { useDownloadStore } from "../stores/download"
import { DownloadStatus } from "../types/episode"
import { format } from "date-fns"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
import { createSignal, For, Show, createMemo, createEffect } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
type MyShowsPageProps = {
focused: boolean
onPlayEpisode?: (episode: Episode, feed: Feed) => void
onExit?: () => void
}
focused: boolean;
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
onExit?: () => void;
};
type FocusPane = "shows" | "episodes"
type FocusPane = "shows" | "episodes";
export function MyShowsPage(props: MyShowsPageProps) {
const feedStore = useFeedStore()
const downloadStore = useDownloadStore()
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
const [showIndex, setShowIndex] = createSignal(0)
const [episodeIndex, setEpisodeIndex] = createSignal(0)
const [isRefreshing, setIsRefreshing] = createSignal(false)
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows");
const [showIndex, setShowIndex] = createSignal(0);
const [episodeIndex, setEpisodeIndex] = createSignal(0);
const [isRefreshing, setIsRefreshing] = createSignal(false);
/** 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 s = shows()
const idx = showIndex()
return idx < s.length ? s[idx] : undefined
})
const s = shows();
const idx = showIndex();
return idx < s.length ? s[idx] : undefined;
});
const episodes = createMemo(() => {
const show = selectedShow()
if (!show) return []
const show = selectedShow();
if (!show) return [];
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
createEffect(() => {
const idx = episodeIndex()
const eps = episodes()
const show = selectedShow()
if (!show || eps.length === 0) return
const idx = episodeIndex();
const eps = episodes();
const show = selectedShow();
if (!show || eps.length === 0) return;
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD
if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) {
feedStore.loadMoreEpisodes(show.id)
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD;
if (
nearBottom &&
feedStore.hasMoreEpisodes(show.id) &&
!feedStore.isLoadingMore()
) {
feedStore.loadMoreEpisodes(show.id);
}
})
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
return format(date, "MMM d, yyyy");
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const hrs = Math.floor(mins / 60)
if (hrs > 0) return `${hrs}h ${mins % 60}m`
return `${mins}m`
}
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
/** Get download status label for an episode */
const downloadLabel = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId)
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "[Q]"
return "[Q]";
case DownloadStatus.DOWNLOADING: {
const pct = downloadStore.getDownloadProgress(episodeId)
return `[${pct}%]`
const pct = downloadStore.getDownloadProgress(episodeId);
return `[${pct}%]`;
}
case DownloadStatus.COMPLETED:
return "[DL]"
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]"
return "[ERR]";
default:
return ""
return "";
}
}
};
/** Get download status color */
const downloadColor = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId)
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "yellow"
return "yellow";
case DownloadStatus.DOWNLOADING:
return "cyan"
return "cyan";
case DownloadStatus.COMPLETED:
return "green"
return "green";
case DownloadStatus.FAILED:
return "red"
return "red";
default:
return "gray"
return "gray";
}
}
};
const handleRefresh = async () => {
const show = selectedShow()
if (!show) return
setIsRefreshing(true)
await feedStore.refreshFeed(show.id)
setIsRefreshing(false)
}
const show = selectedShow();
if (!show) return;
setIsRefreshing(true);
await feedStore.refreshFeed(show.id);
setIsRefreshing(false);
};
const handleUnsubscribe = () => {
const show = selectedShow()
if (!show) return
feedStore.removeFeed(show.id)
setShowIndex((i) => Math.max(0, i - 1))
setEpisodeIndex(0)
}
const show = selectedShow();
if (!show) return;
feedStore.removeFeed(show.id);
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
};
useKeyboard((key) => {
if (!props.focused) return
if (!props.focused) return;
const pane = focusPane()
const pane = focusPane();
// Navigate between panes
if (key.name === "right" || key.name === "l") {
if (pane === "shows" && selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
setFocusPane("episodes");
setEpisodeIndex(0);
}
return
return;
}
if (key.name === "left" || key.name === "h") {
if (pane === "episodes") {
setFocusPane("shows")
setFocusPane("shows");
}
return
return;
}
if (key.name === "tab") {
if (pane === "shows" && selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
setFocusPane("episodes");
setEpisodeIndex(0);
} else {
setFocusPane("shows")
setFocusPane("shows");
}
return
return;
}
if (pane === "shows") {
const s = shows()
const s = shows();
if (key.name === "down" || key.name === "j") {
setShowIndex((i) => Math.min(s.length - 1, i + 1))
setEpisodeIndex(0)
setShowIndex((i) => Math.min(s.length - 1, i + 1));
setEpisodeIndex(0);
} else if (key.name === "up" || key.name === "k") {
setShowIndex((i) => Math.max(0, i - 1))
setEpisodeIndex(0)
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
} else if (key.name === "return" || key.name === "enter") {
if (selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
setFocusPane("episodes");
setEpisodeIndex(0);
}
} else if (key.name === "d") {
handleUnsubscribe()
handleUnsubscribe();
} else if (key.name === "r") {
handleRefresh()
handleRefresh();
} else if (key.name === "escape") {
props.onExit?.()
props.onExit?.();
}
} else if (pane === "episodes") {
const eps = episodes()
const eps = episodes();
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") {
setEpisodeIndex((i) => Math.max(0, i - 1))
setEpisodeIndex((i) => Math.max(0, i - 1));
} else if (key.name === "return" || key.name === "enter") {
const ep = eps[episodeIndex()]
const show = selectedShow()
if (ep && show) props.onPlayEpisode?.(ep, show)
const ep = eps[episodeIndex()];
const show = selectedShow();
if (ep && show) props.onPlayEpisode?.(ep, show);
} else if (key.name === "d") {
const ep = eps[episodeIndex()]
const show = selectedShow()
const ep = eps[episodeIndex()];
const show = selectedShow();
if (ep && show) {
const status = downloadStore.getDownloadStatus(ep.id)
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
downloadStore.startDownload(ep, show.id)
} else if (status === DownloadStatus.DOWNLOADING || status === DownloadStatus.QUEUED) {
downloadStore.cancelDownload(ep.id)
const status = downloadStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
downloadStore.startDownload(ep, show.id);
} else if (
status === DownloadStatus.DOWNLOADING ||
status === DownloadStatus.QUEUED
) {
downloadStore.cancelDownload(ep.id);
}
}
} else if (key.name === "pageup") {
setEpisodeIndex((i) => Math.max(0, i - 10))
setEpisodeIndex((i) => Math.max(0, i - 10));
} 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") {
handleRefresh()
handleRefresh();
} else if (key.name === "escape") {
setFocusPane("shows")
key.stopPropagation()
setFocusPane("shows");
key.stopPropagation();
}
}
})
});
return {
showsPanel: () => (
@@ -223,7 +233,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box>
}
>
<scrollbox height="100%" focused={props.focused && focusPane() === "shows"}>
<scrollbox
height="100%"
focused={props.focused && focusPane() === "shows"}
>
<For each={shows()}>
{(feed, index) => (
<box
@@ -233,8 +246,8 @@ export function MyShowsPage(props: MyShowsPageProps) {
paddingRight={1}
backgroundColor={index() === showIndex() ? "#333" : undefined}
onMouseDown={() => {
setShowIndex(index())
setEpisodeIndex(0)
setShowIndex(index());
setEpisodeIndex(0);
}}
>
<text fg={index() === showIndex() ? "cyan" : "gray"}>
@@ -270,7 +283,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box>
}
>
<scrollbox height="100%" focused={props.focused && focusPane() === "episodes"}>
<scrollbox
height="100%"
focused={props.focused && focusPane() === "episodes"}
>
<For each={episodes()}>
{(episode, index) => (
<box
@@ -278,15 +294,21 @@ export function MyShowsPage(props: MyShowsPageProps) {
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === episodeIndex() ? "#333" : undefined}
backgroundColor={
index() === episodeIndex() ? "#333" : undefined
}
onMouseDown={() => setEpisodeIndex(index())}
>
<box flexDirection="row" gap={1}>
<text fg={index() === episodeIndex() ? "cyan" : "gray"}>
{index() === episodeIndex() ? ">" : " "}
</text>
<text fg={index() === episodeIndex() ? "white" : undefined}>
{episode.episodeNumber ? `#${episode.episodeNumber} ` : ""}
<text
fg={index() === episodeIndex() ? "white" : undefined}
>
{episode.episodeNumber
? `#${episode.episodeNumber} `
: ""}
{episode.title}
</text>
</box>
@@ -294,7 +316,9 @@ export function MyShowsPage(props: MyShowsPageProps) {
<text fg="gray">{formatDate(episode.pubDate)}</text>
<text fg="gray">{formatDuration(episode.duration)}</text>
<Show when={downloadLabel(episode.id)}>
<text fg={downloadColor(episode.id)}>{downloadLabel(episode.id)}</text>
<text fg={downloadColor(episode.id)}>
{downloadLabel(episode.id)}
</text>
</Show>
</box>
</box>
@@ -305,7 +329,13 @@ export function MyShowsPage(props: MyShowsPageProps) {
<text fg="yellow">Loading more episodes...</text>
</box>
</Show>
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}>
<Show
when={
!feedStore.isLoadingMore() &&
selectedShow() &&
feedStore.hasMoreEpisodes(selectedShow()!.id)
}
>
<box paddingLeft={2} paddingTop={1}>
<text fg="gray">Scroll down for more episodes</text>
</box>
@@ -318,5 +348,5 @@ export function MyShowsPage(props: MyShowsPageProps) {
focusPane,
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.
*
* Replaces MergedWaveform during playback. Spawns an independent ffmpeg
* Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* 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 { loadCavaCore, type CavaCore, type CavaCoreConfig } from "../utils/cavacore"
import { AudioStreamReader } from "../utils/audio-stream-reader"
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
/** Audio URL — used to start the ffmpeg decode stream */
audioUrl: string
audioUrl: string;
/** Current playback position in seconds */
position: number
position: number;
/** Total duration in seconds */
duration: number
duration: number;
/** Whether audio is currently playing */
isPlaying: boolean
isPlaying: boolean;
/** Playback speed multiplier (default: 1) */
speed?: number
speed?: number;
/** Number of frequency bars / columns */
resolution?: number
resolution?: number;
/** Callback when user clicks to seek */
onSeek?: (seconds: number) => void
onSeek?: (seconds: number) => void;
/** Visualizer configuration overrides */
visualizerConfig?: Partial<CavaCoreConfig>
}
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** 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) */
const FRAME_INTERVAL = 33
const FRAME_INTERVAL = 33;
/** 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 ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
const resolution = () => props.resolution ?? 32
const resolution = () => props.resolution ?? 32;
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([])
const [barData, setBarData] = createSignal<number[]>([]);
// Track whether cavacore is available
const [available, setAvailable] = createSignal(false)
const [available, setAvailable] = createSignal(false);
let cava: CavaCore | null = null
let reader: AudioStreamReader | null = null
let frameTimer: ReturnType<typeof setInterval> | null = null
let sampleBuffer: Float64Array | null = null
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
if (cava) return true
if (cava) return true;
cava = loadCavaCore()
cava = loadCavaCore();
if (!cava) {
setAvailable(false)
return false
setAvailable(false);
return false;
}
setAvailable(true)
return true
}
setAvailable(true);
return true;
};
// ── Start/stop the visualization pipeline ──────────────────────────
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
const config: CavaCoreConfig = {
@@ -88,56 +99,57 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
}
cava.init(config)
};
cava.init(config);
// 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)
if (!reader || reader.url !== url) {
if (reader) reader.stop()
reader = new AudioStreamReader({ url })
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed)
reader.start(position, speed);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL)
}
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer)
frameTimer = null
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop()
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy()
cava.destroy();
}
sampleBuffer = null
}
sampleBuffer = null;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer)
if (count === 0) return
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Feed samples to cavacore → get frequency bars
const input = count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer
const output = cava.execute(input)
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
// 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 ─────────────
//
@@ -159,14 +171,14 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(() => props.position)
startVisualization(url, pos, speed)
const pos = untrack(() => props.position);
startVisualization(url, pos, speed);
} else {
stopVisualization()
stopVisualization();
}
},
),
)
);
// ── 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
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0
let lastSyncPosition = 0;
createEffect(
on(
() => props.position,
(pos) => {
if (!props.isPlaying || !reader?.running) {
lastSyncPosition = pos
return
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition)
lastSyncPosition = pos
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
const speed = props.speed ?? 1
reader.restart(pos, speed)
const speed = props.speed ?? 1;
reader.restart(pos, speed);
}
},
),
)
);
// Cleanup on unmount
onCleanup(() => {
stopVisualization()
stopVisualization();
if (reader) {
reader.stop()
reader = null
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy()
cava.destroy();
}
})
});
// ── Rendering ──────────────────────────────────────────────────────
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 bars = barData()
const numBars = resolution()
const bars = barData();
const numBars = resolution();
// If no data yet, show empty placeholder
if (bars.length === 0) {
const placeholder = ".".repeat(numBars)
const placeholder = ".".repeat(numBars);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
</box>
)
);
}
const played = Math.floor(numBars * playedRatio())
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
const futureColor = "#3b4252"
const played = Math.floor(numBars * playedRatio());
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = bars
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("")
.join("");
const futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("")
.join("");
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
</box>
)
}
);
};
const handleClick = (event: { x: number }) => {
const numBars = resolution()
const ratio = numBars === 0 ? 0 : event.x / numBars
const numBars = resolution();
const ratio = numBars === 0 ? 0 : event.x / numBars;
const next = Math.max(
0,
Math.min(props.duration, Math.round(props.duration * ratio)),
)
props.onSeek?.(next)
}
);
props.onSeek?.(next);
};
return (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</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 type { SearchResult } from "../types/source"
import { SourceBadge } from "./SourceBadge"
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
type ResultCardProps = {
result: SearchResult
selected: boolean
onSelect: () => void
onSubscribe?: () => void
}
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const podcast = () => props.result.podcast
const podcast = () => props.result.podcast;
return (
<box
@@ -21,7 +21,11 @@ export function ResultCard(props: ResultCardProps) {
backgroundColor={props.selected ? "#222" : undefined}
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">
<text fg={props.selected ? "cyan" : "white"}>
<strong>{podcast().title}</strong>
@@ -67,13 +71,13 @@ export function ResultCard(props: ResultCardProps) {
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.()
props.onSubscribe?.()
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg="cyan">[+] Add to Feeds</text>
</box>
</Show>
</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
*/
import { createSignal, createEffect, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useSearchStore } from "../stores/search"
import { SearchResults } from "./SearchResults"
import { SearchHistory } from "./SearchHistory"
import type { SearchResult } from "../types/source"
import { createSignal, createEffect, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
type SearchPageProps = {
focused: boolean
onSubscribe?: (result: SearchResult) => void
onInputFocusChange?: (focused: boolean) => void
onExit?: () => void
}
focused: boolean;
onSubscribe?: (result: SearchResult) => void;
onInputFocusChange?: (focused: boolean) => void;
onExit?: () => void;
};
type FocusArea = "input" | "results" | "history"
type FocusArea = "input" | "results" | "history";
export function SearchPage(props: SearchPageProps) {
const searchStore = useSearchStore()
const [focusArea, setFocusArea] = createSignal<FocusArea>("input")
const [inputValue, setInputValue] = createSignal("")
const [resultIndex, setResultIndex] = createSignal(0)
const [historyIndex, setHistoryIndex] = createSignal(0)
const searchStore = useSearchStore();
const [focusArea, setFocusArea] = createSignal<FocusArea>("input");
const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0);
// Keep parent informed about input focus state
createEffect(() => {
const isInputFocused = props.focused && focusArea() === "input"
props.onInputFocusChange?.(isInputFocused)
})
const isInputFocused = props.focused && focusArea() === "input";
props.onInputFocusChange?.(isInputFocused);
});
const handleSearch = async () => {
const query = inputValue().trim()
const query = inputValue().trim();
if (query) {
await searchStore.search(query)
await searchStore.search(query);
if (searchStore.results().length > 0) {
setFocusArea("results")
setResultIndex(0)
setFocusArea("results");
setResultIndex(0);
}
}
}
};
const handleHistorySelect = async (query: string) => {
setInputValue(query)
await searchStore.search(query)
setInputValue(query);
await searchStore.search(query);
if (searchStore.results().length > 0) {
setFocusArea("results")
setResultIndex(0)
setFocusArea("results");
setResultIndex(0);
}
}
};
const handleResultSelect = (result: SearchResult) => {
props.onSubscribe?.(result)
searchStore.markSubscribed(result.podcast.id)
}
props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id);
};
// Keyboard navigation
useKeyboard((key) => {
if (!props.focused) return
if (!props.focused) return;
const area = focusArea()
const area = focusArea();
// Enter to search from input
if ((key.name === "return" || key.name === "enter") && area === "input") {
handleSearch()
return
handleSearch();
return;
}
// Tab to cycle focus areas
if (key.name === "tab" && !key.shift) {
if (area === "input") {
if (searchStore.results().length > 0) {
setFocusArea("results")
setFocusArea("results");
} else if (searchStore.history().length > 0) {
setFocusArea("history")
setFocusArea("history");
}
} else if (area === "results") {
if (searchStore.history().length > 0) {
setFocusArea("history")
setFocusArea("history");
} else {
setFocusArea("input")
setFocusArea("input");
}
} else {
setFocusArea("input")
setFocusArea("input");
}
return
return;
}
if (key.name === "tab" && key.shift) {
if (area === "input") {
if (searchStore.history().length > 0) {
setFocusArea("history")
setFocusArea("history");
} else if (searchStore.results().length > 0) {
setFocusArea("results")
setFocusArea("results");
}
} else if (area === "history") {
if (searchStore.results().length > 0) {
setFocusArea("results")
setFocusArea("results");
} else {
setFocusArea("input")
setFocusArea("input");
}
} else {
setFocusArea("input")
setFocusArea("input");
}
return
return;
}
// Up/Down for results and history
if (area === "results") {
const results = searchStore.results()
const results = searchStore.results();
if (key.name === "down" || key.name === "j") {
setResultIndex((i) => Math.min(i + 1, results.length - 1))
return
setResultIndex((i) => Math.min(i + 1, results.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
setResultIndex((i) => Math.max(i - 1, 0))
return
setResultIndex((i) => Math.max(i - 1, 0));
return;
}
if (key.name === "return" || key.name === "enter") {
const result = results[resultIndex()]
if (result) handleResultSelect(result)
return
const result = results[resultIndex()];
if (result) handleResultSelect(result);
return;
}
}
if (area === "history") {
const history = searchStore.history()
const history = searchStore.history();
if (key.name === "down" || key.name === "j") {
setHistoryIndex((i) => Math.min(i + 1, history.length - 1))
return
setHistoryIndex((i) => Math.min(i + 1, history.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
setHistoryIndex((i) => Math.max(i - 1, 0))
return
setHistoryIndex((i) => Math.max(i - 1, 0));
return;
}
if (key.name === "return" || key.name === "enter") {
const query = history[historyIndex()]
if (query) handleHistorySelect(query)
return
const query = history[historyIndex()];
if (query) handleHistorySelect(query);
return;
}
}
// Escape goes back to input or up one level
if (key.name === "escape") {
if (area === "input") {
props.onExit?.()
props.onExit?.();
} else {
setFocusArea("input")
key.stopPropagation()
setFocusArea("input");
key.stopPropagation();
}
return
return;
}
// "/" focuses search input
if (key.name === "/" && area !== "input") {
setFocusArea("input")
return
setFocusArea("input");
return;
}
})
});
return (
<box flexDirection="column" height="100%" gap={1}>
{/* Search Header */}
<box flexDirection="column" gap={1}>
<text>
<strong>Search Podcasts</strong>
</text>
<text>
<strong>Search Podcasts</strong>
</text>
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
@@ -174,7 +174,7 @@ export function SearchPage(props: SearchPageProps) {
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value)
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={props.focused && focusArea() === "input"}
@@ -234,17 +234,17 @@ export function SearchPage(props: SearchPageProps) {
</box>
{/* History Sidebar */}
<box width={30} border>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={focusArea() === "history"}
<box width={30} border>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={focusArea() === "history"}
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory}
@@ -262,5 +262,5 @@ export function SearchPage(props: SearchPageProps) {
<text fg="gray">[Esc] Up</text>
</box>
</box>
)
);
}

View File

@@ -2,32 +2,35 @@
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js"
import type { SearchResult } from "../types/source"
import { ResultCard } from "./ResultCard"
import { ResultDetail } from "./ResultDetail"
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[]
selectedIndex: number
focused: boolean
onSelect?: (result: SearchResult) => void
onChange?: (index: number) => void
isSearching?: boolean
error?: string | null
}
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index)
}
props.onChange?.(index);
};
return (
<Show when={!props.isSearching} fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}>
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
@@ -40,7 +43,9 @@ export function SearchResults(props: SearchResultsProps) {
when={props.results.length > 0}
fallback={
<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>
}
>
@@ -71,5 +76,5 @@ export function SearchResults(props: SearchResultsProps) {
</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 = {
value: string
onChange: (value: string) => void
}
value: string;
onChange: (value: string) => void;
};
export function FilePicker(props: FilePickerProps) {
const format = detectFormat(props.value)
const format = detectFormat(props.value);
return (
<box style={{ flexDirection: "column", gap: 1 }}>
@@ -18,5 +18,5 @@ export function FilePicker(props: FilePickerProps) {
/>
<text>Format: {format}</text>
</box>
)
);
}

View File

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

View File

@@ -3,39 +3,39 @@
* Displays OAuth limitations and alternative authentication methods
*/
import { createSignal } from "solid-js"
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth"
import { createSignal } from "solid-js";
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
interface OAuthPlaceholderProps {
focused?: boolean
onBack?: () => void
onNavigateToCode?: () => void
focused?: boolean;
onBack?: () => void;
onNavigateToCode?: () => void;
}
type FocusField = "code" | "back"
type FocusField = "code" | "back";
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 }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode()
props.onNavigateToCode();
} else if (focusField() === "back" && props.onBack) {
props.onBack()
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack()
props.onBack();
}
}
};
return (
<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>
</box>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,19 +2,20 @@
* App state persistence via JSON file in XDG_CONFIG_HOME
*
* 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 { backupConfigFile } from "./config-backup"
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings"
import { DEFAULT_THEME } from "../constants/themes"
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup";
import type {
AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes";
const APP_STATE_FILE = "app-state.json"
const PROGRESS_FILE = "progress.json"
const LEGACY_APP_STATE_KEY = "podtui_app_state"
const LEGACY_PROGRESS_KEY = "podtui_progress"
const APP_STATE_FILE = "app-state.json";
const PROGRESS_FILE = "progress.json";
// --- Defaults ---
@@ -24,7 +25,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
}
};
const defaultSettings: AppSettings = {
theme: "system",
@@ -32,141 +33,89 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
}
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
}
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
}
};
// --- App State ---
/** Load app state from JSON file */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const filePath = getConfigFilePath(APP_STATE_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return defaultState
const filePath = getConfigFilePath(APP_STATE_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return defaultState;
const raw = await file.json()
if (!raw || typeof raw !== "object") return defaultState
const raw = await file.json();
if (!raw || typeof raw !== "object") return defaultState;
const parsed = raw as Partial<AppState>
const parsed = raw as Partial<AppState>;
return {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
};
} catch {
return defaultState
return defaultState;
}
}
/** Save app state to JSON file */
export async function saveAppStateToFile(state: AppState): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(APP_STATE_FILE)
const filePath = getConfigFilePath(APP_STATE_FILE)
await Bun.write(filePath, JSON.stringify(state, null, 2))
await ensureConfigDir();
await backupConfigFile(APP_STATE_FILE);
const filePath = getConfigFilePath(APP_STATE_FILE);
await Bun.write(filePath, JSON.stringify(state, null, 2));
} catch {
// 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 {
episodeId: string
position: number
duration: number
timestamp: string | Date
playbackSpeed?: number
episodeId: string;
position: number;
duration: number;
timestamp: string | Date;
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> {
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return {}
const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return {};
const raw = await file.json()
if (!raw || typeof raw !== "object") return {}
return raw as Record<string, ProgressEntry>
const raw = await file.json();
if (!raw || typeof raw !== "object") return {};
return raw as Record<string, ProgressEntry>;
} catch {
return {}
return {};
}
}
/** 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 {
await ensureConfigDir()
await backupConfigFile(PROGRESS_FILE)
const filePath = getConfigFilePath(PROGRESS_FILE)
await Bun.write(filePath, JSON.stringify(data, null, 2))
await ensureConfigDir();
await backupConfigFile(PROGRESS_FILE);
const filePath = getConfigFilePath(PROGRESS_FILE);
await Bun.write(filePath, JSON.stringify(data, null, 2));
} catch {
// 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
* 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 {
migrateAppStateFromLocalStorage,
migrateProgressFromLocalStorage,
} from "./app-persistence"
import {
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "./feeds-persistence"
import { getConfigFilePath } from "./config-dir";
// --- Validation helpers ---
/** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
return v !== null && typeof v === "object" && !Array.isArray(v);
}
/** Validate AppState JSON structure */
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = []
export function validateAppState(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] }
return { valid: false, errors: ["app-state.json is not an object"] };
}
// settings
if (data.settings !== undefined) {
if (!isObject(data.settings)) {
errors.push("settings must be an object")
errors.push("settings must be an object");
} else {
const s = data.settings as Record<string, unknown>
if (s.theme !== undefined && typeof s.theme !== "string") errors.push("settings.theme must be a string")
if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number")
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number")
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string")
const s = data.settings as Record<string, unknown>;
if (s.theme !== undefined && typeof s.theme !== "string")
errors.push("settings.theme must be a string");
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
errors.push("settings.fontSize must be a number");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
errors.push("settings.playbackSpeed must be a number");
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
errors.push("settings.downloadPath must be a string");
}
}
// preferences
if (data.preferences !== undefined) {
if (!isObject(data.preferences)) {
errors.push("preferences must be an object")
errors.push("preferences must be an object");
} else {
const p = data.preferences as Record<string, unknown>
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") errors.push("preferences.showExplicit must be a boolean")
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean")
const p = data.preferences as Record<string, unknown>;
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
errors.push("preferences.showExplicit must be a boolean");
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
errors.push("preferences.autoDownload must be a boolean");
}
}
// customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object")
errors.push("customTheme must be an object");
}
return { valid: errors.length === 0, errors }
return { valid: errors.length === 0, errors };
}
/** Validate feeds JSON structure */
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = []
export function validateFeeds(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] }
return { valid: false, errors: ["feeds.json is not an array"] };
}
for (let i = 0; i < data.length; i++) {
const feed = data[i]
const feed = data[i];
if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`)
continue
errors.push(`feeds[${i}] is not an object`);
continue;
}
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`)
if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`)
if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`)
if (typeof feed.id !== "string")
errors.push(`feeds[${i}].id must be a string`);
if (!isObject(feed.podcast))
errors.push(`feeds[${i}].podcast must be an object`);
if (!Array.isArray(feed.episodes))
errors.push(`feeds[${i}].episodes must be an array`);
}
return { valid: errors.length === 0, errors }
return { valid: errors.length === 0, errors };
}
/** Validate progress JSON structure */
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = []
export function validateProgress(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] }
return { valid: false, errors: ["progress.json is not an object"] };
}
for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`)
continue
errors.push(`progress["${key}"] is not an object`);
continue;
}
const p = value as Record<string, unknown>
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`)
if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`)
if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`)
const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string")
errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.position !== "number")
errors.push(`progress["${key}"].position must be a number`);
if (typeof p.duration !== "number")
errors.push(`progress["${key}"].duration must be a number`);
}
return { valid: errors.length === 0, errors }
return { valid: errors.length === 0, errors };
}
// --- Safe config file reading ---
@@ -115,52 +124,27 @@ export async function safeReadConfigFile<T>(
validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> {
try {
const filePath = getConfigFilePath(filename)
const file = Bun.file(filePath)
const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath);
if (!(await file.exists())) {
return { data: null, errors: [] }
return { data: null, errors: [] };
}
const text = await file.text()
let parsed: unknown
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text)
parsed = JSON.parse(text);
} 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) {
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) {
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
*
* 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 { backupConfigFile } from "./config-backup"
import type { Feed } from "../types/feed"
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup";
import type { Feed } from "../types/feed";
const FEEDS_FILE = "feeds.json"
const SOURCES_FILE = "sources.json"
const FEEDS_FILE = "feeds.json";
const SOURCES_FILE = "sources.json";
/** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed {
@@ -25,31 +24,31 @@ function reviveDates(feed: Feed): Feed {
...ep,
pubDate: new Date(ep.pubDate),
})),
}
};
}
/** Load feeds from JSON file */
export async function loadFeedsFromFile(): Promise<Feed[]> {
try {
const filePath = getConfigFilePath(FEEDS_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return []
const filePath = getConfigFilePath(FEEDS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return [];
const raw = await file.json()
if (!Array.isArray(raw)) return []
return raw.map(reviveDates)
const raw = await file.json();
if (!Array.isArray(raw)) return [];
return raw.map(reviveDates);
} catch {
return []
return [];
}
}
/** Save feeds to JSON file */
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(FEEDS_FILE)
const filePath = getConfigFilePath(FEEDS_FILE)
await Bun.write(filePath, JSON.stringify(feeds, null, 2))
await ensureConfigDir();
await backupConfigFile(FEEDS_FILE);
const filePath = getConfigFilePath(FEEDS_FILE);
await Bun.write(filePath, JSON.stringify(feeds, null, 2));
} catch {
// Silently ignore write errors
}
@@ -58,75 +57,26 @@ export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
/** Load sources from JSON file */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const filePath = getConfigFilePath(SOURCES_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return null
const filePath = getConfigFilePath(SOURCES_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return null;
const raw = await file.json()
if (!Array.isArray(raw)) return null
return raw as T[]
const raw = await file.json();
if (!Array.isArray(raw)) return null;
return raw as T[];
} catch {
return null
return null;
}
}
/** Save sources to JSON file */
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(SOURCES_FILE)
const filePath = getConfigFilePath(SOURCES_FILE)
await Bun.write(filePath, JSON.stringify(sources, null, 2))
await ensureConfigDir();
await backupConfigFile(SOURCES_FILE);
const filePath = getConfigFilePath(SOURCES_FILE);
await Bun.write(filePath, JSON.stringify(sources, null, 2));
} catch {
// 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 { ansiToRgba } from "./ansi-to-rgba"
import { generateGrayScale, generateMutedTextColor, tint } from "./color-generation"
import type { ThemeJson } from "../types/theme-schema"
import { RGBA, type TerminalColors } from "@opentui/core";
import { ansiToRgba } from "./ansi-to-rgba";
import {
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() {
cached = null
cached = null;
}
export function detectSystemTheme(colors: TerminalColors) {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000")
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
const mode = luminance > 0.5 ? "light" : "dark"
return { mode, background: bg }
const bg = RGBA.fromHex(
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
);
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
const mode = luminance > 0.5 ? "light" : "dark";
return { mode, background: bg };
}
export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
cached = colors
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"
export function generateSystemTheme(
colors: TerminalColors,
mode: "dark" | "light",
): ThemeJson {
cached = colors;
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 value = colors.palette[i]
if (value) return RGBA.fromHex(value)
return ansiToRgba(i)
}
const value = colors.palette[i];
if (value) return RGBA.fromHex(value);
return ansiToRgba(i);
};
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
const grays = generateGrayScale(bg, isDark);
const textMuted = generateMutedTextColor(bg, isDark);
const ansi = {
black: col(0),
@@ -43,13 +56,13 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
white: col(7),
redBright: col(9),
greenBright: col(10),
}
};
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansi.green, diffAlpha)
const diffRemovedBg = tint(bg, ansi.red, diffAlpha)
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha)
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha)
const diffAlpha = isDark ? 0.22 : 0.14;
const diffAddedBg = tint(bg, ansi.green, diffAlpha);
const diffRemovedBg = tint(bg, ansi.red, diffAlpha);
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha);
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha);
return {
theme: {
@@ -68,7 +81,7 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
backgroundElement: grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
border: fg,
borderActive: grays[8],
diffAdded: ansi.green,
diffRemoved: ansi.red,
@@ -106,5 +119,5 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
syntaxOperator: ansi.cyan,
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