This commit is contained in:
2026-02-07 19:05:45 -05:00
parent 5bd393c9cd
commit bcf248f7dd
8 changed files with 93 additions and 249 deletions

View File

@@ -1,15 +1,9 @@
import { createSignal, createMemo, ErrorBoundary, Accessor } from "solid-js"; import { createSignal, createMemo, ErrorBoundary, Accessor } from "solid-js";
import { useSelectionHandler } from "@opentui/solid"; import { useSelectionHandler } from "@opentui/solid";
import { TabNavigation } from "./components/TabNavigation"; import { 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 { 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";
@@ -21,8 +15,7 @@ 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 } from "./types/navigation"; import { DIRECTION, LayerGraph, TABS } from "./utils/navigation";
import { LayerGraph, TABS } from "./utils/navigation";
import { useTheme } from "./context/ThemeContext"; import { useTheme } from "./context/ThemeContext";
export interface PageProps { export interface PageProps {
@@ -119,6 +112,7 @@ export function App() {
> >
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} /> <TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
{LayerGraph[activeTab()]({ depth: activeDepth })} {LayerGraph[activeTab()]({ depth: activeDepth })}
{/**TODO: Contextual controls based on tab/depth**/}
</box> </box>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@@ -11,6 +11,9 @@ import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { PageProps } from "@/App"; import { PageProps } from "@/App";
enum FeedPaneType {
FEED = 1,
}
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
export function FeedPage(props: PageProps) { export function FeedPage(props: PageProps) {
@@ -60,7 +63,7 @@ export function FeedPage(props: PageProps) {
} }
> >
{/**TODO: figure out wtf to do here **/} {/**TODO: figure out wtf to do here **/}
<scrollbox height="100%" focused={true}> <scrollbox height="100%" focused={props.depth() == FeedPaneType.FEED}>
<For each={allEpisodes()}> <For each={allEpisodes()}>
{(item, index) => ( {(item, index) => (
<box <box

View File

@@ -15,16 +15,15 @@ import type { Feed } from "@/types/feed";
import { PageProps } from "@/App"; import { PageProps } from "@/App";
enum MyShowsPaneType { enum MyShowsPaneType {
SHOWS, SHOWS = 1,
EPISODES, EPISODES = 2,
} }
export const MyShowsPaneCount = 2 export const MyShowsPaneCount = 2;
export function MyShowsPage(props: PageProps) { export function MyShowsPage(props: PageProps) {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const [focusPane, setFocusPane] = createSignal<>("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,8 +127,8 @@ export function MyShowsPage(props: PageProps) {
setEpisodeIndex(0); setEpisodeIndex(0);
}; };
return { return (
showsPanel: () => ( <box flexDirection="row" flexGrow={1}>
<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>
@@ -144,7 +143,10 @@ export function MyShowsPage(props: PageProps) {
</box> </box>
} }
> >
<scrollbox height="100%" focused={props.depth}> <scrollbox
height="100%"
focused={props.depth() == MyShowsPaneType.SHOWS}
>
<For each={shows()}> <For each={shows()}>
{(feed, index) => ( {(feed, index) => (
<box <box
@@ -171,9 +173,6 @@ export function MyShowsPage(props: PageProps) {
</scrollbox> </scrollbox>
</Show> </Show>
</box> </box>
),
episodesPanel: () => (
<box flexDirection="column" height="100%"> <box flexDirection="column" height="100%">
<Show <Show
when={selectedShow()} when={selectedShow()}
@@ -193,7 +192,7 @@ export function MyShowsPage(props: PageProps) {
> >
<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) => (
@@ -252,9 +251,6 @@ export function MyShowsPage(props: PageProps) {
</Show> </Show>
</Show> </Show>
</box> </box>
), </box>
);
focusPane,
selectedShow,
};
} }

View File

@@ -1,9 +1,15 @@
import { PageProps } from "@/App";
import { PlaybackControls } from "./PlaybackControls"; import { PlaybackControls } from "./PlaybackControls";
import { RealtimeWaveform } from "./RealtimeWaveform"; import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
export function PlayerPage() { enum PlayerPaneType {
PLAYER = 1,
}
export const PlayerPaneCount = 1;
export function PlayerPage(props: PageProps) {
const audio = useAudio(); const audio = useAudio();
const progressPercent = () => { const progressPercent = () => {
@@ -63,11 +69,6 @@ export function PlayerPage() {
onSpeedChange={(s: number) => audio.setSpeed(s)} onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)} 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> </box>
); );
} }

View File

@@ -8,35 +8,35 @@ 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";
import { PageProps } from "@/App";
import { MyShowsPage } from "../MyShows/MyShowsPage";
type SearchPageProps = { enum SearchPaneType {
focused: boolean; INPUT = 1,
onSubscribe?: (result: SearchResult) => void; RESULTS = 2,
onInputFocusChange?: (focused: boolean) => void; HISTORY = 3,
onExit?: () => void; }
}; export const SearchPaneCount = 3;
type FocusArea = "input" | "results" | "history"; export function SearchPage(props: PageProps) {
export function SearchPage(props: SearchPageProps) {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
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(() => { // TODO: have a global input focused prop in useKeyboard hook
const isInputFocused = props.focused && focusArea() === "input"; //createEffect(() => {
props.onInputFocusChange?.(isInputFocused); //const isInputFocused = props.focused && focusArea() === "input";
}); //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"); //TODO: move level
setResultIndex(0); setResultIndex(0);
} }
} }
@@ -46,120 +46,16 @@ export function SearchPage(props: SearchPageProps) {
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"); //TODO: move level
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
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 ( return (
<box flexDirection="column" height="100%" gap={1}> <box flexDirection="column" height="100%" gap={1}>
{/* Search Header */} {/* Search Header */}
@@ -177,7 +73,7 @@ export function SearchPage(props: SearchPageProps) {
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.depth() === SearchPaneType.INPUT}
width={50} width={50}
/> />
<box <box
@@ -205,7 +101,9 @@ export function SearchPage(props: SearchPageProps) {
{/* Results Panel */} {/* Results Panel */}
<box flexDirection="column" flexGrow={1} border> <box flexDirection="column" flexGrow={1} border>
<box padding={1}> <box padding={1}>
<text fg={focusArea() === "results" ? "cyan" : "gray"}> <text
fg={props.depth() === SearchPaneType.RESULTS ? "cyan" : "gray"}
>
Results ({searchStore.results().length}) Results ({searchStore.results().length})
</text> </text>
</box> </box>
@@ -224,7 +122,7 @@ export function SearchPage(props: SearchPageProps) {
<SearchResults <SearchResults
results={searchStore.results()} results={searchStore.results()}
selectedIndex={resultIndex()} selectedIndex={resultIndex()}
focused={focusArea() === "results"} focused={props.depth() === SearchPaneType.RESULTS}
onSelect={handleResultSelect} onSelect={handleResultSelect}
onChange={setResultIndex} onChange={setResultIndex}
isSearching={searchStore.isSearching()} isSearching={searchStore.isSearching()}
@@ -237,14 +135,16 @@ export function SearchPage(props: SearchPageProps) {
<box width={30} border> <box width={30} border>
<box padding={1} flexDirection="column"> <box padding={1} flexDirection="column">
<box paddingBottom={1}> <box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}> <text
fg={props.depth() === SearchPaneType.HISTORY ? "cyan" : "gray"}
>
History History
</text> </text>
</box> </box>
<SearchHistory <SearchHistory
history={searchStore.history()} history={searchStore.history()}
selectedIndex={historyIndex()} selectedIndex={historyIndex()}
focused={focusArea() === "history"} focused={props.depth() === SearchPaneType.HISTORY}
onSelect={handleHistorySelect} onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory} onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory} onClear={searchStore.clearHistory}
@@ -253,14 +153,6 @@ export function SearchPage(props: SearchPageProps) {
</box> </box>
</box> </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> </box>
); );
} }

View File

@@ -5,65 +5,33 @@ 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";
import { PageProps } from "@/App";
type SettingsScreenProps = { enum SettingsPaneType {
accountLabel: string; SYNC = 1,
accountStatus: "signed-in" | "signed-out"; SOURCES = 2,
onOpenAccount?: () => void; PREFERENCES = 3,
onExit?: () => void; VISUALIZER = 4,
}; ACCOUNT = 5,
}
export const SettingsPaneCount = 5;
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account"; const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
{ id: SettingsPaneType.SYNC, label: "Sync" },
const SECTIONS: Array<{ id: SectionId; label: string }> = [ { id: SettingsPaneType.SOURCES, label: "Sources" },
{ id: "sync", label: "Sync" }, { id: SettingsPaneType.PREFERENCES, label: "Preferences" },
{ id: "sources", label: "Sources" }, { id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
{ id: "preferences", label: "Preferences" }, { id: SettingsPaneType.ACCOUNT, label: "Account" },
{ id: "visualizer", label: "Visualizer" },
{ id: "account", label: "Account" },
]; ];
export function SettingsPage(props: SettingsScreenProps) { export function SettingsPage(props: PageProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [activeSection, setActiveSection] = createSignal<SectionId>("sync"); const [activeSection, setActiveSection] = createSignal<SettingsPaneType>(
SettingsPaneType.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 ( return (
<box flexDirection="column" gap={1} height="100%"> <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}> <box flexDirection="row" gap={1}>
<For each={SECTIONS}> <For each={SECTIONS}>
{(section, index) => ( {(section, index) => (
@@ -88,33 +56,22 @@ export function SettingsPage(props: SettingsScreenProps) {
</box> </box>
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}> <box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
{activeSection() === "sync" && <SyncPanel />} {activeSection() === SettingsPaneType.SYNC && <SyncPanel />}
{activeSection() === "sources" && <SourceManager focused />} {activeSection() === SettingsPaneType.SOURCES && (
{activeSection() === "preferences" && <PreferencesPanel />} <SourceManager focused />
{activeSection() === "visualizer" && <VisualizerSettings />} )}
{activeSection() === "account" && ( {activeSection() === SettingsPaneType.PREFERENCES && (
<PreferencesPanel />
)}
{activeSection() === SettingsPaneType.VISUALIZER && (
<VisualizerSettings />
)}
{activeSection() === SettingsPaneType.ACCOUNT && (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Account</text> <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>
)} )}
</box> </box>
<text fg={theme.textMuted}>Enter to dive | Esc up</text>
</box> </box>
); );
} }

View File

@@ -1,4 +0,0 @@
export enum DIRECTION {
Increment,
Decrement,
}

View File

@@ -1,9 +1,14 @@
import { DiscoverPage } from "@/pages/Discover/DiscoverPage"; import { DiscoverPage, DiscoverPaneCount } from "@/pages/Discover/DiscoverPage";
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage"; import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage"; import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage";
import { PlayerPage } from "@/pages/Player/PlayerPage"; import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
import { SearchPage } from "@/pages/Search/SearchPage"; import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import { SettingsPage } from "@/pages/Settings/SettingsPage"; import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
export enum DIRECTION {
Increment,
Decrement,
}
export enum TABS { export enum TABS {
FEED, FEED,
@@ -28,5 +33,5 @@ export const LayerDepths = {
[TABS.DISCOVER]: DiscoverPaneCount, [TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount, [TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount, [TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingPaneCount, [TABS.SETTINGS]: SettingsPaneCount,
}; };