diff --git a/src/App.tsx b/src/App.tsx index 66795d3..985478b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ -import { createSignal, createMemo, ErrorBoundary } from "solid-js"; +import { createSignal, createMemo, ErrorBoundary, Accessor } from "solid-js"; import { useSelectionHandler } from "@opentui/solid"; -import { Layout } from "./Layout"; -import { TabId, 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"; @@ -10,7 +9,6 @@ import { OAuthPlaceholder } from "@/tabs/Settings/OAuthPlaceholder"; import { SyncProfile } from "@/tabs/Settings/SyncProfile"; import { SearchPage } from "@/tabs/Search/SearchPage"; import { DiscoverPage } from "@/tabs/Discover/DiscoverPage"; -import { Player } from "@/tabs/Player/Player"; import { SettingsScreen } from "@/tabs/Settings/SettingsScreen"; import { useAuthStore } from "@/stores/auth"; import { useFeedStore } from "@/stores/feed"; @@ -24,9 +22,16 @@ import { useRenderer } from "@opentui/solid"; import type { AuthScreen } from "@/types/auth"; import type { Episode } from "@/types/episode"; import { DIRECTION } from "./types/navigation"; +import { LayerGraph, TABS } from "./utils/navigation"; +import { useTheme } from "./context/ThemeContext"; + +export interface PageProps { + depth: Accessor; +} export function App() { - const [activeTab, setActiveTab] = createSignal("feed"); + const [activeTab, setActiveTab] = createSignal(TABS.FEED); + const [activeDepth, setActiveDepth] = createSignal(0); // not fixed matrix size const [authScreen, setAuthScreen] = createSignal("login"); const [showAuthPanel, setShowAuthPanel] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false); @@ -37,31 +42,18 @@ export function App() { const toast = useToast(); const renderer = useRenderer(); - // Global multimedia key handling — active when Player tab is NOT - // focused (Player.tsx handles its own keys when focused). useMultimediaKeys({ - playerFocused: () => activeTab() === "player" && layerDepth() > 0, + playerFocused: () => activeTab() === TABS.PLAYER && layerDepth() > 0, inputFocused: () => inputFocused(), hasEpisode: () => !!audio.currentEpisode(), }); const handlePlayEpisode = (episode: Episode) => { audio.play(episode); - setActiveTab("player"); + setActiveTab(TABS.PLAYER); 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), - }); - useAppKeyboard({ layerDepth, onAction: (action, direction) => { @@ -91,7 +83,6 @@ export function App() { }, }); - // Copy selected text to clipboard when selection ends (mouse release) useSelectionHandler((selection: any) => { if (!selection) return; const text = selection.getSelectedText?.(); @@ -107,225 +98,7 @@ export function App() { }); }); - const getPanels = createMemo(() => { - const tab = activeTab(); - - switch (tab) { - case "feed": - return { - panels: [ - { - title: "Feed - Latest Episodes", - content: ( - 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: ( - 0} - onLogout={() => { - auth.logout(); - setShowAuthPanel(false); - }} - onManageSync={() => setShowAuthPanel(false)} - /> - ), - }, - ], - activePanelIndex: 0, - hint: "Esc back", - }; - } - - const authContent = () => { - switch (authScreen()) { - case "code": - return ( - 0} - onBack={() => setAuthScreen("login")} - /> - ); - case "oauth": - return ( - 0} - onBack={() => setAuthScreen("login")} - onNavigateToCode={() => setAuthScreen("code")} - /> - ); - default: - return ( - 0} - onNavigateToCode={() => setAuthScreen("code")} - onNavigateToOAuth={() => setAuthScreen("oauth")} - /> - ); - } - }; - - return { - panels: [ - { - title: "Sign In", - content: authContent(), - }, - ], - activePanelIndex: 0, - hint: "Esc back", - }; - } - - return { - panels: [ - { - title: "Settings", - content: ( - 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: ( - 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: ( - 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: ( - 0} - onExit={() => setLayerDepth(0)} - /> - ), - }, - ], - activePanelIndex: 0, - hint: "Space play/pause | Esc back", - }; - - default: - return { - panels: [ - { - title: tab, - content: ( - - Coming soon - - ), - }, - ], - activePanelIndex: 0, - hint: "", - }; - } - }); - + const { theme } = useTheme(); return ( ( @@ -338,13 +111,15 @@ export function App() { )} > - - } - panels={getPanels().panels} - activePanelIndex={getPanels().activePanelIndex} - /> + + + {LayerGraph[activeTab()]({ depth: activeDepth })} + ); } diff --git a/src/Layout.tsx b/src/Layout.tsx deleted file mode 100644 index 4a4890a..0000000 --- a/src/Layout.tsx +++ /dev/null @@ -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 ( - - {props.header} - - - - {(panel, index) => ( - - {/* Panel header */} - - - - {panel.title} - - - - - {/* Panel body */} - - {panel.content} - - - )} - - - - ); -} diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx index 7a598c8..91e7358 100644 --- a/src/components/TabNavigation.tsx +++ b/src/components/TabNavigation.tsx @@ -1,18 +1,19 @@ import { useTheme } from "@/context/ThemeContext"; +import { TABS } from "@/utils/navigation"; import { For } from "solid-js"; interface TabNavigationProps { - activeTab: TabId; - onTabSelect: (tab: TabId) => void; + activeTab: TABS; + onTabSelect: (tab: TABS) => void; } export const tabs: TabDefinition[] = [ - { id: "feed", label: "Feed" }, - { id: "shows", label: "My Shows" }, - { id: "discover", label: "Discover" }, - { id: "search", label: "Search" }, - { id: "player", label: "Player" }, - { id: "settings", label: "Settings" }, + { id: TABS.FEED, label: "Feed" }, + { id: TABS.MYSHOWS, label: "My Shows" }, + { id: TABS.DISCOVER, label: "Discover" }, + { id: TABS.SEARCH, label: "Search" }, + { id: TABS.PLAYER, label: "Player" }, + { id: TABS.SETTINGS, label: "Settings" }, ]; 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 = { - id: TabId; + id: TABS; label: string; }; diff --git a/src/tabs/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx similarity index 51% rename from src/tabs/Discover/DiscoverPage.tsx rename to src/pages/Discover/DiscoverPage.tsx index 86d8fc5..57b68c4 100644 --- a/src/tabs/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -7,121 +7,19 @@ 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"; -type DiscoverPageProps = { - focused: boolean; - onExit?: () => void; -}; +enum DiscoverPagePaneType { + CATEGORIES = 1, + SHOWS = 2, +} +export const DiscoverPaneCount = 2; -type FocusArea = "categories" | "shows"; - -export function DiscoverPage(props: DiscoverPageProps) { +export function DiscoverPage(props: PageProps) { const discoverStore = useDiscoverStore(); - const [focusArea, setFocusArea] = createSignal("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); @@ -131,7 +29,6 @@ export function DiscoverPage(props: DiscoverPageProps) { const handleShowSelect = (index: number) => { setShowIndex(index); - setFocusArea("shows"); }; const handleSubscribe = (podcast: { id: string }) => { @@ -149,7 +46,13 @@ export function DiscoverPage(props: DiscoverPageProps) { gap={1} width={20} > - + Categories: @@ -180,7 +83,9 @@ export function DiscoverPage(props: DiscoverPageProps) { borderColor={theme.border} > - + Trending in{" "} {DISCOVER_CATEGORIES.find( (c) => c.id === discoverStore.selectedCategory(), @@ -210,7 +115,8 @@ export function DiscoverPage(props: DiscoverPageProps) { handleShowSelect(index())} onSubscribe={() => handleSubscribe(podcast)} diff --git a/src/tabs/Discover/PodcastCard.tsx b/src/pages/Discover/PodcastCard.tsx similarity index 100% rename from src/tabs/Discover/PodcastCard.tsx rename to src/pages/Discover/PodcastCard.tsx diff --git a/src/tabs/Feed/FeedDetail.tsx b/src/pages/Feed/FeedDetail.tsx similarity index 100% rename from src/tabs/Feed/FeedDetail.tsx rename to src/pages/Feed/FeedDetail.tsx diff --git a/src/tabs/Feed/FeedFilter.tsx b/src/pages/Feed/FeedFilter.tsx similarity index 100% rename from src/tabs/Feed/FeedFilter.tsx rename to src/pages/Feed/FeedFilter.tsx diff --git a/src/tabs/Feed/FeedItem.tsx b/src/pages/Feed/FeedItem.tsx similarity index 100% rename from src/tabs/Feed/FeedItem.tsx rename to src/pages/Feed/FeedItem.tsx diff --git a/src/tabs/Feed/FeedList.tsx b/src/pages/Feed/FeedList.tsx similarity index 100% rename from src/tabs/Feed/FeedList.tsx rename to src/pages/Feed/FeedList.tsx diff --git a/src/tabs/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx similarity index 69% rename from src/tabs/Feed/FeedPage.tsx rename to src/pages/Feed/FeedPage.tsx index a9a9291..b6e2c9e 100644 --- a/src/tabs/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -4,20 +4,16 @@ */ import { createSignal, For, Show } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; import { useFeedStore } from "@/stores/feed"; import { format } from "date-fns"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { useTheme } from "@/context/ThemeContext"; +import { PageProps } from "@/App"; -type FeedPageProps = { - focused: boolean; - 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 [selectedIndex, setSelectedIndex] = createSignal(0); const [isRefreshing, setIsRefreshing] = createSignal(false); @@ -41,33 +37,6 @@ export function FeedPage(props: FeedPageProps) { 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(); return ( Refreshing feeds... - {/* Episode list */} 0} fallback={ @@ -91,7 +59,8 @@ export function FeedPage(props: FeedPageProps) { } > - + {/**TODO: figure out wtf to do here **/} + {(item, index) => ( void; - onExit?: () => void; -}; +enum MyShowsPaneType { + SHOWS, + EPISODES, +} -type FocusPane = "shows" | "episodes"; +export const MyShowsPaneCount = 2 -export function MyShowsPage(props: MyShowsPageProps) { +export function MyShowsPage(props: PageProps) { const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); - const [focusPane, setFocusPane] = createSignal("shows"); + const [focusPane, setFocusPane] = createSignal<>("shows"); const [showIndex, setShowIndex] = createSignal(0); const [episodeIndex, setEpisodeIndex] = createSignal(0); const [isRefreshing, setIsRefreshing] = createSignal(false); @@ -128,95 +128,6 @@ export function MyShowsPage(props: MyShowsPageProps) { setEpisodeIndex(0); }; - useKeyboard((key) => { - if (!props.focused) return; - - 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: () => ( @@ -233,10 +144,7 @@ export function MyShowsPage(props: MyShowsPageProps) { } > - + {(feed, index) => ( { + 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 ( + + + + Now Playing + + + {formatTime(audio.position())} / {formatTime(audio.duration())} ( + {progressPercent()}%) + + + + {audio.error() && {audio.error()}} + + + + {audio.currentEpisode()?.title} + + {audio.currentEpisode()?.description} + + { + const viz = useAppStore().state().settings.visualizer; + return { + bars: viz.bars, + noiseReduction: viz.noiseReduction, + lowCutOff: viz.lowCutOff, + highCutOff: viz.highCutOff, + }; + })()} + /> + + + 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)} + /> + + + Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc + back + + + ); +} diff --git a/src/tabs/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx similarity index 76% rename from src/tabs/Player/RealtimeWaveform.tsx rename to src/pages/Player/RealtimeWaveform.tsx index 534b2b4..af5c6f8 100644 --- a/src/tabs/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -14,25 +14,11 @@ import { type CavaCoreConfig, } from "@/utils/cavacore"; import { AudioStreamReader } from "@/utils/audio-stream-reader"; +import { useAudio } from "@/hooks/useAudio"; // ── Types ──────────────────────────────────────────────────────────── 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; }; @@ -58,7 +44,7 @@ const SAMPLES_PER_FRAME = 512; // ── Component ──────────────────────────────────────────────────────── export function RealtimeWaveform(props: RealtimeWaveformProps) { - const resolution = () => props.resolution ?? 32; + const audio = useAudio(); // Frequency bar values (0.0–1.0 per bar) const [barData, setBarData] = createSignal([]); @@ -95,7 +81,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // Initialize cavacore with current resolution + any overrides const config: CavaCoreConfig = { - bars: resolution(), + bars: 32, sampleRate: 44100, channels: 1, ...props.visualizerConfig, @@ -151,27 +137,17 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { 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( on( [ - () => props.isPlaying, - () => props.audioUrl, - () => props.speed ?? 1, - resolution, + audio.isPlaying, + () => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here + audio.speed, + () => 32, ], ([playing, url, speed]) => { if (playing && url) { - const pos = untrack(() => props.position); + const pos = untrack(audio.position); startVisualization(url, pos, speed); } else { stopVisualization(); @@ -189,23 +165,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { let lastSyncPosition = 0; createEffect( - on( - () => props.position, - (pos) => { - if (!props.isPlaying || !reader?.running) { - lastSyncPosition = pos; - return; - } - - const delta = Math.abs(pos - lastSyncPosition); + on(audio.position, (pos) => { + if (!audio.isPlaying || !reader?.running) { lastSyncPosition = pos; + return; + } - if (delta > 2) { - const speed = props.speed ?? 1; - reader.restart(pos, speed); - } - }, - ), + const delta = Math.abs(pos - lastSyncPosition); + lastSyncPosition = pos; + + if (delta > 2) { + reader.restart(pos, audio.speed() ?? 1); + } + }), ); // Cleanup on unmount @@ -224,11 +196,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // ── Rendering ────────────────────────────────────────────────────── 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 bars = barData(); - const numBars = resolution(); + const numBars = 32; // If no data yet, show empty placeholder if (bars.length === 0) { @@ -241,7 +215,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { } const played = Math.floor(numBars * playedRatio()); - const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"; + const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590"; const futureColor = "#3b4252"; const playedChars = bars @@ -263,13 +237,13 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { }; const handleClick = (event: { x: number }) => { - const numBars = resolution(); - const ratio = numBars === 0 ? 0 : event.x / numBars; + const numBars = 32; + const ratio = event.x / numBars; const next = Math.max( 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 ( diff --git a/src/tabs/Search/ResultCard.tsx b/src/pages/Search/ResultCard.tsx similarity index 100% rename from src/tabs/Search/ResultCard.tsx rename to src/pages/Search/ResultCard.tsx diff --git a/src/tabs/Search/ResultDetail.tsx b/src/pages/Search/ResultDetail.tsx similarity index 100% rename from src/tabs/Search/ResultDetail.tsx rename to src/pages/Search/ResultDetail.tsx diff --git a/src/tabs/Search/SearchHistory.tsx b/src/pages/Search/SearchHistory.tsx similarity index 100% rename from src/tabs/Search/SearchHistory.tsx rename to src/pages/Search/SearchHistory.tsx diff --git a/src/tabs/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx similarity index 100% rename from src/tabs/Search/SearchPage.tsx rename to src/pages/Search/SearchPage.tsx diff --git a/src/tabs/Search/SearchResults.tsx b/src/pages/Search/SearchResults.tsx similarity index 100% rename from src/tabs/Search/SearchResults.tsx rename to src/pages/Search/SearchResults.tsx diff --git a/src/tabs/Search/SourceBadge.tsx b/src/pages/Search/SourceBadge.tsx similarity index 100% rename from src/tabs/Search/SourceBadge.tsx rename to src/pages/Search/SourceBadge.tsx diff --git a/src/tabs/Settings/ExportDialog.tsx b/src/pages/Settings/ExportDialog.tsx similarity index 100% rename from src/tabs/Settings/ExportDialog.tsx rename to src/pages/Settings/ExportDialog.tsx diff --git a/src/tabs/Settings/FilePicker.tsx b/src/pages/Settings/FilePicker.tsx similarity index 100% rename from src/tabs/Settings/FilePicker.tsx rename to src/pages/Settings/FilePicker.tsx diff --git a/src/tabs/Settings/ImportDialog.tsx b/src/pages/Settings/ImportDialog.tsx similarity index 100% rename from src/tabs/Settings/ImportDialog.tsx rename to src/pages/Settings/ImportDialog.tsx diff --git a/src/tabs/Settings/LoginScreen.tsx b/src/pages/Settings/LoginScreen.tsx similarity index 100% rename from src/tabs/Settings/LoginScreen.tsx rename to src/pages/Settings/LoginScreen.tsx diff --git a/src/tabs/Settings/OAuthPlaceholder.tsx b/src/pages/Settings/OAuthPlaceholder.tsx similarity index 100% rename from src/tabs/Settings/OAuthPlaceholder.tsx rename to src/pages/Settings/OAuthPlaceholder.tsx diff --git a/src/tabs/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx similarity index 100% rename from src/tabs/Settings/PreferencesPanel.tsx rename to src/pages/Settings/PreferencesPanel.tsx diff --git a/src/tabs/Settings/SettingsScreen.tsx b/src/pages/Settings/SettingsPage.tsx similarity index 98% rename from src/tabs/Settings/SettingsScreen.tsx rename to src/pages/Settings/SettingsPage.tsx index 1c77e87..d57160f 100644 --- a/src/tabs/Settings/SettingsScreen.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -23,7 +23,7 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [ { id: "account", label: "Account" }, ]; -export function SettingsScreen(props: SettingsScreenProps) { +export function SettingsPage(props: SettingsScreenProps) { const { theme } = useTheme(); const [activeSection, setActiveSection] = createSignal("sync"); diff --git a/src/tabs/Settings/SourceManager.tsx b/src/pages/Settings/SourceManager.tsx similarity index 100% rename from src/tabs/Settings/SourceManager.tsx rename to src/pages/Settings/SourceManager.tsx diff --git a/src/tabs/Settings/SyncError.tsx b/src/pages/Settings/SyncError.tsx similarity index 100% rename from src/tabs/Settings/SyncError.tsx rename to src/pages/Settings/SyncError.tsx diff --git a/src/tabs/Settings/SyncPanel.tsx b/src/pages/Settings/SyncPanel.tsx similarity index 100% rename from src/tabs/Settings/SyncPanel.tsx rename to src/pages/Settings/SyncPanel.tsx diff --git a/src/tabs/Settings/SyncProfile.tsx b/src/pages/Settings/SyncProfile.tsx similarity index 100% rename from src/tabs/Settings/SyncProfile.tsx rename to src/pages/Settings/SyncProfile.tsx diff --git a/src/tabs/Settings/SyncProgress.tsx b/src/pages/Settings/SyncProgress.tsx similarity index 100% rename from src/tabs/Settings/SyncProgress.tsx rename to src/pages/Settings/SyncProgress.tsx diff --git a/src/tabs/Settings/SyncStatus.tsx b/src/pages/Settings/SyncStatus.tsx similarity index 100% rename from src/tabs/Settings/SyncStatus.tsx rename to src/pages/Settings/SyncStatus.tsx diff --git a/src/tabs/Settings/VisualizerSettings.tsx b/src/pages/Settings/VisualizerSettings.tsx similarity index 100% rename from src/tabs/Settings/VisualizerSettings.tsx rename to src/pages/Settings/VisualizerSettings.tsx diff --git a/src/tabs/Player/Player.tsx b/src/tabs/Player/Player.tsx deleted file mode 100644 index 76d90c6..0000000 --- a/src/tabs/Player/Player.tsx +++ /dev/null @@ -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 ( - - - - Now Playing - - - {formatTime(audio.position())} / {formatTime(dur())} ( - {progressPercent()}%) - - - - {audio.error() && {audio.error()}} - - - - {episode().title} - - {episode().description} - - audio.seek(next)} - visualizerConfig={(() => { - const viz = useAppStore().state().settings.visualizer; - return { - bars: viz.bars, - noiseReduction: viz.noiseReduction, - lowCutOff: viz.lowCutOff, - highCutOff: viz.highCutOff, - }; - })()} - /> - - - { - 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)} - /> - - - Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc - back - - - ); -} diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index dcc7f67..3170706 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -1,49 +1,9 @@ -enum FEEDTABTYPE { - LATEST, -} -export const FeedTab = { - [FEEDTABTYPE.LATEST]: { - size: 1, - title: "Feed - Latest Episodes", - scrolling: true, - }, -}; -enum MYSHOWSTYPE { - SHOWLIST, - EPISODELIST, -} -export const MyShowsTab = { - [MYSHOWSTYPE.SHOWLIST]: { size: 0.3, title: "My Shows", scrolling: true }, - [MYSHOWSTYPE.EPISODELIST]: { - size: 0.7, - title: " - Episodes", - scrolling: true, - }, -}; - -enum DiscoverTab { - CATEGORIES, - CATEGORYLIST, -} - -export enum CATEGORIES { - ALL, - TECHNOLOGY, - SCIENCE, - COMEDY, - NEWS, - BUSINESS, - HEALTH, - EDUCATION, - SPORTS, - TRUECRIME, - ARTS, -} -export const SearchTab = []; - -export const PlayerTab = []; - -export const SettingsTab = []; +import { DiscoverPage } from "@/pages/Discover/DiscoverPage"; +import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage"; +import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage"; +import { PlayerPage } from "@/pages/Player/PlayerPage"; +import { SearchPage } from "@/pages/Search/SearchPage"; +import { SettingsPage } from "@/pages/Settings/SettingsPage"; export enum TABS { FEED, @@ -55,10 +15,18 @@ export enum TABS { } export const LayerGraph = { - [TABS.FEED]: FeedTab, - [TABS.MYSHOWS]: MyShowsTab, - [TABS.DISCOVER]: DiscoverTab, - [TABS.SEARCH]: SearchTab, - [TABS.PLAYER]: PlayerTab, - [TABS.SETTINGS]: SettingsTab, + [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]: SettingPaneCount, };