file ordering
This commit is contained in:
45
src/App.tsx
45
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<TabId>("feed");
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
type BoxLayoutProps = {
|
||||
children?: JSX.Element
|
||||
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
justifyContent?:
|
||||
| "flex-start"
|
||||
| "flex-end"
|
||||
| "center"
|
||||
| "space-between"
|
||||
| "space-around"
|
||||
| "space-evenly"
|
||||
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||
gap?: number
|
||||
width?: number | "auto" | `${number}%`
|
||||
height?: number | "auto" | `${number}%`
|
||||
padding?: number
|
||||
margin?: number
|
||||
border?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function BoxLayout(props: BoxLayoutProps) {
|
||||
return (
|
||||
<box
|
||||
style={{
|
||||
flexDirection: props.flexDirection,
|
||||
justifyContent: props.justifyContent,
|
||||
alignItems: props.alignItems,
|
||||
gap: props.gap,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
padding: props.padding,
|
||||
margin: props.margin,
|
||||
}}
|
||||
border={props.border}
|
||||
title={props.title}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -3,97 +3,99 @@
|
||||
* 8-character alphanumeric code input for sync authentication
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useAuthStore } from "../stores/auth"
|
||||
import { AUTH_CONFIG } from "../config/auth"
|
||||
import { createSignal } from "solid-js";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AUTH_CONFIG } from "@/config/auth";
|
||||
|
||||
interface CodeValidationProps {
|
||||
focused?: boolean
|
||||
onBack?: () => void
|
||||
focused?: boolean;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
type FocusField = "code" | "submit" | "back"
|
||||
type FocusField = "code" | "submit" | "back";
|
||||
|
||||
export function CodeValidation(props: CodeValidationProps) {
|
||||
const auth = useAuthStore()
|
||||
const [code, setCode] = createSignal("")
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("code")
|
||||
const [codeError, setCodeError] = createSignal<string | null>(null)
|
||||
const auth = useAuthStore();
|
||||
const [code, setCode] = createSignal("");
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
||||
const [codeError, setCodeError] = createSignal<string | null>(null);
|
||||
|
||||
const fields: FocusField[] = ["code", "submit", "back"]
|
||||
const fields: FocusField[] = ["code", "submit", "back"];
|
||||
|
||||
/** Format code as user types (uppercase, alphanumeric only) */
|
||||
const handleCodeInput = (value: string) => {
|
||||
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
||||
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
// Limit to max length
|
||||
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength)
|
||||
setCode(limited)
|
||||
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
|
||||
setCode(limited);
|
||||
|
||||
// Clear error when typing
|
||||
if (codeError()) {
|
||||
setCodeError(null)
|
||||
setCodeError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateCode = (value: string): boolean => {
|
||||
if (!value) {
|
||||
setCodeError("Code is required")
|
||||
return false
|
||||
setCodeError("Code is required");
|
||||
return false;
|
||||
}
|
||||
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
||||
setCodeError(`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`)
|
||||
return false
|
||||
setCodeError(
|
||||
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
|
||||
setCodeError("Code must contain only letters and numbers")
|
||||
return false
|
||||
setCodeError("Code must contain only letters and numbers");
|
||||
return false;
|
||||
}
|
||||
setCodeError(null)
|
||||
return true
|
||||
}
|
||||
setCodeError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateCode(code())) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await auth.validateCode(code())
|
||||
const success = await auth.validateCode(code());
|
||||
if (!success && auth.error) {
|
||||
setCodeError(auth.error.message)
|
||||
setCodeError(auth.error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "submit") {
|
||||
handleSubmit()
|
||||
handleSubmit();
|
||||
} else if (focusField() === "back" && props.onBack) {
|
||||
props.onBack()
|
||||
props.onBack();
|
||||
}
|
||||
} else if (key.name === "escape" && props.onBack) {
|
||||
props.onBack()
|
||||
props.onBack();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const codeProgress = () => {
|
||||
const len = code().length
|
||||
const max = AUTH_CONFIG.codeValidation.codeLength
|
||||
return `${len}/${max}`
|
||||
}
|
||||
const len = code().length;
|
||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
||||
return `${len}/${max}`;
|
||||
};
|
||||
|
||||
const codeDisplay = () => {
|
||||
const current = code()
|
||||
const max = AUTH_CONFIG.codeValidation.codeLength
|
||||
const filled = current.split("")
|
||||
const empty = Array(max - filled.length).fill("_")
|
||||
return [...filled, ...empty].join(" ")
|
||||
}
|
||||
const current = code();
|
||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
||||
const filled = current.split("");
|
||||
const empty = Array(max - filled.length).fill("_");
|
||||
return [...filled, ...empty].join(" ");
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
@@ -103,7 +105,9 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg="gray">Enter your 8-character sync code to link your account.</text>
|
||||
<text fg="gray">
|
||||
Enter your 8-character sync code to link your account.
|
||||
</text>
|
||||
<text fg="gray">You can get this code from the web portal.</text>
|
||||
|
||||
<box height={1} />
|
||||
@@ -115,7 +119,13 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
</text>
|
||||
|
||||
<box border padding={1}>
|
||||
<text fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}>
|
||||
<text
|
||||
fg={
|
||||
code().length === AUTH_CONFIG.codeValidation.codeLength
|
||||
? "green"
|
||||
: "yellow"
|
||||
}
|
||||
>
|
||||
{codeDisplay()}
|
||||
</text>
|
||||
</box>
|
||||
@@ -129,9 +139,7 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
width={30}
|
||||
/>
|
||||
|
||||
{codeError() && (
|
||||
<text fg="red">{codeError()}</text>
|
||||
)}
|
||||
{codeError() && <text fg="red">{codeError()}</text>}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
@@ -160,13 +168,11 @@ export function CodeValidation(props: CodeValidationProps) {
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text fg="red">{auth.error.message}</text>
|
||||
)}
|
||||
{auth.error && <text fg="red">{auth.error.message}</text>}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
type ColumnProps = {
|
||||
children?: JSX.Element
|
||||
gap?: number
|
||||
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||
justifyContent?:
|
||||
| "flex-start"
|
||||
| "flex-end"
|
||||
| "center"
|
||||
| "space-between"
|
||||
| "space-around"
|
||||
| "space-evenly"
|
||||
width?: number | "auto" | `${number}%`
|
||||
height?: number | "auto" | `${number}%`
|
||||
padding?: number
|
||||
}
|
||||
|
||||
export function Column(props: ColumnProps) {
|
||||
return (
|
||||
<box
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
gap: props.gap,
|
||||
alignItems: props.alignItems,
|
||||
justifyContent: props.justifyContent,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
padding: props.padding,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
type FileInfoProps = {
|
||||
path: string
|
||||
format: string
|
||||
size: string
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export function FileInfo(props: FileInfoProps) {
|
||||
return (
|
||||
<box border title="File Info" style={{ padding: 1, flexDirection: "column" }}>
|
||||
<text>Path: {props.path}</text>
|
||||
<text>Format: {props.format}</text>
|
||||
<text>Size: {props.size}</text>
|
||||
<text>Modified: {props.modifiedAt}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
|
||||
export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const getLayerIndicator = () => {
|
||||
const indicators = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const isActive = i <= layerDepth
|
||||
const color = isActive ? theme.accent : theme.textMuted
|
||||
const size = isActive ? "●" : "○"
|
||||
indicators.push(
|
||||
<text fg={color} marginRight={1}>
|
||||
{size}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="row" alignItems="center">
|
||||
<text fg={theme.textMuted} marginRight={1}>Depth:</text>
|
||||
{getLayerIndicator()}
|
||||
<text fg={theme.textMuted} marginLeft={1}>
|
||||
{layerDepth}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +1,63 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { Show, For, createMemo } from "solid-js"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import type { JSX } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { Show, For } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
type PanelConfig = {
|
||||
/** Panel content */
|
||||
content: JSX.Element
|
||||
content: JSX.Element;
|
||||
/** Panel title shown in header */
|
||||
title?: string
|
||||
title?: string;
|
||||
/** Fixed width (leave undefined for flex) */
|
||||
width?: number
|
||||
width?: number;
|
||||
/** Whether this panel is currently focused */
|
||||
focused?: boolean
|
||||
}
|
||||
focused?: boolean;
|
||||
};
|
||||
|
||||
type LayoutProps = {
|
||||
/** Top tab bar */
|
||||
header?: JSX.Element
|
||||
header?: JSX.Element;
|
||||
/** Bottom status bar */
|
||||
footer?: JSX.Element
|
||||
footer?: JSX.Element;
|
||||
/** Panels to display left-to-right like a file explorer */
|
||||
panels: PanelConfig[]
|
||||
panels: PanelConfig[];
|
||||
/** Index of the currently active/focused panel */
|
||||
activePanelIndex?: number
|
||||
}
|
||||
activePanelIndex?: number;
|
||||
};
|
||||
|
||||
export function Layout(props: LayoutProps) {
|
||||
const context = useTheme()
|
||||
|
||||
const panelBg = (index: number): RGBA => {
|
||||
const backgrounds = context.theme.layerBackgrounds
|
||||
const backgrounds = theme.layerBackgrounds;
|
||||
const layers = [
|
||||
backgrounds?.layer0 ?? context.theme.background,
|
||||
backgrounds?.layer1 ?? context.theme.backgroundPanel,
|
||||
backgrounds?.layer2 ?? context.theme.backgroundElement,
|
||||
backgrounds?.layer3 ?? context.theme.backgroundMenu,
|
||||
]
|
||||
return layers[Math.min(index, layers.length - 1)]
|
||||
}
|
||||
backgrounds?.layer0 ?? theme.background,
|
||||
backgrounds?.layer1 ?? theme.backgroundPanel,
|
||||
backgrounds?.layer2 ?? theme.backgroundElement,
|
||||
backgrounds?.layer3 ?? theme.backgroundMenu,
|
||||
];
|
||||
return layers[Math.min(index, layers.length - 1)];
|
||||
};
|
||||
|
||||
const borderColor = (index: number): RGBA | string => {
|
||||
const isActive = index === (props.activePanelIndex ?? 0)
|
||||
const isActive = index === (props.activePanelIndex ?? 0);
|
||||
return isActive
|
||||
? (context.theme.accent ?? context.theme.primary)
|
||||
: (context.theme.border ?? context.theme.textMuted)
|
||||
}
|
||||
? (theme.accent ?? theme.primary)
|
||||
: (theme.border ?? theme.textMuted);
|
||||
};
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundColor={context.theme.background}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* Header - tab bar */}
|
||||
<Show when={props.header}>
|
||||
<box
|
||||
style={{
|
||||
height: 3,
|
||||
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
|
||||
backgroundColor: theme.surface ?? theme.backgroundPanel,
|
||||
}}
|
||||
>
|
||||
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
|
||||
@@ -68,16 +67,13 @@ export function Layout(props: LayoutProps) {
|
||||
</Show>
|
||||
|
||||
{/* Main content: side-by-side panels */}
|
||||
<box
|
||||
flexDirection="row"
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<box flexDirection="row" style={{ flexGrow: 1 }}>
|
||||
<For each={props.panels}>
|
||||
{(panel, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderColor={borderColor(index())}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={panelBg(index())}
|
||||
style={{
|
||||
flexGrow: panel.width ? 0 : 1,
|
||||
@@ -92,13 +88,18 @@ export function Layout(props: LayoutProps) {
|
||||
height: 1,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
backgroundColor: index() === (props.activePanelIndex ?? 0)
|
||||
? (context.theme.accent ?? context.theme.primary)
|
||||
: (context.theme.surface ?? context.theme.backgroundPanel),
|
||||
backgroundColor:
|
||||
index() === (props.activePanelIndex ?? 0)
|
||||
? (theme.accent ?? theme.primary)
|
||||
: (theme.surface ?? theme.backgroundPanel),
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={index() === (props.activePanelIndex ?? 0) ? "black" : undefined}
|
||||
fg={
|
||||
index() === (props.activePanelIndex ?? 0)
|
||||
? "black"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<strong>{panel.title}</strong>
|
||||
</text>
|
||||
@@ -124,14 +125,12 @@ export function Layout(props: LayoutProps) {
|
||||
<box
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
|
||||
backgroundColor: theme.surface ?? theme.backgroundPanel,
|
||||
}}
|
||||
>
|
||||
<box style={{ padding: 1 }}>
|
||||
{props.footer}
|
||||
</box>
|
||||
<box style={{ padding: 1 }}>{props.footer}</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* MergedWaveform — unified progress bar + waveform display
|
||||
*
|
||||
* Shows waveform bars coloured to indicate played vs unplayed portions.
|
||||
* The played section doubles as the progress indicator, replacing the
|
||||
* separate progress bar. Click-to-seek is supported.
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, onCleanup } from "solid-js";
|
||||
import { getWaveformData } from "../utils/audio-waveform";
|
||||
|
||||
type MergedWaveformProps = {
|
||||
/** Audio URL — used to generate or retrieve waveform data */
|
||||
audioUrl: string;
|
||||
/** Current playback position in seconds */
|
||||
position: number;
|
||||
/** Total duration in seconds */
|
||||
duration: number;
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean;
|
||||
/** Number of data points / columns */
|
||||
resolution?: number;
|
||||
/** Callback when user clicks to seek */
|
||||
onSeek?: (seconds: number) => void;
|
||||
};
|
||||
|
||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||
|
||||
export function MergedWaveform(props: MergedWaveformProps) {
|
||||
const resolution = () => props.resolution ?? 64;
|
||||
|
||||
// Waveform data — start with sync/cached, kick off async extraction
|
||||
const [data, setData] = createSignal<number[]>();
|
||||
|
||||
// When the audioUrl changes, attempt async extraction for real data
|
||||
createEffect(() => {
|
||||
const url = props.audioUrl;
|
||||
const res = resolution();
|
||||
if (!url) return;
|
||||
|
||||
let cancelled = false;
|
||||
getWaveformData(url, res).then((result) => {
|
||||
if (!cancelled) setData(result);
|
||||
});
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
|
||||
const playedRatio = () =>
|
||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
|
||||
|
||||
const renderLine = () => {
|
||||
const d = data();
|
||||
if (!d) {
|
||||
console.error("no data recieved");
|
||||
return;
|
||||
}
|
||||
const played = Math.floor(d.length * playedRatio());
|
||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
|
||||
const futureColor = "#3b4252";
|
||||
|
||||
const playedChars = d
|
||||
.slice(0, played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("");
|
||||
|
||||
const futureChars = d
|
||||
.slice(played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg={playedColor}>{playedChars || " "}</text>
|
||||
<text fg={futureColor}>{futureChars || " "}</text>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const handleClick = (event: { x: number }) => {
|
||||
const d = data();
|
||||
const ratio = !d || d.length === 0 ? 0 : event.x / d.length;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(props.duration, Math.round(props.duration * ratio)),
|
||||
);
|
||||
props.onSeek?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<box border padding={1} onMouseDown={handleClick}>
|
||||
{renderLine()}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { PlaybackControls } from "./PlaybackControls"
|
||||
import { MergedWaveform } from "./MergedWaveform"
|
||||
import { RealtimeWaveform, isCavacoreAvailable } from "./RealtimeWaveform"
|
||||
import { useAudio } from "../hooks/useAudio"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import type { Episode } from "../types/episode"
|
||||
|
||||
type PlayerProps = {
|
||||
focused: boolean
|
||||
episode?: Episode | null
|
||||
onExit?: () => void
|
||||
}
|
||||
|
||||
const SAMPLE_EPISODE: Episode = {
|
||||
id: "sample-ep",
|
||||
podcastId: "sample-podcast",
|
||||
title: "A Tour of the Productive Mind",
|
||||
description: "A short guided session on building creative focus.",
|
||||
audioUrl: "",
|
||||
duration: 2780,
|
||||
pubDate: new Date(),
|
||||
}
|
||||
|
||||
export function Player(props: PlayerProps) {
|
||||
const audio = useAudio()
|
||||
|
||||
// The episode to display — prefer a passed-in episode, then the
|
||||
// currently-playing episode, then fall back to the sample.
|
||||
const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE
|
||||
const dur = () => audio.duration() || episode().duration || 1
|
||||
|
||||
useKeyboard((key: { name: string }) => {
|
||||
if (!props.focused) return
|
||||
if (key.name === "space") {
|
||||
if (audio.currentEpisode()) {
|
||||
audio.togglePlayback()
|
||||
} else {
|
||||
// Nothing loaded yet — start playing the displayed episode
|
||||
const ep = episode()
|
||||
if (ep.audioUrl) {
|
||||
audio.play(ep)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === "escape") {
|
||||
props.onExit?.()
|
||||
return
|
||||
}
|
||||
if (key.name === "left") {
|
||||
audio.seekRelative(-10)
|
||||
}
|
||||
if (key.name === "right") {
|
||||
audio.seekRelative(10)
|
||||
}
|
||||
if (key.name === "up") {
|
||||
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))))
|
||||
}
|
||||
if (key.name === "down") {
|
||||
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))))
|
||||
}
|
||||
if (key.name === "s") {
|
||||
const next = audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2))
|
||||
audio.setSpeed(next)
|
||||
}
|
||||
})
|
||||
|
||||
const progressPercent = () => {
|
||||
const d = dur()
|
||||
if (d <= 0) return 0
|
||||
return Math.min(100, Math.round((audio.position() / d) * 100))
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Now Playing</strong>
|
||||
</text>
|
||||
<text fg="gray">
|
||||
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{audio.error() && (
|
||||
<text fg="red">{audio.error()}</text>
|
||||
)}
|
||||
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg="white">
|
||||
<strong>{episode().title}</strong>
|
||||
</text>
|
||||
<text fg="gray">{episode().description}</text>
|
||||
|
||||
{isCavacoreAvailable() ? (
|
||||
<RealtimeWaveform
|
||||
audioUrl={episode().audioUrl}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
speed={audio.speed()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer
|
||||
return {
|
||||
bars: viz.bars,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
) : (
|
||||
<MergedWaveform
|
||||
audioUrl={episode().audioUrl}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
/>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<PlaybackControls
|
||||
isPlaying={audio.isPlaying()}
|
||||
volume={audio.volume()}
|
||||
speed={audio.speed()}
|
||||
backendName={audio.backendName()}
|
||||
hasAudioUrl={!!episode().audioUrl}
|
||||
onToggle={() => {
|
||||
if (audio.currentEpisode()) {
|
||||
audio.togglePlayback()
|
||||
} else {
|
||||
const ep = episode()
|
||||
if (ep.audioUrl) audio.play(ep)
|
||||
}
|
||||
}}
|
||||
onPrev={() => audio.seek(0)}
|
||||
onNext={() => audio.seek(dur())}
|
||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||
/>
|
||||
|
||||
<text fg="gray">Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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())}</>
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Show } from "solid-js"
|
||||
import { format } from "date-fns"
|
||||
import type { SearchResult } from "../types/source"
|
||||
import { SourceBadge } from "./SourceBadge"
|
||||
|
||||
type ResultDetailProps = {
|
||||
result?: SearchResult
|
||||
onSubscribe?: (result: SearchResult) => void
|
||||
}
|
||||
|
||||
export function ResultDetail(props: ResultDetailProps) {
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1} height="100%">
|
||||
<Show
|
||||
when={props.result}
|
||||
fallback={
|
||||
<text fg="gray">Select a result to see details.</text>
|
||||
}
|
||||
>
|
||||
{(result) => (
|
||||
<>
|
||||
<text fg="white">
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
|
||||
<SourceBadge
|
||||
sourceId={result().sourceId}
|
||||
sourceName={result().sourceName}
|
||||
sourceType={result().sourceType}
|
||||
/>
|
||||
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg="gray">by {result().podcast.author}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg="gray">{result().podcast.description}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
{(result().podcast.categories ?? []).map((category) => (
|
||||
<text fg="yellow">[{category}]</text>
|
||||
))}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
|
||||
|
||||
<text fg="gray">
|
||||
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
||||
</text>
|
||||
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={18}
|
||||
onMouseDown={() => props.onSubscribe?.(result())}
|
||||
>
|
||||
<text fg="cyan">[+] Add to Feeds</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg="green">Already subscribed</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
type RowProps = {
|
||||
children?: JSX.Element
|
||||
gap?: number
|
||||
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||
justifyContent?:
|
||||
| "flex-start"
|
||||
| "flex-end"
|
||||
| "center"
|
||||
| "space-between"
|
||||
| "space-around"
|
||||
| "space-evenly"
|
||||
width?: number | "auto" | `${number}%`
|
||||
height?: number | "auto" | `${number}%`
|
||||
padding?: number
|
||||
}
|
||||
|
||||
export function Row(props: RowProps) {
|
||||
return (
|
||||
<box
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: props.gap,
|
||||
alignItems: props.alignItems,
|
||||
justifyContent: props.justifyContent,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
padding: props.padding,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shortcuts } from "../config/shortcuts"
|
||||
import { shortcuts } from "@/config/shortcuts";
|
||||
|
||||
export function ShortcutHelp() {
|
||||
return (
|
||||
@@ -22,5 +22,5 @@ export function ShortcutHelp() {
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { SourceType } from "../types/source"
|
||||
|
||||
type SourceBadgeProps = {
|
||||
sourceId: string
|
||||
sourceName?: string
|
||||
sourceType?: SourceType
|
||||
}
|
||||
|
||||
const typeLabel = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return "API"
|
||||
if (sourceType === SourceType.RSS) return "RSS"
|
||||
if (sourceType === SourceType.CUSTOM) return "Custom"
|
||||
return "Source"
|
||||
}
|
||||
|
||||
const typeColor = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return "cyan"
|
||||
if (sourceType === SourceType.RSS) return "green"
|
||||
if (sourceType === SourceType.CUSTOM) return "yellow"
|
||||
return "gray"
|
||||
}
|
||||
|
||||
export function SourceBadge(props: SourceBadgeProps) {
|
||||
const label = () => props.sourceName || props.sourceId
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={1} padding={0}>
|
||||
<text fg={typeColor(props.sourceType)}>
|
||||
[{typeLabel(props.sourceType)}]
|
||||
</text>
|
||||
<text fg="gray">{label()}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
export type TabId =
|
||||
| "feed"
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
import { Tab, type TabId } from "./Tab"
|
||||
import { Tab, type TabId } from "./Tab";
|
||||
|
||||
type TabNavigationProps = {
|
||||
activeTab: TabId
|
||||
onTabSelect: (tab: TabId) => void
|
||||
}
|
||||
activeTab: TabId;
|
||||
onTabSelect: (tab: TabId) => void;
|
||||
};
|
||||
|
||||
export function TabNavigation(props: TabNavigationProps) {
|
||||
return (
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<Tab tab={{ id: "feed", label: "Feed" }} active={props.activeTab === "feed"} onSelect={props.onTabSelect} />
|
||||
<Tab tab={{ id: "shows", label: "My Shows" }} active={props.activeTab === "shows"} onSelect={props.onTabSelect} />
|
||||
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
|
||||
<Tab tab={{ id: "search", label: "Search" }} active={props.activeTab === "search"} onSelect={props.onTabSelect} />
|
||||
<Tab tab={{ id: "player", label: "Player" }} active={props.activeTab === "player"} onSelect={props.onTabSelect} />
|
||||
<Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} />
|
||||
<Tab
|
||||
tab={{ id: "feed", label: "Feed" }}
|
||||
active={props.activeTab === "feed"}
|
||||
onSelect={props.onTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
tab={{ id: "shows", label: "My Shows" }}
|
||||
active={props.activeTab === "shows"}
|
||||
onSelect={props.onTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
tab={{ id: "discover", label: "Discover" }}
|
||||
active={props.activeTab === "discover"}
|
||||
onSelect={props.onTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
tab={{ id: "search", label: "Search" }}
|
||||
active={props.activeTab === "search"}
|
||||
onSelect={props.onTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
tab={{ id: "player", label: "Player" }}
|
||||
active={props.activeTab === "player"}
|
||||
onSelect={props.onTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
tab={{ id: "settings", label: "Settings" }}
|
||||
active={props.activeTab === "settings"}
|
||||
onSelect={props.onTabSelect}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
type WaveformProps = {
|
||||
data: number[]
|
||||
position: number
|
||||
duration: number
|
||||
isPlaying: boolean
|
||||
onSeek?: (next: number) => void
|
||||
}
|
||||
|
||||
const bars = [".", "-", "~", "=", "#"]
|
||||
|
||||
export function Waveform(props: WaveformProps) {
|
||||
const playedRatio = () => (props.duration === 0 ? 0 : props.position / props.duration)
|
||||
|
||||
const renderLine = () => {
|
||||
const playedCount = Math.floor(props.data.length * playedRatio())
|
||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
|
||||
const futureColor = "#3b4252"
|
||||
const played = props.data
|
||||
.map((value, index) =>
|
||||
index <= playedCount
|
||||
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
const upcoming = props.data
|
||||
.map((value, index) =>
|
||||
index > playedCount
|
||||
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg={playedColor}>{played || " "}</text>
|
||||
<text fg={futureColor}>{upcoming || " "}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = (event: { x: number }) => {
|
||||
const ratio = props.data.length === 0 ? 0 : event.x / props.data.length
|
||||
const next = Math.max(0, Math.min(props.duration, Math.round(props.duration * ratio)))
|
||||
props.onSeek?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<box border padding={1} onMouseDown={handleClick}>
|
||||
{renderLine()}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,39 @@
|
||||
// Hack: Force TERM to tmux-256color when running in tmux to enable
|
||||
// correct palette detection in @opentui/core
|
||||
if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
|
||||
process.env.TERM = "tmux-256color"
|
||||
}
|
||||
//if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
|
||||
//process.env.TERM = "tmux-256color"
|
||||
//}
|
||||
|
||||
import { render, useRenderer } from "@opentui/solid"
|
||||
import { App } from "./App"
|
||||
import { ThemeProvider } from "./context/ThemeContext"
|
||||
import { ToastProvider, Toast } from "./ui/toast"
|
||||
import { KeybindProvider } from "./context/KeybindContext"
|
||||
import { DialogProvider } from "./ui/dialog"
|
||||
import { CommandProvider } from "./ui/command"
|
||||
import { render, useRenderer } from "@opentui/solid";
|
||||
import { App } from "./App";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
import { ToastProvider, Toast } from "./ui/toast";
|
||||
import { KeybindProvider } from "./context/KeybindContext";
|
||||
import { DialogProvider } from "./ui/dialog";
|
||||
import { CommandProvider } from "./ui/command";
|
||||
|
||||
function RendererSetup(props: { children: unknown }) {
|
||||
const renderer = useRenderer()
|
||||
renderer.disableStdoutInterception()
|
||||
return props.children
|
||||
const renderer = useRenderer();
|
||||
renderer.disableStdoutInterception();
|
||||
return props.children;
|
||||
}
|
||||
|
||||
render(() => (
|
||||
<RendererSetup>
|
||||
<ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
</RendererSetup>
|
||||
), { useThread: false })
|
||||
render(
|
||||
() => (
|
||||
<RendererSetup>
|
||||
<ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
</RendererSetup>
|
||||
),
|
||||
{ useThread: false },
|
||||
);
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
* CategoryFilter component - Horizontal category filter tabs
|
||||
*/
|
||||
|
||||
import { For } from "solid-js"
|
||||
import type { DiscoverCategory } from "../stores/discover"
|
||||
import { For } from "solid-js";
|
||||
import type { DiscoverCategory } from "@/stores/discover";
|
||||
|
||||
type CategoryFilterProps = {
|
||||
categories: DiscoverCategory[]
|
||||
selectedCategory: string
|
||||
focused: boolean
|
||||
onSelect?: (categoryId: string) => void
|
||||
}
|
||||
categories: DiscoverCategory[];
|
||||
selectedCategory: string;
|
||||
focused: boolean;
|
||||
onSelect?: (categoryId: string) => void;
|
||||
};
|
||||
|
||||
export function CategoryFilter(props: CategoryFilterProps) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexWrap="wrap">
|
||||
<For each={props.categories}>
|
||||
{(category) => {
|
||||
const isSelected = () => props.selectedCategory === category.id
|
||||
const isSelected = () => props.selectedCategory === category.id;
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -32,9 +32,9 @@ export function CategoryFilter(props: CategoryFilterProps) {
|
||||
{category.icon} {category.name}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,150 +2,158 @@
|
||||
* DiscoverPage component - Main discover/browse interface for PodTUI
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "../stores/discover"
|
||||
import { CategoryFilter } from "./CategoryFilter"
|
||||
import { TrendingShows } from "./TrendingShows"
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||
import { CategoryFilter } from "./CategoryFilter";
|
||||
import { TrendingShows } from "./TrendingShows";
|
||||
|
||||
type DiscoverPageProps = {
|
||||
focused: boolean
|
||||
onExit?: () => void
|
||||
}
|
||||
focused: boolean;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
type FocusArea = "categories" | "shows"
|
||||
type FocusArea = "categories" | "shows";
|
||||
|
||||
export function DiscoverPage(props: DiscoverPageProps) {
|
||||
const discoverStore = useDiscoverStore()
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows")
|
||||
const [showIndex, setShowIndex] = createSignal(0)
|
||||
const [categoryIndex, setCategoryIndex] = createSignal(0)
|
||||
const discoverStore = useDiscoverStore();
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows");
|
||||
const [showIndex, setShowIndex] = createSignal(0);
|
||||
const [categoryIndex, setCategoryIndex] = createSignal(0);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
if (!props.focused) return;
|
||||
|
||||
const area = focusArea()
|
||||
const area = focusArea();
|
||||
|
||||
// Tab switches focus between categories and shows
|
||||
if (key.name === "tab") {
|
||||
if (key.shift) {
|
||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
|
||||
} else {
|
||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if ((key.name === "return" || key.name === "enter") && area === "categories") {
|
||||
setFocusArea("shows")
|
||||
return
|
||||
if (
|
||||
(key.name === "return" || key.name === "enter") &&
|
||||
area === "categories"
|
||||
) {
|
||||
setFocusArea("shows");
|
||||
return;
|
||||
}
|
||||
|
||||
// Category navigation
|
||||
if (area === "categories") {
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
const nextIndex = Math.max(0, categoryIndex() - 1)
|
||||
setCategoryIndex(nextIndex)
|
||||
const cat = DISCOVER_CATEGORIES[nextIndex]
|
||||
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||
setShowIndex(0)
|
||||
return
|
||||
const nextIndex = Math.max(0, categoryIndex() - 1);
|
||||
setCategoryIndex(nextIndex);
|
||||
const cat = DISCOVER_CATEGORIES[nextIndex];
|
||||
if (cat) discoverStore.setSelectedCategory(cat.id);
|
||||
setShowIndex(0);
|
||||
return;
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
const nextIndex = Math.min(DISCOVER_CATEGORIES.length - 1, categoryIndex() + 1)
|
||||
setCategoryIndex(nextIndex)
|
||||
const cat = DISCOVER_CATEGORIES[nextIndex]
|
||||
if (cat) discoverStore.setSelectedCategory(cat.id)
|
||||
setShowIndex(0)
|
||||
return
|
||||
const nextIndex = Math.min(
|
||||
DISCOVER_CATEGORIES.length - 1,
|
||||
categoryIndex() + 1,
|
||||
);
|
||||
setCategoryIndex(nextIndex);
|
||||
const cat = DISCOVER_CATEGORIES[nextIndex];
|
||||
if (cat) discoverStore.setSelectedCategory(cat.id);
|
||||
setShowIndex(0);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
// Select category and move to shows
|
||||
setFocusArea("shows")
|
||||
return
|
||||
setFocusArea("shows");
|
||||
return;
|
||||
}
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setFocusArea("shows")
|
||||
return
|
||||
setFocusArea("shows");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Shows navigation
|
||||
if (area === "shows") {
|
||||
const shows = discoverStore.filteredPodcasts()
|
||||
const shows = discoverStore.filteredPodcasts();
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
if (shows.length === 0) return
|
||||
setShowIndex((i) => Math.min(i + 1, shows.length - 1))
|
||||
return
|
||||
if (shows.length === 0) return;
|
||||
setShowIndex((i) => Math.min(i + 1, shows.length - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
if (shows.length === 0) {
|
||||
setFocusArea("categories")
|
||||
return
|
||||
setFocusArea("categories");
|
||||
return;
|
||||
}
|
||||
const newIndex = showIndex() - 1
|
||||
const newIndex = showIndex() - 1;
|
||||
if (newIndex < 0) {
|
||||
setFocusArea("categories")
|
||||
setFocusArea("categories");
|
||||
} else {
|
||||
setShowIndex(newIndex)
|
||||
setShowIndex(newIndex);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
// Subscribe/unsubscribe
|
||||
const podcast = shows[showIndex()]
|
||||
const podcast = shows[showIndex()];
|
||||
if (podcast) {
|
||||
discoverStore.toggleSubscription(podcast.id)
|
||||
discoverStore.toggleSubscription(podcast.id);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === "escape") {
|
||||
if (area === "shows") {
|
||||
setFocusArea("categories")
|
||||
key.stopPropagation()
|
||||
setFocusArea("categories");
|
||||
key.stopPropagation();
|
||||
} else {
|
||||
props.onExit?.()
|
||||
props.onExit?.();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh with 'r'
|
||||
if (key.name === "r") {
|
||||
discoverStore.refresh()
|
||||
return
|
||||
discoverStore.refresh();
|
||||
return;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handleCategorySelect = (categoryId: string) => {
|
||||
discoverStore.setSelectedCategory(categoryId)
|
||||
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId)
|
||||
if (index >= 0) setCategoryIndex(index)
|
||||
setShowIndex(0)
|
||||
}
|
||||
discoverStore.setSelectedCategory(categoryId);
|
||||
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
|
||||
if (index >= 0) setCategoryIndex(index);
|
||||
setShowIndex(0);
|
||||
};
|
||||
|
||||
const handleShowSelect = (index: number) => {
|
||||
setShowIndex(index)
|
||||
setFocusArea("shows")
|
||||
}
|
||||
setShowIndex(index);
|
||||
setFocusArea("shows");
|
||||
};
|
||||
|
||||
const handleSubscribe = (podcast: { id: string }) => {
|
||||
discoverStore.toggleSubscription(podcast.id)
|
||||
}
|
||||
discoverStore.toggleSubscription(podcast.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%" gap={1}>
|
||||
{/* Header */}
|
||||
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||
<text>
|
||||
<strong>Discover Podcasts</strong>
|
||||
</text>
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<text>
|
||||
<strong>Discover Podcasts</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg="gray">
|
||||
{discoverStore.filteredPodcasts().length} shows
|
||||
</text>
|
||||
<text fg="gray">{discoverStore.filteredPodcasts().length} shows</text>
|
||||
<box onMouseDown={() => discoverStore.refresh()}>
|
||||
<text fg="cyan">[R] Refresh</text>
|
||||
</box>
|
||||
@@ -169,15 +177,14 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
||||
|
||||
{/* Trending Shows */}
|
||||
<box flexDirection="column" flexGrow={1} border>
|
||||
<box padding={1}>
|
||||
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||
Trending in {
|
||||
DISCOVER_CATEGORIES.find(
|
||||
(c) => c.id === discoverStore.selectedCategory()
|
||||
)?.name ?? "All"
|
||||
}
|
||||
</text>
|
||||
</box>
|
||||
<box padding={1}>
|
||||
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||
Trending in{" "}
|
||||
{DISCOVER_CATEGORIES.find(
|
||||
(c) => c.id === discoverStore.selectedCategory(),
|
||||
)?.name ?? "All"}
|
||||
</text>
|
||||
</box>
|
||||
<TrendingShows
|
||||
podcasts={discoverStore.filteredPodcasts()}
|
||||
selectedIndex={showIndex()}
|
||||
@@ -197,5 +204,5 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
||||
<text fg="gray">[R] Refresh</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,21 @@
|
||||
* PodcastCard component - Reusable card for displaying podcast info
|
||||
*/
|
||||
|
||||
import { Show, For } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { Show, For } from "solid-js";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
|
||||
type PodcastCardProps = {
|
||||
podcast: Podcast
|
||||
selected: boolean
|
||||
compact?: boolean
|
||||
onSelect?: () => void
|
||||
onSubscribe?: () => void
|
||||
}
|
||||
podcast: Podcast;
|
||||
selected: boolean;
|
||||
compact?: boolean;
|
||||
onSelect?: () => void;
|
||||
onSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export function PodcastCard(props: PodcastCardProps) {
|
||||
const handleSubscribeClick = () => {
|
||||
props.onSubscribe?.()
|
||||
}
|
||||
props.onSubscribe?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -51,7 +51,11 @@ export function PodcastCard(props: PodcastCardProps) {
|
||||
</Show>
|
||||
|
||||
{/* Categories and Subscribe Button */}
|
||||
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
marginTop={props.compact ? 0 : 1}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
||||
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
||||
@@ -69,5 +73,5 @@ export function PodcastCard(props: PodcastCardProps) {
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
* TrendingShows component - Grid/list of trending podcasts
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import { PodcastCard } from "./PodcastCard"
|
||||
import { For, Show } from "solid-js";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
import { PodcastCard } from "./PodcastCard";
|
||||
|
||||
type TrendingShowsProps = {
|
||||
podcasts: Podcast[]
|
||||
selectedIndex: number
|
||||
focused: boolean
|
||||
isLoading: boolean
|
||||
onSelect?: (index: number) => void
|
||||
onSubscribe?: (podcast: Podcast) => void
|
||||
}
|
||||
podcasts: Podcast[];
|
||||
selectedIndex: number;
|
||||
focused: boolean;
|
||||
isLoading: boolean;
|
||||
onSelect?: (index: number) => void;
|
||||
onSubscribe?: (podcast: Podcast) => void;
|
||||
};
|
||||
|
||||
export function TrendingShows(props: TrendingShowsProps) {
|
||||
return (
|
||||
@@ -47,5 +47,5 @@ export function TrendingShows(props: TrendingShowsProps) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,80 +3,80 @@
|
||||
* Shows podcast info and episode list
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { Episode } from "../types/episode"
|
||||
import { format } from "date-fns"
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface FeedDetailProps {
|
||||
feed: Feed
|
||||
focused?: boolean
|
||||
onBack?: () => void
|
||||
onPlayEpisode?: (episode: Episode) => void
|
||||
feed: Feed;
|
||||
focused?: boolean;
|
||||
onBack?: () => void;
|
||||
onPlayEpisode?: (episode: Episode) => void;
|
||||
}
|
||||
|
||||
export function FeedDetail(props: FeedDetailProps) {
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [showInfo, setShowInfo] = createSignal(true)
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [showInfo, setShowInfo] = createSignal(true);
|
||||
|
||||
const episodes = () => {
|
||||
// Sort episodes by publication date (newest first)
|
||||
return [...props.feed.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||
)
|
||||
}
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) {
|
||||
return `${hrs}h ${mins % 60}m`
|
||||
return `${hrs}h ${mins % 60}m`;
|
||||
}
|
||||
return `${mins}m`
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy")
|
||||
}
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
const eps = episodes()
|
||||
const eps = episodes();
|
||||
|
||||
if (key.name === "escape" && props.onBack) {
|
||||
props.onBack()
|
||||
return
|
||||
props.onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "i") {
|
||||
setShowInfo((v) => !v)
|
||||
return
|
||||
setShowInfo((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1))
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const episode = eps[selectedIndex()]
|
||||
const episode = eps[selectedIndex()];
|
||||
if (episode && props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode)
|
||||
props.onPlayEpisode(episode);
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0)
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(eps.length - 1)
|
||||
setSelectedIndex(eps.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10))
|
||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10))
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
handleKeyPress(key)
|
||||
})
|
||||
if (!props.focused) return;
|
||||
handleKeyPress(key);
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
@@ -120,9 +120,7 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||
</text>
|
||||
{props.feed.isPinned && (
|
||||
<text fg="yellow">[Pinned]</text>
|
||||
)}
|
||||
{props.feed.isPinned && <text fg="yellow">[Pinned]</text>}
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -145,9 +143,9 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
padding={1}
|
||||
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index())
|
||||
setSelectedIndex(index());
|
||||
if (props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode)
|
||||
props.onPlayEpisode(episode);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -174,5 +172,5 @@ export function FeedDetail(props: FeedDetailProps) {
|
||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,54 +3,56 @@
|
||||
* Toggle and filter options for feed list
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { FeedFilter } from "../types/feed"
|
||||
import { createSignal } from "solid-js";
|
||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||
import type { FeedFilter } from "@/types/feed";
|
||||
|
||||
interface FeedFilterProps {
|
||||
filter: FeedFilter
|
||||
focused?: boolean
|
||||
onFilterChange: (filter: FeedFilter) => void
|
||||
filter: FeedFilter;
|
||||
focused?: boolean;
|
||||
onFilterChange: (filter: FeedFilter) => void;
|
||||
}
|
||||
|
||||
type FilterField = "visibility" | "sort" | "pinned" | "search"
|
||||
type FilterField = "visibility" | "sort" | "pinned" | "search";
|
||||
|
||||
export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility")
|
||||
const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "")
|
||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
||||
const [searchValue, setSearchValue] = createSignal(
|
||||
props.filter.searchQuery || "",
|
||||
);
|
||||
|
||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"]
|
||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"];
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "visibility") {
|
||||
cycleVisibility()
|
||||
cycleVisibility();
|
||||
} else if (focusField() === "sort") {
|
||||
cycleSort()
|
||||
cycleSort();
|
||||
} else if (focusField() === "pinned") {
|
||||
togglePinned()
|
||||
togglePinned();
|
||||
}
|
||||
} else if (key.name === "space") {
|
||||
if (focusField() === "pinned") {
|
||||
togglePinned()
|
||||
togglePinned();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cycleVisibility = () => {
|
||||
const current = props.filter.visibility
|
||||
let next: FeedVisibility | "all"
|
||||
if (current === "all") next = FeedVisibility.PUBLIC
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||
else next = "all"
|
||||
props.onFilterChange({ ...props.filter, visibility: next })
|
||||
}
|
||||
const current = props.filter.visibility;
|
||||
let next: FeedVisibility | "all";
|
||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
||||
else next = "all";
|
||||
props.onFilterChange({ ...props.filter, visibility: next });
|
||||
};
|
||||
|
||||
const cycleSort = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
@@ -58,52 +60,54 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
]
|
||||
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField)
|
||||
const nextIndex = (currentIndex + 1) % sortOptions.length
|
||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] })
|
||||
}
|
||||
];
|
||||
const currentIndex = sortOptions.indexOf(
|
||||
props.filter.sortBy as FeedSortField,
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % sortOptions.length;
|
||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
|
||||
};
|
||||
|
||||
const togglePinned = () => {
|
||||
props.onFilterChange({
|
||||
...props.filter,
|
||||
pinnedOnly: !props.filter.pinnedOnly,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInput = (value: string) => {
|
||||
setSearchValue(value)
|
||||
props.onFilterChange({ ...props.filter, searchQuery: value })
|
||||
}
|
||||
setSearchValue(value);
|
||||
props.onFilterChange({ ...props.filter, searchQuery: value });
|
||||
};
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = props.filter.visibility
|
||||
if (vis === "all") return "All"
|
||||
if (vis === "public") return "Public"
|
||||
return "Private"
|
||||
}
|
||||
const vis = props.filter.visibility;
|
||||
if (vis === "all") return "All";
|
||||
if (vis === "public") return "Public";
|
||||
return "Private";
|
||||
};
|
||||
|
||||
const visibilityColor = () => {
|
||||
const vis = props.filter.visibility
|
||||
if (vis === "public") return "green"
|
||||
if (vis === "private") return "yellow"
|
||||
return "white"
|
||||
}
|
||||
const vis = props.filter.visibility;
|
||||
if (vis === "public") return "green";
|
||||
if (vis === "private") return "yellow";
|
||||
return "white";
|
||||
};
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = props.filter.sortBy
|
||||
const sort = props.filter.sortBy;
|
||||
switch (sort) {
|
||||
case "title":
|
||||
return "Title"
|
||||
return "Title";
|
||||
case "episodeCount":
|
||||
return "Episodes"
|
||||
return "Episodes";
|
||||
case "latestEpisode":
|
||||
return "Latest"
|
||||
return "Latest";
|
||||
case "updated":
|
||||
default:
|
||||
return "Updated"
|
||||
return "Updated";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
@@ -119,7 +123,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text>
|
||||
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>
|
||||
Show:
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -143,7 +149,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text>
|
||||
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>
|
||||
Pinned:
|
||||
</text>
|
||||
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||
</text>
|
||||
@@ -165,5 +173,5 @@ export function FeedFilterComponent(props: FeedFilterProps) {
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,39 +3,39 @@
|
||||
* Displays a single feed/podcast in the list
|
||||
*/
|
||||
|
||||
import type { Feed, FeedVisibility } from "../types/feed"
|
||||
import { format } from "date-fns"
|
||||
import type { Feed, FeedVisibility } from "@/types/feed";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface FeedItemProps {
|
||||
feed: Feed
|
||||
isSelected: boolean
|
||||
showEpisodeCount?: boolean
|
||||
showLastUpdated?: boolean
|
||||
compact?: boolean
|
||||
feed: Feed;
|
||||
isSelected: boolean;
|
||||
showEpisodeCount?: boolean;
|
||||
showLastUpdated?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function FeedItem(props: FeedItemProps) {
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d")
|
||||
}
|
||||
return format(date, "MMM d");
|
||||
};
|
||||
|
||||
const episodeCount = () => props.feed.episodes.length
|
||||
const episodeCount = () => props.feed.episodes.length;
|
||||
const unplayedCount = () => {
|
||||
// This would be calculated based on episode status
|
||||
return props.feed.episodes.length
|
||||
}
|
||||
return props.feed.episodes.length;
|
||||
};
|
||||
|
||||
const visibilityIcon = () => {
|
||||
return props.feed.visibility === "public" ? "[P]" : "[*]"
|
||||
}
|
||||
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
||||
};
|
||||
|
||||
const visibilityColor = () => {
|
||||
return props.feed.visibility === "public" ? "green" : "yellow"
|
||||
}
|
||||
return props.feed.visibility === "public" ? "green" : "yellow";
|
||||
};
|
||||
|
||||
const pinnedIndicator = () => {
|
||||
return props.feed.isPinned ? "*" : " "
|
||||
}
|
||||
return props.feed.isPinned ? "*" : " ";
|
||||
};
|
||||
|
||||
if (props.compact) {
|
||||
// Compact single-line view
|
||||
@@ -54,11 +54,9 @@ export function FeedItem(props: FeedItemProps) {
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
{props.feed.customName || props.feed.podcast.title}
|
||||
</text>
|
||||
{props.showEpisodeCount && (
|
||||
<text fg="gray">({episodeCount()})</text>
|
||||
)}
|
||||
{props.showEpisodeCount && <text fg="gray">({episodeCount()})</text>}
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Full view with details
|
||||
@@ -105,5 +103,5 @@ export function FeedItem(props: FeedItemProps) {
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,87 +3,87 @@
|
||||
* Scrollable list of feeds with keyboard navigation and mouse support
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { FeedItem } from "./FeedItem"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { FeedItem } from "./FeedItem";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
||||
import type { Feed } from "@/types/feed";
|
||||
|
||||
interface FeedListProps {
|
||||
focused?: boolean
|
||||
compact?: boolean
|
||||
showEpisodeCount?: boolean
|
||||
showLastUpdated?: boolean
|
||||
onSelectFeed?: (feed: Feed) => void
|
||||
onOpenFeed?: (feed: Feed) => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
focused?: boolean;
|
||||
compact?: boolean;
|
||||
showEpisodeCount?: boolean;
|
||||
showLastUpdated?: boolean;
|
||||
onSelectFeed?: (feed: Feed) => void;
|
||||
onOpenFeed?: (feed: Feed) => void;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeedList(props: FeedListProps) {
|
||||
const feedStore = useFeedStore()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const feedStore = useFeedStore();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const filteredFeeds = () => feedStore.getFilteredFeeds()
|
||||
const filteredFeeds = () => feedStore.getFilteredFeeds();
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
if (key.name === "escape") {
|
||||
props.onFocusChange?.(false)
|
||||
return
|
||||
props.onFocusChange?.(false);
|
||||
return;
|
||||
}
|
||||
const feeds = filteredFeeds()
|
||||
const feeds = filteredFeeds();
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1))
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const feed = feeds[selectedIndex()]
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed && props.onOpenFeed) {
|
||||
props.onOpenFeed(feed)
|
||||
props.onOpenFeed(feed);
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0)
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(feeds.length - 1)
|
||||
setSelectedIndex(feeds.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 5))
|
||||
setSelectedIndex((i) => Math.max(0, i - 5));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
|
||||
} else if (key.name === "p") {
|
||||
// Toggle pin on selected feed
|
||||
const feed = feeds[selectedIndex()]
|
||||
const feed = feeds[selectedIndex()];
|
||||
if (feed) {
|
||||
feedStore.togglePinned(feed.id)
|
||||
feedStore.togglePinned(feed.id);
|
||||
}
|
||||
} else if (key.name === "f") {
|
||||
// Cycle visibility filter
|
||||
cycleVisibilityFilter()
|
||||
cycleVisibilityFilter();
|
||||
} else if (key.name === "s") {
|
||||
// Cycle sort
|
||||
cycleSortField()
|
||||
cycleSortField();
|
||||
}
|
||||
|
||||
// Notify selection change
|
||||
const selectedFeed = feeds[selectedIndex()]
|
||||
const selectedFeed = feeds[selectedIndex()];
|
||||
if (selectedFeed && props.onSelectFeed) {
|
||||
props.onSelectFeed(selectedFeed)
|
||||
props.onSelectFeed(selectedFeed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
handleKeyPress(key)
|
||||
})
|
||||
if (!props.focused) return;
|
||||
handleKeyPress(key);
|
||||
});
|
||||
|
||||
const cycleVisibilityFilter = () => {
|
||||
const current = feedStore.filter().visibility
|
||||
let next: FeedVisibility | "all"
|
||||
if (current === "all") next = FeedVisibility.PUBLIC
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||
else next = "all"
|
||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next })
|
||||
}
|
||||
const current = feedStore.filter().visibility;
|
||||
let next: FeedVisibility | "all";
|
||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
||||
else next = "all";
|
||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
|
||||
};
|
||||
|
||||
const cycleSortField = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
@@ -91,45 +91,49 @@ export function FeedList(props: FeedListProps) {
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
]
|
||||
const current = feedStore.filter().sortBy as FeedSortField
|
||||
const idx = sortOptions.indexOf(current)
|
||||
const next = sortOptions[(idx + 1) % sortOptions.length]
|
||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next })
|
||||
}
|
||||
];
|
||||
const current = feedStore.filter().sortBy as FeedSortField;
|
||||
const idx = sortOptions.indexOf(current);
|
||||
const next = sortOptions[(idx + 1) % sortOptions.length];
|
||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
|
||||
};
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = feedStore.filter().visibility
|
||||
if (vis === "all") return "All"
|
||||
if (vis === "public") return "Public"
|
||||
return "Private"
|
||||
}
|
||||
const vis = feedStore.filter().visibility;
|
||||
if (vis === "all") return "All";
|
||||
if (vis === "public") return "Public";
|
||||
return "Private";
|
||||
};
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = feedStore.filter().sortBy
|
||||
const sort = feedStore.filter().sortBy;
|
||||
switch (sort) {
|
||||
case "title": return "Title"
|
||||
case "episodeCount": return "Episodes"
|
||||
case "latestEpisode": return "Latest"
|
||||
default: return "Updated"
|
||||
case "title":
|
||||
return "Title";
|
||||
case "episodeCount":
|
||||
return "Episodes";
|
||||
case "latestEpisode":
|
||||
return "Latest";
|
||||
default:
|
||||
return "Updated";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedClick = (feed: Feed, index: number) => {
|
||||
setSelectedIndex(index)
|
||||
setSelectedIndex(index);
|
||||
if (props.onSelectFeed) {
|
||||
props.onSelectFeed(feed)
|
||||
props.onSelectFeed(feed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeedDoubleClick = (feed: Feed) => {
|
||||
if (props.onOpenFeed) {
|
||||
props.onOpenFeed(feed)
|
||||
props.onOpenFeed(feed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with filter controls */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||
<text>
|
||||
@@ -137,18 +141,10 @@ export function FeedList(props: FeedListProps) {
|
||||
</text>
|
||||
<text fg="gray">({filteredFeeds().length} feeds)</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleVisibilityFilter}
|
||||
>
|
||||
<box border padding={0} onMouseDown={cycleVisibilityFilter}>
|
||||
<text fg="cyan">[f] {visibilityLabel()}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleSortField}
|
||||
>
|
||||
<box border padding={0} onMouseDown={cycleSortField}>
|
||||
<text fg="cyan">[s] {sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -189,5 +185,5 @@ export function FeedList(props: FeedListProps) {
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,69 +3,69 @@
|
||||
* Reverse chronological order, like an inbox/timeline
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import { format } from "date-fns"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Feed } from "../types/feed"
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { format } from "date-fns";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
|
||||
type FeedPageProps = {
|
||||
focused: boolean
|
||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void
|
||||
onExit?: () => void
|
||||
}
|
||||
focused: boolean;
|
||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
export function FeedPage(props: FeedPageProps) {
|
||||
const feedStore = useFeedStore()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [isRefreshing, setIsRefreshing] = createSignal(false)
|
||||
const feedStore = useFeedStore();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
||||
|
||||
const allEpisodes = () => feedStore.getAllEpisodesChronological()
|
||||
const allEpisodes = () => feedStore.getAllEpisodesChronological();
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy")
|
||||
}
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
await feedStore.refreshAllFeeds()
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
setIsRefreshing(true);
|
||||
await feedStore.refreshAllFeeds();
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
if (!props.focused) return;
|
||||
|
||||
const episodes = allEpisodes()
|
||||
const episodes = allEpisodes();
|
||||
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1))
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1));
|
||||
} else if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const item = episodes[selectedIndex()]
|
||||
if (item) props.onPlayEpisode?.(item.episode, item.feed)
|
||||
const item = episodes[selectedIndex()];
|
||||
if (item) props.onPlayEpisode?.(item.episode, item.feed);
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0)
|
||||
setSelectedIndex(0);
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(episodes.length - 1)
|
||||
setSelectedIndex(episodes.length - 1);
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10))
|
||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10))
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10));
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh()
|
||||
handleRefresh();
|
||||
} else if (key.name === "escape") {
|
||||
props.onExit?.()
|
||||
props.onExit?.();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%">
|
||||
@@ -95,7 +95,9 @@ export function FeedPage(props: FeedPageProps) {
|
||||
paddingRight={1}
|
||||
paddingTop={0}
|
||||
paddingBottom={0}
|
||||
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||
backgroundColor={
|
||||
index() === selectedIndex() ? "#333" : undefined
|
||||
}
|
||||
onMouseDown={() => setSelectedIndex(index())}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -117,5 +119,5 @@ export function FeedPage(props: FeedPageProps) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4,208 +4,218 @@
|
||||
* Right panel: episodes for the selected show
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show, createMemo, createEffect } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import { useDownloadStore } from "../stores/download"
|
||||
import { DownloadStatus } from "../types/episode"
|
||||
import { format } from "date-fns"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Feed } from "../types/feed"
|
||||
import { createSignal, For, Show, createMemo, createEffect } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import { format } from "date-fns";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
|
||||
type MyShowsPageProps = {
|
||||
focused: boolean
|
||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void
|
||||
onExit?: () => void
|
||||
}
|
||||
focused: boolean;
|
||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
type FocusPane = "shows" | "episodes"
|
||||
type FocusPane = "shows" | "episodes";
|
||||
|
||||
export function MyShowsPage(props: MyShowsPageProps) {
|
||||
const feedStore = useFeedStore()
|
||||
const downloadStore = useDownloadStore()
|
||||
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
|
||||
const [showIndex, setShowIndex] = createSignal(0)
|
||||
const [episodeIndex, setEpisodeIndex] = createSignal(0)
|
||||
const [isRefreshing, setIsRefreshing] = createSignal(false)
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows");
|
||||
const [showIndex, setShowIndex] = createSignal(0);
|
||||
const [episodeIndex, setEpisodeIndex] = createSignal(0);
|
||||
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
||||
|
||||
/** Threshold: load more when within this many items of the end */
|
||||
const LOAD_MORE_THRESHOLD = 5
|
||||
const LOAD_MORE_THRESHOLD = 5;
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds()
|
||||
const shows = () => feedStore.getFilteredFeeds();
|
||||
|
||||
const selectedShow = createMemo(() => {
|
||||
const s = shows()
|
||||
const idx = showIndex()
|
||||
return idx < s.length ? s[idx] : undefined
|
||||
})
|
||||
const s = shows();
|
||||
const idx = showIndex();
|
||||
return idx < s.length ? s[idx] : undefined;
|
||||
});
|
||||
|
||||
const episodes = createMemo(() => {
|
||||
const show = selectedShow()
|
||||
if (!show) return []
|
||||
const show = selectedShow();
|
||||
if (!show) return [];
|
||||
return [...show.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||
)
|
||||
})
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
// Detect when user navigates near the bottom and load more episodes
|
||||
createEffect(() => {
|
||||
const idx = episodeIndex()
|
||||
const eps = episodes()
|
||||
const show = selectedShow()
|
||||
if (!show || eps.length === 0) return
|
||||
const idx = episodeIndex();
|
||||
const eps = episodes();
|
||||
const show = selectedShow();
|
||||
if (!show || eps.length === 0) return;
|
||||
|
||||
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD
|
||||
if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) {
|
||||
feedStore.loadMoreEpisodes(show.id)
|
||||
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD;
|
||||
if (
|
||||
nearBottom &&
|
||||
feedStore.hasMoreEpisodes(show.id) &&
|
||||
!feedStore.isLoadingMore()
|
||||
) {
|
||||
feedStore.loadMoreEpisodes(show.id);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy")
|
||||
}
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
/** Get download status label for an episode */
|
||||
const downloadLabel = (episodeId: string): string => {
|
||||
const status = downloadStore.getDownloadStatus(episodeId)
|
||||
const status = downloadStore.getDownloadStatus(episodeId);
|
||||
switch (status) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "[Q]"
|
||||
return "[Q]";
|
||||
case DownloadStatus.DOWNLOADING: {
|
||||
const pct = downloadStore.getDownloadProgress(episodeId)
|
||||
return `[${pct}%]`
|
||||
const pct = downloadStore.getDownloadProgress(episodeId);
|
||||
return `[${pct}%]`;
|
||||
}
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "[DL]"
|
||||
return "[DL]";
|
||||
case DownloadStatus.FAILED:
|
||||
return "[ERR]"
|
||||
return "[ERR]";
|
||||
default:
|
||||
return ""
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Get download status color */
|
||||
const downloadColor = (episodeId: string): string => {
|
||||
const status = downloadStore.getDownloadStatus(episodeId)
|
||||
const status = downloadStore.getDownloadStatus(episodeId);
|
||||
switch (status) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "yellow"
|
||||
return "yellow";
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return "cyan"
|
||||
return "cyan";
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "green"
|
||||
return "green";
|
||||
case DownloadStatus.FAILED:
|
||||
return "red"
|
||||
return "red";
|
||||
default:
|
||||
return "gray"
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const show = selectedShow()
|
||||
if (!show) return
|
||||
setIsRefreshing(true)
|
||||
await feedStore.refreshFeed(show.id)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
setIsRefreshing(true);
|
||||
await feedStore.refreshFeed(show.id);
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
const handleUnsubscribe = () => {
|
||||
const show = selectedShow()
|
||||
if (!show) return
|
||||
feedStore.removeFeed(show.id)
|
||||
setShowIndex((i) => Math.max(0, i - 1))
|
||||
setEpisodeIndex(0)
|
||||
}
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
feedStore.removeFeed(show.id);
|
||||
setShowIndex((i) => Math.max(0, i - 1));
|
||||
setEpisodeIndex(0);
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
if (!props.focused) return;
|
||||
|
||||
const pane = focusPane()
|
||||
const pane = focusPane();
|
||||
|
||||
// Navigate between panes
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
if (pane === "shows" && selectedShow()) {
|
||||
setFocusPane("episodes")
|
||||
setEpisodeIndex(0)
|
||||
setFocusPane("episodes");
|
||||
setEpisodeIndex(0);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
if (pane === "episodes") {
|
||||
setFocusPane("shows")
|
||||
setFocusPane("shows");
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (key.name === "tab") {
|
||||
if (pane === "shows" && selectedShow()) {
|
||||
setFocusPane("episodes")
|
||||
setEpisodeIndex(0)
|
||||
setFocusPane("episodes");
|
||||
setEpisodeIndex(0);
|
||||
} else {
|
||||
setFocusPane("shows")
|
||||
setFocusPane("shows");
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (pane === "shows") {
|
||||
const s = shows()
|
||||
const s = shows();
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setShowIndex((i) => Math.min(s.length - 1, i + 1))
|
||||
setEpisodeIndex(0)
|
||||
setShowIndex((i) => Math.min(s.length - 1, i + 1));
|
||||
setEpisodeIndex(0);
|
||||
} else if (key.name === "up" || key.name === "k") {
|
||||
setShowIndex((i) => Math.max(0, i - 1))
|
||||
setEpisodeIndex(0)
|
||||
setShowIndex((i) => Math.max(0, i - 1));
|
||||
setEpisodeIndex(0);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (selectedShow()) {
|
||||
setFocusPane("episodes")
|
||||
setEpisodeIndex(0)
|
||||
setFocusPane("episodes");
|
||||
setEpisodeIndex(0);
|
||||
}
|
||||
} else if (key.name === "d") {
|
||||
handleUnsubscribe()
|
||||
handleUnsubscribe();
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh()
|
||||
handleRefresh();
|
||||
} else if (key.name === "escape") {
|
||||
props.onExit?.()
|
||||
props.onExit?.();
|
||||
}
|
||||
} else if (pane === "episodes") {
|
||||
const eps = episodes()
|
||||
const eps = episodes();
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1))
|
||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1));
|
||||
} else if (key.name === "up" || key.name === "k") {
|
||||
setEpisodeIndex((i) => Math.max(0, i - 1))
|
||||
setEpisodeIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const ep = eps[episodeIndex()]
|
||||
const show = selectedShow()
|
||||
if (ep && show) props.onPlayEpisode?.(ep, show)
|
||||
const ep = eps[episodeIndex()];
|
||||
const show = selectedShow();
|
||||
if (ep && show) props.onPlayEpisode?.(ep, show);
|
||||
} else if (key.name === "d") {
|
||||
const ep = eps[episodeIndex()]
|
||||
const show = selectedShow()
|
||||
const ep = eps[episodeIndex()];
|
||||
const show = selectedShow();
|
||||
if (ep && show) {
|
||||
const status = downloadStore.getDownloadStatus(ep.id)
|
||||
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
||||
downloadStore.startDownload(ep, show.id)
|
||||
} else if (status === DownloadStatus.DOWNLOADING || status === DownloadStatus.QUEUED) {
|
||||
downloadStore.cancelDownload(ep.id)
|
||||
const status = downloadStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
downloadStore.startDownload(ep, show.id);
|
||||
} else if (
|
||||
status === DownloadStatus.DOWNLOADING ||
|
||||
status === DownloadStatus.QUEUED
|
||||
) {
|
||||
downloadStore.cancelDownload(ep.id);
|
||||
}
|
||||
}
|
||||
} else if (key.name === "pageup") {
|
||||
setEpisodeIndex((i) => Math.max(0, i - 10))
|
||||
setEpisodeIndex((i) => Math.max(0, i - 10));
|
||||
} else if (key.name === "pagedown") {
|
||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10))
|
||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10));
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh()
|
||||
handleRefresh();
|
||||
} else if (key.name === "escape") {
|
||||
setFocusPane("shows")
|
||||
key.stopPropagation()
|
||||
setFocusPane("shows");
|
||||
key.stopPropagation();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
showsPanel: () => (
|
||||
@@ -223,7 +233,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" focused={props.focused && focusPane() === "shows"}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={props.focused && focusPane() === "shows"}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => (
|
||||
<box
|
||||
@@ -233,8 +246,8 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === showIndex() ? "#333" : undefined}
|
||||
onMouseDown={() => {
|
||||
setShowIndex(index())
|
||||
setEpisodeIndex(0)
|
||||
setShowIndex(index());
|
||||
setEpisodeIndex(0);
|
||||
}}
|
||||
>
|
||||
<text fg={index() === showIndex() ? "cyan" : "gray"}>
|
||||
@@ -270,7 +283,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" focused={props.focused && focusPane() === "episodes"}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={props.focused && focusPane() === "episodes"}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(episode, index) => (
|
||||
<box
|
||||
@@ -278,15 +294,21 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === episodeIndex() ? "#333" : undefined}
|
||||
backgroundColor={
|
||||
index() === episodeIndex() ? "#333" : undefined
|
||||
}
|
||||
onMouseDown={() => setEpisodeIndex(index())}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={index() === episodeIndex() ? "cyan" : "gray"}>
|
||||
{index() === episodeIndex() ? ">" : " "}
|
||||
</text>
|
||||
<text fg={index() === episodeIndex() ? "white" : undefined}>
|
||||
{episode.episodeNumber ? `#${episode.episodeNumber} ` : ""}
|
||||
<text
|
||||
fg={index() === episodeIndex() ? "white" : undefined}
|
||||
>
|
||||
{episode.episodeNumber
|
||||
? `#${episode.episodeNumber} `
|
||||
: ""}
|
||||
{episode.title}
|
||||
</text>
|
||||
</box>
|
||||
@@ -294,7 +316,9 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||
<Show when={downloadLabel(episode.id)}>
|
||||
<text fg={downloadColor(episode.id)}>{downloadLabel(episode.id)}</text>
|
||||
<text fg={downloadColor(episode.id)}>
|
||||
{downloadLabel(episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
@@ -305,7 +329,13 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
<text fg="yellow">Loading more episodes...</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}>
|
||||
<Show
|
||||
when={
|
||||
!feedStore.isLoadingMore() &&
|
||||
selectedShow() &&
|
||||
feedStore.hasMoreEpisodes(selectedShow()!.id)
|
||||
}
|
||||
>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<text fg="gray">Scroll down for more episodes</text>
|
||||
</box>
|
||||
@@ -318,5 +348,5 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
||||
|
||||
focusPane,
|
||||
selectedShow,
|
||||
}
|
||||
};
|
||||
}
|
||||
147
src/tabs/Player/Player.tsx
Normal file
147
src/tabs/Player/Player.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { PlaybackControls } from "./PlaybackControls";
|
||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import type { Episode } from "@/types/episode";
|
||||
|
||||
type PlayerProps = {
|
||||
focused: boolean;
|
||||
episode?: Episode | null;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
const SAMPLE_EPISODE: Episode = {
|
||||
id: "sample-ep",
|
||||
podcastId: "sample-podcast",
|
||||
title: "A Tour of the Productive Mind",
|
||||
description: "A short guided session on building creative focus.",
|
||||
audioUrl: "",
|
||||
duration: 2780,
|
||||
pubDate: new Date(),
|
||||
};
|
||||
|
||||
export function Player(props: PlayerProps) {
|
||||
const audio = useAudio();
|
||||
|
||||
// The episode to display — prefer a passed-in episode, then the
|
||||
// currently-playing episode, then fall back to the sample.
|
||||
const episode = () =>
|
||||
props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE;
|
||||
const dur = () => audio.duration() || episode().duration || 1;
|
||||
|
||||
useKeyboard((key: { name: string }) => {
|
||||
if (!props.focused) return;
|
||||
if (key.name === "space") {
|
||||
if (audio.currentEpisode()) {
|
||||
audio.togglePlayback();
|
||||
} else {
|
||||
// Nothing loaded yet — start playing the displayed episode
|
||||
const ep = episode();
|
||||
if (ep.audioUrl) {
|
||||
audio.play(ep);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === "escape") {
|
||||
props.onExit?.();
|
||||
return;
|
||||
}
|
||||
if (key.name === "left") {
|
||||
audio.seekRelative(-10);
|
||||
}
|
||||
if (key.name === "right") {
|
||||
audio.seekRelative(10);
|
||||
}
|
||||
if (key.name === "up") {
|
||||
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))));
|
||||
}
|
||||
if (key.name === "down") {
|
||||
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))));
|
||||
}
|
||||
if (key.name === "s") {
|
||||
const next =
|
||||
audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2));
|
||||
audio.setSpeed(next);
|
||||
}
|
||||
});
|
||||
|
||||
const progressPercent = () => {
|
||||
const d = dur();
|
||||
if (d <= 0) return 0;
|
||||
return Math.min(100, Math.round((audio.position() / d) * 100));
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Now Playing</strong>
|
||||
</text>
|
||||
<text fg="gray">
|
||||
{formatTime(audio.position())} / {formatTime(dur())} (
|
||||
{progressPercent()}%)
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{audio.error() && <text fg="red">{audio.error()}</text>}
|
||||
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg="white">
|
||||
<strong>{episode().title}</strong>
|
||||
</text>
|
||||
<text fg="gray">{episode().description}</text>
|
||||
|
||||
<RealtimeWaveform
|
||||
audioUrl={episode().audioUrl}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
speed={audio.speed()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
return {
|
||||
bars: viz.bars,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<PlaybackControls
|
||||
isPlaying={audio.isPlaying()}
|
||||
volume={audio.volume()}
|
||||
speed={audio.speed()}
|
||||
backendName={audio.backendName()}
|
||||
hasAudioUrl={!!episode().audioUrl}
|
||||
onToggle={() => {
|
||||
if (audio.currentEpisode()) {
|
||||
audio.togglePlayback();
|
||||
} else {
|
||||
const ep = episode();
|
||||
if (ep.audioUrl) audio.play(ep);
|
||||
}
|
||||
}}
|
||||
onPrev={() => audio.seek(0)}
|
||||
onNext={() => audio.seek(dur())}
|
||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||
/>
|
||||
|
||||
<text fg="gray">
|
||||
Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc
|
||||
back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +1,97 @@
|
||||
/**
|
||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
||||
*
|
||||
* Replaces MergedWaveform during playback. Spawns an independent ffmpeg
|
||||
* Spawns an independent ffmpeg
|
||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
||||
* for FFT analysis, and renders frequency bars as colored terminal
|
||||
* characters at ~30fps.
|
||||
*
|
||||
* Falls back gracefully if cavacore is unavailable (loadCavaCore returns null).
|
||||
* Same prop interface as MergedWaveform for drop-in replacement.
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js"
|
||||
import { loadCavaCore, type CavaCore, type CavaCoreConfig } from "../utils/cavacore"
|
||||
import { AudioStreamReader } from "../utils/audio-stream-reader"
|
||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||
import {
|
||||
loadCavaCore,
|
||||
type CavaCore,
|
||||
type CavaCoreConfig,
|
||||
} from "@/utils/cavacore";
|
||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type RealtimeWaveformProps = {
|
||||
/** Audio URL — used to start the ffmpeg decode stream */
|
||||
audioUrl: string
|
||||
audioUrl: string;
|
||||
/** Current playback position in seconds */
|
||||
position: number
|
||||
position: number;
|
||||
/** Total duration in seconds */
|
||||
duration: number
|
||||
duration: number;
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean
|
||||
isPlaying: boolean;
|
||||
/** Playback speed multiplier (default: 1) */
|
||||
speed?: number
|
||||
speed?: number;
|
||||
/** Number of frequency bars / columns */
|
||||
resolution?: number
|
||||
resolution?: number;
|
||||
/** Callback when user clicks to seek */
|
||||
onSeek?: (seconds: number) => void
|
||||
onSeek?: (seconds: number) => void;
|
||||
/** Visualizer configuration overrides */
|
||||
visualizerConfig?: Partial<CavaCoreConfig>
|
||||
}
|
||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||
};
|
||||
|
||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"]
|
||||
const BARS = [
|
||||
" ",
|
||||
"\u2581",
|
||||
"\u2582",
|
||||
"\u2583",
|
||||
"\u2584",
|
||||
"\u2585",
|
||||
"\u2586",
|
||||
"\u2587",
|
||||
"\u2588",
|
||||
];
|
||||
|
||||
/** Target frame interval in ms (~30 fps) */
|
||||
const FRAME_INTERVAL = 33
|
||||
const FRAME_INTERVAL = 33;
|
||||
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512
|
||||
const SAMPLES_PER_FRAME = 512;
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const resolution = () => props.resolution ?? 32
|
||||
const resolution = () => props.resolution ?? 32;
|
||||
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([])
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
|
||||
// Track whether cavacore is available
|
||||
const [available, setAvailable] = createSignal(false)
|
||||
const [available, setAvailable] = createSignal(false);
|
||||
|
||||
let cava: CavaCore | null = null
|
||||
let reader: AudioStreamReader | null = null
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sampleBuffer: Float64Array | null = null
|
||||
let cava: CavaCore | null = null;
|
||||
let reader: AudioStreamReader | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
|
||||
const initCava = () => {
|
||||
if (cava) return true
|
||||
if (cava) return true;
|
||||
|
||||
cava = loadCavaCore()
|
||||
cava = loadCavaCore();
|
||||
if (!cava) {
|
||||
setAvailable(false)
|
||||
return false
|
||||
setAvailable(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setAvailable(true)
|
||||
return true
|
||||
}
|
||||
setAvailable(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
|
||||
const startVisualization = (url: string, position: number, speed: number) => {
|
||||
stopVisualization()
|
||||
stopVisualization();
|
||||
|
||||
if (!url || !initCava() || !cava) return
|
||||
if (!url || !initCava() || !cava) return;
|
||||
|
||||
// Initialize cavacore with current resolution + any overrides
|
||||
const config: CavaCoreConfig = {
|
||||
@@ -88,56 +99,57 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
...props.visualizerConfig,
|
||||
}
|
||||
cava.init(config)
|
||||
};
|
||||
cava.init(config);
|
||||
|
||||
// Pre-allocate sample read buffer
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME)
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||
|
||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
||||
if (!reader || reader.url !== url) {
|
||||
if (reader) reader.stop()
|
||||
reader = new AudioStreamReader({ url })
|
||||
if (reader) reader.stop();
|
||||
reader = new AudioStreamReader({ url });
|
||||
}
|
||||
reader.start(position, speed)
|
||||
reader.start(position, speed);
|
||||
|
||||
// Start render loop
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL)
|
||||
}
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
};
|
||||
|
||||
const stopVisualization = () => {
|
||||
if (frameTimer) {
|
||||
clearInterval(frameTimer)
|
||||
frameTimer = null
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
}
|
||||
if (reader) {
|
||||
reader.stop()
|
||||
reader.stop();
|
||||
// Don't null reader — we reuse it across start/stop cycles
|
||||
}
|
||||
if (cava?.isReady) {
|
||||
cava.destroy()
|
||||
cava.destroy();
|
||||
}
|
||||
sampleBuffer = null
|
||||
}
|
||||
sampleBuffer = null;
|
||||
};
|
||||
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||
|
||||
// Read available PCM samples from the stream
|
||||
const count = reader.read(sampleBuffer)
|
||||
if (count === 0) return
|
||||
const count = reader.read(sampleBuffer);
|
||||
if (count === 0) return;
|
||||
|
||||
// Feed samples to cavacore → get frequency bars
|
||||
const input = count < sampleBuffer.length
|
||||
? sampleBuffer.subarray(0, count)
|
||||
: sampleBuffer
|
||||
const output = cava.execute(input)
|
||||
const input =
|
||||
count < sampleBuffer.length
|
||||
? sampleBuffer.subarray(0, count)
|
||||
: sampleBuffer;
|
||||
const output = cava.execute(input);
|
||||
|
||||
// Copy bar values to a new array for the signal
|
||||
setBarData(Array.from(output))
|
||||
}
|
||||
setBarData(Array.from(output));
|
||||
};
|
||||
|
||||
// ── Single unified effect: respond to all prop changes ─────────────
|
||||
//
|
||||
@@ -159,14 +171,14 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
],
|
||||
([playing, url, speed]) => {
|
||||
if (playing && url) {
|
||||
const pos = untrack(() => props.position)
|
||||
startVisualization(url, pos, speed)
|
||||
const pos = untrack(() => props.position);
|
||||
startVisualization(url, pos, speed);
|
||||
} else {
|
||||
stopVisualization()
|
||||
stopVisualization();
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
||||
//
|
||||
@@ -175,107 +187,94 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
// This is intentionally a separate effect — it should NOT trigger a
|
||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
||||
|
||||
let lastSyncPosition = 0
|
||||
let lastSyncPosition = 0;
|
||||
createEffect(
|
||||
on(
|
||||
() => props.position,
|
||||
(pos) => {
|
||||
if (!props.isPlaying || !reader?.running) {
|
||||
lastSyncPosition = pos
|
||||
return
|
||||
lastSyncPosition = pos;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Math.abs(pos - lastSyncPosition)
|
||||
lastSyncPosition = pos
|
||||
const delta = Math.abs(pos - lastSyncPosition);
|
||||
lastSyncPosition = pos;
|
||||
|
||||
if (delta > 2) {
|
||||
const speed = props.speed ?? 1
|
||||
reader.restart(pos, speed)
|
||||
const speed = props.speed ?? 1;
|
||||
reader.restart(pos, speed);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopVisualization()
|
||||
stopVisualization();
|
||||
if (reader) {
|
||||
reader.stop()
|
||||
reader = null
|
||||
reader.stop();
|
||||
reader = null;
|
||||
}
|
||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
||||
if (cava?.isReady) {
|
||||
cava.destroy()
|
||||
cava.destroy();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
const playedRatio = () =>
|
||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration)
|
||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
|
||||
|
||||
const renderLine = () => {
|
||||
const bars = barData()
|
||||
const numBars = resolution()
|
||||
const bars = barData();
|
||||
const numBars = resolution();
|
||||
|
||||
// If no data yet, show empty placeholder
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(numBars)
|
||||
const placeholder = ".".repeat(numBars);
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg="#3b4252">{placeholder}</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const played = Math.floor(numBars * playedRatio())
|
||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
|
||||
const futureColor = "#3b4252"
|
||||
const played = Math.floor(numBars * playedRatio());
|
||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
|
||||
const futureColor = "#3b4252";
|
||||
|
||||
const playedChars = bars
|
||||
.slice(0, played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("")
|
||||
.join("");
|
||||
|
||||
const futureChars = bars
|
||||
.slice(played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("")
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg={playedColor}>{playedChars || " "}</text>
|
||||
<text fg={futureColor}>{futureChars || " "}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleClick = (event: { x: number }) => {
|
||||
const numBars = resolution()
|
||||
const ratio = numBars === 0 ? 0 : event.x / numBars
|
||||
const numBars = resolution();
|
||||
const ratio = numBars === 0 ? 0 : event.x / numBars;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(props.duration, Math.round(props.duration * ratio)),
|
||||
)
|
||||
props.onSeek?.(next)
|
||||
}
|
||||
);
|
||||
props.onSeek?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<box border padding={1} onMouseDown={handleClick}>
|
||||
{renderLine()}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cavacore is available on this system.
|
||||
* Useful for deciding whether to show RealtimeWaveform or MergedWaveform.
|
||||
*/
|
||||
let _cavacoreAvailable: boolean | null = null
|
||||
export function isCavacoreAvailable(): boolean {
|
||||
if (_cavacoreAvailable === null) {
|
||||
const cava = loadCavaCore()
|
||||
_cavacoreAvailable = cava !== null
|
||||
}
|
||||
return _cavacoreAvailable
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Show } from "solid-js"
|
||||
import type { SearchResult } from "../types/source"
|
||||
import { SourceBadge } from "./SourceBadge"
|
||||
import { Show } from "solid-js";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { SourceBadge } from "./SourceBadge";
|
||||
|
||||
type ResultCardProps = {
|
||||
result: SearchResult
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
onSubscribe?: () => void
|
||||
}
|
||||
result: SearchResult;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export function ResultCard(props: ResultCardProps) {
|
||||
const podcast = () => props.result.podcast
|
||||
const podcast = () => props.result.podcast;
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -21,7 +21,11 @@ export function ResultCard(props: ResultCardProps) {
|
||||
backgroundColor={props.selected ? "#222" : undefined}
|
||||
onMouseDown={props.onSelect}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={props.selected ? "cyan" : "white"}>
|
||||
<strong>{podcast().title}</strong>
|
||||
@@ -67,13 +71,13 @@ export function ResultCard(props: ResultCardProps) {
|
||||
paddingRight={1}
|
||||
width={18}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation?.()
|
||||
props.onSubscribe?.()
|
||||
event.stopPropagation?.();
|
||||
props.onSubscribe?.();
|
||||
}}
|
||||
>
|
||||
<text fg="cyan">[+] Add to Feeds</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
73
src/tabs/Search/ResultDetail.tsx
Normal file
73
src/tabs/Search/ResultDetail.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Show } from "solid-js";
|
||||
import { format } from "date-fns";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { SourceBadge } from "./SourceBadge";
|
||||
|
||||
type ResultDetailProps = {
|
||||
result?: SearchResult;
|
||||
onSubscribe?: (result: SearchResult) => void;
|
||||
};
|
||||
|
||||
export function ResultDetail(props: ResultDetailProps) {
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1} height="100%">
|
||||
<Show
|
||||
when={props.result}
|
||||
fallback={<text fg="gray">Select a result to see details.</text>}
|
||||
>
|
||||
{(result) => (
|
||||
<>
|
||||
<text fg="white">
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
|
||||
<SourceBadge
|
||||
sourceId={result().sourceId}
|
||||
sourceName={result().sourceName}
|
||||
sourceType={result().sourceType}
|
||||
/>
|
||||
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg="gray">by {result().podcast.author}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg="gray">{result().podcast.description}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
{(result().podcast.categories ?? []).map((category) => (
|
||||
<text fg="yellow">[{category}]</text>
|
||||
))}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
|
||||
|
||||
<text fg="gray">
|
||||
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
||||
</text>
|
||||
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={18}
|
||||
onMouseDown={() => props.onSubscribe?.(result())}
|
||||
>
|
||||
<text fg="cyan">[+] Add to Feeds</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg="green">Already subscribed</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -2,171 +2,171 @@
|
||||
* SearchPage component - Main search interface for PodTUI
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useSearchStore } from "../stores/search"
|
||||
import { SearchResults } from "./SearchResults"
|
||||
import { SearchHistory } from "./SearchHistory"
|
||||
import type { SearchResult } from "../types/source"
|
||||
import { createSignal, createEffect, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { SearchResults } from "./SearchResults";
|
||||
import { SearchHistory } from "./SearchHistory";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
|
||||
type SearchPageProps = {
|
||||
focused: boolean
|
||||
onSubscribe?: (result: SearchResult) => void
|
||||
onInputFocusChange?: (focused: boolean) => void
|
||||
onExit?: () => void
|
||||
}
|
||||
focused: boolean;
|
||||
onSubscribe?: (result: SearchResult) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
type FocusArea = "input" | "results" | "history"
|
||||
type FocusArea = "input" | "results" | "history";
|
||||
|
||||
export function SearchPage(props: SearchPageProps) {
|
||||
const searchStore = useSearchStore()
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("input")
|
||||
const [inputValue, setInputValue] = createSignal("")
|
||||
const [resultIndex, setResultIndex] = createSignal(0)
|
||||
const [historyIndex, setHistoryIndex] = createSignal(0)
|
||||
const searchStore = useSearchStore();
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("input");
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const [resultIndex, setResultIndex] = createSignal(0);
|
||||
const [historyIndex, setHistoryIndex] = createSignal(0);
|
||||
|
||||
// Keep parent informed about input focus state
|
||||
createEffect(() => {
|
||||
const isInputFocused = props.focused && focusArea() === "input"
|
||||
props.onInputFocusChange?.(isInputFocused)
|
||||
})
|
||||
const isInputFocused = props.focused && focusArea() === "input";
|
||||
props.onInputFocusChange?.(isInputFocused);
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = inputValue().trim()
|
||||
const query = inputValue().trim();
|
||||
if (query) {
|
||||
await searchStore.search(query)
|
||||
await searchStore.search(query);
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setResultIndex(0)
|
||||
setFocusArea("results");
|
||||
setResultIndex(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistorySelect = async (query: string) => {
|
||||
setInputValue(query)
|
||||
await searchStore.search(query)
|
||||
setInputValue(query);
|
||||
await searchStore.search(query);
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setResultIndex(0)
|
||||
setFocusArea("results");
|
||||
setResultIndex(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultSelect = (result: SearchResult) => {
|
||||
props.onSubscribe?.(result)
|
||||
searchStore.markSubscribed(result.podcast.id)
|
||||
}
|
||||
props.onSubscribe?.(result);
|
||||
searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
if (!props.focused) return;
|
||||
|
||||
const area = focusArea()
|
||||
const area = focusArea();
|
||||
|
||||
// Enter to search from input
|
||||
if ((key.name === "return" || key.name === "enter") && area === "input") {
|
||||
handleSearch()
|
||||
return
|
||||
handleSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab to cycle focus areas
|
||||
if (key.name === "tab" && !key.shift) {
|
||||
if (area === "input") {
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setFocusArea("results");
|
||||
} else if (searchStore.history().length > 0) {
|
||||
setFocusArea("history")
|
||||
setFocusArea("history");
|
||||
}
|
||||
} else if (area === "results") {
|
||||
if (searchStore.history().length > 0) {
|
||||
setFocusArea("history")
|
||||
setFocusArea("history");
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
setFocusArea("input");
|
||||
}
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
setFocusArea("input");
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "tab" && key.shift) {
|
||||
if (area === "input") {
|
||||
if (searchStore.history().length > 0) {
|
||||
setFocusArea("history")
|
||||
setFocusArea("history");
|
||||
} else if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setFocusArea("results");
|
||||
}
|
||||
} else if (area === "history") {
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setFocusArea("results");
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
setFocusArea("input");
|
||||
}
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
setFocusArea("input");
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down for results and history
|
||||
if (area === "results") {
|
||||
const results = searchStore.results()
|
||||
const results = searchStore.results();
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setResultIndex((i) => Math.min(i + 1, results.length - 1))
|
||||
return
|
||||
setResultIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setResultIndex((i) => Math.max(i - 1, 0))
|
||||
return
|
||||
setResultIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const result = results[resultIndex()]
|
||||
if (result) handleResultSelect(result)
|
||||
return
|
||||
const result = results[resultIndex()];
|
||||
if (result) handleResultSelect(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (area === "history") {
|
||||
const history = searchStore.history()
|
||||
const history = searchStore.history();
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setHistoryIndex((i) => Math.min(i + 1, history.length - 1))
|
||||
return
|
||||
setHistoryIndex((i) => Math.min(i + 1, history.length - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setHistoryIndex((i) => Math.max(i - 1, 0))
|
||||
return
|
||||
setHistoryIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const query = history[historyIndex()]
|
||||
if (query) handleHistorySelect(query)
|
||||
return
|
||||
const query = history[historyIndex()];
|
||||
if (query) handleHistorySelect(query);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape goes back to input or up one level
|
||||
if (key.name === "escape") {
|
||||
if (area === "input") {
|
||||
props.onExit?.()
|
||||
props.onExit?.();
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
key.stopPropagation()
|
||||
setFocusArea("input");
|
||||
key.stopPropagation();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// "/" focuses search input
|
||||
if (key.name === "/" && area !== "input") {
|
||||
setFocusArea("input")
|
||||
return
|
||||
setFocusArea("input");
|
||||
return;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%" gap={1}>
|
||||
{/* Search Header */}
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>
|
||||
<strong>Search Podcasts</strong>
|
||||
</text>
|
||||
<text>
|
||||
<strong>Search Podcasts</strong>
|
||||
</text>
|
||||
|
||||
{/* Search Input */}
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
@@ -174,7 +174,7 @@ export function SearchPage(props: SearchPageProps) {
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={(value) => {
|
||||
setInputValue(value)
|
||||
setInputValue(value);
|
||||
}}
|
||||
placeholder="Enter podcast name, topic, or author..."
|
||||
focused={props.focused && focusArea() === "input"}
|
||||
@@ -234,17 +234,17 @@ export function SearchPage(props: SearchPageProps) {
|
||||
</box>
|
||||
|
||||
{/* History Sidebar */}
|
||||
<box width={30} border>
|
||||
<box padding={1} flexDirection="column">
|
||||
<box paddingBottom={1}>
|
||||
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
|
||||
History
|
||||
</text>
|
||||
</box>
|
||||
<SearchHistory
|
||||
history={searchStore.history()}
|
||||
selectedIndex={historyIndex()}
|
||||
focused={focusArea() === "history"}
|
||||
<box width={30} border>
|
||||
<box padding={1} flexDirection="column">
|
||||
<box paddingBottom={1}>
|
||||
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
|
||||
History
|
||||
</text>
|
||||
</box>
|
||||
<SearchHistory
|
||||
history={searchStore.history()}
|
||||
selectedIndex={historyIndex()}
|
||||
focused={focusArea() === "history"}
|
||||
onSelect={handleHistorySelect}
|
||||
onRemove={searchStore.removeFromHistory}
|
||||
onClear={searchStore.clearHistory}
|
||||
@@ -262,5 +262,5 @@ export function SearchPage(props: SearchPageProps) {
|
||||
<text fg="gray">[Esc] Up</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,32 +2,35 @@
|
||||
* SearchResults component for displaying podcast search results
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
import type { SearchResult } from "../types/source"
|
||||
import { ResultCard } from "./ResultCard"
|
||||
import { ResultDetail } from "./ResultDetail"
|
||||
import { For, Show } from "solid-js";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import { ResultCard } from "./ResultCard";
|
||||
import { ResultDetail } from "./ResultDetail";
|
||||
|
||||
type SearchResultsProps = {
|
||||
results: SearchResult[]
|
||||
selectedIndex: number
|
||||
focused: boolean
|
||||
onSelect?: (result: SearchResult) => void
|
||||
onChange?: (index: number) => void
|
||||
isSearching?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
results: SearchResult[];
|
||||
selectedIndex: number;
|
||||
focused: boolean;
|
||||
onSelect?: (result: SearchResult) => void;
|
||||
onChange?: (index: number) => void;
|
||||
isSearching?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export function SearchResults(props: SearchResultsProps) {
|
||||
const handleSelect = (index: number) => {
|
||||
props.onChange?.(index)
|
||||
}
|
||||
props.onChange?.(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={!props.isSearching} fallback={
|
||||
<box padding={1}>
|
||||
<text fg="yellow">Searching...</text>
|
||||
</box>
|
||||
}>
|
||||
<Show
|
||||
when={!props.isSearching}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="yellow">Searching...</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!props.error}
|
||||
fallback={
|
||||
@@ -40,7 +43,9 @@ export function SearchResults(props: SearchResultsProps) {
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">No results found. Try a different search term.</text>
|
||||
<text fg="gray">
|
||||
No results found. Try a different search term.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -71,5 +76,5 @@ export function SearchResults(props: SearchResultsProps) {
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
);
|
||||
}
|
||||
34
src/tabs/Search/SourceBadge.tsx
Normal file
34
src/tabs/Search/SourceBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { SourceType } from "@/types/source";
|
||||
|
||||
type SourceBadgeProps = {
|
||||
sourceId: string;
|
||||
sourceName?: string;
|
||||
sourceType?: SourceType;
|
||||
};
|
||||
|
||||
const typeLabel = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return "API";
|
||||
if (sourceType === SourceType.RSS) return "RSS";
|
||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
||||
return "Source";
|
||||
};
|
||||
|
||||
const typeColor = (sourceType?: SourceType) => {
|
||||
if (sourceType === SourceType.API) return "cyan";
|
||||
if (sourceType === SourceType.RSS) return "green";
|
||||
if (sourceType === SourceType.CUSTOM) return "yellow";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
export function SourceBadge(props: SourceBadgeProps) {
|
||||
const label = () => props.sourceName || props.sourceId;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={1} padding={0}>
|
||||
<text fg={typeColor(props.sourceType)}>
|
||||
[{typeLabel(props.sourceType)}]
|
||||
</text>
|
||||
<text fg="gray">{label()}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { detectFormat } from "../utils/file-detector"
|
||||
import { detectFormat } from "@/utils/file-detector";
|
||||
|
||||
type FilePickerProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function FilePicker(props: FilePickerProps) {
|
||||
const format = detectFormat(props.value)
|
||||
const format = detectFormat(props.value);
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
@@ -18,5 +18,5 @@ export function FilePicker(props: FilePickerProps) {
|
||||
/>
|
||||
<text>Format: {format}</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,84 +3,84 @@
|
||||
* Email/password login with links to code validation and OAuth
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useAuthStore } from "../stores/auth"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { AUTH_CONFIG } from "../config/auth"
|
||||
import { createSignal } from "solid-js";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { AUTH_CONFIG } from "@/config/auth";
|
||||
|
||||
interface LoginScreenProps {
|
||||
focused?: boolean
|
||||
onNavigateToCode?: () => void
|
||||
onNavigateToOAuth?: () => void
|
||||
focused?: boolean;
|
||||
onNavigateToCode?: () => void;
|
||||
onNavigateToOAuth?: () => void;
|
||||
}
|
||||
|
||||
type FocusField = "email" | "password" | "submit" | "code" | "oauth"
|
||||
type FocusField = "email" | "password" | "submit" | "code" | "oauth";
|
||||
|
||||
export function LoginScreen(props: LoginScreenProps) {
|
||||
const auth = useAuthStore()
|
||||
const { theme } = useTheme()
|
||||
const [email, setEmail] = createSignal("")
|
||||
const [password, setPassword] = createSignal("")
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("email")
|
||||
const [emailError, setEmailError] = createSignal<string | null>(null)
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const auth = useAuthStore();
|
||||
const { theme } = useTheme();
|
||||
const [email, setEmail] = createSignal("");
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("email");
|
||||
const [emailError, setEmailError] = createSignal<string | null>(null);
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null);
|
||||
|
||||
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"]
|
||||
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
|
||||
|
||||
const validateEmail = (value: string): boolean => {
|
||||
if (!value) {
|
||||
setEmailError("Email is required")
|
||||
return false
|
||||
setEmailError("Email is required");
|
||||
return false;
|
||||
}
|
||||
if (!AUTH_CONFIG.email.pattern.test(value)) {
|
||||
setEmailError("Invalid email format")
|
||||
return false
|
||||
setEmailError("Invalid email format");
|
||||
return false;
|
||||
}
|
||||
setEmailError(null)
|
||||
return true
|
||||
}
|
||||
setEmailError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
const validatePassword = (value: string): boolean => {
|
||||
if (!value) {
|
||||
setPasswordError("Password is required")
|
||||
return false
|
||||
setPasswordError("Password is required");
|
||||
return false;
|
||||
}
|
||||
if (value.length < AUTH_CONFIG.password.minLength) {
|
||||
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`)
|
||||
return false
|
||||
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
|
||||
return false;
|
||||
}
|
||||
setPasswordError(null)
|
||||
return true
|
||||
}
|
||||
setPasswordError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isEmailValid = validateEmail(email())
|
||||
const isPasswordValid = validatePassword(password())
|
||||
const isEmailValid = validateEmail(email());
|
||||
const isPasswordValid = validatePassword(password());
|
||||
|
||||
if (!isEmailValid || !isPasswordValid) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await auth.login({ email: email(), password: password() })
|
||||
}
|
||||
await auth.login({ email: email(), password: password() });
|
||||
};
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "submit") {
|
||||
handleSubmit()
|
||||
handleSubmit();
|
||||
} else if (focusField() === "code" && props.onNavigateToCode) {
|
||||
props.onNavigateToCode()
|
||||
props.onNavigateToCode();
|
||||
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
|
||||
props.onNavigateToOAuth()
|
||||
props.onNavigateToOAuth();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
@@ -92,7 +92,9 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
|
||||
{/* Email field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg={focusField() === "email" ? theme.primary : undefined}>Email:</text>
|
||||
<text fg={focusField() === "email" ? theme.primary : undefined}>
|
||||
Email:
|
||||
</text>
|
||||
<input
|
||||
value={email()}
|
||||
onInput={setEmail}
|
||||
@@ -100,9 +102,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
focused={props.focused && focusField() === "email"}
|
||||
width={30}
|
||||
/>
|
||||
{emailError() && (
|
||||
<text fg={theme.error}>{emailError()}</text>
|
||||
)}
|
||||
{emailError() && <text fg={theme.error}>{emailError()}</text>}
|
||||
</box>
|
||||
|
||||
{/* Password field */}
|
||||
@@ -117,9 +117,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
focused={props.focused && focusField() === "password"}
|
||||
width={30}
|
||||
/>
|
||||
{passwordError() && (
|
||||
<text fg={theme.error}>{passwordError()}</text>
|
||||
)}
|
||||
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
@@ -129,7 +127,9 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "submit" ? theme.primary : undefined}
|
||||
backgroundColor={
|
||||
focusField() === "submit" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<text fg={focusField() === "submit" ? theme.text : undefined}>
|
||||
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
||||
@@ -138,9 +138,7 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text fg={theme.error}>{auth.error.message}</text>
|
||||
)}
|
||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
@@ -173,5 +171,5 @@ export function LoginScreen(props: LoginScreenProps) {
|
||||
|
||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,39 +3,39 @@
|
||||
* Displays OAuth limitations and alternative authentication methods
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth"
|
||||
import { createSignal } from "solid-js";
|
||||
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
|
||||
|
||||
interface OAuthPlaceholderProps {
|
||||
focused?: boolean
|
||||
onBack?: () => void
|
||||
onNavigateToCode?: () => void
|
||||
focused?: boolean;
|
||||
onBack?: () => void;
|
||||
onNavigateToCode?: () => void;
|
||||
}
|
||||
|
||||
type FocusField = "code" | "back"
|
||||
type FocusField = "code" | "back";
|
||||
|
||||
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("code")
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
||||
|
||||
const fields: FocusField[] = ["code", "back"]
|
||||
const fields: FocusField[] = ["code", "back"];
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "code" && props.onNavigateToCode) {
|
||||
props.onNavigateToCode()
|
||||
props.onNavigateToCode();
|
||||
} else if (focusField() === "back" && props.onBack) {
|
||||
props.onBack()
|
||||
props.onBack();
|
||||
}
|
||||
} else if (key.name === "escape" && props.onBack) {
|
||||
props.onBack()
|
||||
props.onBack();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
@@ -121,5 +121,5 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import type { ThemeName } from "../types/settings"
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import type { ThemeName } from "@/types/settings";
|
||||
|
||||
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"
|
||||
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
|
||||
|
||||
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||
{ value: "system", label: "System" },
|
||||
@@ -13,68 +13,77 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||
{ value: "tokyo", label: "Tokyo" },
|
||||
{ value: "nord", label: "Nord" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]
|
||||
];
|
||||
|
||||
export function PreferencesPanel() {
|
||||
const appStore = useAppStore()
|
||||
const { theme } = useTheme()
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("theme")
|
||||
const appStore = useAppStore();
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("theme");
|
||||
|
||||
const settings = () => appStore.state().settings
|
||||
const preferences = () => appStore.state().preferences
|
||||
const settings = () => appStore.state().settings;
|
||||
const preferences = () => appStore.state().preferences;
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const fields: FocusField[] = ["theme", "font", "speed", "explicit", "auto"]
|
||||
const idx = fields.indexOf(focusField())
|
||||
const fields: FocusField[] = [
|
||||
"theme",
|
||||
"font",
|
||||
"speed",
|
||||
"explicit",
|
||||
"auto",
|
||||
];
|
||||
const idx = fields.indexOf(focusField());
|
||||
const next = key.shift
|
||||
? (idx - 1 + fields.length) % fields.length
|
||||
: (idx + 1) % fields.length
|
||||
setFocusField(fields[next])
|
||||
return
|
||||
: (idx + 1) % fields.length;
|
||||
setFocusField(fields[next]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1)
|
||||
stepValue(-1);
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1)
|
||||
stepValue(1);
|
||||
}
|
||||
if (key.name === "space" || key.name === "return" || key.name === "enter") {
|
||||
toggleValue()
|
||||
toggleValue();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField()
|
||||
const field = focusField();
|
||||
if (field === "theme") {
|
||||
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme)
|
||||
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length
|
||||
appStore.setTheme(THEME_LABELS[next].value)
|
||||
return
|
||||
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
|
||||
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length;
|
||||
appStore.setTheme(THEME_LABELS[next].value);
|
||||
return;
|
||||
}
|
||||
if (field === "font") {
|
||||
const next = Math.min(20, Math.max(10, settings().fontSize + delta))
|
||||
appStore.updateSettings({ fontSize: next })
|
||||
return
|
||||
const next = Math.min(20, Math.max(10, settings().fontSize + delta));
|
||||
appStore.updateSettings({ fontSize: next });
|
||||
return;
|
||||
}
|
||||
if (field === "speed") {
|
||||
const next = Math.min(2, Math.max(0.5, settings().playbackSpeed + delta * 0.1))
|
||||
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) })
|
||||
const next = Math.min(
|
||||
2,
|
||||
Math.max(0.5, settings().playbackSpeed + delta * 0.1),
|
||||
);
|
||||
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleValue = () => {
|
||||
const field = focusField()
|
||||
const field = focusField();
|
||||
if (field === "explicit") {
|
||||
appStore.updatePreferences({ showExplicit: !preferences().showExplicit })
|
||||
appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
|
||||
}
|
||||
if (field === "auto") {
|
||||
appStore.updatePreferences({ autoDownload: !preferences().autoDownload })
|
||||
appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard(handleKey)
|
||||
useKeyboard(handleKey);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
@@ -82,15 +91,21 @@ export function PreferencesPanel() {
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>Theme:</text>
|
||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
|
||||
Theme:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text>
|
||||
<text fg={theme.text}>
|
||||
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>Font Size:</text>
|
||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
|
||||
Font Size:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{settings().fontSize}px</text>
|
||||
</box>
|
||||
@@ -98,7 +113,9 @@ export function PreferencesPanel() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>Playback:</text>
|
||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
|
||||
Playback:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
||||
</box>
|
||||
@@ -106,9 +123,15 @@ export function PreferencesPanel() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "explicit" ? theme.primary : theme.textMuted}>Show Explicit:</text>
|
||||
<text
|
||||
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Show Explicit:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={preferences().showExplicit ? theme.success : theme.textMuted}>
|
||||
<text
|
||||
fg={preferences().showExplicit ? theme.success : theme.textMuted}
|
||||
>
|
||||
{preferences().showExplicit ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -116,9 +139,13 @@ export function PreferencesPanel() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>Auto Download:</text>
|
||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
|
||||
Auto Download:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={preferences().autoDownload ? theme.success : theme.textMuted}>
|
||||
<text
|
||||
fg={preferences().autoDownload ? theme.success : theme.textMuted}
|
||||
>
|
||||
{preferences().autoDownload ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -128,5 +155,5 @@ export function PreferencesPanel() {
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createSignal, For } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { SourceManager } from "./SourceManager"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { PreferencesPanel } from "./PreferencesPanel"
|
||||
import { SyncPanel } from "./SyncPanel"
|
||||
import { VisualizerSettings } from "./VisualizerSettings"
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { SourceManager } from "./SourceManager";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PreferencesPanel } from "./PreferencesPanel";
|
||||
import { SyncPanel } from "./SyncPanel";
|
||||
import { VisualizerSettings } from "./VisualizerSettings";
|
||||
|
||||
type SettingsScreenProps = {
|
||||
accountLabel: string
|
||||
accountStatus: "signed-in" | "signed-out"
|
||||
onOpenAccount?: () => void
|
||||
onExit?: () => void
|
||||
}
|
||||
accountLabel: string;
|
||||
accountStatus: "signed-in" | "signed-out";
|
||||
onOpenAccount?: () => void;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account"
|
||||
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account";
|
||||
|
||||
const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
||||
{ id: "sync", label: "Sync" },
|
||||
@@ -21,41 +21,47 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
||||
{ id: "preferences", label: "Preferences" },
|
||||
{ id: "visualizer", label: "Visualizer" },
|
||||
{ id: "account", label: "Account" },
|
||||
]
|
||||
];
|
||||
|
||||
export function SettingsScreen(props: SettingsScreenProps) {
|
||||
const { theme } = useTheme()
|
||||
const [activeSection, setActiveSection] = createSignal<SectionId>("sync")
|
||||
const { theme } = useTheme();
|
||||
const [activeSection, setActiveSection] = createSignal<SectionId>("sync");
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
props.onExit?.()
|
||||
return
|
||||
props.onExit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "tab") {
|
||||
const idx = SECTIONS.findIndex((s) => s.id === activeSection())
|
||||
const idx = SECTIONS.findIndex((s) => s.id === activeSection());
|
||||
const next = key.shift
|
||||
? (idx - 1 + SECTIONS.length) % SECTIONS.length
|
||||
: (idx + 1) % SECTIONS.length
|
||||
setActiveSection(SECTIONS[next].id)
|
||||
return
|
||||
: (idx + 1) % SECTIONS.length;
|
||||
setActiveSection(SECTIONS[next].id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "1") setActiveSection("sync")
|
||||
if (key.name === "2") setActiveSection("sources")
|
||||
if (key.name === "3") setActiveSection("preferences")
|
||||
if (key.name === "4") setActiveSection("visualizer")
|
||||
if (key.name === "5") setActiveSection("account")
|
||||
})
|
||||
if (key.name === "1") setActiveSection("sync");
|
||||
if (key.name === "2") setActiveSection("sources");
|
||||
if (key.name === "3") setActiveSection("preferences");
|
||||
if (key.name === "4") setActiveSection("visualizer");
|
||||
if (key.name === "5") setActiveSection("account");
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} height="100%">
|
||||
<box flexDirection="row" justifyContent="space-between" alignItems="center">
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<text>
|
||||
<strong>Settings</strong>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>[Tab] Switch section | 1-5 jump | Esc up</text>
|
||||
<text fg={theme.textMuted}>
|
||||
[Tab] Switch section | 1-5 jump | Esc up
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -64,10 +70,16 @@ export function SettingsScreen(props: SettingsScreenProps) {
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={activeSection() === section.id ? theme.primary : undefined}
|
||||
backgroundColor={
|
||||
activeSection() === section.id ? theme.primary : undefined
|
||||
}
|
||||
onMouseDown={() => setActiveSection(section.id)}
|
||||
>
|
||||
<text fg={activeSection() === section.id ? theme.text : theme.textMuted}>
|
||||
<text
|
||||
fg={
|
||||
activeSection() === section.id ? theme.text : theme.textMuted
|
||||
}
|
||||
>
|
||||
[{index() + 1}] {section.label}
|
||||
</text>
|
||||
</box>
|
||||
@@ -85,7 +97,13 @@ export function SettingsScreen(props: SettingsScreenProps) {
|
||||
<text fg={theme.textMuted}>Account</text>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={theme.textMuted}>Status:</text>
|
||||
<text fg={props.accountStatus === "signed-in" ? theme.success : theme.warning}>
|
||||
<text
|
||||
fg={
|
||||
props.accountStatus === "signed-in"
|
||||
? theme.success
|
||||
: theme.warning
|
||||
}
|
||||
>
|
||||
{props.accountLabel}
|
||||
</text>
|
||||
</box>
|
||||
@@ -98,5 +116,5 @@ export function SettingsScreen(props: SettingsScreenProps) {
|
||||
|
||||
<text fg={theme.textMuted}>Enter to dive | Esc up</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,39 +3,39 @@
|
||||
* Add, remove, and configure podcast sources
|
||||
*/
|
||||
|
||||
import { createSignal, For } from "solid-js"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { SourceType } from "../types/source"
|
||||
import type { PodcastSource } from "../types/source"
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SourceType } from "@/types/source";
|
||||
import type { PodcastSource } from "@/types/source";
|
||||
|
||||
interface SourceManagerProps {
|
||||
focused?: boolean
|
||||
onClose?: () => void
|
||||
focused?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"
|
||||
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
|
||||
|
||||
export function SourceManager(props: SourceManagerProps) {
|
||||
const feedStore = useFeedStore()
|
||||
const { theme } = useTheme()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("list")
|
||||
const [newSourceUrl, setNewSourceUrl] = createSignal("")
|
||||
const [newSourceName, setNewSourceName] = createSignal("")
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const feedStore = useFeedStore();
|
||||
const { theme } = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("list");
|
||||
const [newSourceUrl, setNewSourceUrl] = createSignal("");
|
||||
const [newSourceName, setNewSourceName] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const sources = () => feedStore.sources()
|
||||
const sources = () => feedStore.sources();
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "escape") {
|
||||
if (focusArea() !== "list") {
|
||||
setFocusArea("list")
|
||||
setError(null)
|
||||
setFocusArea("list");
|
||||
setError(null);
|
||||
} else if (props.onClose) {
|
||||
props.onClose()
|
||||
props.onClose();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "tab") {
|
||||
@@ -46,82 +46,100 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
"explicit",
|
||||
"add",
|
||||
"url",
|
||||
]
|
||||
const idx = areas.indexOf(focusArea())
|
||||
];
|
||||
const idx = areas.indexOf(focusArea());
|
||||
const nextIdx = key.shift
|
||||
? (idx - 1 + areas.length) % areas.length
|
||||
: (idx + 1) % areas.length
|
||||
setFocusArea(areas[nextIdx])
|
||||
return
|
||||
: (idx + 1) % areas.length;
|
||||
setFocusArea(areas[nextIdx]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusArea() === "list") {
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1))
|
||||
} else if (key.name === "return" || key.name === "enter" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
|
||||
} else if (
|
||||
key.name === "return" ||
|
||||
key.name === "enter" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source) {
|
||||
feedStore.toggleSource(source.id)
|
||||
feedStore.toggleSource(source.id);
|
||||
}
|
||||
} else if (key.name === "d" || key.name === "delete") {
|
||||
const source = sources()[selectedIndex()]
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source) {
|
||||
const removed = feedStore.removeSource(source.id)
|
||||
const removed = feedStore.removeSource(source.id);
|
||||
if (!removed) {
|
||||
setError("Cannot remove default sources")
|
||||
setError("Cannot remove default sources");
|
||||
}
|
||||
}
|
||||
} else if (key.name === "a") {
|
||||
setFocusArea("add")
|
||||
setFocusArea("add");
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "country") {
|
||||
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (
|
||||
key.name === "enter" ||
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.country === "US" ? "GB" : "US"
|
||||
feedStore.updateSource(source.id, { country: next })
|
||||
const next = source.country === "US" ? "GB" : "US";
|
||||
feedStore.updateSource(source.id, { country: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "explicit") {
|
||||
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (
|
||||
key.name === "enter" ||
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
feedStore.updateSource(source.id, { allowExplicit: !source.allowExplicit })
|
||||
feedStore.updateSource(source.id, {
|
||||
allowExplicit: !source.allowExplicit,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "language") {
|
||||
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (
|
||||
key.name === "enter" ||
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.language === "ja_jp" ? "en_us" : "ja_jp"
|
||||
feedStore.updateSource(source.id, { language: next })
|
||||
const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
|
||||
feedStore.updateSource(source.id, { language: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSource = () => {
|
||||
const url = newSourceUrl().trim()
|
||||
const name = newSourceName().trim() || `Custom Source`
|
||||
const url = newSourceUrl().trim();
|
||||
const name = newSourceName().trim() || `Custom Source`;
|
||||
|
||||
if (!url) {
|
||||
setError("URL is required")
|
||||
return
|
||||
setError("URL is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url)
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError("Invalid URL format")
|
||||
return
|
||||
setError("Invalid URL format");
|
||||
return;
|
||||
}
|
||||
|
||||
feedStore.addSource({
|
||||
@@ -130,25 +148,25 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
baseUrl: url,
|
||||
enabled: true,
|
||||
description: `Custom RSS feed: ${url}`,
|
||||
})
|
||||
});
|
||||
|
||||
setNewSourceUrl("")
|
||||
setNewSourceName("")
|
||||
setFocusArea("list")
|
||||
setError(null)
|
||||
}
|
||||
setNewSourceUrl("");
|
||||
setNewSourceName("");
|
||||
setFocusArea("list");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const getSourceIcon = (source: PodcastSource) => {
|
||||
if (source.type === SourceType.API) return "[API]"
|
||||
if (source.type === SourceType.RSS) return "[RSS]"
|
||||
return "[?]"
|
||||
}
|
||||
if (source.type === SourceType.API) return "[API]";
|
||||
if (source.type === SourceType.RSS) return "[RSS]";
|
||||
return "[?]";
|
||||
};
|
||||
|
||||
const selectedSource = () => sources()[selectedIndex()]
|
||||
const isApiSource = () => selectedSource()?.type === SourceType.API
|
||||
const sourceCountry = () => selectedSource()?.country || "US"
|
||||
const sourceExplicit = () => selectedSource()?.allowExplicit !== false
|
||||
const sourceLanguage = () => selectedSource()?.language || "en_us"
|
||||
const selectedSource = () => sources()[selectedIndex()];
|
||||
const isApiSource = () => selectedSource()?.type === SourceType.API;
|
||||
const sourceCountry = () => selectedSource()?.country || "US";
|
||||
const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
|
||||
const sourceLanguage = () => selectedSource()?.language || "en_us";
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
@@ -161,11 +179,13 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
||||
|
||||
{/* Source list */}
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>Sources:</text>
|
||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
|
||||
Sources:
|
||||
</text>
|
||||
<scrollbox height={6}>
|
||||
<For each={sources()}>
|
||||
{(source, index) => (
|
||||
@@ -179,16 +199,18 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index())
|
||||
setFocusArea("list")
|
||||
feedStore.toggleSource(source.id)
|
||||
setSelectedIndex(index());
|
||||
setFocusArea("list");
|
||||
feedStore.toggleSource(source.id);
|
||||
}}
|
||||
>
|
||||
<text fg={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? theme.primary
|
||||
: theme.textMuted
|
||||
}>
|
||||
<text
|
||||
fg={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? theme.primary
|
||||
: theme.textMuted
|
||||
}
|
||||
>
|
||||
{focusArea() === "list" && index() === selectedIndex()
|
||||
? ">"
|
||||
: " "}
|
||||
@@ -210,49 +232,78 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<text fg={theme.textMuted}>Space/Enter to toggle, d to delete, a to add</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Space/Enter to toggle, d to delete, a to add
|
||||
</text>
|
||||
|
||||
{/* API settings */}
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={isApiSource() ? theme.textMuted : theme.accent}>
|
||||
{isApiSource() ? "API Settings" : "API Settings (select an API source)"}
|
||||
{isApiSource()
|
||||
? "API Settings"
|
||||
: "API Settings (select an API source)"}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusArea() === "country" ? theme.primary : undefined}
|
||||
backgroundColor={
|
||||
focusArea() === "country" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<text fg={focusArea() === "country" ? theme.primary : theme.textMuted}>
|
||||
<text
|
||||
fg={focusArea() === "country" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Country: {sourceCountry()}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusArea() === "language" ? theme.primary : undefined}
|
||||
backgroundColor={
|
||||
focusArea() === "language" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<text fg={focusArea() === "language" ? theme.primary : theme.textMuted}>
|
||||
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
||||
<text
|
||||
fg={
|
||||
focusArea() === "language" ? theme.primary : theme.textMuted
|
||||
}
|
||||
>
|
||||
Language:{" "}
|
||||
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusArea() === "explicit" ? theme.primary : undefined}
|
||||
backgroundColor={
|
||||
focusArea() === "explicit" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<text fg={focusArea() === "explicit" ? theme.primary : theme.textMuted}>
|
||||
<text
|
||||
fg={
|
||||
focusArea() === "explicit" ? theme.primary : theme.textMuted
|
||||
}
|
||||
>
|
||||
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Enter/Space to toggle focused setting</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Enter/Space to toggle focused setting
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Add new source form */}
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "add" || focusArea() === "url" ? theme.primary : theme.textMuted}>
|
||||
<text
|
||||
fg={
|
||||
focusArea() === "add" || focusArea() === "url"
|
||||
? theme.primary
|
||||
: theme.textMuted
|
||||
}
|
||||
>
|
||||
Add New Source:
|
||||
</text>
|
||||
|
||||
@@ -272,8 +323,8 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
<input
|
||||
value={newSourceUrl()}
|
||||
onInput={(v) => {
|
||||
setNewSourceUrl(v)
|
||||
setError(null)
|
||||
setNewSourceUrl(v);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://example.com/feed.rss"
|
||||
focused={props.focused && focusArea() === "url"}
|
||||
@@ -281,22 +332,15 @@ export function SourceManager(props: SourceManagerProps) {
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
width={15}
|
||||
onMouseDown={handleAddSource}
|
||||
>
|
||||
<box border padding={0} width={15} onMouseDown={handleAddSource}>
|
||||
<text fg={theme.success}>[+] Add Source</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Error message */}
|
||||
{error() && (
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
)}
|
||||
{error() && <text fg={theme.error}>{error()}</text>}
|
||||
|
||||
<text fg={theme.textMuted}>Tab to switch sections, Esc to close</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,60 +3,60 @@
|
||||
* Displays user profile information and sync status
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useAuthStore } from "../stores/auth"
|
||||
import { format } from "date-fns"
|
||||
import { createSignal } from "solid-js";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface SyncProfileProps {
|
||||
focused?: boolean
|
||||
onLogout?: () => void
|
||||
onManageSync?: () => void
|
||||
focused?: boolean;
|
||||
onLogout?: () => void;
|
||||
onManageSync?: () => void;
|
||||
}
|
||||
|
||||
type FocusField = "sync" | "export" | "logout"
|
||||
type FocusField = "sync" | "export" | "logout";
|
||||
|
||||
export function SyncProfile(props: SyncProfileProps) {
|
||||
const auth = useAuthStore()
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("sync")
|
||||
const [lastSyncTime] = createSignal<Date | null>(new Date())
|
||||
const auth = useAuthStore();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("sync");
|
||||
const [lastSyncTime] = createSignal<Date | null>(new Date());
|
||||
|
||||
const fields: FocusField[] = ["sync", "export", "logout"]
|
||||
const fields: FocusField[] = ["sync", "export", "logout"];
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const currentIndex = fields.indexOf(focusField());
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
: (currentIndex + 1) % fields.length;
|
||||
setFocusField(fields[nextIndex]);
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "sync" && props.onManageSync) {
|
||||
props.onManageSync()
|
||||
props.onManageSync();
|
||||
} else if (focusField() === "logout" && props.onLogout) {
|
||||
handleLogout()
|
||||
handleLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
auth.logout()
|
||||
auth.logout();
|
||||
if (props.onLogout) {
|
||||
props.onLogout()
|
||||
props.onLogout();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null | undefined): string => {
|
||||
if (!date) return "Never"
|
||||
return format(date, "MMM d, yyyy HH:mm")
|
||||
}
|
||||
if (!date) return "Never";
|
||||
return format(date, "MMM d, yyyy HH:mm");
|
||||
};
|
||||
|
||||
const user = () => auth.state().user
|
||||
const user = () => auth.state().user;
|
||||
|
||||
// Get user initials for avatar
|
||||
const userInitials = () => {
|
||||
const name = user()?.name || "?"
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
const name = user()?.name || "?";
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
@@ -69,7 +69,14 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
{/* User avatar and info */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
{/* ASCII avatar */}
|
||||
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center">
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
width={8}
|
||||
height={4}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<text fg="cyan">{userInitials()}</text>
|
||||
</box>
|
||||
|
||||
@@ -144,5 +151,5 @@ export function SyncProfile(props: SyncProfileProps) {
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter to select</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -5,95 +5,100 @@
|
||||
* frequency cutoffs. All changes persist via the app store.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { isCavacoreAvailable } from "./RealtimeWaveform"
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut"
|
||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
|
||||
|
||||
const FIELDS: FocusField[] = ["bars", "sensitivity", "noise", "lowCut", "highCut"]
|
||||
const FIELDS: FocusField[] = [
|
||||
"bars",
|
||||
"sensitivity",
|
||||
"noise",
|
||||
"lowCut",
|
||||
"highCut",
|
||||
];
|
||||
|
||||
export function VisualizerSettings() {
|
||||
const appStore = useAppStore()
|
||||
const { theme } = useTheme()
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("bars")
|
||||
const appStore = useAppStore();
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("bars");
|
||||
|
||||
const viz = () => appStore.state().settings.visualizer
|
||||
const viz = () => appStore.state().settings.visualizer;
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const idx = FIELDS.indexOf(focusField())
|
||||
const idx = FIELDS.indexOf(focusField());
|
||||
const next = key.shift
|
||||
? (idx - 1 + FIELDS.length) % FIELDS.length
|
||||
: (idx + 1) % FIELDS.length
|
||||
setFocusField(FIELDS[next])
|
||||
return
|
||||
: (idx + 1) % FIELDS.length;
|
||||
setFocusField(FIELDS[next]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1)
|
||||
stepValue(-1);
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1)
|
||||
stepValue(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField()
|
||||
const v = viz()
|
||||
const field = focusField();
|
||||
const v = viz();
|
||||
|
||||
switch (field) {
|
||||
case "bars": {
|
||||
// Step by 8: 8, 16, 24, 32, ..., 128
|
||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8))
|
||||
appStore.updateVisualizer({ bars: next })
|
||||
break
|
||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8));
|
||||
appStore.updateVisualizer({ bars: next });
|
||||
break;
|
||||
}
|
||||
case "sensitivity": {
|
||||
// Toggle: 0 (manual) or 1 (auto)
|
||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 })
|
||||
break
|
||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 });
|
||||
break;
|
||||
}
|
||||
case "noise": {
|
||||
// Step by 0.05: 0.0 – 1.0
|
||||
const next = Math.min(1, Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))))
|
||||
appStore.updateVisualizer({ noiseReduction: next })
|
||||
break
|
||||
const next = Math.min(
|
||||
1,
|
||||
Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))),
|
||||
);
|
||||
appStore.updateVisualizer({ noiseReduction: next });
|
||||
break;
|
||||
}
|
||||
case "lowCut": {
|
||||
// Step by 10: 20 – 500 Hz
|
||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10))
|
||||
appStore.updateVisualizer({ lowCutOff: next })
|
||||
break
|
||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10));
|
||||
appStore.updateVisualizer({ lowCutOff: next });
|
||||
break;
|
||||
}
|
||||
case "highCut": {
|
||||
// Step by 500: 1000 – 20000 Hz
|
||||
const next = Math.min(20000, Math.max(1000, v.highCutOff + delta * 500))
|
||||
appStore.updateVisualizer({ highCutOff: next })
|
||||
break
|
||||
const next = Math.min(
|
||||
20000,
|
||||
Math.max(1000, v.highCutOff + delta * 500),
|
||||
);
|
||||
appStore.updateVisualizer({ highCutOff: next });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard(handleKey)
|
||||
|
||||
const cavacoreStatus = isCavacoreAvailable()
|
||||
useKeyboard(handleKey);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Visualizer</text>
|
||||
|
||||
{!cavacoreStatus && (
|
||||
<text fg={theme.warning}>
|
||||
cavacore not available — using static waveform
|
||||
</text>
|
||||
)}
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>Bars:</text>
|
||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
|
||||
Bars:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().bars}</text>
|
||||
</box>
|
||||
@@ -101,9 +106,17 @@ export function VisualizerSettings() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text>
|
||||
<text
|
||||
fg={
|
||||
focusField() === "sensitivity" ? theme.primary : theme.textMuted
|
||||
}
|
||||
>
|
||||
Auto Sensitivity:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}>
|
||||
<text
|
||||
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
|
||||
>
|
||||
{viz().sensitivity === 1 ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -111,7 +124,9 @@ export function VisualizerSettings() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text>
|
||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
|
||||
Noise Reduction:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
||||
</box>
|
||||
@@ -119,7 +134,11 @@ export function VisualizerSettings() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text>
|
||||
<text
|
||||
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Low Cutoff:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
||||
</box>
|
||||
@@ -127,7 +146,11 @@ export function VisualizerSettings() {
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text>
|
||||
<text
|
||||
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
High Cutoff:
|
||||
</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
||||
</box>
|
||||
@@ -137,5 +160,5 @@ export function VisualizerSettings() {
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
)
|
||||
);
|
||||
}
|
||||
128
tasks/INDEX.md
128
tasks/INDEX.md
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,45 +0,0 @@
|
||||
# 03. Add RSS Content Type Detection
|
||||
|
||||
meta:
|
||||
id: rss-content-parsing-03
|
||||
feature: rss-content-parsing
|
||||
priority: P2
|
||||
depends_on: []
|
||||
tags: [rss, parsing, utilities]
|
||||
|
||||
objective:
|
||||
- Create utility to detect if RSS feed content is HTML or plain text
|
||||
- Analyze content type in description and other text fields
|
||||
- Return appropriate parsing strategy
|
||||
|
||||
deliverables:
|
||||
- Content type detection function
|
||||
- Type classification utility
|
||||
- Integration points for different parsers
|
||||
|
||||
steps:
|
||||
1. Create `src/utils/rss-content-detector.ts`
|
||||
2. Implement content type detection based on HTML tags
|
||||
3. Add detection for common HTML entities and tags
|
||||
4. Return type enum (HTML, PLAIN_TEXT, UNKNOWN)
|
||||
5. Add unit tests for detection accuracy
|
||||
|
||||
tests:
|
||||
- Unit: Test HTML detection with various HTML snippets
|
||||
- Unit: Test plain text detection with text-only content
|
||||
- Unit: Test edge cases (mixed content, malformed HTML)
|
||||
|
||||
acceptance_criteria:
|
||||
- Function correctly identifies HTML vs plain text content
|
||||
- Handles common HTML patterns and entities
|
||||
- Returns UNKNOWN for unclassifiable content
|
||||
|
||||
validation:
|
||||
- Test with HTML description from real RSS feeds
|
||||
- Test with plain text descriptions
|
||||
- Verify UNKNOWN cases are handled gracefully
|
||||
|
||||
notes:
|
||||
- Look for common HTML tags: <div>, <p>, <br>, <a>, <b>, <i>
|
||||
- Check for HTML entities: <, >, &, ", '
|
||||
- Consider content length threshold for HTML detection
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,15 +0,0 @@
|
||||
# Text Selection Copy to Clipboard
|
||||
|
||||
Objective: When text is selected in the TUI, copy it to the clipboard on release
|
||||
|
||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
Tasks
|
||||
- [ ] 01 — Add text selection event handling → `01-text-selection-copy.md`
|
||||
- [ ] 02 — Implement clipboard copy on selection release → `02-clipboard-copy-on-release.md`
|
||||
|
||||
Dependencies
|
||||
- 01 -> 02
|
||||
|
||||
Exit criteria
|
||||
- Users can select text and it gets copied to clipboard when selection is released
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user