diff --git a/src/App.tsx b/src/App.tsx index 633279a..e0af267 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("feed"); diff --git a/src/components/BoxLayout.tsx b/src/components/BoxLayout.tsx deleted file mode 100644 index 3ca3dfc..0000000 --- a/src/components/BoxLayout.tsx +++ /dev/null @@ -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 ( - - {props.children} - - ) -} diff --git a/src/components/CodeValidation.tsx b/src/components/CodeValidation.tsx index 59d5886..2414070 100644 --- a/src/components/CodeValidation.tsx +++ b/src/components/CodeValidation.tsx @@ -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("code") - const [codeError, setCodeError] = createSignal(null) + const auth = useAuthStore(); + const [code, setCode] = createSignal(""); + const [focusField, setFocusField] = createSignal("code"); + const [codeError, setCodeError] = createSignal(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 ( @@ -103,7 +105,9 @@ export function CodeValidation(props: CodeValidationProps) { - Enter your 8-character sync code to link your account. + + Enter your 8-character sync code to link your account. + You can get this code from the web portal. @@ -115,7 +119,13 @@ export function CodeValidation(props: CodeValidationProps) { - + {codeDisplay()} @@ -129,9 +139,7 @@ export function CodeValidation(props: CodeValidationProps) { width={30} /> - {codeError() && ( - {codeError()} - )} + {codeError() && {codeError()}} @@ -160,13 +168,11 @@ export function CodeValidation(props: CodeValidationProps) { {/* Auth error message */} - {auth.error && ( - {auth.error.message} - )} + {auth.error && {auth.error.message}} Tab to navigate, Enter to select, Esc to go back - ) + ); } diff --git a/src/components/Column.tsx b/src/components/Column.tsx deleted file mode 100644 index 8685287..0000000 --- a/src/components/Column.tsx +++ /dev/null @@ -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 ( - - {props.children} - - ) -} diff --git a/src/components/FileInfo.tsx b/src/components/FileInfo.tsx deleted file mode 100644 index 8bb8fab..0000000 --- a/src/components/FileInfo.tsx +++ /dev/null @@ -1,17 +0,0 @@ -type FileInfoProps = { - path: string - format: string - size: string - modifiedAt: string -} - -export function FileInfo(props: FileInfoProps) { - return ( - - Path: {props.path} - Format: {props.format} - Size: {props.size} - Modified: {props.modifiedAt} - - ) -} diff --git a/src/components/KeyboardHandler.tsx b/src/components/KeyboardHandler.tsx deleted file mode 100644 index 0cc42e7..0000000 --- a/src/components/KeyboardHandler.tsx +++ /dev/null @@ -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} -} diff --git a/src/components/LayerIndicator.tsx b/src/components/LayerIndicator.tsx deleted file mode 100644 index 507c871..0000000 --- a/src/components/LayerIndicator.tsx +++ /dev/null @@ -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( - - {size} - - ) - } - return indicators - } - - return ( - - Depth: - {getLayerIndicator()} - - {layerDepth} - - - ) -} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 960186a..332a40a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 ( {/* Header - tab bar */} @@ -68,16 +67,13 @@ export function Layout(props: LayoutProps) { {/* Main content: side-by-side panels */} - + {(panel, index) => ( {panel.title} @@ -124,14 +125,12 @@ export function Layout(props: LayoutProps) { - - {props.footer} - + {props.footer} - ) + ); } diff --git a/src/components/MergedWaveform.tsx b/src/components/MergedWaveform.tsx deleted file mode 100644 index e9babde..0000000 --- a/src/components/MergedWaveform.tsx +++ /dev/null @@ -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(); - - // 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 ( - - {playedChars || " "} - {futureChars || " "} - - ); - }; - - 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 ( - - {renderLine()} - - ); -} diff --git a/src/components/Player.tsx b/src/components/Player.tsx deleted file mode 100644 index b8912ab..0000000 --- a/src/components/Player.tsx +++ /dev/null @@ -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 ( - - - - Now Playing - - - {formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%) - - - - {audio.error() && ( - {audio.error()} - )} - - - - {episode().title} - - {episode().description} - - {isCavacoreAvailable() ? ( - audio.seek(next)} - visualizerConfig={(() => { - const viz = useAppStore().state().settings.visualizer - return { - bars: viz.bars, - noiseReduction: viz.noiseReduction, - lowCutOff: viz.lowCutOff, - highCutOff: viz.highCutOff, - } - })()} - /> - ) : ( - audio.seek(next)} - /> - )} - - - { - 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)} - /> - - Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back - - ) -} diff --git a/src/components/ResponsiveContainer.tsx b/src/components/ResponsiveContainer.tsx deleted file mode 100644 index f07767e..0000000 --- a/src/components/ResponsiveContainer.tsx +++ /dev/null @@ -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())} -} diff --git a/src/components/ResultDetail.tsx b/src/components/ResultDetail.tsx deleted file mode 100644 index 9ff3c2f..0000000 --- a/src/components/ResultDetail.tsx +++ /dev/null @@ -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 ( - - Select a result to see details. - } - > - {(result) => ( - <> - - {result().podcast.title} - - - - - - by {result().podcast.author} - - - - {result().podcast.description} - - - 0}> - - {(result().podcast.categories ?? []).map((category) => ( - [{category}] - ))} - - - - Feed: {result().podcast.feedUrl} - - - Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")} - - - - props.onSubscribe?.(result())} - > - [+] Add to Feeds - - - - - Already subscribed - - - )} - - - ) -} diff --git a/src/components/Row.tsx b/src/components/Row.tsx deleted file mode 100644 index 01b7bc2..0000000 --- a/src/components/Row.tsx +++ /dev/null @@ -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 ( - - {props.children} - - ) -} diff --git a/src/components/ShortcutHelp.tsx b/src/components/ShortcutHelp.tsx index 550a4b5..ceb2b26 100644 --- a/src/components/ShortcutHelp.tsx +++ b/src/components/ShortcutHelp.tsx @@ -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() { - ) + ); } diff --git a/src/components/SourceBadge.tsx b/src/components/SourceBadge.tsx deleted file mode 100644 index 4e8a0c3..0000000 --- a/src/components/SourceBadge.tsx +++ /dev/null @@ -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 ( - - - [{typeLabel(props.sourceType)}] - - {label()} - - ) -} diff --git a/src/components/Tab.tsx b/src/components/Tab.tsx index fc4d225..f2d384d 100644 --- a/src/components/Tab.tsx +++ b/src/components/Tab.tsx @@ -1,4 +1,4 @@ -import { useTheme } from "../context/ThemeContext"; +import { useTheme } from "@/context/ThemeContext"; export type TabId = | "feed" diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx index 5074148..45e387e 100644 --- a/src/components/TabNavigation.tsx +++ b/src/components/TabNavigation.tsx @@ -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 ( - - - - - - + + + + + + - ) + ); } diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx deleted file mode 100644 index 70a83aa..0000000 --- a/src/components/Waveform.tsx +++ /dev/null @@ -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 ( - - {played || " "} - {upcoming || " "} - - ) - } - - 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 ( - - {renderLine()} - - ) -} diff --git a/src/index.tsx b/src/index.tsx index 428e4a9..99c8c9e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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(() => ( - - - - - - - - - - - - - - -), { useThread: false }) +render( + () => ( + + + + + + + + + + + + + + + ), + { useThread: false }, +); diff --git a/src/components/CategoryFilter.tsx b/src/tabs/Discover/CategoryFilter.tsx similarity index 77% rename from src/components/CategoryFilter.tsx rename to src/tabs/Discover/CategoryFilter.tsx index 875237d..6610ff8 100644 --- a/src/components/CategoryFilter.tsx +++ b/src/tabs/Discover/CategoryFilter.tsx @@ -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 ( {(category) => { - const isSelected = () => props.selectedCategory === category.id + const isSelected = () => props.selectedCategory === category.id; return ( - ) + ); }} - ) + ); } diff --git a/src/components/DiscoverPage.tsx b/src/tabs/Discover/DiscoverPage.tsx similarity index 53% rename from src/components/DiscoverPage.tsx rename to src/tabs/Discover/DiscoverPage.tsx index 309dbce..c9c7a17 100644 --- a/src/components/DiscoverPage.tsx +++ b/src/tabs/Discover/DiscoverPage.tsx @@ -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("shows") - const [showIndex, setShowIndex] = createSignal(0) - const [categoryIndex, setCategoryIndex] = createSignal(0) + const discoverStore = useDiscoverStore(); + const [focusArea, setFocusArea] = createSignal("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 ( {/* Header */} - - - Discover Podcasts - + + + Discover Podcasts + - - {discoverStore.filteredPodcasts().length} shows - + {discoverStore.filteredPodcasts().length} shows discoverStore.refresh()}> [R] Refresh @@ -169,15 +177,14 @@ export function DiscoverPage(props: DiscoverPageProps) { {/* Trending Shows */} - - - Trending in { - DISCOVER_CATEGORIES.find( - (c) => c.id === discoverStore.selectedCategory() - )?.name ?? "All" - } - - + + + Trending in{" "} + {DISCOVER_CATEGORIES.find( + (c) => c.id === discoverStore.selectedCategory(), + )?.name ?? "All"} + + [R] Refresh - ) + ); } diff --git a/src/components/PodcastCard.tsx b/src/tabs/Discover/PodcastCard.tsx similarity index 83% rename from src/components/PodcastCard.tsx rename to src/tabs/Discover/PodcastCard.tsx index ee82b33..11e8fc6 100644 --- a/src/components/PodcastCard.tsx +++ b/src/tabs/Discover/PodcastCard.tsx @@ -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 ( {/* Categories and Subscribe Button */} - + 0}> @@ -69,5 +73,5 @@ export function PodcastCard(props: PodcastCardProps) { - ) + ); } diff --git a/src/components/TrendingShows.tsx b/src/tabs/Discover/TrendingShows.tsx similarity index 78% rename from src/components/TrendingShows.tsx rename to src/tabs/Discover/TrendingShows.tsx index 06ca81e..68694eb 100644 --- a/src/components/TrendingShows.tsx +++ b/src/tabs/Discover/TrendingShows.tsx @@ -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) { - ) + ); } diff --git a/src/components/FeedDetail.tsx b/src/tabs/Feed/FeedDetail.tsx similarity index 75% rename from src/components/FeedDetail.tsx rename to src/tabs/Feed/FeedDetail.tsx index b7dd44f..39dc338 100644 --- a/src/components/FeedDetail.tsx +++ b/src/tabs/Feed/FeedDetail.tsx @@ -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 ( @@ -120,9 +120,7 @@ export function FeedDetail(props: FeedDetailProps) { {props.feed.visibility === "public" ? "[Public]" : "[Private]"} - {props.feed.isPinned && ( - [Pinned] - )} + {props.feed.isPinned && [Pinned]} @@ -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 - ) + ); } diff --git a/src/components/FeedFilter.tsx b/src/tabs/Feed/FeedFilter.tsx similarity index 67% rename from src/components/FeedFilter.tsx rename to src/tabs/Feed/FeedFilter.tsx index f3a9392..835d2d1 100644 --- a/src/components/FeedFilter.tsx +++ b/src/tabs/Feed/FeedFilter.tsx @@ -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("visibility") - const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "") + const [focusField, setFocusField] = createSignal("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 ( @@ -119,7 +123,9 @@ export function FeedFilterComponent(props: FeedFilterProps) { backgroundColor={focusField() === "visibility" ? "#333" : undefined} > - Show: + + Show: + {visibilityLabel()} @@ -143,7 +149,9 @@ export function FeedFilterComponent(props: FeedFilterProps) { backgroundColor={focusField() === "pinned" ? "#333" : undefined} > - Pinned: + + Pinned: + {props.filter.pinnedOnly ? "Yes" : "No"} @@ -165,5 +173,5 @@ export function FeedFilterComponent(props: FeedFilterProps) { Tab to navigate, Enter/Space to toggle - ) + ); } diff --git a/src/components/FeedItem.tsx b/src/tabs/Feed/FeedItem.tsx similarity index 81% rename from src/components/FeedItem.tsx rename to src/tabs/Feed/FeedItem.tsx index ba6c5ec..cb7a5e1 100644 --- a/src/components/FeedItem.tsx +++ b/src/tabs/Feed/FeedItem.tsx @@ -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) { {props.feed.customName || props.feed.podcast.title} - {props.showEpisodeCount && ( - ({episodeCount()}) - )} + {props.showEpisodeCount && ({episodeCount()})} - ) + ); } // Full view with details @@ -105,5 +103,5 @@ export function FeedItem(props: FeedItemProps) { )} - ) + ); } diff --git a/src/components/FeedList.tsx b/src/tabs/Feed/FeedList.tsx similarity index 59% rename from src/components/FeedList.tsx rename to src/tabs/Feed/FeedList.tsx index 6f0b86d..c454a08 100644 --- a/src/components/FeedList.tsx +++ b/src/tabs/Feed/FeedList.tsx @@ -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 ( - + {/* Header with filter controls */} @@ -137,18 +141,10 @@ export function FeedList(props: FeedListProps) { ({filteredFeeds().length} feeds) - + [f] {visibilityLabel()} - + [s] {sortLabel()} @@ -189,5 +185,5 @@ export function FeedList(props: FeedListProps) { - ) + ); } diff --git a/src/components/FeedPage.tsx b/src/tabs/Feed/FeedPage.tsx similarity index 66% rename from src/components/FeedPage.tsx rename to src/tabs/Feed/FeedPage.tsx index fbb7fd7..55ab1e1 100644 --- a/src/components/FeedPage.tsx +++ b/src/tabs/Feed/FeedPage.tsx @@ -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 ( @@ -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())} > @@ -117,5 +119,5 @@ export function FeedPage(props: FeedPageProps) { - ) + ); } diff --git a/src/components/MyShowsPage.tsx b/src/tabs/MyShows/MyShowsPage.tsx similarity index 55% rename from src/components/MyShowsPage.tsx rename to src/tabs/MyShows/MyShowsPage.tsx index 7422620..4752490 100644 --- a/src/components/MyShowsPage.tsx +++ b/src/tabs/MyShows/MyShowsPage.tsx @@ -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("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("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) { } > - + {(feed, index) => ( { - setShowIndex(index()) - setEpisodeIndex(0) + setShowIndex(index()); + setEpisodeIndex(0); }} > @@ -270,7 +283,10 @@ export function MyShowsPage(props: MyShowsPageProps) { } > - + {(episode, index) => ( setEpisodeIndex(index())} > {index() === episodeIndex() ? ">" : " "} - - {episode.episodeNumber ? `#${episode.episodeNumber} ` : ""} + + {episode.episodeNumber + ? `#${episode.episodeNumber} ` + : ""} {episode.title} @@ -294,7 +316,9 @@ export function MyShowsPage(props: MyShowsPageProps) { {formatDate(episode.pubDate)} {formatDuration(episode.duration)} - {downloadLabel(episode.id)} + + {downloadLabel(episode.id)} + @@ -305,7 +329,13 @@ export function MyShowsPage(props: MyShowsPageProps) { Loading more episodes... - + Scroll down for more episodes @@ -318,5 +348,5 @@ export function MyShowsPage(props: MyShowsPageProps) { focusPane, selectedShow, - } + }; } diff --git a/src/components/PlaybackControls.tsx b/src/tabs/Player/PlaybackControls.tsx similarity index 100% rename from src/components/PlaybackControls.tsx rename to src/tabs/Player/PlaybackControls.tsx diff --git a/src/tabs/Player/Player.tsx b/src/tabs/Player/Player.tsx new file mode 100644 index 0000000..76d90c6 --- /dev/null +++ b/src/tabs/Player/Player.tsx @@ -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 ( + + + + Now Playing + + + {formatTime(audio.position())} / {formatTime(dur())} ( + {progressPercent()}%) + + + + {audio.error() && {audio.error()}} + + + + {episode().title} + + {episode().description} + + audio.seek(next)} + visualizerConfig={(() => { + const viz = useAppStore().state().settings.visualizer; + return { + bars: viz.bars, + noiseReduction: viz.noiseReduction, + lowCutOff: viz.lowCutOff, + highCutOff: viz.highCutOff, + }; + })()} + /> + + + { + 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)} + /> + + + Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc + back + + + ); +} diff --git a/src/components/RealtimeWaveform.tsx b/src/tabs/Player/RealtimeWaveform.tsx similarity index 63% rename from src/components/RealtimeWaveform.tsx rename to src/tabs/Player/RealtimeWaveform.tsx index 55b27ef..534b2b4 100644 --- a/src/components/RealtimeWaveform.tsx +++ b/src/tabs/Player/RealtimeWaveform.tsx @@ -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 -} + visualizerConfig?: Partial; +}; /** 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.0–1.0 per bar) - const [barData, setBarData] = createSignal([]) + const [barData, setBarData] = createSignal([]); // 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 | null = null - let sampleBuffer: Float64Array | null = null + let cava: CavaCore | null = null; + let reader: AudioStreamReader | null = null; + let frameTimer: ReturnType | 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 ( {placeholder} - ) + ); } - 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 ( {playedChars || " "} {futureChars || " "} - ) - } + ); + }; 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 ( {renderLine()} - ) -} - -/** - * 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 + ); } diff --git a/src/components/ResultCard.tsx b/src/tabs/Search/ResultCard.tsx similarity index 79% rename from src/components/ResultCard.tsx rename to src/tabs/Search/ResultCard.tsx index 3a97f15..5cc09be 100644 --- a/src/components/ResultCard.tsx +++ b/src/tabs/Search/ResultCard.tsx @@ -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 ( - + {podcast().title} @@ -67,13 +71,13 @@ export function ResultCard(props: ResultCardProps) { paddingRight={1} width={18} onMouseDown={(event) => { - event.stopPropagation?.() - props.onSubscribe?.() + event.stopPropagation?.(); + props.onSubscribe?.(); }} > [+] Add to Feeds - ) + ); } diff --git a/src/tabs/Search/ResultDetail.tsx b/src/tabs/Search/ResultDetail.tsx new file mode 100644 index 0000000..b61e47d --- /dev/null +++ b/src/tabs/Search/ResultDetail.tsx @@ -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 ( + + Select a result to see details.} + > + {(result) => ( + <> + + {result().podcast.title} + + + + + + by {result().podcast.author} + + + + {result().podcast.description} + + + 0}> + + {(result().podcast.categories ?? []).map((category) => ( + [{category}] + ))} + + + + Feed: {result().podcast.feedUrl} + + + Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")} + + + + props.onSubscribe?.(result())} + > + [+] Add to Feeds + + + + + Already subscribed + + + )} + + + ); +} diff --git a/src/components/SearchHistory.tsx b/src/tabs/Search/SearchHistory.tsx similarity index 100% rename from src/components/SearchHistory.tsx rename to src/tabs/Search/SearchHistory.tsx diff --git a/src/components/SearchPage.tsx b/src/tabs/Search/SearchPage.tsx similarity index 64% rename from src/components/SearchPage.tsx rename to src/tabs/Search/SearchPage.tsx index e44507c..efa89e9 100644 --- a/src/components/SearchPage.tsx +++ b/src/tabs/Search/SearchPage.tsx @@ -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("input") - const [inputValue, setInputValue] = createSignal("") - const [resultIndex, setResultIndex] = createSignal(0) - const [historyIndex, setHistoryIndex] = createSignal(0) + const searchStore = useSearchStore(); + const [focusArea, setFocusArea] = createSignal("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 ( {/* Search Header */} - - Search Podcasts - + + Search Podcasts + {/* Search Input */} @@ -174,7 +174,7 @@ export function SearchPage(props: SearchPageProps) { { - 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) { {/* History Sidebar */} - - - - - History - - - + + + + History + + + [Esc] Up - ) + ); } diff --git a/src/components/SearchResults.tsx b/src/tabs/Search/SearchResults.tsx similarity index 68% rename from src/components/SearchResults.tsx rename to src/tabs/Search/SearchResults.tsx index 2d2a9a8..1f90f1e 100644 --- a/src/components/SearchResults.tsx +++ b/src/tabs/Search/SearchResults.tsx @@ -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 ( - - Searching... - - }> + + Searching... + + } + > 0} fallback={ - No results found. Try a different search term. + + No results found. Try a different search term. + } > @@ -71,5 +76,5 @@ export function SearchResults(props: SearchResultsProps) { - ) + ); } diff --git a/src/tabs/Search/SourceBadge.tsx b/src/tabs/Search/SourceBadge.tsx new file mode 100644 index 0000000..6f65b2a --- /dev/null +++ b/src/tabs/Search/SourceBadge.tsx @@ -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 ( + + + [{typeLabel(props.sourceType)}] + + {label()} + + ); +} diff --git a/src/components/ExportDialog.tsx b/src/tabs/Settings/ExportDialog.tsx similarity index 100% rename from src/components/ExportDialog.tsx rename to src/tabs/Settings/ExportDialog.tsx diff --git a/src/components/FilePicker.tsx b/src/tabs/Settings/FilePicker.tsx similarity index 68% rename from src/components/FilePicker.tsx rename to src/tabs/Settings/FilePicker.tsx index 998ace9..08c2c0e 100644 --- a/src/components/FilePicker.tsx +++ b/src/tabs/Settings/FilePicker.tsx @@ -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 ( @@ -18,5 +18,5 @@ export function FilePicker(props: FilePickerProps) { /> Format: {format} - ) + ); } diff --git a/src/components/ImportDialog.tsx b/src/tabs/Settings/ImportDialog.tsx similarity index 100% rename from src/components/ImportDialog.tsx rename to src/tabs/Settings/ImportDialog.tsx diff --git a/src/components/LoginScreen.tsx b/src/tabs/Settings/LoginScreen.tsx similarity index 68% rename from src/components/LoginScreen.tsx rename to src/tabs/Settings/LoginScreen.tsx index 5628b0b..50b62de 100644 --- a/src/components/LoginScreen.tsx +++ b/src/tabs/Settings/LoginScreen.tsx @@ -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("email") - const [emailError, setEmailError] = createSignal(null) - const [passwordError, setPasswordError] = createSignal(null) + const auth = useAuthStore(); + const { theme } = useTheme(); + const [email, setEmail] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [focusField, setFocusField] = createSignal("email"); + const [emailError, setEmailError] = createSignal(null); + const [passwordError, setPasswordError] = createSignal(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 ( @@ -92,7 +92,9 @@ export function LoginScreen(props: LoginScreenProps) { {/* Email field */} - Email: + + Email: + - {emailError() && ( - {emailError()} - )} + {emailError() && {emailError()}} {/* Password field */} @@ -117,9 +117,7 @@ export function LoginScreen(props: LoginScreenProps) { focused={props.focused && focusField() === "password"} width={30} /> - {passwordError() && ( - {passwordError()} - )} + {passwordError() && {passwordError()}} @@ -129,7 +127,9 @@ export function LoginScreen(props: LoginScreenProps) { {auth.isLoading ? "Signing in..." : "[Enter] Sign In"} @@ -138,9 +138,7 @@ export function LoginScreen(props: LoginScreenProps) { {/* Auth error message */} - {auth.error && ( - {auth.error.message} - )} + {auth.error && {auth.error.message}} @@ -173,5 +171,5 @@ export function LoginScreen(props: LoginScreenProps) { Tab to navigate, Enter to select - ) + ); } diff --git a/src/components/OAuthPlaceholder.tsx b/src/tabs/Settings/OAuthPlaceholder.tsx similarity index 86% rename from src/components/OAuthPlaceholder.tsx rename to src/tabs/Settings/OAuthPlaceholder.tsx index c8e99ed..10f83ce 100644 --- a/src/components/OAuthPlaceholder.tsx +++ b/src/tabs/Settings/OAuthPlaceholder.tsx @@ -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("code") + const [focusField, setFocusField] = createSignal("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 ( @@ -121,5 +121,5 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) { Tab to navigate, Enter to select, Esc to go back - ) + ); } diff --git a/src/components/PreferencesPanel.tsx b/src/tabs/Settings/PreferencesPanel.tsx similarity index 62% rename from src/components/PreferencesPanel.tsx rename to src/tabs/Settings/PreferencesPanel.tsx index 82968c3..fe1b15e 100644 --- a/src/components/PreferencesPanel.tsx +++ b/src/tabs/Settings/PreferencesPanel.tsx @@ -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("theme") + const appStore = useAppStore(); + const { theme } = useTheme(); + const [focusField, setFocusField] = createSignal("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 ( @@ -82,15 +91,21 @@ export function PreferencesPanel() { - Theme: + + Theme: + - {THEME_LABELS.find((t) => t.value === settings().theme)?.label} + + {THEME_LABELS.find((t) => t.value === settings().theme)?.label} + [Left/Right] - Font Size: + + Font Size: + {settings().fontSize}px @@ -98,7 +113,9 @@ export function PreferencesPanel() { - Playback: + + Playback: + {settings().playbackSpeed}x @@ -106,9 +123,15 @@ export function PreferencesPanel() { - Show Explicit: + + Show Explicit: + - + {preferences().showExplicit ? "On" : "Off"} @@ -116,9 +139,13 @@ export function PreferencesPanel() { - Auto Download: + + Auto Download: + - + {preferences().autoDownload ? "On" : "Off"} @@ -128,5 +155,5 @@ export function PreferencesPanel() { Tab to move focus, Left/Right to adjust - ) + ); } diff --git a/src/components/SettingsScreen.tsx b/src/tabs/Settings/SettingsScreen.tsx similarity index 57% rename from src/components/SettingsScreen.tsx rename to src/tabs/Settings/SettingsScreen.tsx index b10e926..1c77e87 100644 --- a/src/components/SettingsScreen.tsx +++ b/src/tabs/Settings/SettingsScreen.tsx @@ -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("sync") + const { theme } = useTheme(); + const [activeSection, setActiveSection] = createSignal("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 ( - + Settings - [Tab] Switch section | 1-5 jump | Esc up + + [Tab] Switch section | 1-5 jump | Esc up + @@ -64,10 +70,16 @@ export function SettingsScreen(props: SettingsScreenProps) { setActiveSection(section.id)} > - + [{index() + 1}] {section.label} @@ -85,7 +97,13 @@ export function SettingsScreen(props: SettingsScreenProps) { Account Status: - + {props.accountLabel} @@ -98,5 +116,5 @@ export function SettingsScreen(props: SettingsScreenProps) { Enter to dive | Esc up - ) + ); } diff --git a/src/components/SourceManager.tsx b/src/tabs/Settings/SourceManager.tsx similarity index 53% rename from src/components/SourceManager.tsx rename to src/tabs/Settings/SourceManager.tsx index 3da0177..818c71d 100644 --- a/src/components/SourceManager.tsx +++ b/src/tabs/Settings/SourceManager.tsx @@ -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("list") - const [newSourceUrl, setNewSourceUrl] = createSignal("") - const [newSourceName, setNewSourceName] = createSignal("") - const [error, setError] = createSignal(null) + const feedStore = useFeedStore(); + const { theme } = useTheme(); + const [selectedIndex, setSelectedIndex] = createSignal(0); + const [focusArea, setFocusArea] = createSignal("list"); + const [newSourceUrl, setNewSourceUrl] = createSignal(""); + const [newSourceName, setNewSourceName] = createSignal(""); + const [error, setError] = createSignal(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 ( @@ -161,11 +179,13 @@ export function SourceManager(props: SourceManagerProps) { - Manage where to search for podcasts + Manage where to search for podcasts {/* Source list */} - Sources: + + 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); }} > - + {focusArea() === "list" && index() === selectedIndex() ? ">" : " "} @@ -210,49 +232,78 @@ export function SourceManager(props: SourceManagerProps) { )} - Space/Enter to toggle, d to delete, a to add + + Space/Enter to toggle, d to delete, a to add + {/* API settings */} - {isApiSource() ? "API Settings" : "API Settings (select an API source)"} + {isApiSource() + ? "API Settings" + : "API Settings (select an API source)"} - + Country: {sourceCountry()} - - Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} + + Language:{" "} + {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} - + Explicit: {sourceExplicit() ? "Yes" : "No"} - Enter/Space to toggle focused setting + + Enter/Space to toggle focused setting + {/* Add new source form */} - + Add New Source: @@ -272,8 +323,8 @@ export function SourceManager(props: SourceManagerProps) { { - 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) { /> - + [+] Add Source {/* Error message */} - {error() && ( - {error()} - )} + {error() && {error()}} Tab to switch sections, Esc to close - ) + ); } diff --git a/src/components/SyncError.tsx b/src/tabs/Settings/SyncError.tsx similarity index 100% rename from src/components/SyncError.tsx rename to src/tabs/Settings/SyncError.tsx diff --git a/src/components/SyncPanel.tsx b/src/tabs/Settings/SyncPanel.tsx similarity index 100% rename from src/components/SyncPanel.tsx rename to src/tabs/Settings/SyncPanel.tsx diff --git a/src/components/SyncProfile.tsx b/src/tabs/Settings/SyncProfile.tsx similarity index 75% rename from src/components/SyncProfile.tsx rename to src/tabs/Settings/SyncProfile.tsx index a5fc966..eb05f11 100644 --- a/src/components/SyncProfile.tsx +++ b/src/tabs/Settings/SyncProfile.tsx @@ -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("sync") - const [lastSyncTime] = createSignal(new Date()) + const auth = useAuthStore(); + const [focusField, setFocusField] = createSignal("sync"); + const [lastSyncTime] = createSignal(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 ( @@ -69,7 +69,14 @@ export function SyncProfile(props: SyncProfileProps) { {/* User avatar and info */} {/* ASCII avatar */} - + {userInitials()} @@ -144,5 +151,5 @@ export function SyncProfile(props: SyncProfileProps) { Tab to navigate, Enter to select - ) + ); } diff --git a/src/components/SyncProgress.tsx b/src/tabs/Settings/SyncProgress.tsx similarity index 100% rename from src/components/SyncProgress.tsx rename to src/tabs/Settings/SyncProgress.tsx diff --git a/src/components/SyncStatus.tsx b/src/tabs/Settings/SyncStatus.tsx similarity index 100% rename from src/components/SyncStatus.tsx rename to src/tabs/Settings/SyncStatus.tsx diff --git a/src/components/VisualizerSettings.tsx b/src/tabs/Settings/VisualizerSettings.tsx similarity index 60% rename from src/components/VisualizerSettings.tsx rename to src/tabs/Settings/VisualizerSettings.tsx index 80cc72d..1f73ecf 100644 --- a/src/components/VisualizerSettings.tsx +++ b/src/tabs/Settings/VisualizerSettings.tsx @@ -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("bars") + const appStore = useAppStore(); + const { theme } = useTheme(); + const [focusField, setFocusField] = createSignal("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 ( Visualizer - {!cavacoreStatus && ( - - cavacore not available — using static waveform - - )} - - Bars: + + Bars: + {viz().bars} @@ -101,9 +106,17 @@ export function VisualizerSettings() { - Auto Sensitivity: + + Auto Sensitivity: + - + {viz().sensitivity === 1 ? "On" : "Off"} @@ -111,7 +124,9 @@ export function VisualizerSettings() { - Noise Reduction: + + Noise Reduction: + {viz().noiseReduction.toFixed(2)} @@ -119,7 +134,11 @@ export function VisualizerSettings() { - Low Cutoff: + + Low Cutoff: + {viz().lowCutOff} Hz @@ -127,7 +146,11 @@ export function VisualizerSettings() { - High Cutoff: + + High Cutoff: + {viz().highCutOff} Hz @@ -137,5 +160,5 @@ export function VisualizerSettings() { Tab to move focus, Left/Right to adjust - ) + ); } diff --git a/tasks/INDEX.md b/tasks/INDEX.md deleted file mode 100644 index cb505b2..0000000 --- a/tasks/INDEX.md +++ /dev/null @@ -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 diff --git a/tasks/audio-playback-fix/01-fix-volume-speed-controls.md b/tasks/audio-playback-fix/01-fix-volume-speed-controls.md deleted file mode 100644 index c345972..0000000 --- a/tasks/audio-playback-fix/01-fix-volume-speed-controls.md +++ /dev/null @@ -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) diff --git a/tasks/audio-playback-fix/02-add-multimedia-key-detection.md b/tasks/audio-playback-fix/02-add-multimedia-key-detection.md deleted file mode 100644 index 4b74296..0000000 --- a/tasks/audio-playback-fix/02-add-multimedia-key-detection.md +++ /dev/null @@ -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 diff --git a/tasks/audio-playback-fix/03-implement-platform-media-integration.md b/tasks/audio-playback-fix/03-implement-platform-media-integration.md deleted file mode 100644 index 70b8537..0000000 --- a/tasks/audio-playback-fix/03-implement-platform-media-integration.md +++ /dev/null @@ -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 diff --git a/tasks/audio-playback-fix/04-add-media-key-listeners.md b/tasks/audio-playback-fix/04-add-media-key-listeners.md deleted file mode 100644 index 735d38f..0000000 --- a/tasks/audio-playback-fix/04-add-media-key-listeners.md +++ /dev/null @@ -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 diff --git a/tasks/audio-playback-fix/05-test-multimedia-controls.md b/tasks/audio-playback-fix/05-test-multimedia-controls.md deleted file mode 100644 index 60be29c..0000000 --- a/tasks/audio-playback-fix/05-test-multimedia-controls.md +++ /dev/null @@ -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 diff --git a/tasks/audio-playback-fix/README.md b/tasks/audio-playback-fix/README.md deleted file mode 100644 index ca23268..0000000 --- a/tasks/audio-playback-fix/README.md +++ /dev/null @@ -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 diff --git a/tasks/config-persistence/23-config-directory-setup.md b/tasks/config-persistence/23-config-directory-setup.md deleted file mode 100644 index 58ba305..0000000 --- a/tasks/config-persistence/23-config-directory-setup.md +++ /dev/null @@ -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 diff --git a/tasks/config-persistence/24-feeds-persistence-refactor.md b/tasks/config-persistence/24-feeds-persistence-refactor.md deleted file mode 100644 index f1b91d5..0000000 --- a/tasks/config-persistence/24-feeds-persistence-refactor.md +++ /dev/null @@ -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 diff --git a/tasks/config-persistence/25-theme-persistence-refactor.md b/tasks/config-persistence/25-theme-persistence-refactor.md deleted file mode 100644 index 89d2a4d..0000000 --- a/tasks/config-persistence/25-theme-persistence-refactor.md +++ /dev/null @@ -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 diff --git a/tasks/config-persistence/26-config-file-validation.md b/tasks/config-persistence/26-config-file-validation.md deleted file mode 100644 index f764bfa..0000000 --- a/tasks/config-persistence/26-config-file-validation.md +++ /dev/null @@ -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 diff --git a/tasks/config-persistence/27-config-file-backup.md b/tasks/config-persistence/27-config-file-backup.md deleted file mode 100644 index c6b555b..0000000 --- a/tasks/config-persistence/27-config-file-backup.md +++ /dev/null @@ -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 diff --git a/tasks/config-persistence/README.md b/tasks/config-persistence/README.md deleted file mode 100644 index b92acee..0000000 --- a/tasks/config-persistence/README.md +++ /dev/null @@ -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 diff --git a/tasks/discover-categories-fix/20-category-filter-debug.md b/tasks/discover-categories-fix/20-category-filter-debug.md deleted file mode 100644 index 2d1a6f0..0000000 --- a/tasks/discover-categories-fix/20-category-filter-debug.md +++ /dev/null @@ -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 diff --git a/tasks/discover-categories-fix/21-category-state-sync.md b/tasks/discover-categories-fix/21-category-state-sync.md deleted file mode 100644 index ccd828f..0000000 --- a/tasks/discover-categories-fix/21-category-state-sync.md +++ /dev/null @@ -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 diff --git a/tasks/discover-categories-fix/22-category-navigation-fix.md b/tasks/discover-categories-fix/22-category-navigation-fix.md deleted file mode 100644 index 8cd5db7..0000000 --- a/tasks/discover-categories-fix/22-category-navigation-fix.md +++ /dev/null @@ -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 diff --git a/tasks/discover-categories-fix/README.md b/tasks/discover-categories-fix/README.md deleted file mode 100644 index 2cd2fb1..0000000 --- a/tasks/discover-categories-fix/README.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/14-download-storage-structure.md b/tasks/episode-downloads/14-download-storage-structure.md deleted file mode 100644 index 028a0f7..0000000 --- a/tasks/episode-downloads/14-download-storage-structure.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/15-episode-download-utility.md b/tasks/episode-downloads/15-episode-download-utility.md deleted file mode 100644 index 6531c69..0000000 --- a/tasks/episode-downloads/15-episode-download-utility.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/16-download-progress-tracking.md b/tasks/episode-downloads/16-download-progress-tracking.md deleted file mode 100644 index b5d66dd..0000000 --- a/tasks/episode-downloads/16-download-progress-tracking.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/17-download-ui-component.md b/tasks/episode-downloads/17-download-ui-component.md deleted file mode 100644 index deaae8a..0000000 --- a/tasks/episode-downloads/17-download-ui-component.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/18-auto-download-settings.md b/tasks/episode-downloads/18-auto-download-settings.md deleted file mode 100644 index 76ceb6b..0000000 --- a/tasks/episode-downloads/18-auto-download-settings.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/19-download-queue-management.md b/tasks/episode-downloads/19-download-queue-management.md deleted file mode 100644 index 2ff0c30..0000000 --- a/tasks/episode-downloads/19-download-queue-management.md +++ /dev/null @@ -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 diff --git a/tasks/episode-downloads/README.md b/tasks/episode-downloads/README.md deleted file mode 100644 index cb0b70e..0000000 --- a/tasks/episode-downloads/README.md +++ /dev/null @@ -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 diff --git a/tasks/episode-infinite-scroll/10-episode-list-scroll-handler.md b/tasks/episode-infinite-scroll/10-episode-list-scroll-handler.md deleted file mode 100644 index db9eecf..0000000 --- a/tasks/episode-infinite-scroll/10-episode-list-scroll-handler.md +++ /dev/null @@ -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 diff --git a/tasks/episode-infinite-scroll/11-paginated-episode-loading.md b/tasks/episode-infinite-scroll/11-paginated-episode-loading.md deleted file mode 100644 index 715b225..0000000 --- a/tasks/episode-infinite-scroll/11-paginated-episode-loading.md +++ /dev/null @@ -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 diff --git a/tasks/episode-infinite-scroll/12-episode-list-state-management.md b/tasks/episode-infinite-scroll/12-episode-list-state-management.md deleted file mode 100644 index 9fb6447..0000000 --- a/tasks/episode-infinite-scroll/12-episode-list-state-management.md +++ /dev/null @@ -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 diff --git a/tasks/episode-infinite-scroll/13-load-more-indicator.md b/tasks/episode-infinite-scroll/13-load-more-indicator.md deleted file mode 100644 index c54ecfa..0000000 --- a/tasks/episode-infinite-scroll/13-load-more-indicator.md +++ /dev/null @@ -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 diff --git a/tasks/episode-infinite-scroll/README.md b/tasks/episode-infinite-scroll/README.md deleted file mode 100644 index 3a16ee9..0000000 --- a/tasks/episode-infinite-scroll/README.md +++ /dev/null @@ -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 diff --git a/tasks/merged-waveform/06-waveform-audio-analysis.md b/tasks/merged-waveform/06-waveform-audio-analysis.md deleted file mode 100644 index bc579a9..0000000 --- a/tasks/merged-waveform/06-waveform-audio-analysis.md +++ /dev/null @@ -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 diff --git a/tasks/merged-waveform/07-merged-waveform-component.md b/tasks/merged-waveform/07-merged-waveform-component.md deleted file mode 100644 index 1a0f69f..0000000 --- a/tasks/merged-waveform/07-merged-waveform-component.md +++ /dev/null @@ -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 diff --git a/tasks/merged-waveform/08-realtime-waveform-rendering.md b/tasks/merged-waveform/08-realtime-waveform-rendering.md deleted file mode 100644 index 12077b0..0000000 --- a/tasks/merged-waveform/08-realtime-waveform-rendering.md +++ /dev/null @@ -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 diff --git a/tasks/merged-waveform/09-waveform-performance-optimization.md b/tasks/merged-waveform/09-waveform-performance-optimization.md deleted file mode 100644 index e59c2a4..0000000 --- a/tasks/merged-waveform/09-waveform-performance-optimization.md +++ /dev/null @@ -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) diff --git a/tasks/merged-waveform/README.md b/tasks/merged-waveform/README.md deleted file mode 100644 index 8b66b7b..0000000 --- a/tasks/merged-waveform/README.md +++ /dev/null @@ -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 diff --git a/tasks/real-time-audio-visualization/01-copy-cavacore-files.md b/tasks/real-time-audio-visualization/01-copy-cavacore-files.md deleted file mode 100644 index ff58563..0000000 --- a/tasks/real-time-audio-visualization/01-copy-cavacore-files.md +++ /dev/null @@ -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 diff --git a/tasks/real-time-audio-visualization/02-integrate-cavacore-library.md b/tasks/real-time-audio-visualization/02-integrate-cavacore-library.md deleted file mode 100644 index a18a1ec..0000000 --- a/tasks/real-time-audio-visualization/02-integrate-cavacore-library.md +++ /dev/null @@ -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 diff --git a/tasks/real-time-audio-visualization/03-create-audio-stream-reader.md b/tasks/real-time-audio-visualization/03-create-audio-stream-reader.md deleted file mode 100644 index 4acce28..0000000 --- a/tasks/real-time-audio-visualization/03-create-audio-stream-reader.md +++ /dev/null @@ -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) diff --git a/tasks/real-time-audio-visualization/04-create-realtime-waveform-component.md b/tasks/real-time-audio-visualization/04-create-realtime-waveform-component.md deleted file mode 100644 index 1bdd4b9..0000000 --- a/tasks/real-time-audio-visualization/04-create-realtime-waveform-component.md +++ /dev/null @@ -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 diff --git a/tasks/real-time-audio-visualization/05-update-player-visualization.md b/tasks/real-time-audio-visualization/05-update-player-visualization.md deleted file mode 100644 index 44e0cff..0000000 --- a/tasks/real-time-audio-visualization/05-update-player-visualization.md +++ /dev/null @@ -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 diff --git a/tasks/real-time-audio-visualization/06-add-visualizer-controls.md b/tasks/real-time-audio-visualization/06-add-visualizer-controls.md deleted file mode 100644 index 03895d7..0000000 --- a/tasks/real-time-audio-visualization/06-add-visualizer-controls.md +++ /dev/null @@ -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 diff --git a/tasks/real-time-audio-visualization/README.md b/tasks/real-time-audio-visualization/README.md deleted file mode 100644 index 657a665..0000000 --- a/tasks/real-time-audio-visualization/README.md +++ /dev/null @@ -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 diff --git a/tasks/rss-content-parsing/03-rss-content-detection.md b/tasks/rss-content-parsing/03-rss-content-detection.md deleted file mode 100644 index 3bec913..0000000 --- a/tasks/rss-content-parsing/03-rss-content-detection.md +++ /dev/null @@ -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:
,

,
, , , -- Check for HTML entities: <, >, &, ", ' -- Consider content length threshold for HTML detection diff --git a/tasks/rss-content-parsing/04-html-content-extraction.md b/tasks/rss-content-parsing/04-html-content-extraction.md deleted file mode 100644 index 6908379..0000000 --- a/tasks/rss-content-parsing/04-html-content-extraction.md +++ /dev/null @@ -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` diff --git a/tasks/rss-content-parsing/05-plain-text-content-handling.md b/tasks/rss-content-parsing/05-plain-text-content-handling.md deleted file mode 100644 index 4df0d4f..0000000 --- a/tasks/rss-content-parsing/05-plain-text-content-handling.md +++ /dev/null @@ -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 diff --git a/tasks/rss-content-parsing/README.md b/tasks/rss-content-parsing/README.md deleted file mode 100644 index 6ec379d..0000000 --- a/tasks/rss-content-parsing/README.md +++ /dev/null @@ -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 diff --git a/tasks/text-selection-copy/01-text-selection-copy.md b/tasks/text-selection-copy/01-text-selection-copy.md deleted file mode 100644 index 136c34d..0000000 --- a/tasks/text-selection-copy/01-text-selection-copy.md +++ /dev/null @@ -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 diff --git a/tasks/text-selection-copy/02-clipboard-copy-on-release.md b/tasks/text-selection-copy/02-clipboard-copy-on-release.md deleted file mode 100644 index ce5610a..0000000 --- a/tasks/text-selection-copy/02-clipboard-copy-on-release.md +++ /dev/null @@ -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 diff --git a/tasks/text-selection-copy/README.md b/tasks/text-selection-copy/README.md deleted file mode 100644 index 6ab1a94..0000000 --- a/tasks/text-selection-copy/README.md +++ /dev/null @@ -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 diff --git a/tsconfig.json b/tsconfig.json index 173f81f..bc77489 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,13 +12,7 @@ "types": ["bun-types"], "baseUrl": ".", "paths": { - "@/components/*": ["src/components/*"], - "@/stores/*": ["src/stores/*"], - "@/types/*": ["src/types/*"], - "@/utils/*": ["src/utils/*"], - "@/hooks/*": ["src/hooks/*"], - "@/api/*": ["src/api/*"], - "@/data/*": ["src/data/*"] + "@/*": ["src/*"], } }, "include": ["src/**/*", "tests/**/*"]