diff --git a/src/App.tsx b/src/App.tsx index c985d55..280dc7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,8 @@ import { createSignal, ErrorBoundary } from "solid-js"; import { Layout } from "./components/Layout"; import { Navigation } from "./components/Navigation"; import { TabNavigation } from "./components/TabNavigation"; -import { FeedList } from "./components/FeedList"; +import { FeedPage } from "./components/FeedPage"; +import { MyShowsPage } from "./components/MyShowsPage"; import { LoginScreen } from "./components/LoginScreen"; import { CodeValidation } from "./components/CodeValidation"; import { OAuthPlaceholder } from "./components/OAuthPlaceholder"; @@ -20,7 +21,7 @@ import type { TabId } from "./components/Tab"; import type { AuthScreen } from "./types/auth"; export function App() { - const [activeTab, setActiveTab] = createSignal("settings"); + const [activeTab, setActiveTab] = createSignal("feed"); const [authScreen, setAuthScreen] = createSignal("login"); const [showAuthPanel, setShowAuthPanel] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false); @@ -29,6 +30,15 @@ export function App() { const feedStore = useFeedStore(); const appStore = useAppStore(); + // My Shows page returns panel renderers + const myShows = MyShowsPage({ + get focused() { return activeTab() === "shows" && layerDepth() > 0 }, + onPlayEpisode: (episode, feed) => { + // TODO: play episode + }, + onExit: () => setLayerDepth(0), + }); + // Centralized keyboard handler for all tab navigation and shortcuts useAppKeyboard({ get activeTab() { @@ -58,151 +68,228 @@ export function App() { }, }); - const renderContent = () => { + const getPanels = () => { const tab = activeTab(); switch (tab) { - case "feeds": - return ( - 0} - showEpisodeCount={true} - showLastUpdated={true} - onFocusChange={() => setLayerDepth(0)} - onOpenFeed={(feed) => { - // Would open feed detail view - }} - /> - ); + case "feed": + return { + panels: [ + { + title: "Feed - Latest Episodes", + content: ( + 0} + onPlayEpisode={(episode, feed) => { + // TODO: play 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": - // Show auth panel or sync panel based on state if (showAuthPanel()) { if (auth.isAuthenticated) { - return ( - 0} - onLogout={() => { - auth.logout(); - setShowAuthPanel(false); - }} - onManageSync={() => setShowAuthPanel(false)} - /> - ); + return { + panels: [{ + title: "Account", + content: ( + 0} + onLogout={() => { + auth.logout(); + setShowAuthPanel(false); + }} + onManageSync={() => setShowAuthPanel(false)} + /> + ), + }], + activePanelIndex: 0, + hint: "Esc back", + }; } - switch (authScreen()) { - case "code": - return ( - 0} - onBack={() => setAuthScreen("login")} - /> - ); - case "oauth": - return ( - 0} - onBack={() => setAuthScreen("login")} - onNavigateToCode={() => setAuthScreen("code")} - /> - ); - case "login": - default: - return ( - 0} - onNavigateToCode={() => setAuthScreen("code")} - onNavigateToOAuth={() => setAuthScreen("oauth")} - /> - ); - } + 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 ( - setShowAuthPanel(true)} - accountLabel={ - auth.isAuthenticated - ? `Signed in as ${auth.user?.email}` - : "Not signed in" - } - accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"} - onExit={() => setLayerDepth(0)} - /> - ); + 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 ( - 0} - onExit={() => setLayerDepth(0)} - /> - ); + 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 ( - 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, - ); + 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, - ); - } - }} - /> - ); + 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 ( - 0} onExit={() => setLayerDepth(0)} /> - ); + return { + panels: [{ + title: "Player", + content: ( + 0} onExit={() => setLayerDepth(0)} /> + ), + }], + activePanelIndex: 0, + hint: "Space play/pause | Esc back", + }; default: - return ( - - - {tab} -
- Coming soon -
-
- ); + return { + panels: [{ + title: tab, + content: ( + + Coming soon + + ), + }], + activePanelIndex: 0, + hint: "", + }; } }; return ( - - } - footer={} - > - - ( - - - Error rendering tab: {err?.message ?? String(err)}{"\n"} - Press a number key (1-5) to switch tabs. - - - )}> - {renderContent()} - + ( + + + Error: {err?.message ?? String(err)}{"\n"} + Press a number key (1-6) to switch tabs. + - + )}> + + } + footer={ + + + {getPanels().hint} + + } + panels={getPanels().panels} + activePanelIndex={getPanels().activePanelIndex} + /> + ); } diff --git a/src/components/FeedPage.tsx b/src/components/FeedPage.tsx new file mode 100644 index 0000000..fbb7fd7 --- /dev/null +++ b/src/components/FeedPage.tsx @@ -0,0 +1,121 @@ +/** + * FeedPage - Shows latest episodes across all subscribed shows + * Reverse chronological order, like an inbox/timeline + */ + +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" + +type FeedPageProps = { + focused: boolean + onPlayEpisode?: (episode: Episode, feed: Feed) => void + onExit?: () => void +} + +export function FeedPage(props: FeedPageProps) { + const feedStore = useFeedStore() + const [selectedIndex, setSelectedIndex] = createSignal(0) + const [isRefreshing, setIsRefreshing] = createSignal(false) + + const allEpisodes = () => feedStore.getAllEpisodesChronological() + + const formatDate = (date: Date): string => { + return format(date, "MMM d, yyyy") + } + + const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60) + const hrs = Math.floor(mins / 60) + if (hrs > 0) return `${hrs}h ${mins % 60}m` + return `${mins}m` + } + + const handleRefresh = async () => { + setIsRefreshing(true) + await feedStore.refreshAllFeeds() + 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" || key.name === "enter") { + 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?.() + } + }) + + return ( + + {/* Status line */} + + Refreshing feeds... + + + {/* Episode list */} + 0} + fallback={ + + + No episodes yet. Subscribe to shows from Discover or Search. + + + } + > + + + {(item, index) => ( + setSelectedIndex(index())} + > + + + {index() === selectedIndex() ? ">" : " "} + + + {item.episode.title} + + + + {item.feed.podcast.title} + {formatDate(item.episode.pubDate)} + {formatDuration(item.episode.duration)} + + + )} + + + + + ) +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index d5677c6..960186a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,40 +1,51 @@ import type { JSX } from "solid-js" import type { RGBA } from "@opentui/core" -import { Show, createMemo } from "solid-js" +import { Show, For, createMemo } from "solid-js" import { useTheme } from "../context/ThemeContext" -import { LayerIndicator } from "./LayerIndicator" -type LayerConfig = { - depth: number - background: RGBA +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 - children?: JSX.Element - layerDepth?: number + /** 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 context = useTheme() - // Get layer configuration based on depth - wrapped in createMemo for reactivity - const currentLayer = createMemo((): LayerConfig => { - const depth = props.layerDepth || 0 + const panelBg = (index: number): RGBA => { const backgrounds = context.theme.layerBackgrounds - const depthMap: Record = { - 0: { depth: 0, background: backgrounds?.layer0 ?? context.theme.background }, - 1: { depth: 1, background: backgrounds?.layer1 ?? context.theme.backgroundPanel }, - 2: { depth: 2, background: backgrounds?.layer2 ?? context.theme.backgroundElement }, - 3: { depth: 3, background: backgrounds?.layer3 ?? context.theme.backgroundMenu }, - } + const layers = [ + backgrounds?.layer0 ?? context.theme.background, + backgrounds?.layer1 ?? context.theme.backgroundPanel, + backgrounds?.layer2 ?? context.theme.backgroundElement, + backgrounds?.layer3 ?? context.theme.backgroundMenu, + ] + return layers[Math.min(index, layers.length - 1)] + } - return depthMap[depth] || { depth: 0, background: context.theme.background } - }) + const borderColor = (index: number): RGBA | string => { + const isActive = index === (props.activePanelIndex ?? 0) + return isActive + ? (context.theme.accent ?? context.theme.primary) + : (context.theme.border ?? context.theme.textMuted) + } - // Note: No need for a ready check here - the ThemeProvider uses - // createSimpleContext which gates children rendering until ready return ( - {/* Header */} - }> + {/* Header - tab bar */} + - + {props.header} - {/* Main content area with layer background */} + {/* Main content: side-by-side panels */} - - {props.children} - + + {(panel, index) => ( + + {/* Panel header */} + + + + {panel.title} + + + + + {/* Panel body */} + + {panel.content} + + + )} + - {/* Footer */} - }> + {/* Footer - status/nav bar */} + - - {/* Layer indicator */} - - - - - - - ) } diff --git a/src/components/MyShowsPage.tsx b/src/components/MyShowsPage.tsx new file mode 100644 index 0000000..22a5bd2 --- /dev/null +++ b/src/components/MyShowsPage.tsx @@ -0,0 +1,242 @@ +/** + * MyShowsPage - Two-panel file-explorer style view + * Left panel: list of subscribed shows + * Right panel: episodes for the selected show + */ + +import { createSignal, For, Show, createMemo } 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" + +type MyShowsPageProps = { + focused: boolean + onPlayEpisode?: (episode: Episode, feed: Feed) => void + onExit?: () => void +} + +type FocusPane = "shows" | "episodes" + +export function MyShowsPage(props: MyShowsPageProps) { + const feedStore = useFeedStore() + const [focusPane, setFocusPane] = createSignal("shows") + const [showIndex, setShowIndex] = createSignal(0) + const [episodeIndex, setEpisodeIndex] = createSignal(0) + const [isRefreshing, setIsRefreshing] = createSignal(false) + + const shows = () => feedStore.getFilteredFeeds() + + const selectedShow = createMemo(() => { + const s = shows() + const idx = showIndex() + return idx < s.length ? s[idx] : undefined + }) + + const episodes = createMemo(() => { + const show = selectedShow() + if (!show) return [] + return [...show.episodes].sort( + (a, b) => b.pubDate.getTime() - a.pubDate.getTime() + ) + }) + + const formatDate = (date: Date): string => { + return format(date, "MMM d, yyyy") + } + + const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60) + const hrs = Math.floor(mins / 60) + if (hrs > 0) return `${hrs}h ${mins % 60}m` + return `${mins}m` + } + + const handleRefresh = async () => { + const show = selectedShow() + if (!show) return + setIsRefreshing(true) + await feedStore.refreshFeed(show.id) + setIsRefreshing(false) + } + + const handleUnsubscribe = () => { + const show = selectedShow() + if (!show) return + feedStore.removeFeed(show.id) + setShowIndex((i) => Math.max(0, i - 1)) + 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 === "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") + } + } + }) + + return { + showsPanel: () => ( + + + Refreshing... + + 0} + fallback={ + + + No shows yet. Subscribe from Discover or Search. + + + } + > + + + {(feed, index) => ( + { + setShowIndex(index()) + setEpisodeIndex(0) + }} + > + + {index() === showIndex() ? ">" : " "} + + + {feed.customName || feed.podcast.title} + + ({feed.episodes.length}) + + )} + + + + + ), + + episodesPanel: () => ( + + + Select a show + + } + > + 0} + fallback={ + + No episodes. Press [r] to refresh. + + } + > + + + {(episode, index) => ( + setEpisodeIndex(index())} + > + + + {index() === episodeIndex() ? ">" : " "} + + + {episode.episodeNumber ? `#${episode.episodeNumber} ` : ""} + {episode.title} + + + + {formatDate(episode.pubDate)} + {formatDuration(episode.duration)} + + + )} + + + + + + ), + + focusPane, + selectedShow, + } +} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 59f18ce..312e712 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -9,9 +9,11 @@ export function Navigation(props: NavigationProps) { return ( - {props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "} + {props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "} - {props.activeTab === "feeds" ? "[" : " "}My Feeds{props.activeTab === "feeds" ? "]" : " "} + {props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "} + + {props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "} {props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "} diff --git a/src/components/Tab.tsx b/src/components/Tab.tsx index 9e078b6..6797fd5 100644 --- a/src/components/Tab.tsx +++ b/src/components/Tab.tsx @@ -1,6 +1,6 @@ import { useTheme } from "../context/ThemeContext" -export type TabId = "discover" | "feeds" | "search" | "player" | "settings" +export type TabId = "feed" | "shows" | "discover" | "search" | "player" | "settings" export type TabDefinition = { id: TabId @@ -8,8 +8,9 @@ export type TabDefinition = { } export const tabs: TabDefinition[] = [ + { id: "feed", label: "Feed" }, + { id: "shows", label: "My Shows" }, { id: "discover", label: "Discover" }, - { id: "feeds", label: "My Feeds" }, { id: "search", label: "Search" }, { id: "player", label: "Player" }, { id: "settings", label: "Settings" }, diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx index 9961a1f..5074148 100644 --- a/src/components/TabNavigation.tsx +++ b/src/components/TabNavigation.tsx @@ -8,8 +8,9 @@ type TabNavigationProps = { export function TabNavigation(props: TabNavigationProps) { return ( + + - diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index bc91d95..6c7f70a 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -7,7 +7,7 @@ import { useKeyboard, useRenderer } from "@opentui/solid" import type { TabId } from "../components/Tab" import type { Accessor } from "solid-js" -const TAB_ORDER: TabId[] = ["discover", "feeds", "search", "player", "settings"] +const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"] type ShortcutOptions = { activeTab: TabId @@ -89,24 +89,28 @@ export function useAppKeyboard(options: ShortcutOptions) { return } - // Number keys for direct tab access (1-5) + // Number keys for direct tab access (1-6) if (key.name === "1") { - options.onTabChange("discover") + options.onTabChange("feed") return } if (key.name === "2") { - options.onTabChange("feeds") + options.onTabChange("shows") return } if (key.name === "3") { - options.onTabChange("search") + options.onTabChange("discover") return } if (key.name === "4") { - options.onTabChange("player") + options.onTabChange("search") return } if (key.name === "5") { + options.onTabChange("player") + return + } + if (key.name === "6") { options.onTabChange("settings") return } diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 08d95a6..0221384 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -10,6 +10,13 @@ import type { Podcast } from "../types/podcast" import type { Episode, EpisodeStatus } from "../types/episode" import type { PodcastSource, SourceType } from "../types/source" import { DEFAULT_SOURCES } from "../types/source" +import { parseRSSFeed } from "../api/rss-parser" + +/** Max episodes to fetch on refresh */ +const MAX_EPISODES_REFRESH = 50 + +/** Max episodes to fetch on initial subscribe */ +const MAX_EPISODES_SUBSCRIBE = 20 /** Storage keys */ const STORAGE_KEYS = { @@ -17,125 +24,10 @@ const STORAGE_KEYS = { sources: "podtui_sources", } -/** Create initial mock feeds for demonstration */ -function createMockFeeds(): Feed[] { - const now = new Date() - return [ - { - id: "1", - podcast: { - id: "p1", - title: "The Daily Tech News", - description: "Your daily dose of technology news and insights from around the world. We cover the latest in AI, software, hardware, and digital culture.", - feedUrl: "https://example.com/tech.rss", - author: "Tech Media Inc", - categories: ["Technology", "News"], - lastUpdated: now, - isSubscribed: true, - }, - episodes: createMockEpisodes("p1", 25), - visibility: "public" as FeedVisibility, - sourceId: "rss", - lastUpdated: now, - isPinned: true, - }, - { - id: "2", - podcast: { - id: "p2", - title: "Code & Coffee", - description: "Weekly discussions about programming, software development, and the developer lifestyle. Best enjoyed with your morning coffee.", - feedUrl: "https://example.com/code.rss", - author: "Developer Collective", - categories: ["Technology", "Programming"], - lastUpdated: new Date(Date.now() - 86400000), - isSubscribed: true, - }, - episodes: createMockEpisodes("p2", 50), - visibility: "private" as FeedVisibility, - sourceId: "rss", - lastUpdated: new Date(Date.now() - 86400000), - isPinned: false, - }, - { - id: "3", - podcast: { - id: "p3", - title: "Science Explained", - description: "Breaking down complex scientific topics for curious minds. From quantum physics to biology, we make science accessible.", - feedUrl: "https://example.com/science.rss", - author: "Science Network", - categories: ["Science", "Education"], - lastUpdated: new Date(Date.now() - 172800000), - isSubscribed: true, - }, - episodes: createMockEpisodes("p3", 120), - visibility: "public" as FeedVisibility, - sourceId: "itunes", - lastUpdated: new Date(Date.now() - 172800000), - isPinned: false, - }, - { - id: "4", - podcast: { - id: "p4", - title: "History Uncovered", - description: "Deep dives into fascinating historical events and figures you never learned about in school.", - feedUrl: "https://example.com/history.rss", - author: "History Channel", - categories: ["History", "Education"], - lastUpdated: new Date(Date.now() - 259200000), - isSubscribed: true, - }, - episodes: createMockEpisodes("p4", 80), - visibility: "public" as FeedVisibility, - sourceId: "rss", - lastUpdated: new Date(Date.now() - 259200000), - isPinned: true, - }, - { - id: "5", - podcast: { - id: "p5", - title: "Startup Stories", - description: "Founders share their journey from idea to exit. Learn from their successes and failures.", - feedUrl: "https://example.com/startup.rss", - author: "Entrepreneur Media", - categories: ["Business", "Technology"], - lastUpdated: new Date(Date.now() - 345600000), - isSubscribed: true, - }, - episodes: createMockEpisodes("p5", 45), - visibility: "private" as FeedVisibility, - sourceId: "itunes", - lastUpdated: new Date(Date.now() - 345600000), - isPinned: false, - }, - ] -} - -/** Create mock episodes for a podcast */ -function createMockEpisodes(podcastId: string, count: number): Episode[] { - const episodes: Episode[] = [] - for (let i = 0; i < count; i++) { - episodes.push({ - id: `${podcastId}-ep-${i + 1}`, - podcastId, - title: `Episode ${count - i}: Sample Episode Title`, - description: `This is the description for episode ${count - i}. It contains interesting content about various topics.`, - audioUrl: `https://example.com/audio/${podcastId}/${i + 1}.mp3`, - duration: 1800 + Math.random() * 3600, // 30-90 minutes - pubDate: new Date(Date.now() - i * 604800000), // Weekly episodes - episodeNumber: count - i, - }) - } - return episodes -} - /** Load feeds from localStorage */ function loadFeeds(): Feed[] { if (typeof localStorage === "undefined") { - return createMockFeeds() + return [] } try { @@ -160,7 +52,7 @@ function loadFeeds(): Feed[] { // Ignore errors } - return createMockFeeds() + return [] } /** Save feeds to localStorage */ @@ -287,12 +179,31 @@ export function createFeedStore() { return allEpisodes } - /** Add a new feed */ - const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => { + /** Fetch latest episodes from an RSS feed URL */ + const fetchEpisodes = async (feedUrl: string, limit: number): Promise => { + try { + const response = await fetch(feedUrl, { + headers: { + "Accept-Encoding": "identity", + "Accept": "application/rss+xml, application/xml, text/xml, */*", + }, + }) + if (!response.ok) return [] + const xml = await response.text() + const parsed = parseRSSFeed(xml, feedUrl) + return parsed.episodes.slice(0, limit) + } catch { + return [] + } + } + + /** Add a new feed and auto-fetch latest 20 episodes */ + const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => { + const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE) const newFeed: Feed = { id: crypto.randomUUID(), podcast, - episodes: [], + episodes, visibility, sourceId, lastUpdated: new Date(), @@ -306,6 +217,28 @@ export function createFeedStore() { return newFeed } + /** Refresh a single feed - re-fetch latest 50 episodes */ + const refreshFeed = async (feedId: string) => { + const feed = getFeed(feedId) + if (!feed) return + const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH) + setFeeds((prev) => { + const updated = prev.map((f) => + f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f + ) + saveFeeds(updated) + return updated + }) + } + + /** Refresh all feeds */ + const refreshAllFeeds = async () => { + const currentFeeds = feeds() + for (const feed of currentFeeds) { + await refreshFeed(feed.id) + } + } + /** Remove a feed */ const removeFeed = (feedId: string) => { setFeeds((prev) => { @@ -417,6 +350,8 @@ export function createFeedStore() { removeFeed, updateFeed, togglePinned, + refreshFeed, + refreshAllFeeds, addSource, removeSource, toggleSource,