file ordering

This commit is contained in:
2026-02-06 14:55:42 -05:00
parent 1293d30225
commit 1e3b794b8e
101 changed files with 1662 additions and 4224 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");

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,4 +1,4 @@
import { useTheme } from "../context/ThemeContext";
import { useTheme } from "@/context/ThemeContext";
export type TabId =
| "feed"

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,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

@@ -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

@@ -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

View File

@@ -1,64 +0,0 @@
# 05. Update Player component to use realtime visualization
meta:
id: real-time-audio-visualization-05
feature: real-time-audio-visualization
priority: P1
depends_on: [real-time-audio-visualization-04]
tags: [integration, player]
objective:
- Replace static waveform display with real-time visualization
- Update Player.tsx to use RealtimeWaveform component
- Ensure seamless transition and proper state management
deliverables:
- Updated src/components/Player.tsx
- Updated src/components/MergedWaveform.tsx (optional, for fallback)
- Documentation of changes
steps:
- Update Player.tsx:
- Import RealtimeWaveform component
- Replace MergedWaveform with RealtimeWaveform
- Pass same props (audioUrl, position, duration, isPlaying, onSeek)
- Remove audioUrl from props if no longer needed
- Test with different audio formats
- Add fallback handling:
- If realtime visualization fails, show static waveform
- Graceful degradation for systems without FFTW
- Update component documentation
- Test all player controls work with new visualization
- Verify keyboard shortcuts still work
- Test seek, pause, resume, volume, speed controls
tests:
- Unit: Test Player with RealtimeWaveform
- Integration: Test complete playback flow
- Integration: Test seek functionality
- Integration: Test pause/resume
- Integration: Test volume and speed changes
- Integration: Test with different audio formats
- Manual: Verify all player features work correctly
acceptance_criteria:
- Player displays real-time visualization during playback
- All player controls work correctly
- Seek functionality works with visualization
- Graceful fallback for systems without FFTW
- No regression in existing functionality
- Visual style matches design requirements
validation:
- Run: `bun run build` to verify compilation
- Run: `bun test` for integration tests
- Manual: Play various audio files
- Manual: Test all keyboard shortcuts
- Performance: Monitor frame rate and CPU usage
notes:
- Keep MergedWaveform as fallback option
- Consider showing a loading state while visualizer initializes
- May need to handle the case where mpv doesn't support audio pipe
- The visualizer should integrate smoothly with existing Player layout
- Consider adding a toggle to switch between static and realtime visualization

View File

@@ -1,78 +0,0 @@
# 06. Add visualizer controls and settings
meta:
id: real-time-audio-visualization-06
feature: real-time-audio-visualization
priority: P2
depends_on: [real-time-audio-visualization-05]
tags: [ui, controls, settings]
objective:
- Add user controls for visualizer settings
- Create settings panel for customization
- Allow users to adjust visualizer parameters
deliverables:
- src/components/VisualizerSettings.tsx - Settings component
- Updated src/components/Player.tsx - Settings panel integration
- src/types/settings.ts - Visualizer settings type definition
- src/stores/settings.ts - Settings state management
steps:
- Define visualizer settings types:
- Number of bars (resolution)
- Sensitivity (autosens toggle + manual value)
- Noise reduction level
- Frequency cutoffs (low/high)
- Bar width and spacing
- Color scheme options
- Create VisualizerSettings component:
- Display current settings
- Allow adjusting each parameter
- Show real-time feedback
- Save settings to app store
- Integrate with Player component:
- Add settings button
- Show settings panel when toggled
- Apply settings to RealtimeWaveform component
- Update settings state management:
- Load saved settings from app store
- Save settings on change
- Provide default values
- Create UI for settings:
- Keyboard shortcuts for quick adjustment
- Visual feedback for changes
- Help text for each setting
- Add settings persistence
- Create tests for settings component
tests:
- Unit: Test settings type definitions
- Unit: Test settings state management
- Unit: Test VisualizerSettings component
- Integration: Test settings apply to visualization
- Integration: Test settings persistence
- Manual: Test all settings controls
acceptance_criteria:
- VisualizerSettings component renders correctly
- All settings can be adjusted
- Changes apply in real-time
- Settings persist between sessions
- Keyboard shortcuts work
- Component handles invalid settings gracefully
validation:
- Run: `bun test` for unit tests
- Run: `bun test` for integration tests
- Manual: Test all settings
- Manual: Test keyboard shortcuts
- Manual: Test settings persistence
notes:
- Settings should have sensible defaults
- Some settings may require visualizer re-initialization
- Consider limiting certain settings to avoid performance issues
- Add tooltips or help text for complex settings
- Settings should be optional - users can start without them
- Keep UI simple and intuitive

View File

@@ -1,30 +0,0 @@
# Real-time Audio Visualization
Objective: Integrate cava library for real-time audio visualization in Player component
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — Copy cavacore library files to project → `01-copy-cavacore-files.md`
- [x] 02 — Integrate cavacore library for audio analysis → `02-integrate-cavacore-library.md`
- [x] 03 — Create audio stream reader for real-time data → `03-create-audio-stream-reader.md`
- [x] 04 — Create realtime waveform component → `04-create-realtime-waveform-component.md`
- [x] 05 — Update Player component to use realtime visualization → `05-update-player-visualization.md`
- [x] 06 — Add visualizer controls and settings → `06-add-visualizer-controls.md`
Dependencies
- 01 depends on (none)
- 02 depends on 01
- 03 depends on 02
- 04 depends on 03
- 05 depends on 04
- 06 depends on 05
Exit criteria
- Audio visualization updates in real-time during playback
- Waveform bars respond to actual audio frequencies
- Visualizer controls (sensitivity, bar count) work
- Performance is smooth with 60fps updates
- All necessary cava files are integrated into project
Note: Files from cava/ directory will be removed after integration

View File

@@ -1,45 +0,0 @@
# 03. Add RSS Content Type Detection
meta:
id: rss-content-parsing-03
feature: rss-content-parsing
priority: P2
depends_on: []
tags: [rss, parsing, utilities]
objective:
- Create utility to detect if RSS feed content is HTML or plain text
- Analyze content type in description and other text fields
- Return appropriate parsing strategy
deliverables:
- Content type detection function
- Type classification utility
- Integration points for different parsers
steps:
1. Create `src/utils/rss-content-detector.ts`
2. Implement content type detection based on HTML tags
3. Add detection for common HTML entities and tags
4. Return type enum (HTML, PLAIN_TEXT, UNKNOWN)
5. Add unit tests for detection accuracy
tests:
- Unit: Test HTML detection with various HTML snippets
- Unit: Test plain text detection with text-only content
- Unit: Test edge cases (mixed content, malformed HTML)
acceptance_criteria:
- Function correctly identifies HTML vs plain text content
- Handles common HTML patterns and entities
- Returns UNKNOWN for unclassifiable content
validation:
- Test with HTML description from real RSS feeds
- Test with plain text descriptions
- Verify UNKNOWN cases are handled gracefully
notes:
- Look for common HTML tags: <div>, <p>, <br>, <a>, <b>, <i>
- Check for HTML entities: &lt;, &gt;, &amp;, &quot;, &apos;
- Consider content length threshold for HTML detection

View File

@@ -1,47 +0,0 @@
# 04. Implement HTML Content Extraction
meta:
id: rss-content-parsing-04
feature: rss-content-parsing
priority: P2
depends_on: [rss-content-parsing-03]
tags: [rss, parsing, html]
objective:
- Parse HTML content from RSS feed descriptions
- Extract and sanitize text content
- Convert HTML to plain text for display
deliverables:
- HTML to text conversion utility
- Sanitization function for XSS prevention
- Updated RSS parser integration
steps:
1. Create `src/utils/html-to-text.ts`
2. Implement HTML-to-text conversion algorithm
3. Add XSS sanitization for extracted content
4. Handle common HTML elements (paragraphs, lists, links)
5. Update `parseRSSFeed()` to use new HTML parser
tests:
- Unit: Test HTML to text conversion accuracy
- Integration: Test with HTML-rich RSS feeds
- Security: Test XSS sanitization with malicious HTML
acceptance_criteria:
- HTML content is converted to readable plain text
- No HTML tags remain in output
- Sanitization prevents XSS attacks
- Links are properly converted to text format
validation:
- Test with podcast descriptions containing HTML
- Verify text is readable and properly formatted
- Check for any HTML tag remnants
notes:
- Use existing `decodeEntities()` function from rss-parser.ts
- Preserve line breaks and paragraph structure
- Convert URLs to text format (e.g., "Visit example.com")
- Consider using a lightweight HTML parser like `html-escaper` or `cheerio`

View File

@@ -1,45 +0,0 @@
# 05. Maintain Plain Text Fallback Handling
meta:
id: rss-content-parsing-05
feature: rss-content-parsing
priority: P2
depends_on: [rss-content-parsing-03]
tags: [rss, parsing, fallback]
objective:
- Ensure plain text RSS feeds continue to work correctly
- Maintain backward compatibility with existing functionality
- Handle mixed content scenarios
deliverables:
- Updated parseRSSFeed() for HTML support
- Plain text handling path remains unchanged
- Error handling for parsing failures
steps:
1. Update `parseRSSFeed()` to use content type detection
2. Route to HTML parser or plain text path based on type
3. Add error handling for parsing failures
4. Test with both HTML and plain text feeds
5. Verify backward compatibility
tests:
- Integration: Test with plain text RSS feeds
- Integration: Test with HTML RSS feeds
- Regression: Verify existing functionality still works
acceptance_criteria:
- Plain text feeds parse without errors
- HTML feeds parse correctly with sanitization
- No regression in existing functionality
validation:
- Test with various podcast RSS feeds
- Verify descriptions display correctly
- Check for any parsing errors
notes:
- Plain text path uses existing `decodeEntities()` logic
- Keep existing parseRSSFeed() structure for plain text
- Add logging for parsing strategy selection

View File

@@ -1,18 +0,0 @@
# HTML vs Plain Text RSS Parsing
Objective: Detect and handle both HTML and plain text content in RSS feeds
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 03 — Add content type detection utility → `03-rss-content-detection.md`
- [ ] 04 — Implement HTML content parsing → `04-html-content-extraction.md`
- [ ] 05 — Maintain plain text fallback handling → `05-plain-text-content-handling.md`
Dependencies
- 03 -> 04
- 03 -> 05
Exit criteria
- RSS feeds with HTML content are properly parsed and sanitized
- Plain text feeds continue to work as before

View File

@@ -1,45 +0,0 @@
# 01. Add Text Selection Event Handling
meta:
id: text-selection-copy-01
feature: text-selection-copy
priority: P2
depends_on: []
tags: [ui, events, clipboard]
objective:
- Add event listeners for text selection events in the TUI components
- Detect when text is selected by the user
- Prepare infrastructure for clipboard copy on selection release
deliverables:
- New event bus events for text selection
- Selection state tracking in app store
- Event listener setup in main components
steps:
1. Create new event bus events for text selection (`selection.start`, `selection.end`)
2. Add selection state to app store (selectedText, selectionStart, selectionEnd)
3. Add event listeners to key components that display text
4. Track selection state changes in real-time
5. Add cleanup handlers for event listeners
tests:
- Unit: Test event bus event emission for selection events
- Integration: Verify selection state updates when text is selected
- Manual: Select text in different components and verify state tracking
acceptance_criteria:
- Selection events are emitted when text is selected
- Selection state is properly tracked in app store
- Event listeners are correctly registered and cleaned up
validation:
- Run the app and select text in Player component
- Check app store selection state is updated
- Verify event bus receives selection events
notes:
- Need to handle terminal-specific selection behavior
- Selection might not work in all terminal emulators
- Consider using OSC 52 for clipboard operations

View File

@@ -1,45 +0,0 @@
# 02. Implement Clipboard Copy on Selection Release
meta:
id: text-selection-copy-02
feature: text-selection-copy
priority: P2
depends_on: [text-selection-copy-01]
tags: [clipboard, events, user-experience]
objective:
- Copy selected text to clipboard when selection is released
- Handle terminal clipboard limitations
- Provide user feedback when copy succeeds
deliverables:
- Clipboard copy logic triggered on selection release
- User notifications for copy actions
- Integration with existing clipboard utilities
steps:
1. Add event listener for selection release events
2. Extract selected text from current focus component
3. Use existing Clipboard.copy() utility
4. Emit "clipboard.copied" event for notifications
5. Add visual feedback (toast notification)
tests:
- Unit: Test clipboard copy function with various text inputs
- Integration: Verify copy happens on selection release
- Manual: Select text and verify it appears in clipboard
acceptance_criteria:
- Selected text is copied to clipboard when selection ends
- User receives feedback when copy succeeds
- Works across different terminal environments
validation:
- Select text in Player description
- Verify text is in clipboard after selection ends
- Check for toast notification
notes:
- Use existing Clipboard namespace from `src/utils/clipboard.ts`
- Consider timing of selection release vs terminal refresh
- May need to debounce copy operations

View File

@@ -1,15 +0,0 @@
# Text Selection Copy to Clipboard
Objective: When text is selected in the TUI, copy it to the clipboard on release
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 01 — Add text selection event handling → `01-text-selection-copy.md`
- [ ] 02 — Implement clipboard copy on selection release → `02-clipboard-copy-on-release.md`
Dependencies
- 01 -> 02
Exit criteria
- Users can select text and it gets copied to clipboard when selection is released

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