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