Compare commits
4 Commits
920042ee2a
...
bfea6816ef
| Author | SHA1 | Date | |
|---|---|---|---|
| bfea6816ef | |||
| 75f1f7d6af | |||
| 1e3b794b8e | |||
| 1293d30225 |
137
src/App.tsx
137
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");
|
||||||
@@ -53,7 +52,9 @@ export function App() {
|
|||||||
|
|
||||||
// My Shows page returns panel renderers
|
// My Shows page returns panel renderers
|
||||||
const myShows = MyShowsPage({
|
const myShows = MyShowsPage({
|
||||||
get focused() { return activeTab() === "shows" && layerDepth() > 0 },
|
get focused() {
|
||||||
|
return activeTab() === "shows" && layerDepth() > 0;
|
||||||
|
},
|
||||||
onPlayEpisode: (episode, feed) => {
|
onPlayEpisode: (episode, feed) => {
|
||||||
handlePlayEpisode(episode);
|
handlePlayEpisode(episode);
|
||||||
},
|
},
|
||||||
@@ -69,8 +70,12 @@ export function App() {
|
|||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
setInputFocused(false);
|
setInputFocused(false);
|
||||||
},
|
},
|
||||||
get inputFocused() { return inputFocused() },
|
get inputFocused() {
|
||||||
get navigationEnabled() { return layerDepth() === 0 },
|
return inputFocused();
|
||||||
|
},
|
||||||
|
get navigationEnabled() {
|
||||||
|
return layerDepth() === 0;
|
||||||
|
},
|
||||||
layerDepth,
|
layerDepth,
|
||||||
onLayerChange: (newDepth) => {
|
onLayerChange: (newDepth) => {
|
||||||
setLayerDepth(newDepth);
|
setLayerDepth(newDepth);
|
||||||
@@ -94,18 +99,20 @@ export function App() {
|
|||||||
|
|
||||||
// Copy selected text to clipboard when selection ends (mouse release)
|
// Copy selected text to clipboard when selection ends (mouse release)
|
||||||
useSelectionHandler((selection: any) => {
|
useSelectionHandler((selection: any) => {
|
||||||
if (!selection) return
|
if (!selection) return;
|
||||||
const text = selection.getSelectedText?.()
|
const text = selection.getSelectedText?.();
|
||||||
if (!text || text.trim().length === 0) return
|
if (!text || text.trim().length === 0) return;
|
||||||
|
|
||||||
Clipboard.copy(text).then(() => {
|
Clipboard.copy(text)
|
||||||
|
.then(() => {
|
||||||
emit("toast.show", {
|
emit("toast.show", {
|
||||||
message: "Copied to clipboard",
|
message: "Copied to clipboard",
|
||||||
variant: "info",
|
variant: "info",
|
||||||
duration: 1500,
|
duration: 1500,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}).catch(() => {})
|
.catch(() => {});
|
||||||
})
|
});
|
||||||
|
|
||||||
const getPanels = createMemo(() => {
|
const getPanels = createMemo(() => {
|
||||||
const tab = activeTab();
|
const tab = activeTab();
|
||||||
@@ -156,7 +163,8 @@ export function App() {
|
|||||||
if (showAuthPanel()) {
|
if (showAuthPanel()) {
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: "Account",
|
title: "Account",
|
||||||
content: (
|
content: (
|
||||||
<SyncProfile
|
<SyncProfile
|
||||||
@@ -168,7 +176,8 @@ export function App() {
|
|||||||
onManageSync={() => setShowAuthPanel(false)}
|
onManageSync={() => setShowAuthPanel(false)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "Esc back",
|
hint: "Esc back",
|
||||||
};
|
};
|
||||||
@@ -203,17 +212,20 @@ export function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: "Sign In",
|
title: "Sign In",
|
||||||
content: authContent(),
|
content: authContent(),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "Esc back",
|
hint: "Esc back",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
content: (
|
content: (
|
||||||
<SettingsScreen
|
<SettingsScreen
|
||||||
@@ -223,18 +235,22 @@ export function App() {
|
|||||||
? `Signed in as ${auth.user?.email}`
|
? `Signed in as ${auth.user?.email}`
|
||||||
: "Not signed in"
|
: "Not signed in"
|
||||||
}
|
}
|
||||||
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
|
accountStatus={
|
||||||
|
auth.isAuthenticated ? "signed-in" : "signed-out"
|
||||||
|
}
|
||||||
onExit={() => setLayerDepth(0)}
|
onExit={() => setLayerDepth(0)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "j/k navigate | Enter select | Esc back",
|
hint: "j/k navigate | Enter select | Esc back",
|
||||||
};
|
};
|
||||||
|
|
||||||
case "discover":
|
case "discover":
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: "Discover",
|
title: "Discover",
|
||||||
content: (
|
content: (
|
||||||
<DiscoverPage
|
<DiscoverPage
|
||||||
@@ -242,14 +258,16 @@ export function App() {
|
|||||||
onExit={() => setLayerDepth(0)}
|
onExit={() => setLayerDepth(0)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
|
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
|
||||||
};
|
};
|
||||||
|
|
||||||
case "search":
|
case "search":
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: "Search",
|
title: "Search",
|
||||||
content: (
|
content: (
|
||||||
<SearchPage
|
<SearchPage
|
||||||
@@ -274,33 +292,41 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "Tab switch focus | / search | Enter select | Esc back",
|
hint: "Tab switch focus | / search | Enter select | Esc back",
|
||||||
};
|
};
|
||||||
|
|
||||||
case "player":
|
case "player":
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: "Player",
|
title: "Player",
|
||||||
content: (
|
content: (
|
||||||
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
|
<Player
|
||||||
|
focused={layerDepth() > 0}
|
||||||
|
onExit={() => setLayerDepth(0)}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "Space play/pause | Esc back",
|
hint: "Space play/pause | Esc back",
|
||||||
};
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
panels: [{
|
panels: [
|
||||||
|
{
|
||||||
title: tab,
|
title: tab,
|
||||||
content: (
|
content: (
|
||||||
<box padding={2}>
|
<box padding={2}>
|
||||||
<text>Coming soon</text>
|
<text>Coming soon</text>
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
activePanelIndex: 0,
|
activePanelIndex: 0,
|
||||||
hint: "",
|
hint: "",
|
||||||
};
|
};
|
||||||
@@ -308,24 +334,21 @@ export function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary fallback={(err) => (
|
<ErrorBoundary
|
||||||
|
fallback={(err) => (
|
||||||
<box border padding={2}>
|
<box border padding={2}>
|
||||||
<text fg="red">
|
<text fg="red">
|
||||||
Error: {err?.message ?? String(err)}{"\n"}
|
Error: {err?.message ?? String(err)}
|
||||||
|
{"\n"}
|
||||||
Press a number key (1-6) to switch tabs.
|
Press a number key (1-6) to switch tabs.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
}
|
}
|
||||||
footer={
|
|
||||||
<box flexDirection="row" justifyContent="space-between" width="100%">
|
|
||||||
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
|
||||||
<text fg="gray">{getPanels().hint}</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
panels={getPanels().panels}
|
panels={getPanels().panels}
|
||||||
activePanelIndex={getPanels().activePanelIndex}
|
activePanelIndex={getPanels().activePanelIndex}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
setCodeError(null);
|
||||||
|
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,11 +1,17 @@
|
|||||||
import { useTheme } from "../context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
export type TabId = "feed" | "shows" | "discover" | "search" | "player" | "settings"
|
export type TabId =
|
||||||
|
| "feed"
|
||||||
|
| "shows"
|
||||||
|
| "discover"
|
||||||
|
| "search"
|
||||||
|
| "player"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
export type TabDefinition = {
|
export type TabDefinition = {
|
||||||
id: TabId
|
id: TabId;
|
||||||
label: string
|
label: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const tabs: TabDefinition[] = [
|
export const tabs: TabDefinition[] = [
|
||||||
{ id: "feed", label: "Feed" },
|
{ id: "feed", label: "Feed" },
|
||||||
@@ -14,27 +20,31 @@ export const tabs: TabDefinition[] = [
|
|||||||
{ id: "search", label: "Search" },
|
{ id: "search", label: "Search" },
|
||||||
{ id: "player", label: "Player" },
|
{ id: "player", label: "Player" },
|
||||||
{ id: "settings", label: "Settings" },
|
{ id: "settings", label: "Settings" },
|
||||||
]
|
];
|
||||||
|
|
||||||
type TabProps = {
|
type TabProps = {
|
||||||
tab: TabDefinition
|
tab: TabDefinition;
|
||||||
active: boolean
|
active: boolean;
|
||||||
onSelect: (tab: TabId) => void
|
onSelect: (tab: TabId) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function Tab(props: TabProps) {
|
export function Tab(props: TabProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
border
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
onMouseDown={() => props.onSelect(props.tab.id)}
|
onMouseDown={() => props.onSelect(props.tab.id)}
|
||||||
style={{ padding: 1, backgroundColor: props.active ? theme.primary : "transparent" }}
|
style={{
|
||||||
|
padding: 1,
|
||||||
|
backgroundColor: props.active ? theme.primary : "transparent",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text>
|
<text style={{ fg: theme.text }}>
|
||||||
{props.active ? "[" : " "}
|
{props.active ? "[" : " "}
|
||||||
{props.tab.label}
|
{props.tab.label}
|
||||||
{props.active ? "]" : " "}
|
{props.active ? "]" : " "}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,80 +1,91 @@
|
|||||||
import { createEffect, createMemo, onMount, onCleanup } from "solid-js"
|
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store";
|
||||||
import { useRenderer } from "@opentui/solid"
|
import { useRenderer } from "@opentui/solid";
|
||||||
import type { ThemeName } from "../types/settings"
|
import type { ThemeName } from "../types/settings";
|
||||||
import type { ThemeJson } from "../types/theme-schema"
|
import type { ThemeJson } from "../types/theme-schema";
|
||||||
import { useAppStore } from "../stores/app"
|
import { useAppStore } from "../stores/app";
|
||||||
import { THEME_JSON } from "../constants/themes"
|
import { THEME_JSON } from "../constants/themes";
|
||||||
import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter"
|
import {
|
||||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme"
|
generateSyntax,
|
||||||
import { createSimpleContext } from "./helper"
|
generateSubtleSyntax,
|
||||||
import { setupThemeSignalHandler, emitThemeChanged, emitThemeModeChanged } from "../utils/theme-observer"
|
} from "../utils/syntax-highlighter";
|
||||||
import { createTerminalPalette, type RGBA, type TerminalColors } from "@opentui/core"
|
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
||||||
|
import { createSimpleContext } from "./helper";
|
||||||
|
import {
|
||||||
|
setupThemeSignalHandler,
|
||||||
|
emitThemeChanged,
|
||||||
|
emitThemeModeChanged,
|
||||||
|
} from "../utils/theme-observer";
|
||||||
|
import {
|
||||||
|
createTerminalPalette,
|
||||||
|
type RGBA,
|
||||||
|
type TerminalColors,
|
||||||
|
} from "@opentui/core";
|
||||||
|
|
||||||
type ThemeResolved = {
|
type ThemeResolved = {
|
||||||
primary: RGBA
|
primary: RGBA;
|
||||||
secondary: RGBA
|
secondary: RGBA;
|
||||||
accent: RGBA
|
accent: RGBA;
|
||||||
error: RGBA
|
error: RGBA;
|
||||||
warning: RGBA
|
warning: RGBA;
|
||||||
success: RGBA
|
success: RGBA;
|
||||||
info: RGBA
|
info: RGBA;
|
||||||
text: RGBA
|
text: RGBA;
|
||||||
textMuted: RGBA
|
textMuted: RGBA;
|
||||||
selectedListItemText: RGBA
|
selectedListItemText: RGBA;
|
||||||
background: RGBA
|
background: RGBA;
|
||||||
backgroundPanel: RGBA
|
backgroundPanel: RGBA;
|
||||||
backgroundElement: RGBA
|
backgroundElement: RGBA;
|
||||||
backgroundMenu: RGBA
|
backgroundMenu: RGBA;
|
||||||
border: RGBA
|
border: RGBA;
|
||||||
borderActive: RGBA
|
borderActive: RGBA;
|
||||||
borderSubtle: RGBA
|
borderSubtle: RGBA;
|
||||||
diffAdded: RGBA
|
diffAdded: RGBA;
|
||||||
diffRemoved: RGBA
|
diffRemoved: RGBA;
|
||||||
diffContext: RGBA
|
diffContext: RGBA;
|
||||||
diffHunkHeader: RGBA
|
diffHunkHeader: RGBA;
|
||||||
diffHighlightAdded: RGBA
|
diffHighlightAdded: RGBA;
|
||||||
diffHighlightRemoved: RGBA
|
diffHighlightRemoved: RGBA;
|
||||||
diffAddedBg: RGBA
|
diffAddedBg: RGBA;
|
||||||
diffRemovedBg: RGBA
|
diffRemovedBg: RGBA;
|
||||||
diffContextBg: RGBA
|
diffContextBg: RGBA;
|
||||||
diffLineNumber: RGBA
|
diffLineNumber: RGBA;
|
||||||
diffAddedLineNumberBg: RGBA
|
diffAddedLineNumberBg: RGBA;
|
||||||
diffRemovedLineNumberBg: RGBA
|
diffRemovedLineNumberBg: RGBA;
|
||||||
markdownText: RGBA
|
markdownText: RGBA;
|
||||||
markdownHeading: RGBA
|
markdownHeading: RGBA;
|
||||||
markdownLink: RGBA
|
markdownLink: RGBA;
|
||||||
markdownLinkText: RGBA
|
markdownLinkText: RGBA;
|
||||||
markdownCode: RGBA
|
markdownCode: RGBA;
|
||||||
markdownBlockQuote: RGBA
|
markdownBlockQuote: RGBA;
|
||||||
markdownEmph: RGBA
|
markdownEmph: RGBA;
|
||||||
markdownStrong: RGBA
|
markdownStrong: RGBA;
|
||||||
markdownHorizontalRule: RGBA
|
markdownHorizontalRule: RGBA;
|
||||||
markdownListItem: RGBA
|
markdownListItem: RGBA;
|
||||||
markdownListEnumeration: RGBA
|
markdownListEnumeration: RGBA;
|
||||||
markdownImage: RGBA
|
markdownImage: RGBA;
|
||||||
markdownImageText: RGBA
|
markdownImageText: RGBA;
|
||||||
markdownCodeBlock: RGBA
|
markdownCodeBlock: RGBA;
|
||||||
syntaxComment: RGBA
|
syntaxComment: RGBA;
|
||||||
syntaxKeyword: RGBA
|
syntaxKeyword: RGBA;
|
||||||
syntaxFunction: RGBA
|
syntaxFunction: RGBA;
|
||||||
syntaxVariable: RGBA
|
syntaxVariable: RGBA;
|
||||||
syntaxString: RGBA
|
syntaxString: RGBA;
|
||||||
syntaxNumber: RGBA
|
syntaxNumber: RGBA;
|
||||||
syntaxType: RGBA
|
syntaxType: RGBA;
|
||||||
syntaxOperator: RGBA
|
syntaxOperator: RGBA;
|
||||||
syntaxPunctuation: RGBA
|
syntaxPunctuation: RGBA;
|
||||||
muted?: RGBA
|
muted?: RGBA;
|
||||||
surface?: RGBA
|
surface?: RGBA;
|
||||||
layerBackgrounds?: {
|
layerBackgrounds?: {
|
||||||
layer0: RGBA
|
layer0: RGBA;
|
||||||
layer1: RGBA
|
layer1: RGBA;
|
||||||
layer2: RGBA
|
layer2: RGBA;
|
||||||
layer3: RGBA
|
layer3: RGBA;
|
||||||
}
|
};
|
||||||
_hasSelectedListItemText?: boolean
|
_hasSelectedListItemText?: boolean;
|
||||||
thinkingOpacity?: number
|
thinkingOpacity?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Theme context using the createSimpleContext pattern.
|
* Theme context using the createSimpleContext pattern.
|
||||||
@@ -89,88 +100,104 @@ type ThemeResolved = {
|
|||||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
name: "Theme",
|
name: "Theme",
|
||||||
init: (props: { mode: "dark" | "light" }) => {
|
init: (props: { mode: "dark" | "light" }) => {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore();
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer();
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
themes: THEME_JSON as Record<string, ThemeJson>,
|
themes: THEME_JSON as Record<string, ThemeJson>,
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
active: appStore.state().settings.theme as string,
|
active: appStore.state().settings.theme as string,
|
||||||
system: undefined as undefined | TerminalColors,
|
system: undefined as undefined | TerminalColors,
|
||||||
ready: false,
|
ready: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme()
|
resolveSystemTheme();
|
||||||
loadThemes()
|
loadThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
Object.assign(draft.themes, custom)
|
Object.assign(draft.themes, custom);
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// If custom themes fail to load, fall back to opencode theme
|
// If custom themes fail to load, fall back to opencode theme
|
||||||
setStore("active", "opencode")
|
setStore("active", "opencode");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Only set ready if not waiting for system theme
|
// Only set ready if not waiting for system theme
|
||||||
if (store.active !== "system") {
|
if (store.active !== "system") {
|
||||||
setStore("ready", true)
|
setStore("ready", true);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForCapabilities(timeoutMs = 300) {
|
async function waitForCapabilities(timeoutMs = 300) {
|
||||||
if (renderer.capabilities) return
|
if (renderer.capabilities) return;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
let done = false
|
let done = false;
|
||||||
const onCaps = () => {
|
const onCaps = () => {
|
||||||
if (done) return
|
if (done) return;
|
||||||
done = true
|
done = true;
|
||||||
renderer.off("capabilities", onCaps)
|
renderer.off("capabilities", onCaps);
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
resolve()
|
resolve();
|
||||||
}
|
};
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (done) return
|
if (done) return;
|
||||||
done = true
|
done = true;
|
||||||
renderer.off("capabilities", onCaps)
|
renderer.off("capabilities", onCaps);
|
||||||
resolve()
|
resolve();
|
||||||
}, timeoutMs)
|
}, timeoutMs);
|
||||||
renderer.on("capabilities", onCaps)
|
renderer.on("capabilities", onCaps);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSystemTheme() {
|
async function resolveSystemTheme() {
|
||||||
if (process.env.TMUX) {
|
if (process.env.TMUX) {
|
||||||
await waitForCapabilities()
|
await waitForCapabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
let colors: TerminalColors | null = null
|
let colors: TerminalColors | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
colors = await renderer.getPalette({ size: 16 })
|
colors = await renderer.getPalette({ size: 16 });
|
||||||
} catch {
|
} catch {
|
||||||
colors = null
|
colors = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!colors?.palette?.[0] && process.env.TMUX) {
|
if (!colors?.palette?.[0] && process.env.TMUX) {
|
||||||
const writeOut = (renderer as unknown as { writeOut?: (data: string | Buffer) => boolean }).writeOut
|
const writeOut = (
|
||||||
const writeFn = typeof writeOut === "function" ? writeOut.bind(renderer) : process.stdout.write.bind(process.stdout)
|
renderer as unknown as {
|
||||||
const detector = createTerminalPalette(process.stdin, process.stdout, writeFn, true)
|
writeOut?: (data: string | Buffer) => boolean;
|
||||||
|
}
|
||||||
|
).writeOut;
|
||||||
|
const writeFn =
|
||||||
|
typeof writeOut === "function"
|
||||||
|
? writeOut.bind(renderer)
|
||||||
|
: process.stdout.write.bind(process.stdout);
|
||||||
|
const detector = createTerminalPalette(
|
||||||
|
process.stdin,
|
||||||
|
process.stdout,
|
||||||
|
writeFn,
|
||||||
|
true,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const tmuxColors = await detector.detect({ size: 16, timeout: 1200 })
|
const tmuxColors = await detector.detect({ size: 16, timeout: 1200 });
|
||||||
if (tmuxColors?.palette?.[0]) {
|
if (tmuxColors?.palette?.[0]) {
|
||||||
colors = tmuxColors
|
colors = tmuxColors;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
detector.cleanup()
|
detector.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPalette = Boolean(colors?.palette?.some((value) => Boolean(value)))
|
const hasPalette = Boolean(
|
||||||
const hasDefaultColors = Boolean(colors?.defaultBackground || colors?.defaultForeground)
|
colors?.palette?.some((value) => Boolean(value)),
|
||||||
|
);
|
||||||
|
const hasDefaultColors = Boolean(
|
||||||
|
colors?.defaultBackground || colors?.defaultForeground,
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasPalette && !hasDefaultColors) {
|
if (!hasPalette && !hasDefaultColors) {
|
||||||
// No system colors available, fall back to default
|
// No system colors available, fall back to default
|
||||||
@@ -179,89 +206,100 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.active = "opencode"
|
draft.active = "opencode";
|
||||||
draft.ready = true
|
draft.ready = true;
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (colors) {
|
if (colors) {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.system = colors
|
draft.system = colors;
|
||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
draft.ready = true
|
draft.ready = true;
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(init)
|
onMount(init);
|
||||||
|
|
||||||
// Setup SIGUSR2 signal handler for dynamic theme reload
|
// Setup SIGUSR2 signal handler for dynamic theme reload
|
||||||
// This allows external tools to trigger a theme refresh by sending:
|
// This allows external tools to trigger a theme refresh by sending:
|
||||||
// `kill -USR2 <pid>`
|
// `kill -USR2 <pid>`
|
||||||
const cleanupSignalHandler = setupThemeSignalHandler(() => {
|
const cleanupSignalHandler = setupThemeSignalHandler(() => {
|
||||||
renderer.clearPaletteCache()
|
renderer.clearPaletteCache();
|
||||||
init()
|
init();
|
||||||
})
|
});
|
||||||
onCleanup(cleanupSignalHandler)
|
onCleanup(cleanupSignalHandler);
|
||||||
|
|
||||||
// Sync active theme with app store settings
|
// Sync active theme with app store settings
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const theme = appStore.state().settings.theme
|
const theme = appStore.state().settings.theme;
|
||||||
if (theme) setStore("active", theme)
|
if (theme) setStore("active", theme);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Emit theme change events for observers
|
// Emit theme change events for observers
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const theme = store.active
|
const theme = store.active;
|
||||||
const mode = store.mode
|
const mode = store.mode;
|
||||||
if (store.ready) {
|
if (store.ready) {
|
||||||
emitThemeChanged(theme, mode)
|
emitThemeChanged(theme, mode);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const values = createMemo(() => {
|
const values = createMemo(() => {
|
||||||
return resolveTerminalTheme(store.themes, store.active, store.mode, store.system)
|
return resolveTerminalTheme(
|
||||||
})
|
store.themes,
|
||||||
|
store.active,
|
||||||
|
store.mode,
|
||||||
|
store.system,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const syntax = createMemo(() => generateSyntax(values() as unknown as Record<string, RGBA>))
|
const syntax = createMemo(() =>
|
||||||
|
generateSyntax(values() as unknown as Record<string, RGBA>),
|
||||||
|
);
|
||||||
const subtleSyntax = createMemo(() =>
|
const subtleSyntax = createMemo(() =>
|
||||||
generateSubtleSyntax(values() as unknown as Record<string, RGBA> & { thinkingOpacity?: number })
|
generateSubtleSyntax(
|
||||||
)
|
values() as unknown as Record<string, RGBA> & {
|
||||||
|
thinkingOpacity?: number;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: new Proxy(values(), {
|
theme: new Proxy(values(), {
|
||||||
get(_target, prop) {
|
get(_target, prop) {
|
||||||
// @ts-expect-error - dynamic property access
|
// @ts-expect-error - dynamic property access
|
||||||
return values()[prop]
|
return values()[prop];
|
||||||
},
|
},
|
||||||
}) as ThemeResolved,
|
}) as ThemeResolved,
|
||||||
get selected() {
|
get selected() {
|
||||||
return store.active
|
return store.active;
|
||||||
},
|
},
|
||||||
all() {
|
all() {
|
||||||
return store.themes
|
return store.themes;
|
||||||
},
|
},
|
||||||
syntax,
|
syntax,
|
||||||
subtleSyntax,
|
subtleSyntax,
|
||||||
mode() {
|
mode() {
|
||||||
return store.mode
|
return store.mode;
|
||||||
},
|
},
|
||||||
setMode(mode: "dark" | "light") {
|
setMode(mode: "dark" | "light") {
|
||||||
setStore("mode", mode)
|
setStore("mode", mode);
|
||||||
emitThemeModeChanged(mode)
|
emitThemeModeChanged(mode);
|
||||||
},
|
},
|
||||||
set(theme: string) {
|
set(theme: string) {
|
||||||
appStore.setTheme(theme as ThemeName)
|
appStore.setTheme(theme as ThemeName);
|
||||||
},
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
return store.ready
|
return store.ready;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
// 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>
|
<RendererSetup>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
@@ -33,4 +34,6 @@ render(() => (
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</RendererSetup>
|
</RendererSetup>
|
||||||
), { useThread: false })
|
),
|
||||||
|
{ useThread: false },
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
|
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
|
||||||
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences, VisualizerSettings } from "../types/settings"
|
import type {
|
||||||
import { resolveTheme } from "../utils/theme-resolver"
|
AppSettings,
|
||||||
import type { ThemeJson } from "../types/theme-schema"
|
AppState,
|
||||||
|
ThemeColors,
|
||||||
|
ThemeName,
|
||||||
|
ThemeMode,
|
||||||
|
UserPreferences,
|
||||||
|
VisualizerSettings,
|
||||||
|
} from "../types/settings";
|
||||||
|
import { resolveTheme } from "../utils/theme-resolver";
|
||||||
|
import type { ThemeJson } from "../types/theme-schema";
|
||||||
import {
|
import {
|
||||||
loadAppStateFromFile,
|
loadAppStateFromFile,
|
||||||
saveAppStateToFile,
|
saveAppStateToFile,
|
||||||
migrateAppStateFromLocalStorage,
|
} from "../utils/app-persistence";
|
||||||
} from "../utils/app-persistence"
|
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
bars: 32,
|
bars: 32,
|
||||||
@@ -15,7 +22,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
|
|||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
highCutOff: 10000,
|
highCutOff: 10000,
|
||||||
}
|
};
|
||||||
|
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -23,82 +30,84 @@ const defaultSettings: AppSettings = {
|
|||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
}
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
}
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
preferences: defaultPreferences,
|
preferences: defaultPreferences,
|
||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
}
|
};
|
||||||
|
|
||||||
export function createAppStore() {
|
export function createAppStore() {
|
||||||
// Start with defaults; async load will update once ready
|
// Start with defaults; async load will update once ready
|
||||||
const [state, setState] = createSignal<AppState>(defaultState)
|
const [state, setState] = createSignal<AppState>(defaultState);
|
||||||
|
|
||||||
// Fire-and-forget async initialisation
|
// Fire-and-forget async initialisation
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await migrateAppStateFromLocalStorage()
|
const loaded = await loadAppStateFromFile();
|
||||||
const loaded = await loadAppStateFromFile()
|
setState(loaded);
|
||||||
setState(loaded)
|
};
|
||||||
}
|
init();
|
||||||
init()
|
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next).catch(() => {})
|
saveAppStateToFile(next).catch(() => {});
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateState = (next: AppState) => {
|
const updateState = (next: AppState) => {
|
||||||
setState(next)
|
setState(next);
|
||||||
saveState(next)
|
saveState(next);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateSettings = (updates: Partial<AppSettings>) => {
|
const updateSettings = (updates: Partial<AppSettings>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...state(),
|
...state(),
|
||||||
settings: { ...state().settings, ...updates },
|
settings: { ...state().settings, ...updates },
|
||||||
}
|
};
|
||||||
updateState(next)
|
updateState(next);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...state(),
|
...state(),
|
||||||
preferences: { ...state().preferences, ...updates },
|
preferences: { ...state().preferences, ...updates },
|
||||||
}
|
};
|
||||||
updateState(next)
|
updateState(next);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...state(),
|
...state(),
|
||||||
customTheme: { ...state().customTheme, ...updates },
|
customTheme: { ...state().customTheme, ...updates },
|
||||||
}
|
};
|
||||||
updateState(next)
|
updateState(next);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
visualizer: { ...state().settings.visualizer, ...updates },
|
visualizer: { ...state().settings.visualizer, ...updates },
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const setTheme = (theme: ThemeName) => {
|
const setTheme = (theme: ThemeName) => {
|
||||||
updateSettings({ theme })
|
updateSettings({ theme });
|
||||||
}
|
};
|
||||||
|
|
||||||
const resolveThemeColors = (): ThemeColors => {
|
const resolveThemeColors = (): ThemeColors => {
|
||||||
const theme = state().settings.theme
|
const theme = state().settings.theme;
|
||||||
if (theme === "custom") return state().customTheme
|
if (theme === "custom") return state().customTheme;
|
||||||
if (theme === "system") return DEFAULT_THEME
|
if (theme === "system") return DEFAULT_THEME;
|
||||||
const json = THEME_JSON[theme]
|
const json = THEME_JSON[theme];
|
||||||
if (!json) return DEFAULT_THEME
|
if (!json) return DEFAULT_THEME;
|
||||||
return resolveTheme(json as ThemeJson, "dark" as ThemeMode) as unknown as ThemeColors
|
return resolveTheme(
|
||||||
}
|
json as ThemeJson,
|
||||||
|
"dark" as ThemeMode,
|
||||||
|
) as unknown as ThemeColors;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
@@ -108,14 +117,14 @@ export function createAppStore() {
|
|||||||
updateVisualizer,
|
updateVisualizer,
|
||||||
setTheme,
|
setTheme,
|
||||||
resolveTheme: resolveThemeColors,
|
resolveTheme: resolveThemeColors,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let appStoreInstance: ReturnType<typeof createAppStore> | null = null
|
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
|
||||||
|
|
||||||
export function useAppStore() {
|
export function useAppStore() {
|
||||||
if (!appStoreInstance) {
|
if (!appStoreInstance) {
|
||||||
appStoreInstance = createAppStore()
|
appStoreInstance = createAppStore();
|
||||||
}
|
}
|
||||||
return appStoreInstance
|
return appStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,174 +3,193 @@
|
|||||||
* Manages feed data, sources, and filtering
|
* Manages feed data, sources, and filtering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import { FeedVisibility } from "../types/feed"
|
import { FeedVisibility } from "../types/feed";
|
||||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"
|
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||||
import type { Podcast } from "../types/podcast"
|
import type { Podcast } from "../types/podcast";
|
||||||
import type { Episode, EpisodeStatus } from "../types/episode"
|
import type { Episode, EpisodeStatus } from "../types/episode";
|
||||||
import type { PodcastSource, SourceType } from "../types/source"
|
import type { PodcastSource, SourceType } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source"
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { parseRSSFeed } from "../api/rss-parser"
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
import {
|
import {
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
loadSourcesFromFile,
|
loadSourcesFromFile,
|
||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
migrateFeedsFromLocalStorage,
|
} from "../utils/feeds-persistence";
|
||||||
migrateSourcesFromLocalStorage,
|
import { useDownloadStore } from "./download";
|
||||||
} from "../utils/feeds-persistence"
|
import { DownloadStatus } from "../types/episode";
|
||||||
import { useDownloadStore } from "./download"
|
|
||||||
import { DownloadStatus } from "../types/episode"
|
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk */
|
||||||
const MAX_EPISODES_REFRESH = 50
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
|
|
||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20
|
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||||
|
|
||||||
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
||||||
const fullEpisodeCache = new Map<string, Episode[]>()
|
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||||
|
|
||||||
/** Track how many episodes are currently loaded per feed */
|
/** Track how many episodes are currently loaded per feed */
|
||||||
const episodeLoadCount = new Map<string, number>()
|
const episodeLoadCount = new Map<string, number>();
|
||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget) */
|
/** Save feeds to file (async, fire-and-forget) */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
saveFeedsToFile(feeds).catch(() => {})
|
saveFeedsToFile(feeds).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
saveSourcesToFile(sources).catch(() => {})
|
saveSourcesToFile(sources).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
export function createFeedStore() {
|
export function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([])
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES])
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
|
...DEFAULT_SOURCES,
|
||||||
|
]);
|
||||||
|
|
||||||
// Async initialization: migrate from localStorage, then load from file
|
(async () => {
|
||||||
;(async () => {
|
const loadedFeeds = await loadFeedsFromFile();
|
||||||
await migrateFeedsFromLocalStorage()
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
await migrateSourcesFromLocalStorage()
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
const loadedFeeds = await loadFeedsFromFile()
|
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds)
|
})();
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>()
|
|
||||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
|
|
||||||
})()
|
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||||
visibility: "all",
|
visibility: "all",
|
||||||
sortBy: "updated" as FeedSortField,
|
sortBy: "updated" as FeedSortField,
|
||||||
sortDirection: "desc",
|
sortDirection: "desc",
|
||||||
})
|
});
|
||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false)
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
/** Get filtered and sorted feeds */
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()]
|
let result = [...feeds()];
|
||||||
const f = filter()
|
const f = filter();
|
||||||
|
|
||||||
// Filter by visibility
|
// Filter by visibility
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility)
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
// Filter by source
|
||||||
if (f.sourceId) {
|
if (f.sourceId) {
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId)
|
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by pinned
|
// Filter by pinned
|
||||||
if (f.pinnedOnly) {
|
if (f.pinnedOnly) {
|
||||||
result = result.filter((feed) => feed.isPinned)
|
result = result.filter((feed) => feed.isPinned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
if (f.searchQuery) {
|
if (f.searchQuery) {
|
||||||
const query = f.searchQuery.toLowerCase()
|
const query = f.searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(feed) =>
|
(feed) =>
|
||||||
feed.podcast.title.toLowerCase().includes(query) ||
|
feed.podcast.title.toLowerCase().includes(query) ||
|
||||||
feed.customName?.toLowerCase().includes(query) ||
|
feed.customName?.toLowerCase().includes(query) ||
|
||||||
feed.podcast.description?.toLowerCase().includes(query)
|
feed.podcast.description?.toLowerCase().includes(query),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by selected field
|
// Sort by selected field
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1
|
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
switch (f.sortBy) {
|
switch (f.sortBy) {
|
||||||
case "title":
|
case "title":
|
||||||
return sortDir * (a.customName || a.podcast.title).localeCompare(b.customName || b.podcast.title)
|
return (
|
||||||
|
sortDir *
|
||||||
|
(a.customName || a.podcast.title).localeCompare(
|
||||||
|
b.customName || b.podcast.title,
|
||||||
|
)
|
||||||
|
);
|
||||||
case "episodeCount":
|
case "episodeCount":
|
||||||
return sortDir * (a.episodes.length - b.episodes.length)
|
return sortDir * (a.episodes.length - b.episodes.length);
|
||||||
case "latestEpisode":
|
case "latestEpisode":
|
||||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
|
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
|
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||||
return sortDir * (aLatest - bLatest)
|
return sortDir * (aLatest - bLatest);
|
||||||
case "updated":
|
case "updated":
|
||||||
default:
|
default:
|
||||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
|
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Pinned feeds always first
|
// Pinned feeds always first
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
return 0
|
return 0;
|
||||||
})
|
});
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get episodes in reverse chronological order across all feeds */
|
/** Get episodes in reverse chronological order across all feeds */
|
||||||
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => {
|
const getAllEpisodesChronological = (): Array<{
|
||||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []
|
episode: Episode;
|
||||||
|
feed: Feed;
|
||||||
|
}> => {
|
||||||
|
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||||
|
|
||||||
for (const feed of feeds()) {
|
for (const feed of feeds()) {
|
||||||
for (const episode of feed.episodes) {
|
for (const episode of feed.episodes) {
|
||||||
allEpisodes.push({ episode, feed })
|
allEpisodes.push({ episode, feed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by publication date (newest first)
|
// Sort by publication date (newest first)
|
||||||
allEpisodes.sort((a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime())
|
allEpisodes.sort(
|
||||||
|
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
return allEpisodes
|
return allEpisodes;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||||
const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise<Episode[]> => {
|
const fetchEpisodes = async (
|
||||||
|
feedUrl: string,
|
||||||
|
limit: number,
|
||||||
|
feedId?: string,
|
||||||
|
): Promise<Episode[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (!response.ok) return []
|
if (!response.ok) return [];
|
||||||
const xml = await response.text()
|
const xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feedUrl)
|
const parsed = parseRSSFeed(xml, feedUrl);
|
||||||
const allEpisodes = parsed.episodes
|
const allEpisodes = parsed.episodes;
|
||||||
|
|
||||||
// Cache all parsed episodes for pagination
|
// Cache all parsed episodes for pagination
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
fullEpisodeCache.set(feedId, allEpisodes)
|
fullEpisodeCache.set(feedId, allEpisodes);
|
||||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length))
|
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allEpisodes.slice(0, limit)
|
return allEpisodes.slice(0, limit);
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||||
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
const addFeed = async (
|
||||||
const feedId = crypto.randomUUID()
|
podcast: Podcast,
|
||||||
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId)
|
sourceId: string,
|
||||||
|
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||||
|
) => {
|
||||||
|
const feedId = crypto.randomUUID();
|
||||||
|
const episodes = await fetchEpisodes(
|
||||||
|
podcast.feedUrl,
|
||||||
|
MAX_EPISODES_SUBSCRIBE,
|
||||||
|
feedId,
|
||||||
|
);
|
||||||
const newFeed: Feed = {
|
const newFeed: Feed = {
|
||||||
id: feedId,
|
id: feedId,
|
||||||
podcast,
|
podcast,
|
||||||
@@ -179,220 +198,238 @@ export function createFeedStore() {
|
|||||||
sourceId,
|
sourceId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
}
|
};
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = [...prev, newFeed]
|
const updated = [...prev, newFeed];
|
||||||
saveFeeds(updated)
|
saveFeeds(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
return newFeed
|
return newFeed;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Auto-download newest episodes for a feed */
|
/** Auto-download newest episodes for a feed */
|
||||||
const autoDownloadEpisodes = (feedId: string, newEpisodes: Episode[], count: number) => {
|
const autoDownloadEpisodes = (
|
||||||
|
feedId: string,
|
||||||
|
newEpisodes: Episode[],
|
||||||
|
count: number,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const dlStore = useDownloadStore()
|
const dlStore = useDownloadStore();
|
||||||
// Sort by pubDate descending (newest first)
|
// Sort by pubDate descending (newest first)
|
||||||
const sorted = [...newEpisodes].sort(
|
const sorted = [...newEpisodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
)
|
);
|
||||||
// count = 0 means download all new episodes
|
// count = 0 means download all new episodes
|
||||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted
|
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||||
for (const ep of toDownload) {
|
for (const ep of toDownload) {
|
||||||
const status = dlStore.getDownloadStatus(ep.id)
|
const status = dlStore.getDownloadStatus(ep.id);
|
||||||
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
if (
|
||||||
dlStore.startDownload(ep, feedId)
|
status === DownloadStatus.NONE ||
|
||||||
|
status === DownloadStatus.FAILED
|
||||||
|
) {
|
||||||
|
dlStore.startDownload(ep, feedId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Download store may not be available yet
|
// Download store may not be available yet
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId)
|
const feed = getFeed(feedId);
|
||||||
if (!feed) return
|
if (!feed) return;
|
||||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id))
|
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||||
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId)
|
const episodes = await fetchEpisodes(
|
||||||
|
feed.podcast.feedUrl,
|
||||||
|
MAX_EPISODES_REFRESH,
|
||||||
|
feedId,
|
||||||
|
);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
|
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||||
)
|
);
|
||||||
saveFeeds(updated)
|
saveFeeds(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Auto-download new episodes if enabled for this feed
|
// Auto-download new episodes if enabled for this feed
|
||||||
if (feed.autoDownload) {
|
if (feed.autoDownload) {
|
||||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id))
|
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
||||||
if (newEpisodes.length > 0) {
|
if (newEpisodes.length > 0) {
|
||||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0)
|
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Refresh all feeds */
|
/** Refresh all feeds */
|
||||||
const refreshAllFeeds = async () => {
|
const refreshAllFeeds = async () => {
|
||||||
const currentFeeds = feeds()
|
const currentFeeds = feeds();
|
||||||
for (const feed of currentFeeds) {
|
for (const feed of currentFeeds) {
|
||||||
await refreshFeed(feed.id)
|
await refreshFeed(feed.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Remove a feed */
|
/** Remove a feed */
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
fullEpisodeCache.delete(feedId)
|
fullEpisodeCache.delete(feedId);
|
||||||
episodeLoadCount.delete(feedId)
|
episodeLoadCount.delete(feedId);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.filter((f) => f.id !== feedId)
|
const updated = prev.filter((f) => f.id !== feedId);
|
||||||
saveFeeds(updated)
|
saveFeeds(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Update a feed */
|
/** Update a feed */
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f
|
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||||
)
|
);
|
||||||
saveFeeds(updated)
|
saveFeeds(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Toggle feed pinned status */
|
/** Toggle feed pinned status */
|
||||||
const togglePinned = (feedId: string) => {
|
const togglePinned = (feedId: string) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f
|
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||||
)
|
);
|
||||||
saveFeeds(updated)
|
saveFeeds(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Add a source */
|
/** Add a source */
|
||||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||||
const newSource: PodcastSource = {
|
const newSource: PodcastSource = {
|
||||||
...source,
|
...source,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
}
|
};
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = [...prev, newSource]
|
const updated = [...prev, newSource];
|
||||||
saveSources(updated)
|
saveSources(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
return newSource
|
return newSource;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Update a source */
|
/** Update a source */
|
||||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((source) =>
|
const updated = prev.map((source) =>
|
||||||
source.id === sourceId ? { ...source, ...updates } : source
|
source.id === sourceId ? { ...source, ...updates } : source,
|
||||||
)
|
);
|
||||||
saveSources(updated)
|
saveSources(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Remove a source */
|
/** Remove a source */
|
||||||
const removeSource = (sourceId: string) => {
|
const removeSource = (sourceId: string) => {
|
||||||
// Don't remove default sources
|
// Don't remove default sources
|
||||||
if (sourceId === "itunes" || sourceId === "rss") return false
|
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||||
|
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.filter((s) => s.id !== sourceId)
|
const updated = prev.filter((s) => s.id !== sourceId);
|
||||||
saveSources(updated)
|
saveSources(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Toggle source enabled status */
|
/** Toggle source enabled status */
|
||||||
const toggleSource = (sourceId: string) => {
|
const toggleSource = (sourceId: string) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((s) =>
|
const updated = prev.map((s) =>
|
||||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
|
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||||
)
|
);
|
||||||
saveSources(updated)
|
saveSources(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get feed by ID */
|
/** Get feed by ID */
|
||||||
const getFeed = (feedId: string): Feed | undefined => {
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
return feeds().find((f) => f.id === feedId)
|
return feeds().find((f) => f.id === feedId);
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get selected feed */
|
/** Get selected feed */
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
const id = selectedFeedId()
|
const id = selectedFeedId();
|
||||||
return id ? getFeed(id) : undefined
|
return id ? getFeed(id) : undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
const cached = fullEpisodeCache.get(feedId)
|
const cached = fullEpisodeCache.get(feedId);
|
||||||
if (!cached) return false
|
if (!cached) return false;
|
||||||
const loaded = episodeLoadCount.get(feedId) ?? 0
|
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||||
return loaded < cached.length
|
return loaded < cached.length;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for a feed from the cache.
|
/** Load the next chunk of episodes for a feed from the cache.
|
||||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||||
const loadMoreEpisodes = async (feedId: string) => {
|
const loadMoreEpisodes = async (feedId: string) => {
|
||||||
if (isLoadingMore()) return
|
if (isLoadingMore()) return;
|
||||||
const feed = getFeed(feedId)
|
const feed = getFeed(feedId);
|
||||||
if (!feed) return
|
if (!feed) return;
|
||||||
|
|
||||||
setIsLoadingMore(true)
|
setIsLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
let cached = fullEpisodeCache.get(feedId)
|
let cached = fullEpisodeCache.get(feedId);
|
||||||
|
|
||||||
// If no cache, re-fetch and parse the full feed
|
// If no cache, re-fetch and parse the full feed
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
const response = await fetch(feed.podcast.feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (!response.ok) return
|
if (!response.ok) return;
|
||||||
const xml = await response.text()
|
const xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl)
|
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||||
cached = parsed.episodes
|
cached = parsed.episodes;
|
||||||
fullEpisodeCache.set(feedId, cached)
|
fullEpisodeCache.set(feedId, cached);
|
||||||
// Set current load count to match what's already displayed
|
// Set current load count to match what's already displayed
|
||||||
episodeLoadCount.set(feedId, feed.episodes.length)
|
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length
|
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||||
const newCount = Math.min(currentCount + MAX_EPISODES_REFRESH, cached.length)
|
const newCount = Math.min(
|
||||||
|
currentCount + MAX_EPISODES_REFRESH,
|
||||||
|
cached.length,
|
||||||
|
);
|
||||||
|
|
||||||
if (newCount <= currentCount) return // nothing more to load
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
|
|
||||||
episodeLoadCount.set(feedId, newCount)
|
episodeLoadCount.set(feedId, newCount);
|
||||||
const episodes = cached.slice(0, newCount)
|
const episodes = cached.slice(0, newCount);
|
||||||
|
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, episodes } : f
|
f.id === feedId ? { ...f, episodes } : f,
|
||||||
)
|
);
|
||||||
saveFeeds(updated)
|
saveFeeds(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMore(false)
|
setIsLoadingMore(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Set auto-download settings for a feed */
|
/** Set auto-download settings for a feed */
|
||||||
const setAutoDownload = (feedId: string, enabled: boolean, count: number = 0) => {
|
const setAutoDownload = (
|
||||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count })
|
feedId: string,
|
||||||
}
|
enabled: boolean,
|
||||||
|
count: number = 0,
|
||||||
|
) => {
|
||||||
|
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
@@ -424,15 +461,15 @@ export function createFeedStore() {
|
|||||||
toggleSource,
|
toggleSource,
|
||||||
updateSource,
|
updateSource,
|
||||||
setAutoDownload,
|
setAutoDownload,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton feed store */
|
/** Singleton feed store */
|
||||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||||
|
|
||||||
export function useFeedStore() {
|
export function useFeedStore() {
|
||||||
if (!feedStoreInstance) {
|
if (!feedStoreInstance) {
|
||||||
feedStoreInstance = createFeedStore()
|
feedStoreInstance = createFeedStore();
|
||||||
}
|
}
|
||||||
return feedStoreInstance
|
return feedStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,55 +5,56 @@
|
|||||||
* Tracks position, duration, completion, and last-played timestamp.
|
* Tracks position, duration, completion, and last-played timestamp.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import type { Progress } from "../types/episode"
|
import type { Progress } from "../types/episode";
|
||||||
import {
|
import {
|
||||||
loadProgressFromFile,
|
loadProgressFromFile,
|
||||||
saveProgressToFile,
|
saveProgressToFile,
|
||||||
migrateProgressFromLocalStorage,
|
} from "../utils/app-persistence";
|
||||||
} from "../utils/app-persistence"
|
|
||||||
|
|
||||||
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
||||||
const COMPLETION_THRESHOLD = 0.95
|
const COMPLETION_THRESHOLD = 0.95;
|
||||||
|
|
||||||
/** Minimum seconds of progress before persisting */
|
/** Minimum seconds of progress before persisting */
|
||||||
const MIN_POSITION_TO_SAVE = 5
|
const MIN_POSITION_TO_SAVE = 5;
|
||||||
|
|
||||||
// --- Singleton store ---
|
// --- Singleton store ---
|
||||||
|
|
||||||
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({})
|
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
/** Persist current progress map to file (fire-and-forget) */
|
/** Persist current progress map to file (fire-and-forget) */
|
||||||
function persist(): void {
|
function persist(): void {
|
||||||
saveProgressToFile(progressMap()).catch(() => {})
|
saveProgressToFile(progressMap()).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse raw progress entries from file, reviving Date objects */
|
/** Parse raw progress entries from file, reviving Date objects */
|
||||||
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> {
|
function parseProgressEntries(
|
||||||
const result: Record<string, Progress> = {}
|
raw: Record<string, unknown>,
|
||||||
|
): Record<string, Progress> {
|
||||||
|
const result: Record<string, Progress> = {};
|
||||||
for (const [key, value] of Object.entries(raw)) {
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
const p = value as Record<string, unknown>
|
const p = value as Record<string, unknown>;
|
||||||
result[key] = {
|
result[key] = {
|
||||||
episodeId: p.episodeId as string,
|
episodeId: p.episodeId as string,
|
||||||
position: p.position as number,
|
position: p.position as number,
|
||||||
duration: p.duration as number,
|
duration: p.duration as number,
|
||||||
timestamp: new Date(p.timestamp as string),
|
timestamp: new Date(p.timestamp as string),
|
||||||
playbackSpeed: p.playbackSpeed as number | undefined,
|
playbackSpeed: p.playbackSpeed as number | undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
return result;
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Async initialisation — migrate from localStorage then load from file */
|
|
||||||
async function initProgress(): Promise<void> {
|
async function initProgress(): Promise<void> {
|
||||||
await migrateProgressFromLocalStorage()
|
const raw = await loadProgressFromFile();
|
||||||
const raw = await loadProgressFromFile()
|
const parsed = parseProgressEntries(raw as Record<string, unknown>);
|
||||||
const parsed = parseProgressEntries(raw as Record<string, unknown>)
|
setProgressMap(parsed);
|
||||||
setProgressMap(parsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget init
|
// Fire-and-forget init
|
||||||
initProgress()
|
initProgress();
|
||||||
|
|
||||||
function createProgressStore() {
|
function createProgressStore() {
|
||||||
return {
|
return {
|
||||||
@@ -61,14 +62,14 @@ function createProgressStore() {
|
|||||||
* Get progress for a specific episode.
|
* Get progress for a specific episode.
|
||||||
*/
|
*/
|
||||||
get(episodeId: string): Progress | undefined {
|
get(episodeId: string): Progress | undefined {
|
||||||
return progressMap()[episodeId]
|
return progressMap()[episodeId];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all progress entries.
|
* Get all progress entries.
|
||||||
*/
|
*/
|
||||||
all(): Record<string, Progress> {
|
all(): Record<string, Progress> {
|
||||||
return progressMap()
|
return progressMap();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,7 +81,7 @@ function createProgressStore() {
|
|||||||
duration: number,
|
duration: number,
|
||||||
playbackSpeed?: number,
|
playbackSpeed?: number,
|
||||||
): void {
|
): void {
|
||||||
if (position < MIN_POSITION_TO_SAVE && duration > 0) return
|
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
|
||||||
|
|
||||||
setProgressMap((prev) => ({
|
setProgressMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -91,34 +92,34 @@ function createProgressStore() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
playbackSpeed,
|
playbackSpeed,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
persist()
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an episode is completed.
|
* Check if an episode is completed.
|
||||||
*/
|
*/
|
||||||
isCompleted(episodeId: string): boolean {
|
isCompleted(episodeId: string): boolean {
|
||||||
const p = progressMap()[episodeId]
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return false
|
if (!p || p.duration <= 0) return false;
|
||||||
return p.position / p.duration >= COMPLETION_THRESHOLD
|
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get progress percentage (0-100) for an episode.
|
* Get progress percentage (0-100) for an episode.
|
||||||
*/
|
*/
|
||||||
getPercent(episodeId: string): number {
|
getPercent(episodeId: string): number {
|
||||||
const p = progressMap()[episodeId]
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return 0
|
if (!p || p.duration <= 0) return 0;
|
||||||
return Math.min(100, Math.round((p.position / p.duration) * 100))
|
return Math.min(100, Math.round((p.position / p.duration) * 100));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an episode as completed (set position to duration).
|
* Mark an episode as completed (set position to duration).
|
||||||
*/
|
*/
|
||||||
markCompleted(episodeId: string): void {
|
markCompleted(episodeId: string): void {
|
||||||
const p = progressMap()[episodeId]
|
const p = progressMap()[episodeId];
|
||||||
const duration = p?.duration ?? 0
|
const duration = p?.duration ?? 0;
|
||||||
setProgressMap((prev) => ({
|
setProgressMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[episodeId]: {
|
[episodeId]: {
|
||||||
@@ -128,8 +129,8 @@ function createProgressStore() {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
playbackSpeed: p?.playbackSpeed,
|
playbackSpeed: p?.playbackSpeed,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
persist()
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,29 +138,29 @@ function createProgressStore() {
|
|||||||
*/
|
*/
|
||||||
remove(episodeId: string): void {
|
remove(episodeId: string): void {
|
||||||
setProgressMap((prev) => {
|
setProgressMap((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev };
|
||||||
delete next[episodeId]
|
delete next[episodeId];
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
persist()
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all progress data.
|
* Clear all progress data.
|
||||||
*/
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
setProgressMap({})
|
setProgressMap({});
|
||||||
persist()
|
persist();
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
let instance: ReturnType<typeof createProgressStore> | null = null
|
let instance: ReturnType<typeof createProgressStore> | null = null;
|
||||||
|
|
||||||
export function useProgressStore() {
|
export function useProgressStore() {
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
instance = createProgressStore()
|
instance = createProgressStore();
|
||||||
}
|
}
|
||||||
return instance
|
return instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
flexDirection="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
<text>
|
<text>
|
||||||
<strong>Discover Podcasts</strong>
|
<strong>Discover Podcasts</strong>
|
||||||
</text>
|
</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>
|
||||||
@@ -171,11 +179,10 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
<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
|
||||||
@@ -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,42 +91,46 @@ 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}>
|
||||||
@@ -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 =
|
||||||
|
count < sampleBuffer.length
|
||||||
? sampleBuffer.subarray(0, count)
|
? sampleBuffer.subarray(0, count)
|
||||||
: sampleBuffer
|
: sampleBuffer;
|
||||||
const output = cava.execute(input)
|
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,163 +2,163 @@
|
|||||||
* 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}>
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
|
when={!props.isSearching}
|
||||||
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg="yellow">Searching...</text>
|
<text fg="yellow">Searching...</text>
|
||||||
</box>
|
</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)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
setEmailError(null);
|
||||||
|
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)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
setPasswordError(null);
|
||||||
|
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}>
|
||||||
@@ -165,7 +183,9 @@ export function SourceManager(props: SourceManagerProps) {
|
|||||||
|
|
||||||
{/* 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
|
||||||
|
fg={
|
||||||
focusArea() === "list" && index() === selectedIndex()
|
focusArea() === "list" && index() === selectedIndex()
|
||||||
? theme.primary
|
? theme.primary
|
||||||
: theme.textMuted
|
: 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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -2,19 +2,20 @@
|
|||||||
* App state persistence via JSON file in XDG_CONFIG_HOME
|
* App state persistence via JSON file in XDG_CONFIG_HOME
|
||||||
*
|
*
|
||||||
* Reads and writes app settings, preferences, and custom theme to a JSON file
|
* Reads and writes app settings, preferences, and custom theme to a JSON file
|
||||||
* instead of localStorage. Provides migration from localStorage on first run.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
|
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import { backupConfigFile } from "./config-backup"
|
import { backupConfigFile } from "./config-backup";
|
||||||
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings"
|
import type {
|
||||||
import { DEFAULT_THEME } from "../constants/themes"
|
AppState,
|
||||||
|
AppSettings,
|
||||||
|
UserPreferences,
|
||||||
|
VisualizerSettings,
|
||||||
|
} from "../types/settings";
|
||||||
|
import { DEFAULT_THEME } from "../constants/themes";
|
||||||
|
|
||||||
const APP_STATE_FILE = "app-state.json"
|
const APP_STATE_FILE = "app-state.json";
|
||||||
const PROGRESS_FILE = "progress.json"
|
const PROGRESS_FILE = "progress.json";
|
||||||
|
|
||||||
const LEGACY_APP_STATE_KEY = "podtui_app_state"
|
|
||||||
const LEGACY_PROGRESS_KEY = "podtui_progress"
|
|
||||||
|
|
||||||
// --- Defaults ---
|
// --- Defaults ---
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
|
|||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
highCutOff: 10000,
|
highCutOff: 10000,
|
||||||
}
|
};
|
||||||
|
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -32,141 +33,89 @@ const defaultSettings: AppSettings = {
|
|||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
}
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
}
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
preferences: defaultPreferences,
|
preferences: defaultPreferences,
|
||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
}
|
};
|
||||||
|
|
||||||
// --- App State ---
|
// --- App State ---
|
||||||
|
|
||||||
/** Load app state from JSON file */
|
/** Load app state from JSON file */
|
||||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE)
|
const filePath = getConfigFilePath(APP_STATE_FILE);
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) return defaultState
|
if (!(await file.exists())) return defaultState;
|
||||||
|
|
||||||
const raw = await file.json()
|
const raw = await file.json();
|
||||||
if (!raw || typeof raw !== "object") return defaultState
|
if (!raw || typeof raw !== "object") return defaultState;
|
||||||
|
|
||||||
const parsed = raw as Partial<AppState>
|
const parsed = raw as Partial<AppState>;
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings, ...parsed.settings },
|
settings: { ...defaultSettings, ...parsed.settings },
|
||||||
preferences: { ...defaultPreferences, ...parsed.preferences },
|
preferences: { ...defaultPreferences, ...parsed.preferences },
|
||||||
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
||||||
}
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaultState
|
return defaultState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save app state to JSON file */
|
/** Save app state to JSON file */
|
||||||
export async function saveAppStateToFile(state: AppState): Promise<void> {
|
export async function saveAppStateToFile(state: AppState): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
await backupConfigFile(APP_STATE_FILE)
|
await backupConfigFile(APP_STATE_FILE);
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE)
|
const filePath = getConfigFilePath(APP_STATE_FILE);
|
||||||
await Bun.write(filePath, JSON.stringify(state, null, 2))
|
await Bun.write(filePath, JSON.stringify(state, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate app state from localStorage to file.
|
|
||||||
* Only runs once — if the state file already exists, it's a no-op.
|
|
||||||
*/
|
|
||||||
export async function migrateAppStateFromLocalStorage(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE)
|
|
||||||
const file = Bun.file(filePath)
|
|
||||||
if (await file.exists()) return false
|
|
||||||
|
|
||||||
if (typeof localStorage === "undefined") return false
|
|
||||||
|
|
||||||
const raw = localStorage.getItem(LEGACY_APP_STATE_KEY)
|
|
||||||
if (!raw) return false
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as Partial<AppState>
|
|
||||||
const state: AppState = {
|
|
||||||
settings: { ...defaultSettings, ...parsed.settings },
|
|
||||||
preferences: { ...defaultPreferences, ...parsed.preferences },
|
|
||||||
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveAppStateToFile(state)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Progress ---
|
|
||||||
|
|
||||||
interface ProgressEntry {
|
interface ProgressEntry {
|
||||||
episodeId: string
|
episodeId: string;
|
||||||
position: number
|
position: number;
|
||||||
duration: number
|
duration: number;
|
||||||
timestamp: string | Date
|
timestamp: string | Date;
|
||||||
playbackSpeed?: number
|
playbackSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load progress map from JSON file */
|
/** Load progress map from JSON file */
|
||||||
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> {
|
export async function loadProgressFromFile(): Promise<
|
||||||
|
Record<string, ProgressEntry>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE)
|
const filePath = getConfigFilePath(PROGRESS_FILE);
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) return {}
|
if (!(await file.exists())) return {};
|
||||||
|
|
||||||
const raw = await file.json()
|
const raw = await file.json();
|
||||||
if (!raw || typeof raw !== "object") return {}
|
if (!raw || typeof raw !== "object") return {};
|
||||||
return raw as Record<string, ProgressEntry>
|
return raw as Record<string, ProgressEntry>;
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save progress map to JSON file */
|
/** Save progress map to JSON file */
|
||||||
export async function saveProgressToFile(data: Record<string, unknown>): Promise<void> {
|
export async function saveProgressToFile(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
await backupConfigFile(PROGRESS_FILE)
|
await backupConfigFile(PROGRESS_FILE);
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE)
|
const filePath = getConfigFilePath(PROGRESS_FILE);
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2))
|
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate progress from localStorage to file.
|
|
||||||
* Only runs once — if the progress file already exists, it's a no-op.
|
|
||||||
*/
|
|
||||||
export async function migrateProgressFromLocalStorage(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE)
|
|
||||||
const file = Bun.file(filePath)
|
|
||||||
if (await file.exists()) return false
|
|
||||||
|
|
||||||
if (typeof localStorage === "undefined") return false
|
|
||||||
|
|
||||||
const raw = localStorage.getItem(LEGACY_PROGRESS_KEY)
|
|
||||||
if (!raw) return false
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (!parsed || typeof parsed !== "object") return false
|
|
||||||
|
|
||||||
await saveProgressToFile(parsed as Record<string, unknown>)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,107 +1,116 @@
|
|||||||
/**
|
/**
|
||||||
* Config file validation and migration for PodTUI
|
|
||||||
*
|
|
||||||
* Validates JSON structure of config files, handles corrupted files
|
* Validates JSON structure of config files, handles corrupted files
|
||||||
* gracefully (falling back to defaults), and provides a single
|
* gracefully (falling back to defaults), and provides a single
|
||||||
* entry-point to migrate all localStorage data to XDG config files.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getConfigFilePath } from "./config-dir"
|
import { getConfigFilePath } from "./config-dir";
|
||||||
import {
|
|
||||||
migrateAppStateFromLocalStorage,
|
|
||||||
migrateProgressFromLocalStorage,
|
|
||||||
} from "./app-persistence"
|
|
||||||
import {
|
|
||||||
migrateFeedsFromLocalStorage,
|
|
||||||
migrateSourcesFromLocalStorage,
|
|
||||||
} from "./feeds-persistence"
|
|
||||||
|
|
||||||
// --- Validation helpers ---
|
// --- Validation helpers ---
|
||||||
|
|
||||||
/** Check that a value is a non-null object */
|
/** Check that a value is a non-null object */
|
||||||
function isObject(v: unknown): v is Record<string, unknown> {
|
function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
return v !== null && typeof v === "object" && !Array.isArray(v)
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate AppState JSON structure */
|
/** Validate AppState JSON structure */
|
||||||
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } {
|
export function validateAppState(data: unknown): {
|
||||||
const errors: string[] = []
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
if (!isObject(data)) {
|
if (!isObject(data)) {
|
||||||
return { valid: false, errors: ["app-state.json is not an object"] }
|
return { valid: false, errors: ["app-state.json is not an object"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
if (data.settings !== undefined) {
|
if (data.settings !== undefined) {
|
||||||
if (!isObject(data.settings)) {
|
if (!isObject(data.settings)) {
|
||||||
errors.push("settings must be an object")
|
errors.push("settings must be an object");
|
||||||
} else {
|
} else {
|
||||||
const s = data.settings as Record<string, unknown>
|
const s = data.settings as Record<string, unknown>;
|
||||||
if (s.theme !== undefined && typeof s.theme !== "string") errors.push("settings.theme must be a string")
|
if (s.theme !== undefined && typeof s.theme !== "string")
|
||||||
if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number")
|
errors.push("settings.theme must be a string");
|
||||||
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number")
|
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
|
||||||
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string")
|
errors.push("settings.fontSize must be a number");
|
||||||
|
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
|
||||||
|
errors.push("settings.playbackSpeed must be a number");
|
||||||
|
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
|
||||||
|
errors.push("settings.downloadPath must be a string");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// preferences
|
// preferences
|
||||||
if (data.preferences !== undefined) {
|
if (data.preferences !== undefined) {
|
||||||
if (!isObject(data.preferences)) {
|
if (!isObject(data.preferences)) {
|
||||||
errors.push("preferences must be an object")
|
errors.push("preferences must be an object");
|
||||||
} else {
|
} else {
|
||||||
const p = data.preferences as Record<string, unknown>
|
const p = data.preferences as Record<string, unknown>;
|
||||||
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") errors.push("preferences.showExplicit must be a boolean")
|
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
|
||||||
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean")
|
errors.push("preferences.showExplicit must be a boolean");
|
||||||
|
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
|
||||||
|
errors.push("preferences.autoDownload must be a boolean");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// customTheme
|
// customTheme
|
||||||
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
||||||
errors.push("customTheme must be an object")
|
errors.push("customTheme must be an object");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors }
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate feeds JSON structure */
|
/** Validate feeds JSON structure */
|
||||||
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } {
|
export function validateFeeds(data: unknown): {
|
||||||
const errors: string[] = []
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
return { valid: false, errors: ["feeds.json is not an array"] }
|
return { valid: false, errors: ["feeds.json is not an array"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
const feed = data[i]
|
const feed = data[i];
|
||||||
if (!isObject(feed)) {
|
if (!isObject(feed)) {
|
||||||
errors.push(`feeds[${i}] is not an object`)
|
errors.push(`feeds[${i}] is not an object`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`)
|
if (typeof feed.id !== "string")
|
||||||
if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`)
|
errors.push(`feeds[${i}].id must be a string`);
|
||||||
if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`)
|
if (!isObject(feed.podcast))
|
||||||
|
errors.push(`feeds[${i}].podcast must be an object`);
|
||||||
|
if (!Array.isArray(feed.episodes))
|
||||||
|
errors.push(`feeds[${i}].episodes must be an array`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors }
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate progress JSON structure */
|
/** Validate progress JSON structure */
|
||||||
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } {
|
export function validateProgress(data: unknown): {
|
||||||
const errors: string[] = []
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
if (!isObject(data)) {
|
if (!isObject(data)) {
|
||||||
return { valid: false, errors: ["progress.json is not an object"] }
|
return { valid: false, errors: ["progress.json is not an object"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
if (!isObject(value)) {
|
if (!isObject(value)) {
|
||||||
errors.push(`progress["${key}"] is not an object`)
|
errors.push(`progress["${key}"] is not an object`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
const p = value as Record<string, unknown>
|
const p = value as Record<string, unknown>;
|
||||||
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`)
|
if (typeof p.episodeId !== "string")
|
||||||
if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`)
|
errors.push(`progress["${key}"].episodeId must be a string`);
|
||||||
if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`)
|
if (typeof p.position !== "number")
|
||||||
|
errors.push(`progress["${key}"].position must be a number`);
|
||||||
|
if (typeof p.duration !== "number")
|
||||||
|
errors.push(`progress["${key}"].duration must be a number`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors }
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Safe config file reading ---
|
// --- Safe config file reading ---
|
||||||
@@ -115,52 +124,27 @@ export async function safeReadConfigFile<T>(
|
|||||||
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
||||||
): Promise<{ data: T | null; errors: string[] }> {
|
): Promise<{ data: T | null; errors: string[] }> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(filename)
|
const filePath = getConfigFilePath(filename);
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
return { data: null, errors: [] }
|
return { data: null, errors: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await file.text()
|
const text = await file.text();
|
||||||
let parsed: unknown
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text)
|
parsed = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
return { data: null, errors: [`${filename}: invalid JSON`] }
|
return { data: null, errors: [`${filename}: invalid JSON`] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = validator(parsed)
|
const result = validator(parsed);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
return { data: null, errors: result.errors }
|
return { data: null, errors: result.errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: parsed as T, errors: [] }
|
return { data: parsed as T, errors: [] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { data: null, errors: [`${filename}: ${String(err)}`] }
|
return { data: null, errors: [`${filename}: ${String(err)}`] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Unified migration ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run all localStorage -> file migrations.
|
|
||||||
* Safe to call multiple times; each migration is a no-op if the target
|
|
||||||
* file already exists.
|
|
||||||
*
|
|
||||||
* Returns a summary of what was migrated.
|
|
||||||
*/
|
|
||||||
export async function migrateAllFromLocalStorage(): Promise<{
|
|
||||||
appState: boolean
|
|
||||||
progress: boolean
|
|
||||||
feeds: boolean
|
|
||||||
sources: boolean
|
|
||||||
}> {
|
|
||||||
const [appState, progress, feeds, sources] = await Promise.all([
|
|
||||||
migrateAppStateFromLocalStorage(),
|
|
||||||
migrateProgressFromLocalStorage(),
|
|
||||||
migrateFeedsFromLocalStorage(),
|
|
||||||
migrateSourcesFromLocalStorage(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return { appState, progress, feeds, sources }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
||||||
*
|
*
|
||||||
* Reads and writes feeds to a JSON file instead of localStorage.
|
* Reads and writes feeds to a JSON file instead of localStorage.
|
||||||
* Provides migration from localStorage on first run.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
|
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import { backupConfigFile } from "./config-backup"
|
import { backupConfigFile } from "./config-backup";
|
||||||
import type { Feed } from "../types/feed"
|
import type { Feed } from "../types/feed";
|
||||||
|
|
||||||
const FEEDS_FILE = "feeds.json"
|
const FEEDS_FILE = "feeds.json";
|
||||||
const SOURCES_FILE = "sources.json"
|
const SOURCES_FILE = "sources.json";
|
||||||
|
|
||||||
/** Deserialize date strings back to Date objects in feed data */
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
function reviveDates(feed: Feed): Feed {
|
function reviveDates(feed: Feed): Feed {
|
||||||
@@ -25,31 +24,31 @@ function reviveDates(feed: Feed): Feed {
|
|||||||
...ep,
|
...ep,
|
||||||
pubDate: new Date(ep.pubDate),
|
pubDate: new Date(ep.pubDate),
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load feeds from JSON file */
|
/** Load feeds from JSON file */
|
||||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE)
|
const filePath = getConfigFilePath(FEEDS_FILE);
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) return []
|
if (!(await file.exists())) return [];
|
||||||
|
|
||||||
const raw = await file.json()
|
const raw = await file.json();
|
||||||
if (!Array.isArray(raw)) return []
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map(reviveDates)
|
return raw.map(reviveDates);
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to JSON file */
|
/** Save feeds to JSON file */
|
||||||
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
await backupConfigFile(FEEDS_FILE)
|
await backupConfigFile(FEEDS_FILE);
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE)
|
const filePath = getConfigFilePath(FEEDS_FILE);
|
||||||
await Bun.write(filePath, JSON.stringify(feeds, null, 2))
|
await Bun.write(filePath, JSON.stringify(feeds, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
@@ -58,75 +57,26 @@ export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
|||||||
/** Load sources from JSON file */
|
/** Load sources from JSON file */
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE)
|
const filePath = getConfigFilePath(SOURCES_FILE);
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) return null
|
if (!(await file.exists())) return null;
|
||||||
|
|
||||||
const raw = await file.json()
|
const raw = await file.json();
|
||||||
if (!Array.isArray(raw)) return null
|
if (!Array.isArray(raw)) return null;
|
||||||
return raw as T[]
|
return raw as T[];
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to JSON file */
|
/** Save sources to JSON file */
|
||||||
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
await backupConfigFile(SOURCES_FILE)
|
await backupConfigFile(SOURCES_FILE);
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE)
|
const filePath = getConfigFilePath(SOURCES_FILE);
|
||||||
await Bun.write(filePath, JSON.stringify(sources, null, 2))
|
await Bun.write(filePath, JSON.stringify(sources, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate feeds from localStorage to file.
|
|
||||||
* Only runs once — if the feeds file already exists, it's a no-op.
|
|
||||||
*/
|
|
||||||
export async function migrateFeedsFromLocalStorage(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE)
|
|
||||||
const file = Bun.file(filePath)
|
|
||||||
if (await file.exists()) return false // Already migrated
|
|
||||||
|
|
||||||
if (typeof localStorage === "undefined") return false
|
|
||||||
|
|
||||||
const raw = localStorage.getItem("podtui_feeds")
|
|
||||||
if (!raw) return false
|
|
||||||
|
|
||||||
const feeds = JSON.parse(raw) as Feed[]
|
|
||||||
if (!Array.isArray(feeds) || feeds.length === 0) return false
|
|
||||||
|
|
||||||
await saveFeedsToFile(feeds)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate sources from localStorage to file.
|
|
||||||
*/
|
|
||||||
export async function migrateSourcesFromLocalStorage(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE)
|
|
||||||
const file = Bun.file(filePath)
|
|
||||||
if (await file.exists()) return false
|
|
||||||
|
|
||||||
if (typeof localStorage === "undefined") return false
|
|
||||||
|
|
||||||
const raw = localStorage.getItem("podtui_sources")
|
|
||||||
if (!raw) return false
|
|
||||||
|
|
||||||
const sources = JSON.parse(raw)
|
|
||||||
if (!Array.isArray(sources) || sources.length === 0) return false
|
|
||||||
|
|
||||||
await saveSourcesToFile(sources)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +1,49 @@
|
|||||||
import { RGBA, type TerminalColors } from "@opentui/core"
|
import { RGBA, type TerminalColors } from "@opentui/core";
|
||||||
import { ansiToRgba } from "./ansi-to-rgba"
|
import { ansiToRgba } from "./ansi-to-rgba";
|
||||||
import { generateGrayScale, generateMutedTextColor, tint } from "./color-generation"
|
import {
|
||||||
import type { ThemeJson } from "../types/theme-schema"
|
generateGrayScale,
|
||||||
|
generateMutedTextColor,
|
||||||
|
tint,
|
||||||
|
} from "./color-generation";
|
||||||
|
import type { ThemeJson } from "../types/theme-schema";
|
||||||
|
|
||||||
let cached: TerminalColors | null = null
|
let cached: TerminalColors | null = null;
|
||||||
|
|
||||||
export function clearPaletteCache() {
|
export function clearPaletteCache() {
|
||||||
cached = null
|
cached = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectSystemTheme(colors: TerminalColors) {
|
export function detectSystemTheme(colors: TerminalColors) {
|
||||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000")
|
const bg = RGBA.fromHex(
|
||||||
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
|
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
|
||||||
const mode = luminance > 0.5 ? "light" : "dark"
|
);
|
||||||
return { mode, background: bg }
|
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
|
||||||
|
const mode = luminance > 0.5 ? "light" : "dark";
|
||||||
|
return { mode, background: bg };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
export function generateSystemTheme(
|
||||||
cached = colors
|
colors: TerminalColors,
|
||||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000")
|
mode: "dark" | "light",
|
||||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7] ?? "#ffffff")
|
): ThemeJson {
|
||||||
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
cached = colors;
|
||||||
const isDark = mode === "dark"
|
const bg = RGBA.fromHex(
|
||||||
|
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
|
||||||
|
);
|
||||||
|
const fg = RGBA.fromHex(
|
||||||
|
colors.defaultForeground ?? colors.palette[7] ?? "#ffffff",
|
||||||
|
);
|
||||||
|
const transparent = RGBA.fromInts(0, 0, 0, 0);
|
||||||
|
const isDark = mode === "dark";
|
||||||
|
|
||||||
const col = (i: number) => {
|
const col = (i: number) => {
|
||||||
const value = colors.palette[i]
|
const value = colors.palette[i];
|
||||||
if (value) return RGBA.fromHex(value)
|
if (value) return RGBA.fromHex(value);
|
||||||
return ansiToRgba(i)
|
return ansiToRgba(i);
|
||||||
}
|
};
|
||||||
|
|
||||||
const grays = generateGrayScale(bg, isDark)
|
const grays = generateGrayScale(bg, isDark);
|
||||||
const textMuted = generateMutedTextColor(bg, isDark)
|
const textMuted = generateMutedTextColor(bg, isDark);
|
||||||
|
|
||||||
const ansi = {
|
const ansi = {
|
||||||
black: col(0),
|
black: col(0),
|
||||||
@@ -43,13 +56,13 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
|
|||||||
white: col(7),
|
white: col(7),
|
||||||
redBright: col(9),
|
redBright: col(9),
|
||||||
greenBright: col(10),
|
greenBright: col(10),
|
||||||
}
|
};
|
||||||
|
|
||||||
const diffAlpha = isDark ? 0.22 : 0.14
|
const diffAlpha = isDark ? 0.22 : 0.14;
|
||||||
const diffAddedBg = tint(bg, ansi.green, diffAlpha)
|
const diffAddedBg = tint(bg, ansi.green, diffAlpha);
|
||||||
const diffRemovedBg = tint(bg, ansi.red, diffAlpha)
|
const diffRemovedBg = tint(bg, ansi.red, diffAlpha);
|
||||||
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha)
|
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha);
|
||||||
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha)
|
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: {
|
theme: {
|
||||||
@@ -68,7 +81,7 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
|
|||||||
backgroundElement: grays[3],
|
backgroundElement: grays[3],
|
||||||
backgroundMenu: grays[3],
|
backgroundMenu: grays[3],
|
||||||
borderSubtle: grays[6],
|
borderSubtle: grays[6],
|
||||||
border: grays[7],
|
border: fg,
|
||||||
borderActive: grays[8],
|
borderActive: grays[8],
|
||||||
diffAdded: ansi.green,
|
diffAdded: ansi.green,
|
||||||
diffRemoved: ansi.red,
|
diffRemoved: ansi.red,
|
||||||
@@ -106,5 +119,5 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh
|
|||||||
syntaxOperator: ansi.cyan,
|
syntaxOperator: ansi.cyan,
|
||||||
syntaxPunctuation: fg,
|
syntaxPunctuation: fg,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { ansiToRgba } from "./ansi-to-rgba"
|
|
||||||
import { resolveTheme } from "./theme-resolver"
|
|
||||||
import type { ThemeJson } from "../types/theme-schema"
|
|
||||||
|
|
||||||
describe("theme utils", () => {
|
|
||||||
it("converts ansi codes", () => {
|
|
||||||
const color = ansiToRgba(1)
|
|
||||||
expect(color).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("resolves simple theme", () => {
|
|
||||||
const json: ThemeJson = {
|
|
||||||
theme: {
|
|
||||||
primary: "#ffffff",
|
|
||||||
secondary: "#000000",
|
|
||||||
accent: "#000000",
|
|
||||||
error: "#000000",
|
|
||||||
warning: "#000000",
|
|
||||||
success: "#000000",
|
|
||||||
info: "#000000",
|
|
||||||
text: "#000000",
|
|
||||||
textMuted: "#000000",
|
|
||||||
background: "#000000",
|
|
||||||
backgroundPanel: "#000000",
|
|
||||||
backgroundElement: "#000000",
|
|
||||||
border: "#000000",
|
|
||||||
borderActive: "#000000",
|
|
||||||
borderSubtle: "#000000",
|
|
||||||
diffAdded: "#000000",
|
|
||||||
diffRemoved: "#000000",
|
|
||||||
diffContext: "#000000",
|
|
||||||
diffHunkHeader: "#000000",
|
|
||||||
diffHighlightAdded: "#000000",
|
|
||||||
diffHighlightRemoved: "#000000",
|
|
||||||
diffAddedBg: "#000000",
|
|
||||||
diffRemovedBg: "#000000",
|
|
||||||
diffContextBg: "#000000",
|
|
||||||
diffLineNumber: "#000000",
|
|
||||||
diffAddedLineNumberBg: "#000000",
|
|
||||||
diffRemovedLineNumberBg: "#000000",
|
|
||||||
markdownText: "#000000",
|
|
||||||
markdownHeading: "#000000",
|
|
||||||
markdownLink: "#000000",
|
|
||||||
markdownLinkText: "#000000",
|
|
||||||
markdownCode: "#000000",
|
|
||||||
markdownBlockQuote: "#000000",
|
|
||||||
markdownEmph: "#000000",
|
|
||||||
markdownStrong: "#000000",
|
|
||||||
markdownHorizontalRule: "#000000",
|
|
||||||
markdownListItem: "#000000",
|
|
||||||
markdownListEnumeration: "#000000",
|
|
||||||
markdownImage: "#000000",
|
|
||||||
markdownImageText: "#000000",
|
|
||||||
markdownCodeBlock: "#000000",
|
|
||||||
syntaxComment: "#000000",
|
|
||||||
syntaxKeyword: "#000000",
|
|
||||||
syntaxFunction: "#000000",
|
|
||||||
syntaxVariable: "#000000",
|
|
||||||
syntaxString: "#000000",
|
|
||||||
syntaxNumber: "#000000",
|
|
||||||
syntaxType: "#000000",
|
|
||||||
syntaxOperator: "#000000",
|
|
||||||
syntaxPunctuation: "#000000",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = resolveTheme(json, "dark") as unknown as { primary: unknown }
|
|
||||||
expect(resolved.primary).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const createWaveform = (width: number): number[] => {
|
|
||||||
const data: number[] = []
|
|
||||||
for (let i = 0; i < width; i += 1) {
|
|
||||||
const value = 0.2 + Math.abs(Math.sin(i / 3)) * 0.8
|
|
||||||
data.push(Number(value.toFixed(2)))
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
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
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user