Compare commits
4 Commits
73aa211229
...
64a2ba2751
| Author | SHA1 | Date | |
|---|---|---|---|
| 64a2ba2751 | |||
| bcf248f7dd | |||
| 5bd393c9cd | |||
| 627fb65547 |
323
src/App.tsx
323
src/App.tsx
@@ -1,31 +1,27 @@
|
|||||||
import { createSignal, createMemo, ErrorBoundary } from "solid-js";
|
import { createSignal, createMemo, ErrorBoundary, Accessor } from "solid-js";
|
||||||
import { useSelectionHandler } from "@opentui/solid";
|
import { useKeyboard, useSelectionHandler } from "@opentui/solid";
|
||||||
import { Layout } from "./Layout";
|
import { TabNavigation } from "./components/TabNavigation";
|
||||||
import { TabId, TabNavigation } from "./components/TabNavigation";
|
|
||||||
import { FeedPage } from "@/tabs/Feed/FeedPage";
|
|
||||||
import { MyShowsPage } from "@/tabs/MyShows/MyShowsPage";
|
|
||||||
import { LoginScreen } from "@/tabs/Settings/LoginScreen";
|
|
||||||
import { CodeValidation } from "@/components/CodeValidation";
|
import { CodeValidation } from "@/components/CodeValidation";
|
||||||
import { OAuthPlaceholder } from "@/tabs/Settings/OAuthPlaceholder";
|
|
||||||
import { SyncProfile } from "@/tabs/Settings/SyncProfile";
|
|
||||||
import { SearchPage } from "@/tabs/Search/SearchPage";
|
|
||||||
import { DiscoverPage } from "@/tabs/Discover/DiscoverPage";
|
|
||||||
import { Player } from "@/tabs/Player/Player";
|
|
||||||
import { SettingsScreen } from "@/tabs/Settings/SettingsScreen";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
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 { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { useRenderer } from "@opentui/solid";
|
import { useRenderer } from "@opentui/solid";
|
||||||
import type { AuthScreen } from "@/types/auth";
|
import type { AuthScreen } from "@/types/auth";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
|
import { DIRECTION, LayerGraph, TABS } from "./utils/navigation";
|
||||||
|
import { useTheme } from "./context/ThemeContext";
|
||||||
|
|
||||||
|
export interface PageProps {
|
||||||
|
depth: Accessor<number>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
|
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
||||||
|
const [activeDepth, setActiveDepth] = createSignal(0); // not fixed matrix size
|
||||||
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
|
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
|
||||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false);
|
const [showAuthPanel, setShowAuthPanel] = createSignal(false);
|
||||||
const [inputFocused, setInputFocused] = createSignal(false);
|
const [inputFocused, setInputFocused] = createSignal(false);
|
||||||
@@ -35,69 +31,20 @@ export function App() {
|
|||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
// Global multimedia key handling — active when Player tab is NOT
|
|
||||||
// focused (Player.tsx handles its own keys when focused).
|
|
||||||
useMultimediaKeys({
|
useMultimediaKeys({
|
||||||
playerFocused: () => activeTab() === "player" && layerDepth() > 0,
|
playerFocused: () => activeTab() === TABS.PLAYER && layerDepth() > 0,
|
||||||
inputFocused: () => inputFocused(),
|
inputFocused: () => inputFocused(),
|
||||||
hasEpisode: () => !!audio.currentEpisode(),
|
hasEpisode: () => !!audio.currentEpisode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlayEpisode = (episode: Episode) => {
|
const handlePlayEpisode = (episode: Episode) => {
|
||||||
audio.play(episode);
|
audio.play(episode);
|
||||||
setActiveTab("player");
|
setActiveTab(TABS.PLAYER);
|
||||||
setLayerDepth(1);
|
setLayerDepth(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// My Shows page returns panel renderers
|
|
||||||
const myShows = MyShowsPage({
|
|
||||||
get focused() {
|
|
||||||
return activeTab() === "shows" && layerDepth() > 0;
|
|
||||||
},
|
|
||||||
onPlayEpisode: (episode, feed) => {
|
|
||||||
handlePlayEpisode(episode);
|
|
||||||
},
|
|
||||||
onExit: () => setLayerDepth(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Centralized keyboard handler for all tab navigation and shortcuts
|
|
||||||
useAppKeyboard({
|
|
||||||
get activeTab() {
|
|
||||||
return activeTab();
|
|
||||||
},
|
|
||||||
onTabChange: (tab: TabId) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setInputFocused(false);
|
|
||||||
},
|
|
||||||
get inputFocused() {
|
|
||||||
return inputFocused();
|
|
||||||
},
|
|
||||||
get navigationEnabled() {
|
|
||||||
return layerDepth() === 0;
|
|
||||||
},
|
|
||||||
layerDepth,
|
|
||||||
onLayerChange: (newDepth) => {
|
|
||||||
setLayerDepth(newDepth);
|
|
||||||
},
|
|
||||||
onAction: (action) => {
|
|
||||||
if (action === "escape") {
|
|
||||||
if (layerDepth() > 0) {
|
|
||||||
setLayerDepth(0);
|
|
||||||
setInputFocused(false);
|
|
||||||
} else {
|
|
||||||
setShowAuthPanel(false);
|
|
||||||
setInputFocused(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "enter" && layerDepth() === 0) {
|
|
||||||
setLayerDepth(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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?.();
|
||||||
@@ -113,224 +60,13 @@ export function App() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const getPanels = createMemo(() => {
|
//useKeyboard(
|
||||||
const tab = activeTab();
|
//(keyEvent) => {
|
||||||
|
////handle intra layer navigation
|
||||||
switch (tab) {
|
//if(keyEvent.name == "up" || keyEvent.name)
|
||||||
case "feed":
|
//},
|
||||||
return {
|
//{ release: false }, // Not strictly necessary
|
||||||
panels: [
|
//);
|
||||||
{
|
|
||||||
title: "Feed - Latest Episodes",
|
|
||||||
content: (
|
|
||||||
<FeedPage
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onPlayEpisode={(episode, feed) => {
|
|
||||||
handlePlayEpisode(episode);
|
|
||||||
}}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "j/k navigate | Enter play | r refresh | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "shows":
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "My Shows",
|
|
||||||
width: 35,
|
|
||||||
content: myShows.showsPanel(),
|
|
||||||
focused: myShows.focusPane() === "shows",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: myShows.selectedShow()
|
|
||||||
? `${myShows.selectedShow()!.podcast.title} - Episodes`
|
|
||||||
: "Episodes",
|
|
||||||
content: myShows.episodesPanel(),
|
|
||||||
focused: myShows.focusPane() === "episodes",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: myShows.focusPane() === "shows" ? 0 : 1,
|
|
||||||
hint: "h/l switch panes | j/k navigate | Enter play | r refresh | d unsubscribe | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "settings":
|
|
||||||
if (showAuthPanel()) {
|
|
||||||
if (auth.isAuthenticated) {
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Account",
|
|
||||||
content: (
|
|
||||||
<SyncProfile
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onLogout={() => {
|
|
||||||
auth.logout();
|
|
||||||
setShowAuthPanel(false);
|
|
||||||
}}
|
|
||||||
onManageSync={() => setShowAuthPanel(false)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Esc back",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authContent = () => {
|
|
||||||
switch (authScreen()) {
|
|
||||||
case "code":
|
|
||||||
return (
|
|
||||||
<CodeValidation
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onBack={() => setAuthScreen("login")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "oauth":
|
|
||||||
return (
|
|
||||||
<OAuthPlaceholder
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onBack={() => setAuthScreen("login")}
|
|
||||||
onNavigateToCode={() => setAuthScreen("code")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<LoginScreen
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onNavigateToCode={() => setAuthScreen("code")}
|
|
||||||
onNavigateToOAuth={() => setAuthScreen("oauth")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Sign In",
|
|
||||||
content: authContent(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Esc back",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
content: (
|
|
||||||
<SettingsScreen
|
|
||||||
onOpenAccount={() => setShowAuthPanel(true)}
|
|
||||||
accountLabel={
|
|
||||||
auth.isAuthenticated
|
|
||||||
? `Signed in as ${auth.user?.email}`
|
|
||||||
: "Not signed in"
|
|
||||||
}
|
|
||||||
accountStatus={
|
|
||||||
auth.isAuthenticated ? "signed-in" : "signed-out"
|
|
||||||
}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "j/k navigate | Enter select | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "discover":
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Discover",
|
|
||||||
content: (
|
|
||||||
<DiscoverPage
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "search":
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Search",
|
|
||||||
content: (
|
|
||||||
<SearchPage
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onInputFocusChange={setInputFocused}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
onSubscribe={(result) => {
|
|
||||||
const feeds = feedStore.feeds();
|
|
||||||
const alreadySubscribed = feeds.some(
|
|
||||||
(feed) =>
|
|
||||||
feed.podcast.id === result.podcast.id ||
|
|
||||||
feed.podcast.feedUrl === result.podcast.feedUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadySubscribed) {
|
|
||||||
feedStore.addFeed(
|
|
||||||
{ ...result.podcast, isSubscribed: true },
|
|
||||||
result.sourceId,
|
|
||||||
FeedVisibility.PUBLIC,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Tab switch focus | / search | Enter select | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
case "player":
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: "Player",
|
|
||||||
content: (
|
|
||||||
<Player
|
|
||||||
focused={layerDepth() > 0}
|
|
||||||
onExit={() => setLayerDepth(0)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "Space play/pause | Esc back",
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
title: tab,
|
|
||||||
content: (
|
|
||||||
<box padding={2}>
|
|
||||||
<text>Coming soon</text>
|
|
||||||
</box>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activePanelIndex: 0,
|
|
||||||
hint: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -344,13 +80,16 @@ export function App() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Layout
|
<box
|
||||||
header={
|
flexDirection="row"
|
||||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
width="100%"
|
||||||
}
|
height="100%"
|
||||||
panels={getPanels().panels}
|
backgroundColor={theme.surface}
|
||||||
activePanelIndex={getPanels().activePanelIndex}
|
>
|
||||||
/>
|
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
|
{LayerGraph[activeTab()]({ depth: activeDepth })}
|
||||||
|
{/**TODO: Contextual controls based on tab/depth**/}
|
||||||
|
</box>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/Layout.tsx
111
src/Layout.tsx
@@ -1,111 +0,0 @@
|
|||||||
import type { JSX } from "solid-js";
|
|
||||||
import type { RGBA } from "@opentui/core";
|
|
||||||
import { Show, For } from "solid-js";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
type PanelConfig = {
|
|
||||||
/** Panel content */
|
|
||||||
content: JSX.Element;
|
|
||||||
/** Panel title shown in header */
|
|
||||||
title?: string;
|
|
||||||
/** Fixed width (leave undefined for flex) */
|
|
||||||
width?: number;
|
|
||||||
/** Whether this panel is currently focused */
|
|
||||||
focused?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LayoutProps = {
|
|
||||||
/** Top tab bar */
|
|
||||||
header?: JSX.Element;
|
|
||||||
/** Bottom status bar */
|
|
||||||
footer?: JSX.Element;
|
|
||||||
/** Panels to display left-to-right like a file explorer */
|
|
||||||
panels: PanelConfig[];
|
|
||||||
/** Index of the currently active/focused panel */
|
|
||||||
activePanelIndex?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Layout(props: LayoutProps) {
|
|
||||||
const panelBg = (index: number): RGBA => {
|
|
||||||
const backgrounds = theme.layerBackgrounds;
|
|
||||||
const layers = [
|
|
||||||
backgrounds?.layer0 ?? theme.background,
|
|
||||||
backgrounds?.layer1 ?? theme.backgroundPanel,
|
|
||||||
backgrounds?.layer2 ?? theme.backgroundElement,
|
|
||||||
backgrounds?.layer3 ?? theme.backgroundMenu,
|
|
||||||
];
|
|
||||||
return layers[Math.min(index, layers.length - 1)];
|
|
||||||
};
|
|
||||||
|
|
||||||
const borderColor = (index: number): RGBA | string => {
|
|
||||||
const isActive = index === (props.activePanelIndex ?? 0);
|
|
||||||
return isActive
|
|
||||||
? (theme.accent ?? theme.primary)
|
|
||||||
: (theme.border ?? theme.textMuted);
|
|
||||||
};
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
backgroundColor={theme.surface}
|
|
||||||
>
|
|
||||||
{props.header}
|
|
||||||
|
|
||||||
<box flexDirection="row" style={{ flexGrow: 1 }}>
|
|
||||||
<For each={props.panels}>
|
|
||||||
{(panel, index) => (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
backgroundColor={panelBg(index())}
|
|
||||||
style={{
|
|
||||||
flexGrow: panel.width ? 0 : 1,
|
|
||||||
width: panel.width,
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Panel header */}
|
|
||||||
<Show when={panel.title}>
|
|
||||||
<box
|
|
||||||
style={{
|
|
||||||
height: 1,
|
|
||||||
paddingLeft: 1,
|
|
||||||
paddingRight: 1,
|
|
||||||
backgroundColor:
|
|
||||||
index() === (props.activePanelIndex ?? 0)
|
|
||||||
? (theme.accent ?? theme.primary)
|
|
||||||
: (theme.surface ?? theme.backgroundPanel),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
index() === (props.activePanelIndex ?? 0)
|
|
||||||
? "black"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<strong>{panel.title}</strong>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Panel body */}
|
|
||||||
<box
|
|
||||||
style={{
|
|
||||||
flexGrow: 1,
|
|
||||||
padding: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{panel.content}
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { TABS } from "@/utils/navigation";
|
||||||
import { For } from "solid-js";
|
import { For } from "solid-js";
|
||||||
|
|
||||||
interface TabNavigationProps {
|
interface TabNavigationProps {
|
||||||
activeTab: TabId;
|
activeTab: TABS;
|
||||||
onTabSelect: (tab: TabId) => void;
|
onTabSelect: (tab: TABS) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tabs: TabDefinition[] = [
|
export const tabs: TabDefinition[] = [
|
||||||
{ id: "feed", label: "Feed" },
|
{ id: TABS.FEED, label: "Feed" },
|
||||||
{ id: "shows", label: "My Shows" },
|
{ id: TABS.MYSHOWS, label: "My Shows" },
|
||||||
{ id: "discover", label: "Discover" },
|
{ id: TABS.DISCOVER, label: "Discover" },
|
||||||
{ id: "search", label: "Search" },
|
{ id: TABS.SEARCH, label: "Search" },
|
||||||
{ id: "player", label: "Player" },
|
{ id: TABS.PLAYER, label: "Player" },
|
||||||
{ id: "settings", label: "Settings" },
|
{ id: TABS.SETTINGS, label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TabNavigation(props: TabNavigationProps) {
|
export function TabNavigation(props: TabNavigationProps) {
|
||||||
@@ -52,15 +53,7 @@ export function TabNavigation(props: TabNavigationProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabId =
|
|
||||||
| "feed"
|
|
||||||
| "shows"
|
|
||||||
| "discover"
|
|
||||||
| "search"
|
|
||||||
| "player"
|
|
||||||
| "settings";
|
|
||||||
|
|
||||||
export type TabDefinition = {
|
export type TabDefinition = {
|
||||||
id: TabId;
|
id: TABS;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { ThemeProvider } from "./ThemeContext"
|
|
||||||
|
|
||||||
describe("ThemeContext", () => {
|
|
||||||
it("exports provider", () => {
|
|
||||||
expect(typeof ThemeProvider).toBe("function")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Centralized keyboard shortcuts hook for PodTUI
|
|
||||||
* Single handler to prevent conflicts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
|
|
||||||
const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"]
|
|
||||||
|
|
||||||
type TabId =
|
|
||||||
| "feed"
|
|
||||||
| "shows"
|
|
||||||
| "discover"
|
|
||||||
| "search"
|
|
||||||
| "player"
|
|
||||||
| "settings"
|
|
||||||
|
|
||||||
type ShortcutOptions = {
|
|
||||||
activeTab: TabId
|
|
||||||
onTabChange: (tab: TabId) => void
|
|
||||||
onAction?: (action: string) => void
|
|
||||||
inputFocused?: boolean
|
|
||||||
navigationEnabled?: boolean
|
|
||||||
layerDepth?: Accessor<number>
|
|
||||||
onLayerChange?: (newDepth: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAppKeyboard(options: ShortcutOptions) {
|
|
||||||
const renderer = useRenderer()
|
|
||||||
|
|
||||||
const getNextTab = (current: TabId): TabId => {
|
|
||||||
const idx = TAB_ORDER.indexOf(current)
|
|
||||||
return TAB_ORDER[(idx + 1) % TAB_ORDER.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPrevTab = (current: TabId): TabId => {
|
|
||||||
const idx = TAB_ORDER.indexOf(current)
|
|
||||||
return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
// Always allow quit
|
|
||||||
if (key.ctrl && key.name === "q") {
|
|
||||||
renderer.destroy()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "escape") {
|
|
||||||
options.onAction?.("escape")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip global shortcuts if input is focused (let input handle keys)
|
|
||||||
if (options.inputFocused) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.navigationEnabled === false) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return key cycles tabs (equivalent to Tab)
|
|
||||||
if (key.name === "return") {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer navigation with left/right arrows
|
|
||||||
if (options.layerDepth !== undefined && options.onLayerChange) {
|
|
||||||
const currentDepth = options.layerDepth()
|
|
||||||
const maxLayers = 3
|
|
||||||
|
|
||||||
if (key.name === "right") {
|
|
||||||
if (currentDepth < maxLayers) {
|
|
||||||
options.onLayerChange(currentDepth + 1)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "left") {
|
|
||||||
if (currentDepth > 0) {
|
|
||||||
options.onLayerChange(currentDepth - 1)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab navigation with left/right arrows OR [ and ]
|
|
||||||
if (key.name === "right" || key.name === "]") {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "left" || key.name === "[") {
|
|
||||||
options.onTabChange(getPrevTab(options.activeTab))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number keys for direct tab access (1-6)
|
|
||||||
if (key.name === "1") {
|
|
||||||
options.onTabChange("feed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "2") {
|
|
||||||
options.onTabChange("shows")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "3") {
|
|
||||||
options.onTabChange("discover")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "4") {
|
|
||||||
options.onTabChange("search")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "5") {
|
|
||||||
options.onTabChange("player")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "6") {
|
|
||||||
options.onTabChange("settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab key cycles tabs (Shift+Tab goes backwards)
|
|
||||||
if (key.name === "tab") {
|
|
||||||
if (key.shift) {
|
|
||||||
options.onTabChange(getPrevTab(options.activeTab))
|
|
||||||
} else {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward other actions
|
|
||||||
if (options.onAction) {
|
|
||||||
if (key.ctrl && key.name === "s") {
|
|
||||||
options.onAction("save")
|
|
||||||
} else if (key.ctrl && key.name === "f") {
|
|
||||||
options.onAction("find")
|
|
||||||
} else if (key.name === "?" || (key.shift && key.name === "/")) {
|
|
||||||
options.onAction("help")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
133
src/pages/Discover/DiscoverPage.tsx
Normal file
133
src/pages/Discover/DiscoverPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* DiscoverPage component - Main discover/browse interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, For, Show } from "solid-js";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PodcastCard } from "./PodcastCard";
|
||||||
|
import { PageProps } from "@/App";
|
||||||
|
|
||||||
|
enum DiscoverPagePaneType {
|
||||||
|
CATEGORIES = 1,
|
||||||
|
SHOWS = 2,
|
||||||
|
}
|
||||||
|
export const DiscoverPaneCount = 2;
|
||||||
|
|
||||||
|
export function DiscoverPage(props: PageProps) {
|
||||||
|
const discoverStore = useDiscoverStore();
|
||||||
|
const [showIndex, setShowIndex] = createSignal(0);
|
||||||
|
const [categoryIndex, setCategoryIndex] = createSignal(0);
|
||||||
|
|
||||||
|
const handleCategorySelect = (categoryId: string) => {
|
||||||
|
discoverStore.setSelectedCategory(categoryId);
|
||||||
|
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
|
||||||
|
if (index >= 0) setCategoryIndex(index);
|
||||||
|
setShowIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowSelect = (index: number) => {
|
||||||
|
setShowIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscribe = (podcast: { id: string }) => {
|
||||||
|
discoverStore.toggleSubscription(podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" flexGrow={1} height="100%" gap={1}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={1}
|
||||||
|
borderColor={theme.border}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
width={20}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
props.depth() == DiscoverPagePaneType.CATEGORIES
|
||||||
|
? theme.accent
|
||||||
|
: theme.text
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Categories:
|
||||||
|
</text>
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<For each={discoverStore.categories}>
|
||||||
|
{(category) => {
|
||||||
|
const isSelected = () =>
|
||||||
|
discoverStore.selectedCategory() === category.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border={isSelected()}
|
||||||
|
backgroundColor={isSelected() ? theme.accent : undefined}
|
||||||
|
onMouseDown={() => handleCategorySelect(category.id)}
|
||||||
|
>
|
||||||
|
<text fg={isSelected() ? "cyan" : "gray"}>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={1}
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text
|
||||||
|
fg={props.depth() == DiscoverPagePaneType.SHOWS ? "cyan" : "gray"}
|
||||||
|
>
|
||||||
|
Trending in{" "}
|
||||||
|
{DISCOVER_CATEGORIES.find(
|
||||||
|
(c) => c.id === discoverStore.selectedCategory(),
|
||||||
|
)?.name ?? "All"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="column" height="100%">
|
||||||
|
<Show
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
{discoverStore.filteredPodcasts().length !== 0 ? (
|
||||||
|
<text fg="yellow">Loading trending shows...</text>
|
||||||
|
) : (
|
||||||
|
<text fg="gray">No podcasts found in this category.</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
when={
|
||||||
|
!discoverStore.isLoading() &&
|
||||||
|
discoverStore.filteredPodcasts().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={discoverStore.filteredPodcasts()}>
|
||||||
|
{(podcast, index) => (
|
||||||
|
<PodcastCard
|
||||||
|
podcast={podcast}
|
||||||
|
selected={
|
||||||
|
index() === showIndex() &&
|
||||||
|
props.depth() == DiscoverPagePaneType.SHOWS
|
||||||
|
}
|
||||||
|
onSelect={() => handleShowSelect(index())}
|
||||||
|
onSubscribe={() => handleSubscribe(podcast)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,20 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
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";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PageProps } from "@/App";
|
||||||
|
|
||||||
type FeedPageProps = {
|
enum FeedPaneType {
|
||||||
focused: boolean;
|
FEED = 1,
|
||||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
|
}
|
||||||
onExit?: () => void;
|
export const FeedPaneCount = 1;
|
||||||
};
|
|
||||||
|
|
||||||
export function FeedPage(props: FeedPageProps) {
|
export function FeedPage(props: PageProps) {
|
||||||
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);
|
||||||
@@ -41,33 +40,6 @@ export function FeedPage(props: FeedPageProps) {
|
|||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
|
|
||||||
const episodes = allEpisodes();
|
|
||||||
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1));
|
|
||||||
} else if (key.name === "up" || key.name === "k") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
const item = episodes[selectedIndex()];
|
|
||||||
if (item) props.onPlayEpisode?.(item.episode, item.feed);
|
|
||||||
} else if (key.name === "home" || key.name === "g") {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (key.name === "end") {
|
|
||||||
setSelectedIndex(episodes.length - 1);
|
|
||||||
} else if (key.name === "pageup") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
|
||||||
} else if (key.name === "pagedown") {
|
|
||||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10));
|
|
||||||
} else if (key.name === "r") {
|
|
||||||
handleRefresh();
|
|
||||||
} else if (key.name === "escape") {
|
|
||||||
props.onExit?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -80,7 +52,6 @@ export function FeedPage(props: FeedPageProps) {
|
|||||||
<text fg="yellow">Refreshing feeds...</text>
|
<text fg="yellow">Refreshing feeds...</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<Show
|
<Show
|
||||||
when={allEpisodes().length > 0}
|
when={allEpisodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -91,7 +62,8 @@ export function FeedPage(props: FeedPageProps) {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<scrollbox height="100%" focused={props.focused}>
|
{/**TODO: figure out wtf to do here **/}
|
||||||
|
<scrollbox height="100%" focused={props.depth() == FeedPaneType.FEED}>
|
||||||
<For each={allEpisodes()}>
|
<For each={allEpisodes()}>
|
||||||
{(item, index) => (
|
{(item, index) => (
|
||||||
<box
|
<box
|
||||||
@@ -12,19 +12,18 @@ 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";
|
||||||
|
import { PageProps } from "@/App";
|
||||||
|
|
||||||
type MyShowsPageProps = {
|
enum MyShowsPaneType {
|
||||||
focused: boolean;
|
SHOWS = 1,
|
||||||
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
|
EPISODES = 2,
|
||||||
onExit?: () => void;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
type FocusPane = "shows" | "episodes";
|
export const MyShowsPaneCount = 2;
|
||||||
|
|
||||||
export function MyShowsPage(props: MyShowsPageProps) {
|
export function MyShowsPage(props: PageProps) {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
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);
|
||||||
@@ -128,97 +127,8 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
setEpisodeIndex(0);
|
setEpisodeIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboard((key) => {
|
return (
|
||||||
if (!props.focused) return;
|
<box flexDirection="row" flexGrow={1}>
|
||||||
|
|
||||||
const pane = focusPane();
|
|
||||||
|
|
||||||
// Navigate between panes
|
|
||||||
if (key.name === "right" || key.name === "l") {
|
|
||||||
if (pane === "shows" && selectedShow()) {
|
|
||||||
setFocusPane("episodes");
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "left" || key.name === "h") {
|
|
||||||
if (pane === "episodes") {
|
|
||||||
setFocusPane("shows");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "tab") {
|
|
||||||
if (pane === "shows" && selectedShow()) {
|
|
||||||
setFocusPane("episodes");
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
} else {
|
|
||||||
setFocusPane("shows");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pane === "shows") {
|
|
||||||
const s = shows();
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
setShowIndex((i) => Math.min(s.length - 1, i + 1));
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
} else if (key.name === "up" || key.name === "k") {
|
|
||||||
setShowIndex((i) => Math.max(0, i - 1));
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
|
||||||
if (selectedShow()) {
|
|
||||||
setFocusPane("episodes");
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
}
|
|
||||||
} else if (key.name === "d") {
|
|
||||||
handleUnsubscribe();
|
|
||||||
} else if (key.name === "r") {
|
|
||||||
handleRefresh();
|
|
||||||
} else if (key.name === "escape") {
|
|
||||||
props.onExit?.();
|
|
||||||
}
|
|
||||||
} else if (pane === "episodes") {
|
|
||||||
const eps = episodes();
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1));
|
|
||||||
} else if (key.name === "up" || key.name === "k") {
|
|
||||||
setEpisodeIndex((i) => Math.max(0, i - 1));
|
|
||||||
} else if (key.name === "return" || key.name === "enter") {
|
|
||||||
const ep = eps[episodeIndex()];
|
|
||||||
const show = selectedShow();
|
|
||||||
if (ep && show) props.onPlayEpisode?.(ep, show);
|
|
||||||
} else if (key.name === "d") {
|
|
||||||
const ep = eps[episodeIndex()];
|
|
||||||
const show = selectedShow();
|
|
||||||
if (ep && show) {
|
|
||||||
const status = downloadStore.getDownloadStatus(ep.id);
|
|
||||||
if (
|
|
||||||
status === DownloadStatus.NONE ||
|
|
||||||
status === DownloadStatus.FAILED
|
|
||||||
) {
|
|
||||||
downloadStore.startDownload(ep, show.id);
|
|
||||||
} else if (
|
|
||||||
status === DownloadStatus.DOWNLOADING ||
|
|
||||||
status === DownloadStatus.QUEUED
|
|
||||||
) {
|
|
||||||
downloadStore.cancelDownload(ep.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (key.name === "pageup") {
|
|
||||||
setEpisodeIndex((i) => Math.max(0, i - 10));
|
|
||||||
} else if (key.name === "pagedown") {
|
|
||||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10));
|
|
||||||
} else if (key.name === "r") {
|
|
||||||
handleRefresh();
|
|
||||||
} else if (key.name === "escape") {
|
|
||||||
setFocusPane("shows");
|
|
||||||
key.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
showsPanel: () => (
|
|
||||||
<box flexDirection="column" height="100%">
|
<box flexDirection="column" height="100%">
|
||||||
<Show when={isRefreshing()}>
|
<Show when={isRefreshing()}>
|
||||||
<text fg="yellow">Refreshing...</text>
|
<text fg="yellow">Refreshing...</text>
|
||||||
@@ -235,7 +145,7 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
>
|
>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={props.focused && focusPane() === "shows"}
|
focused={props.depth() == MyShowsPaneType.SHOWS}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => (
|
{(feed, index) => (
|
||||||
@@ -263,9 +173,6 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
),
|
|
||||||
|
|
||||||
episodesPanel: () => (
|
|
||||||
<box flexDirection="column" height="100%">
|
<box flexDirection="column" height="100%">
|
||||||
<Show
|
<Show
|
||||||
when={selectedShow()}
|
when={selectedShow()}
|
||||||
@@ -285,7 +192,7 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
>
|
>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={props.focused && focusPane() === "episodes"}
|
focused={props.depth() == MyShowsPaneType.EPISODES}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(episode, index) => (
|
{(episode, index) => (
|
||||||
@@ -344,9 +251,6 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
),
|
</box>
|
||||||
|
);
|
||||||
focusPane,
|
|
||||||
selectedShow,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
74
src/pages/Player/PlayerPage.tsx
Normal file
74
src/pages/Player/PlayerPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { PageProps } from "@/App";
|
||||||
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
|
enum PlayerPaneType {
|
||||||
|
PLAYER = 1,
|
||||||
|
}
|
||||||
|
export const PlayerPaneCount = 1;
|
||||||
|
|
||||||
|
export function PlayerPage(props: PageProps) {
|
||||||
|
const audio = useAudio();
|
||||||
|
|
||||||
|
const progressPercent = () => {
|
||||||
|
const d = audio.duration();
|
||||||
|
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(audio.duration())} (
|
||||||
|
{progressPercent()}%)
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{audio.error() && <text fg="red">{audio.error()}</text>}
|
||||||
|
|
||||||
|
<box border padding={1} flexDirection="column" gap={1}>
|
||||||
|
<text fg="white">
|
||||||
|
<strong>{audio.currentEpisode()?.title}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg="gray">{audio.currentEpisode()?.description}</text>
|
||||||
|
|
||||||
|
<RealtimeWaveform
|
||||||
|
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={!!audio.currentEpisode()?.audioUrl}
|
||||||
|
onToggle={audio.togglePlayback}
|
||||||
|
onPrev={() => audio.seek(0)}
|
||||||
|
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
|
||||||
|
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||||
|
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,25 +14,11 @@ import {
|
|||||||
type CavaCoreConfig,
|
type CavaCoreConfig,
|
||||||
} from "@/utils/cavacore";
|
} from "@/utils/cavacore";
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type RealtimeWaveformProps = {
|
export type RealtimeWaveformProps = {
|
||||||
/** Audio URL — used to start the ffmpeg decode stream */
|
|
||||||
audioUrl: string;
|
|
||||||
/** Current playback position in seconds */
|
|
||||||
position: number;
|
|
||||||
/** Total duration in seconds */
|
|
||||||
duration: number;
|
|
||||||
/** Whether audio is currently playing */
|
|
||||||
isPlaying: boolean;
|
|
||||||
/** Playback speed multiplier (default: 1) */
|
|
||||||
speed?: number;
|
|
||||||
/** Number of frequency bars / columns */
|
|
||||||
resolution?: number;
|
|
||||||
/** Callback when user clicks to seek */
|
|
||||||
onSeek?: (seconds: number) => void;
|
|
||||||
/** Visualizer configuration overrides */
|
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +44,7 @@ const SAMPLES_PER_FRAME = 512;
|
|||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||||
const resolution = () => props.resolution ?? 32;
|
const audio = useAudio();
|
||||||
|
|
||||||
// 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[]>([]);
|
||||||
@@ -95,7 +81,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides
|
// Initialize cavacore with current resolution + any overrides
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: resolution(),
|
bars: 32,
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
...props.visualizerConfig,
|
...props.visualizerConfig,
|
||||||
@@ -151,27 +137,17 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
setBarData(Array.from(output));
|
setBarData(Array.from(output));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Single unified effect: respond to all prop changes ─────────────
|
|
||||||
//
|
|
||||||
// Instead of three competing effects that each independently call
|
|
||||||
// startVisualization() and race against each other, we use ONE effect
|
|
||||||
// that tracks all relevant inputs. Position is read with untrack()
|
|
||||||
// so normal playback drift doesn't trigger restarts.
|
|
||||||
//
|
|
||||||
// SolidJS on() with an array of accessors compares each element
|
|
||||||
// individually, so the effect only fires when a value actually changes.
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
[
|
[
|
||||||
() => props.isPlaying,
|
audio.isPlaying,
|
||||||
() => props.audioUrl,
|
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
|
||||||
() => props.speed ?? 1,
|
audio.speed,
|
||||||
resolution,
|
() => 32,
|
||||||
],
|
],
|
||||||
([playing, url, speed]) => {
|
([playing, url, speed]) => {
|
||||||
if (playing && url) {
|
if (playing && url) {
|
||||||
const pos = untrack(() => props.position);
|
const pos = untrack(audio.position);
|
||||||
startVisualization(url, pos, speed);
|
startVisualization(url, pos, speed);
|
||||||
} else {
|
} else {
|
||||||
stopVisualization();
|
stopVisualization();
|
||||||
@@ -189,23 +165,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
let lastSyncPosition = 0;
|
let lastSyncPosition = 0;
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(audio.position, (pos) => {
|
||||||
() => props.position,
|
if (!audio.isPlaying || !reader?.running) {
|
||||||
(pos) => {
|
|
||||||
if (!props.isPlaying || !reader?.running) {
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
|
||||||
lastSyncPosition = pos;
|
lastSyncPosition = pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (delta > 2) {
|
const delta = Math.abs(pos - lastSyncPosition);
|
||||||
const speed = props.speed ?? 1;
|
lastSyncPosition = pos;
|
||||||
reader.restart(pos, speed);
|
|
||||||
}
|
if (delta > 2) {
|
||||||
},
|
reader.restart(pos, audio.speed() ?? 1);
|
||||||
),
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
@@ -224,11 +196,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const playedRatio = () =>
|
const playedRatio = () =>
|
||||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
|
audio.duration() <= 0
|
||||||
|
? 0
|
||||||
|
: Math.min(1, audio.position() / audio.duration());
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
const numBars = resolution();
|
const numBars = 32;
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
// If no data yet, show empty placeholder
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
@@ -241,7 +215,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const played = Math.floor(numBars * playedRatio());
|
const played = Math.floor(numBars * playedRatio());
|
||||||
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
|
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
||||||
const futureColor = "#3b4252";
|
const futureColor = "#3b4252";
|
||||||
|
|
||||||
const playedChars = bars
|
const playedChars = bars
|
||||||
@@ -263,13 +237,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (event: { x: number }) => {
|
const handleClick = (event: { x: number }) => {
|
||||||
const numBars = resolution();
|
const numBars = 32;
|
||||||
const ratio = numBars === 0 ? 0 : event.x / numBars;
|
const ratio = event.x / numBars;
|
||||||
const next = Math.max(
|
const next = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(props.duration, Math.round(props.duration * ratio)),
|
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
|
||||||
);
|
);
|
||||||
props.onSeek?.(next);
|
audio.seek(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
158
src/pages/Search/SearchPage.tsx
Normal file
158
src/pages/Search/SearchPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* SearchPage component - Main search interface for PodTUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, createEffect, Show } from "solid-js";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { useSearchStore } from "@/stores/search";
|
||||||
|
import { SearchResults } from "./SearchResults";
|
||||||
|
import { SearchHistory } from "./SearchHistory";
|
||||||
|
import type { SearchResult } from "@/types/source";
|
||||||
|
import { PageProps } from "@/App";
|
||||||
|
import { MyShowsPage } from "../MyShows/MyShowsPage";
|
||||||
|
|
||||||
|
enum SearchPaneType {
|
||||||
|
INPUT = 1,
|
||||||
|
RESULTS = 2,
|
||||||
|
HISTORY = 3,
|
||||||
|
}
|
||||||
|
export const SearchPaneCount = 3;
|
||||||
|
|
||||||
|
export function SearchPage(props: PageProps) {
|
||||||
|
const searchStore = useSearchStore();
|
||||||
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
|
const [resultIndex, setResultIndex] = createSignal(0);
|
||||||
|
const [historyIndex, setHistoryIndex] = createSignal(0);
|
||||||
|
|
||||||
|
// Keep parent informed about input focus state
|
||||||
|
// TODO: have a global input focused prop in useKeyboard hook
|
||||||
|
//createEffect(() => {
|
||||||
|
//const isInputFocused = props.focused && focusArea() === "input";
|
||||||
|
//props.onInputFocusChange?.(isInputFocused);
|
||||||
|
//});
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const query = inputValue().trim();
|
||||||
|
if (query) {
|
||||||
|
await searchStore.search(query);
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
//setFocusArea("results"); //TODO: move level
|
||||||
|
setResultIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistorySelect = async (query: string) => {
|
||||||
|
setInputValue(query);
|
||||||
|
await searchStore.search(query);
|
||||||
|
if (searchStore.results().length > 0) {
|
||||||
|
//setFocusArea("results"); //TODO: move level
|
||||||
|
setResultIndex(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultSelect = (result: SearchResult) => {
|
||||||
|
//props.onSubscribe?.(result);
|
||||||
|
searchStore.markSubscribed(result.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" height="100%" gap={1}>
|
||||||
|
{/* Search Header */}
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text>
|
||||||
|
<strong>Search Podcasts</strong>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text fg="gray">Search:</text>
|
||||||
|
<input
|
||||||
|
value={inputValue()}
|
||||||
|
onInput={(value) => {
|
||||||
|
setInputValue(value);
|
||||||
|
}}
|
||||||
|
placeholder="Enter podcast name, topic, or author..."
|
||||||
|
focused={props.depth() === SearchPaneType.INPUT}
|
||||||
|
width={50}
|
||||||
|
/>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
onMouseDown={handleSearch}
|
||||||
|
>
|
||||||
|
<text fg="cyan">[Enter] Search</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Show when={searchStore.isSearching()}>
|
||||||
|
<text fg="yellow">Searching...</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={searchStore.error()}>
|
||||||
|
<text fg="red">{searchStore.error()}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* Main Content - Results or History */}
|
||||||
|
<box flexDirection="row" height="100%" gap={2}>
|
||||||
|
{/* Results Panel */}
|
||||||
|
<box flexDirection="column" flexGrow={1} border>
|
||||||
|
<box padding={1}>
|
||||||
|
<text
|
||||||
|
fg={props.depth() === SearchPaneType.RESULTS ? "cyan" : "gray"}
|
||||||
|
>
|
||||||
|
Results ({searchStore.results().length})
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<Show
|
||||||
|
when={searchStore.results().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={2}>
|
||||||
|
<text fg="gray">
|
||||||
|
{searchStore.query()
|
||||||
|
? "No results found"
|
||||||
|
: "Enter a search term to find podcasts"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchResults
|
||||||
|
results={searchStore.results()}
|
||||||
|
selectedIndex={resultIndex()}
|
||||||
|
focused={props.depth() === SearchPaneType.RESULTS}
|
||||||
|
onSelect={handleResultSelect}
|
||||||
|
onChange={setResultIndex}
|
||||||
|
isSearching={searchStore.isSearching()}
|
||||||
|
error={searchStore.error()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* History Sidebar */}
|
||||||
|
<box width={30} border>
|
||||||
|
<box padding={1} flexDirection="column">
|
||||||
|
<box paddingBottom={1}>
|
||||||
|
<text
|
||||||
|
fg={props.depth() === SearchPaneType.HISTORY ? "cyan" : "gray"}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<SearchHistory
|
||||||
|
history={searchStore.history()}
|
||||||
|
selectedIndex={historyIndex()}
|
||||||
|
focused={props.depth() === SearchPaneType.HISTORY}
|
||||||
|
onSelect={handleHistorySelect}
|
||||||
|
onRemove={searchStore.removeFromHistory}
|
||||||
|
onClear={searchStore.clearHistory}
|
||||||
|
onChange={setHistoryIndex}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/pages/Settings/SettingsPage.tsx
Normal file
77
src/pages/Settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createSignal, For } from "solid-js";
|
||||||
|
import { useKeyboard } from "@opentui/solid";
|
||||||
|
import { SourceManager } from "./SourceManager";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PreferencesPanel } from "./PreferencesPanel";
|
||||||
|
import { SyncPanel } from "./SyncPanel";
|
||||||
|
import { VisualizerSettings } from "./VisualizerSettings";
|
||||||
|
import { PageProps } from "@/App";
|
||||||
|
|
||||||
|
enum SettingsPaneType {
|
||||||
|
SYNC = 1,
|
||||||
|
SOURCES = 2,
|
||||||
|
PREFERENCES = 3,
|
||||||
|
VISUALIZER = 4,
|
||||||
|
ACCOUNT = 5,
|
||||||
|
}
|
||||||
|
export const SettingsPaneCount = 5;
|
||||||
|
|
||||||
|
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
|
||||||
|
{ id: SettingsPaneType.SYNC, label: "Sync" },
|
||||||
|
{ id: SettingsPaneType.SOURCES, label: "Sources" },
|
||||||
|
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
|
||||||
|
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
|
||||||
|
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsPage(props: PageProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [activeSection, setActiveSection] = createSignal<SettingsPaneType>(
|
||||||
|
SettingsPaneType.SYNC,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} height="100%">
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={SECTIONS}>
|
||||||
|
{(section, index) => (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
padding={0}
|
||||||
|
backgroundColor={
|
||||||
|
activeSection() === section.id ? theme.primary : undefined
|
||||||
|
}
|
||||||
|
onMouseDown={() => setActiveSection(section.id)}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
activeSection() === section.id ? theme.text : theme.textMuted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
[{index() + 1}] {section.label}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
|
||||||
|
{activeSection() === SettingsPaneType.SYNC && <SyncPanel />}
|
||||||
|
{activeSection() === SettingsPaneType.SOURCES && (
|
||||||
|
<SourceManager focused />
|
||||||
|
)}
|
||||||
|
{activeSection() === SettingsPaneType.PREFERENCES && (
|
||||||
|
<PreferencesPanel />
|
||||||
|
)}
|
||||||
|
{activeSection() === SettingsPaneType.VISUALIZER && (
|
||||||
|
<VisualizerSettings />
|
||||||
|
)}
|
||||||
|
{activeSection() === SettingsPaneType.ACCOUNT && (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={theme.textMuted}>Account</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* CategoryFilter component - Horizontal category filter tabs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For } from "solid-js";
|
|
||||||
import type { DiscoverCategory } from "@/stores/discover";
|
|
||||||
|
|
||||||
type CategoryFilterProps = {
|
|
||||||
categories: DiscoverCategory[];
|
|
||||||
selectedCategory: string;
|
|
||||||
focused: boolean;
|
|
||||||
onSelect?: (categoryId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CategoryFilter(props: CategoryFilterProps) {
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={1} flexWrap="wrap">
|
|
||||||
<For each={props.categories}>
|
|
||||||
{(category) => {
|
|
||||||
const isSelected = () => props.selectedCategory === category.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
border={isSelected()}
|
|
||||||
backgroundColor={isSelected() ? "#444" : undefined}
|
|
||||||
onMouseDown={() => props.onSelect?.(category.id)}
|
|
||||||
>
|
|
||||||
<text fg={isSelected() ? "cyan" : "gray"}>
|
|
||||||
{category.icon} {category.name}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* DiscoverPage component - Main discover/browse interface for PodTUI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
|
||||||
import { CategoryFilter } from "./CategoryFilter";
|
|
||||||
import { TrendingShows } from "./TrendingShows";
|
|
||||||
|
|
||||||
type DiscoverPageProps = {
|
|
||||||
focused: boolean;
|
|
||||||
onExit?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FocusArea = "categories" | "shows";
|
|
||||||
|
|
||||||
export function DiscoverPage(props: DiscoverPageProps) {
|
|
||||||
const discoverStore = useDiscoverStore();
|
|
||||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows");
|
|
||||||
const [showIndex, setShowIndex] = createSignal(0);
|
|
||||||
const [categoryIndex, setCategoryIndex] = createSignal(0);
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
|
|
||||||
const area = focusArea();
|
|
||||||
|
|
||||||
// Tab switches focus between categories and shows
|
|
||||||
if (key.name === "tab") {
|
|
||||||
if (key.shift) {
|
|
||||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
|
|
||||||
} else {
|
|
||||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
key.name === "return" &&
|
|
||||||
area === "categories"
|
|
||||||
) {
|
|
||||||
setFocusArea("shows");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category navigation
|
|
||||||
if (area === "categories") {
|
|
||||||
if (key.name === "left" || key.name === "h") {
|
|
||||||
const nextIndex = Math.max(0, categoryIndex() - 1);
|
|
||||||
setCategoryIndex(nextIndex);
|
|
||||||
const cat = DISCOVER_CATEGORIES[nextIndex];
|
|
||||||
if (cat) discoverStore.setSelectedCategory(cat.id);
|
|
||||||
setShowIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "right" || key.name === "l") {
|
|
||||||
const nextIndex = Math.min(
|
|
||||||
DISCOVER_CATEGORIES.length - 1,
|
|
||||||
categoryIndex() + 1,
|
|
||||||
);
|
|
||||||
setCategoryIndex(nextIndex);
|
|
||||||
const cat = DISCOVER_CATEGORIES[nextIndex];
|
|
||||||
if (cat) discoverStore.setSelectedCategory(cat.id);
|
|
||||||
setShowIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "return" || key.name === "enter") {
|
|
||||||
// Select category and move to shows
|
|
||||||
setFocusArea("shows");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
setFocusArea("shows");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shows navigation
|
|
||||||
if (area === "shows") {
|
|
||||||
const shows = discoverStore.filteredPodcasts();
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
if (shows.length === 0) return;
|
|
||||||
setShowIndex((i) => Math.min(i + 1, shows.length - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
if (shows.length === 0) {
|
|
||||||
setFocusArea("categories");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newIndex = showIndex() - 1;
|
|
||||||
if (newIndex < 0) {
|
|
||||||
setFocusArea("categories");
|
|
||||||
} else {
|
|
||||||
setShowIndex(newIndex);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "return" || key.name === "enter") {
|
|
||||||
// Subscribe/unsubscribe
|
|
||||||
const podcast = shows[showIndex()];
|
|
||||||
if (podcast) {
|
|
||||||
discoverStore.toggleSubscription(podcast.id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "escape") {
|
|
||||||
if (area === "shows") {
|
|
||||||
setFocusArea("categories");
|
|
||||||
key.stopPropagation();
|
|
||||||
} else {
|
|
||||||
props.onExit?.();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh with 'r'
|
|
||||||
if (key.name === "r") {
|
|
||||||
discoverStore.refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCategorySelect = (categoryId: string) => {
|
|
||||||
discoverStore.setSelectedCategory(categoryId);
|
|
||||||
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
|
|
||||||
if (index >= 0) setCategoryIndex(index);
|
|
||||||
setShowIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowSelect = (index: number) => {
|
|
||||||
setShowIndex(index);
|
|
||||||
setFocusArea("shows");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubscribe = (podcast: { id: string }) => {
|
|
||||||
discoverStore.toggleSubscription(podcast.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" height="100%" gap={1}>
|
|
||||||
{/* Header */}
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<text>
|
|
||||||
<strong>Discover Podcasts</strong>
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg="gray">{discoverStore.filteredPodcasts().length} shows</text>
|
|
||||||
<box onMouseDown={() => discoverStore.refresh()}>
|
|
||||||
<text fg="cyan">[R] Refresh</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Category Filter */}
|
|
||||||
<box border padding={1}>
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
|
||||||
Categories:
|
|
||||||
</text>
|
|
||||||
<CategoryFilter
|
|
||||||
categories={discoverStore.categories}
|
|
||||||
selectedCategory={discoverStore.selectedCategory()}
|
|
||||||
focused={focusArea() === "categories"}
|
|
||||||
onSelect={handleCategorySelect}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Trending Shows */}
|
|
||||||
<box flexDirection="column" flexGrow={1} border>
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
|
||||||
Trending in{" "}
|
|
||||||
{DISCOVER_CATEGORIES.find(
|
|
||||||
(c) => c.id === discoverStore.selectedCategory(),
|
|
||||||
)?.name ?? "All"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<TrendingShows
|
|
||||||
podcasts={discoverStore.filteredPodcasts()}
|
|
||||||
selectedIndex={showIndex()}
|
|
||||||
focused={focusArea() === "shows"}
|
|
||||||
isLoading={discoverStore.isLoading()}
|
|
||||||
onSelect={handleShowSelect}
|
|
||||||
onSubscribe={handleSubscribe}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Footer Hints */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg="gray">[Tab] Switch focus</text>
|
|
||||||
<text fg="gray">[j/k] Navigate</text>
|
|
||||||
<text fg="gray">[Enter] Subscribe</text>
|
|
||||||
<text fg="gray">[Esc] Up</text>
|
|
||||||
<text fg="gray">[R] Refresh</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* TrendingShows component - Grid/list of trending podcasts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For, Show } from "solid-js";
|
|
||||||
import type { Podcast } from "@/types/podcast";
|
|
||||||
import { PodcastCard } from "./PodcastCard";
|
|
||||||
|
|
||||||
type TrendingShowsProps = {
|
|
||||||
podcasts: Podcast[];
|
|
||||||
selectedIndex: number;
|
|
||||||
focused: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
onSelect?: (index: number) => void;
|
|
||||||
onSubscribe?: (podcast: Podcast) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TrendingShows(props: TrendingShowsProps) {
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" height="100%">
|
|
||||||
<Show when={props.isLoading}>
|
|
||||||
<box padding={2}>
|
|
||||||
<text fg="yellow">Loading trending shows...</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!props.isLoading && props.podcasts.length === 0}>
|
|
||||||
<box padding={2}>
|
|
||||||
<text fg="gray">No podcasts found in this category.</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!props.isLoading && props.podcasts.length > 0}>
|
|
||||||
<scrollbox height={15}>
|
|
||||||
<box flexDirection="column">
|
|
||||||
<For each={props.podcasts}>
|
|
||||||
{(podcast, index) => (
|
|
||||||
<PodcastCard
|
|
||||||
podcast={podcast}
|
|
||||||
selected={index() === props.selectedIndex && props.focused}
|
|
||||||
onSelect={() => props.onSelect?.(index())}
|
|
||||||
onSubscribe={() => props.onSubscribe?.(podcast)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchPage component - Main search interface for PodTUI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal, createEffect, Show } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useSearchStore } from "@/stores/search";
|
|
||||||
import { SearchResults } from "./SearchResults";
|
|
||||||
import { SearchHistory } from "./SearchHistory";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
|
|
||||||
type SearchPageProps = {
|
|
||||||
focused: boolean;
|
|
||||||
onSubscribe?: (result: SearchResult) => void;
|
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
|
||||||
onExit?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FocusArea = "input" | "results" | "history";
|
|
||||||
|
|
||||||
export function SearchPage(props: SearchPageProps) {
|
|
||||||
const searchStore = useSearchStore();
|
|
||||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("input");
|
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
|
||||||
const [resultIndex, setResultIndex] = createSignal(0);
|
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(0);
|
|
||||||
|
|
||||||
// Keep parent informed about input focus state
|
|
||||||
createEffect(() => {
|
|
||||||
const isInputFocused = props.focused && focusArea() === "input";
|
|
||||||
props.onInputFocusChange?.(isInputFocused);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
const query = inputValue().trim();
|
|
||||||
if (query) {
|
|
||||||
await searchStore.search(query);
|
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
setResultIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHistorySelect = async (query: string) => {
|
|
||||||
setInputValue(query);
|
|
||||||
await searchStore.search(query);
|
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
setResultIndex(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResultSelect = (result: SearchResult) => {
|
|
||||||
props.onSubscribe?.(result);
|
|
||||||
searchStore.markSubscribed(result.podcast.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
|
|
||||||
const area = focusArea();
|
|
||||||
|
|
||||||
// Enter to search from input
|
|
||||||
if (key.name === "return" && area === "input") {
|
|
||||||
handleSearch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab to cycle focus areas
|
|
||||||
if (key.name === "tab" && !key.shift) {
|
|
||||||
if (area === "input") {
|
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
} else if (searchStore.history().length > 0) {
|
|
||||||
setFocusArea("history");
|
|
||||||
}
|
|
||||||
} else if (area === "results") {
|
|
||||||
if (searchStore.history().length > 0) {
|
|
||||||
setFocusArea("history");
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "tab" && key.shift) {
|
|
||||||
if (area === "input") {
|
|
||||||
if (searchStore.history().length > 0) {
|
|
||||||
setFocusArea("history");
|
|
||||||
} else if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
}
|
|
||||||
} else if (area === "history") {
|
|
||||||
if (searchStore.results().length > 0) {
|
|
||||||
setFocusArea("results");
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Up/Down for results and history
|
|
||||||
if (area === "results") {
|
|
||||||
const results = searchStore.results();
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
setResultIndex((i) => Math.min(i + 1, results.length - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
setResultIndex((i) => Math.max(i - 1, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "return" || key.name === "enter") {
|
|
||||||
const result = results[resultIndex()];
|
|
||||||
if (result) handleResultSelect(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (area === "history") {
|
|
||||||
const history = searchStore.history();
|
|
||||||
if (key.name === "down" || key.name === "j") {
|
|
||||||
setHistoryIndex((i) => Math.min(i + 1, history.length - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
setHistoryIndex((i) => Math.max(i - 1, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === "return" || key.name === "enter") {
|
|
||||||
const query = history[historyIndex()];
|
|
||||||
if (query) handleHistorySelect(query);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape goes back to input or up one level
|
|
||||||
if (key.name === "escape") {
|
|
||||||
if (area === "input") {
|
|
||||||
props.onExit?.();
|
|
||||||
} else {
|
|
||||||
setFocusArea("input");
|
|
||||||
key.stopPropagation();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "/" focuses search input
|
|
||||||
if (key.name === "/" && area !== "input") {
|
|
||||||
setFocusArea("input");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" height="100%" gap={1}>
|
|
||||||
{/* Search Header */}
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text>
|
|
||||||
<strong>Search Podcasts</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
|
||||||
<text fg="gray">Search:</text>
|
|
||||||
<input
|
|
||||||
value={inputValue()}
|
|
||||||
onInput={(value) => {
|
|
||||||
setInputValue(value);
|
|
||||||
}}
|
|
||||||
placeholder="Enter podcast name, topic, or author..."
|
|
||||||
focused={props.focused && focusArea() === "input"}
|
|
||||||
width={50}
|
|
||||||
/>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={handleSearch}
|
|
||||||
>
|
|
||||||
<text fg="cyan">[Enter] Search</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<Show when={searchStore.isSearching()}>
|
|
||||||
<text fg="yellow">Searching...</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={searchStore.error()}>
|
|
||||||
<text fg="red">{searchStore.error()}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Main Content - Results or History */}
|
|
||||||
<box flexDirection="row" height="100%" gap={2}>
|
|
||||||
{/* Results Panel */}
|
|
||||||
<box flexDirection="column" flexGrow={1} border>
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
|
|
||||||
Results ({searchStore.results().length})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show
|
|
||||||
when={searchStore.results().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={2}>
|
|
||||||
<text fg="gray">
|
|
||||||
{searchStore.query()
|
|
||||||
? "No results found"
|
|
||||||
: "Enter a search term to find podcasts"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SearchResults
|
|
||||||
results={searchStore.results()}
|
|
||||||
selectedIndex={resultIndex()}
|
|
||||||
focused={focusArea() === "results"}
|
|
||||||
onSelect={handleResultSelect}
|
|
||||||
onChange={setResultIndex}
|
|
||||||
isSearching={searchStore.isSearching()}
|
|
||||||
error={searchStore.error()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* History Sidebar */}
|
|
||||||
<box width={30} border>
|
|
||||||
<box padding={1} flexDirection="column">
|
|
||||||
<box paddingBottom={1}>
|
|
||||||
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
|
|
||||||
History
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<SearchHistory
|
|
||||||
history={searchStore.history()}
|
|
||||||
selectedIndex={historyIndex()}
|
|
||||||
focused={focusArea() === "history"}
|
|
||||||
onSelect={handleHistorySelect}
|
|
||||||
onRemove={searchStore.removeFromHistory}
|
|
||||||
onClear={searchStore.clearHistory}
|
|
||||||
onChange={setHistoryIndex}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Footer Hints */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg="gray">[Tab] Switch focus</text>
|
|
||||||
<text fg="gray">[/] Focus search</text>
|
|
||||||
<text fg="gray">[Enter] Select</text>
|
|
||||||
<text fg="gray">[Esc] Up</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { createSignal, For } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { SourceManager } from "./SourceManager";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { PreferencesPanel } from "./PreferencesPanel";
|
|
||||||
import { SyncPanel } from "./SyncPanel";
|
|
||||||
import { VisualizerSettings } from "./VisualizerSettings";
|
|
||||||
|
|
||||||
type SettingsScreenProps = {
|
|
||||||
accountLabel: string;
|
|
||||||
accountStatus: "signed-in" | "signed-out";
|
|
||||||
onOpenAccount?: () => void;
|
|
||||||
onExit?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account";
|
|
||||||
|
|
||||||
const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
|
||||||
{ id: "sync", label: "Sync" },
|
|
||||||
{ id: "sources", label: "Sources" },
|
|
||||||
{ id: "preferences", label: "Preferences" },
|
|
||||||
{ id: "visualizer", label: "Visualizer" },
|
|
||||||
{ id: "account", label: "Account" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsScreen(props: SettingsScreenProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [activeSection, setActiveSection] = createSignal<SectionId>("sync");
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (key.name === "escape") {
|
|
||||||
props.onExit?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const idx = SECTIONS.findIndex((s) => s.id === activeSection());
|
|
||||||
const next = key.shift
|
|
||||||
? (idx - 1 + SECTIONS.length) % SECTIONS.length
|
|
||||||
: (idx + 1) % SECTIONS.length;
|
|
||||||
setActiveSection(SECTIONS[next].id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "1") setActiveSection("sync");
|
|
||||||
if (key.name === "2") setActiveSection("sources");
|
|
||||||
if (key.name === "3") setActiveSection("preferences");
|
|
||||||
if (key.name === "4") setActiveSection("visualizer");
|
|
||||||
if (key.name === "5") setActiveSection("account");
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1} height="100%">
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<text>
|
|
||||||
<strong>Settings</strong>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
[Tab] Switch section | 1-5 jump | Esc up
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<For each={SECTIONS}>
|
|
||||||
{(section, index) => (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={
|
|
||||||
activeSection() === section.id ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
onMouseDown={() => setActiveSection(section.id)}
|
|
||||||
>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
activeSection() === section.id ? theme.text : theme.textMuted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
[{index() + 1}] {section.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
|
|
||||||
{activeSection() === "sync" && <SyncPanel />}
|
|
||||||
{activeSection() === "sources" && <SourceManager focused />}
|
|
||||||
{activeSection() === "preferences" && <PreferencesPanel />}
|
|
||||||
{activeSection() === "visualizer" && <VisualizerSettings />}
|
|
||||||
{activeSection() === "account" && (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Account</text>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<text fg={theme.textMuted}>Status:</text>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
props.accountStatus === "signed-in"
|
|
||||||
? theme.success
|
|
||||||
: theme.warning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.accountLabel}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
|
|
||||||
<text fg={theme.primary}>[A] Manage Account</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Enter to dive | Esc up</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
38
src/utils/navigation.ts
Normal file
38
src/utils/navigation.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { DiscoverPage, DiscoverPaneCount } from "@/pages/Discover/DiscoverPage";
|
||||||
|
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
|
||||||
|
import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage";
|
||||||
|
import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
|
||||||
|
import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
|
||||||
|
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
|
||||||
|
|
||||||
|
export enum DIRECTION {
|
||||||
|
Increment,
|
||||||
|
Decrement,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TABS {
|
||||||
|
FEED = 1,
|
||||||
|
MYSHOWS = 2,
|
||||||
|
DISCOVER = 3,
|
||||||
|
SEARCH = 4,
|
||||||
|
PLAYER = 5,
|
||||||
|
SETTINGS = 6,
|
||||||
|
}
|
||||||
|
export const TabsCount = 6;
|
||||||
|
|
||||||
|
export const LayerGraph = {
|
||||||
|
[TABS.FEED]: FeedPage,
|
||||||
|
[TABS.MYSHOWS]: MyShowsPage,
|
||||||
|
[TABS.DISCOVER]: DiscoverPage,
|
||||||
|
[TABS.SEARCH]: SearchPage,
|
||||||
|
[TABS.PLAYER]: PlayerPage,
|
||||||
|
[TABS.SETTINGS]: SettingsPage,
|
||||||
|
};
|
||||||
|
export const LayerDepths = {
|
||||||
|
[TABS.FEED]: FeedPaneCount,
|
||||||
|
[TABS.MYSHOWS]: MyShowsPaneCount,
|
||||||
|
[TABS.DISCOVER]: DiscoverPaneCount,
|
||||||
|
[TABS.SEARCH]: SearchPaneCount,
|
||||||
|
[TABS.PLAYER]: PlayerPaneCount,
|
||||||
|
[TABS.SETTINGS]: SettingsPaneCount,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user