From cdabf2c3e036b0ae773b1c2b596c41c3a489aa19 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 4 Feb 2026 12:10:30 -0500 Subject: [PATCH] checkpoint --- src/App.tsx | 44 +++++++--- src/api/client.ts | 73 ++++++++++++++++ src/api/rss-parser.ts | 53 ++++++++++++ src/api/source-handler.ts | 94 ++++++++++++++++++++ src/components/DiscoverPage.tsx | 16 ++++ src/components/FeedList.tsx | 7 +- src/components/Layout.tsx | 9 +- src/components/PlaybackControls.tsx | 34 ++++++++ src/components/Player.tsx | 114 ++++++++++++++++++++++++ src/components/PreferencesPanel.tsx | 130 ++++++++++++++++++++++++++++ src/components/SearchPage.tsx | 16 +++- src/components/SettingsScreen.tsx | 94 ++++++++++++++++++++ src/components/Waveform.tsx | 52 +++++++++++ src/constants/themes.ts | 67 ++++++++++++++ src/hooks/useAppKeyboard.ts | 17 +++- src/hooks/useCachedData.ts | 34 ++++++++ src/stores/app.ts | 109 +++++++++++++++++++++++ src/types/settings.ts | 32 +++++++ src/utils/cache.ts | 57 ++++++++++++ src/utils/data-fetcher.ts | 57 ++++++++++++ src/utils/persistence.ts | 77 ++++++++++++++++ src/utils/waveform.ts | 8 ++ 22 files changed, 1176 insertions(+), 18 deletions(-) create mode 100644 src/api/client.ts create mode 100644 src/api/rss-parser.ts create mode 100644 src/api/source-handler.ts create mode 100644 src/components/PlaybackControls.tsx create mode 100644 src/components/Player.tsx create mode 100644 src/components/PreferencesPanel.tsx create mode 100644 src/components/SettingsScreen.tsx create mode 100644 src/components/Waveform.tsx create mode 100644 src/constants/themes.ts create mode 100644 src/hooks/useCachedData.ts create mode 100644 src/stores/app.ts create mode 100644 src/types/settings.ts create mode 100644 src/utils/cache.ts create mode 100644 src/utils/data-fetcher.ts create mode 100644 src/utils/persistence.ts create mode 100644 src/utils/waveform.ts diff --git a/src/App.tsx b/src/App.tsx index b6f99a3..795b970 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { Player } from "./components/Player"; import { SettingsScreen } from "./components/SettingsScreen"; import { useAuthStore } from "./stores/auth"; import { useFeedStore } from "./stores/feed"; +import { useAppStore } from "./stores/app"; import { FeedVisibility } from "./types/feed"; import { useAppKeyboard } from "./hooks/useAppKeyboard"; import type { TabId } from "./components/Tab"; @@ -23,8 +24,10 @@ export function App() { const [authScreen, setAuthScreen] = createSignal("login"); const [showAuthPanel, setShowAuthPanel] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false); + const [layerDepth, setLayerDepth] = createSignal(0); const auth = useAuthStore(); const feedStore = useFeedStore(); + const appStore = useAppStore(); // Centralized keyboard handler for all tab navigation and shortcuts useAppKeyboard({ @@ -33,10 +36,20 @@ export function App() { }, onTabChange: setActiveTab, inputFocused: inputFocused(), + navigationEnabled: layerDepth() === 0, onAction: (action) => { if (action === "escape") { - setShowAuthPanel(false); - setInputFocused(false); + if (layerDepth() > 0) { + setLayerDepth(0); + setInputFocused(false); + } else { + setShowAuthPanel(false); + setInputFocused(false); + } + } + + if (action === "enter" && layerDepth() === 0) { + setLayerDepth(1); } }, }); @@ -48,9 +61,10 @@ export function App() { case "feeds": return ( 0} showEpisodeCount={true} showLastUpdated={true} + onFocusChange={() => setLayerDepth(0)} onOpenFeed={(feed) => { // Would open feed detail view }} @@ -63,7 +77,7 @@ export function App() { if (auth.isAuthenticated) { return ( 0} onLogout={() => { auth.logout(); setShowAuthPanel(false); @@ -77,14 +91,14 @@ export function App() { case "code": return ( 0} onBack={() => setAuthScreen("login")} /> ); case "oauth": return ( 0} onBack={() => setAuthScreen("login")} onNavigateToCode={() => setAuthScreen("code")} /> @@ -93,7 +107,7 @@ export function App() { default: return ( 0} onNavigateToCode={() => setAuthScreen("code")} onNavigateToOAuth={() => setAuthScreen("oauth")} /> @@ -110,17 +124,24 @@ export function App() { : "Not signed in" } accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"} + onExit={() => setLayerDepth(0)} /> ); case "discover": - return ; + return ( + 0} + onExit={() => setLayerDepth(0)} + /> + ); case "search": return ( 0} onInputFocusChange={setInputFocused} + onExit={() => setLayerDepth(0)} onSubscribe={(result) => { const feeds = feedStore.feeds(); const alreadySubscribed = feeds.some( @@ -141,7 +162,9 @@ export function App() { ); case "player": - return ; + return ( + 0} onExit={() => setLayerDepth(0)} /> + ); default: return ( @@ -158,6 +181,7 @@ export function App() { return ( } diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..e93e877 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,73 @@ +import type { Feed } from "../types/feed" +import type { Episode } from "../types/episode" +import type { Podcast } from "../types/podcast" +import type { PodcastSource } from "../types/source" +import { parseRSSFeed } from "@/api/rss-parser" +import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler" + +export const fetchEpisodes = async (feedUrl: string): Promise => { + try { + const response = await fetch(feedUrl) + if (!response.ok) return [] + const xml = await response.text() + return parseRSSFeed(xml, feedUrl).episodes + } catch { + return [] + } +} + +export const fetchFeeds = async ( + sourceIds: string[], + sources: PodcastSource[] +): Promise => { + const active = sources.filter((source) => sourceIds.includes(source.id)) + const feeds: Feed[] = [] + + await Promise.all( + active.map(async (source) => { + try { + if (source.type === "rss") { + const rssFeeds = await handleRSSSource(source) + feeds.push(...rssFeeds) + } else if (source.type === "api") { + const apiFeeds = await handleAPISource(source, "") + feeds.push(...apiFeeds) + } else { + const customFeeds = await handleCustomSource(source, "") + feeds.push(...customFeeds) + } + } catch { + // ignore individual source errors + } + }) + ) + + return feeds +} + +export const searchPodcasts = async ( + query: string, + sources: PodcastSource[] +): Promise => { + const results: Podcast[] = [] + await Promise.all( + sources.map(async (source) => { + try { + if (source.type === "rss") { + const feeds = await handleRSSSource(source) + results.push(...feeds.map((feed: Feed) => feed.podcast)) + } else if (source.type === "api") { + const feeds = await handleAPISource(source, query) + results.push(...feeds.map((feed: Feed) => feed.podcast)) + } else { + const feeds = await handleCustomSource(source, query) + results.push(...feeds.map((feed: Feed) => feed.podcast)) + } + } catch { + // ignore errors + } + }) + ) + + return results +} diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts new file mode 100644 index 0000000..744d026 --- /dev/null +++ b/src/api/rss-parser.ts @@ -0,0 +1,53 @@ +import type { Podcast } from "../types/podcast" +import type { Episode } from "../types/episode" + +const getTagValue = (xml: string, tag: string): string => { + const match = xml.match(new RegExp(`<${tag}[^>]*>([\s\S]*?)`, "i")) + return match?.[1]?.trim() ?? "" +} + +const decodeEntities = (value: string) => + value + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + +export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => { + const channel = xml.match(//i)?.[0] ?? xml + const title = decodeEntities(getTagValue(channel, "title")) || "Untitled Podcast" + const description = decodeEntities(getTagValue(channel, "description")) + const author = decodeEntities(getTagValue(channel, "itunes:author")) + const lastUpdated = new Date() + + const items = channel.match(//gi) ?? [] + const episodes = items.map((item, index) => { + const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}` + const epDescription = decodeEntities(getTagValue(item, "description")) + const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) + const enclosure = item.match(/]*url=["']([^"']+)["'][^>]*>/i) + const audioUrl = enclosure?.[1] ?? "" + + return { + id: `${feedUrl}#${index}`, + podcastId: feedUrl, + title: epTitle, + description: epDescription, + audioUrl, + duration: 0, + pubDate, + } + }) + + return { + id: feedUrl, + title, + description, + author, + feedUrl, + lastUpdated, + isSubscribed: true, + episodes, + } +} diff --git a/src/api/source-handler.ts b/src/api/source-handler.ts new file mode 100644 index 0000000..a9f4cdd --- /dev/null +++ b/src/api/source-handler.ts @@ -0,0 +1,94 @@ +import { FeedVisibility } from "../types/feed" +import type { Feed } from "../types/feed" +import type { PodcastSource } from "../types/source" +import type { Podcast } from "../types/podcast" +import { parseRSSFeed } from "./rss-parser" + +const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => { + return { + id: `${sourceId}-${podcast.id}`, + podcast, + episodes: [], + visibility: FeedVisibility.PUBLIC, + sourceId, + lastUpdated: new Date(), + isPinned: false, + } +} + +export const handleRSSSource = async (source: PodcastSource): Promise => { + if (!source.baseUrl) return [] + const response = await fetch(source.baseUrl) + if (!response.ok) return [] + const xml = await response.text() + const parsed = parseRSSFeed(xml, source.baseUrl) + return [ + { + id: `${source.id}-${parsed.feedUrl}`, + podcast: { + id: parsed.id, + title: parsed.title, + description: parsed.description, + feedUrl: parsed.feedUrl, + author: parsed.author, + categories: parsed.categories, + lastUpdated: parsed.lastUpdated, + isSubscribed: true, + }, + episodes: parsed.episodes, + visibility: FeedVisibility.PUBLIC, + sourceId: source.id, + lastUpdated: parsed.lastUpdated, + isPinned: false, + }, + ] +} + +export const handleAPISource = async ( + source: PodcastSource, + query: string +): Promise => { + const url = new URL(source.baseUrl || "https://itunes.apple.com/search") + url.searchParams.set("term", query || "podcast") + url.searchParams.set("media", "podcast") + url.searchParams.set("entity", "podcast") + url.searchParams.set("country", source.country || "US") + url.searchParams.set("lang", source.language || "en_us") + + const response = await fetch(url.toString()) + if (!response.ok) return [] + const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> } + const results = data.results ?? [] + + return results + .filter((item) => item.collectionName && item.feedUrl) + .map((item) => { + const podcast: Podcast = { + id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`, + title: item.collectionName || "Untitled Podcast", + description: item.collectionName || "", + feedUrl: item.feedUrl || "", + author: item.artistName, + lastUpdated: new Date(), + isSubscribed: false, + } + return buildFeedFromPodcast(podcast, source.id) + }) +} + +export const handleCustomSource = async ( + source: PodcastSource, + query: string +): Promise => { + if (!query) return [] + const podcast: Podcast = { + id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`, + title: `${query} Highlights`, + description: `Curated results for ${query}`, + feedUrl: source.baseUrl || "", + author: source.name, + lastUpdated: new Date(), + isSubscribed: false, + } + return [buildFeedFromPodcast(podcast, source.id)] +} diff --git a/src/components/DiscoverPage.tsx b/src/components/DiscoverPage.tsx index 4d49267..248d59e 100644 --- a/src/components/DiscoverPage.tsx +++ b/src/components/DiscoverPage.tsx @@ -10,6 +10,7 @@ import { TrendingShows } from "./TrendingShows" type DiscoverPageProps = { focused: boolean + onExit?: () => void } type FocusArea = "categories" | "shows" @@ -36,6 +37,11 @@ export function DiscoverPage(props: DiscoverPageProps) { return } + if (key.name === "enter" && area === "categories") { + setFocusArea("shows") + return + } + // Category navigation if (area === "categories") { if (key.name === "left" || key.name === "h") { @@ -96,6 +102,15 @@ export function DiscoverPage(props: DiscoverPageProps) { } } + if (key.name === "escape") { + if (area === "shows") { + setFocusArea("categories") + } else { + props.onExit?.() + } + return + } + // Refresh with 'r' if (key.name === "r") { discoverStore.refresh() @@ -177,6 +192,7 @@ export function DiscoverPage(props: DiscoverPageProps) { [Tab] Switch focus [j/k] Navigate [Enter] Subscribe + [Esc] Up [R] Refresh diff --git a/src/components/FeedList.tsx b/src/components/FeedList.tsx index 69a9438..6f0b86d 100644 --- a/src/components/FeedList.tsx +++ b/src/components/FeedList.tsx @@ -17,6 +17,7 @@ interface FeedListProps { showLastUpdated?: boolean onSelectFeed?: (feed: Feed) => void onOpenFeed?: (feed: Feed) => void + onFocusChange?: (focused: boolean) => void } export function FeedList(props: FeedListProps) { @@ -26,6 +27,10 @@ export function FeedList(props: FeedListProps) { const filteredFeeds = () => feedStore.getFilteredFeeds() const handleKeyPress = (key: { name: string }) => { + if (key.name === "escape") { + props.onFocusChange?.(false) + return + } const feeds = filteredFeeds() if (key.name === "up" || key.name === "k") { @@ -180,7 +185,7 @@ export function FeedList(props: FeedListProps) { {/* Navigation help */} - j/k navigate | Enter open | p pin | f filter | s sort | Click to select + Enter open | Esc up | j/k navigate | p pin | f filter | s sort diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 238d853..8628b38 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,14 +1,21 @@ import type { JSX } from "solid-js" +import type { ThemeColors } from "../types/settings" type LayoutProps = { header?: JSX.Element footer?: JSX.Element children?: JSX.Element + theme?: ThemeColors } export function Layout(props: LayoutProps) { return ( - + {props.header ? {props.header} : } {props.children} {props.footer ? {props.footer} : } diff --git a/src/components/PlaybackControls.tsx b/src/components/PlaybackControls.tsx new file mode 100644 index 0000000..95ae5eb --- /dev/null +++ b/src/components/PlaybackControls.tsx @@ -0,0 +1,34 @@ +type PlaybackControlsProps = { + isPlaying: boolean + volume: number + speed: number + onToggle: () => void + onPrev: () => void + onNext: () => void + onVolumeChange: (value: number) => void + onSpeedChange: (value: number) => void +} + +export function PlaybackControls(props: PlaybackControlsProps) { + return ( + + + [Prev] + + + {props.isPlaying ? "[Pause]" : "[Play]"} + + + [Next] + + + Vol + {Math.round(props.volume * 100)}% + + + Speed + {props.speed}x + + + ) +} diff --git a/src/components/Player.tsx b/src/components/Player.tsx new file mode 100644 index 0000000..13ed79d --- /dev/null +++ b/src/components/Player.tsx @@ -0,0 +1,114 @@ +import { createSignal } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { PlaybackControls } from "./PlaybackControls" +import { Waveform } from "./Waveform" +import { createWaveform } from "../utils/waveform" +import type { Episode } from "../types/episode" + +type PlayerProps = { + focused: boolean + 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 [isPlaying, setIsPlaying] = createSignal(false) + const [position, setPosition] = createSignal(0) + const [volume, setVolume] = createSignal(0.7) + const [speed, setSpeed] = createSignal(1) + + const waveform = () => createWaveform(64) + + useKeyboard((key: { name: string }) => { + if (!props.focused) return + if (key.name === "space") { + setIsPlaying((value: boolean) => !value) + return + } + if (key.name === "escape") { + props.onExit?.() + return + } + if (key.name === "left") { + setPosition((value: number) => Math.max(0, value - 10)) + } + if (key.name === "right") { + setPosition((value: number) => Math.min(SAMPLE_EPISODE.duration, value + 10)) + } + if (key.name === "up") { + setVolume((value: number) => Math.min(1, Number((value + 0.05).toFixed(2)))) + } + if (key.name === "down") { + setVolume((value: number) => Math.max(0, Number((value - 0.05).toFixed(2)))) + } + if (key.name === "s") { + setSpeed((value: number) => (value >= 2 ? 0.5 : Number((value + 0.25).toFixed(2)))) + } + }) + + const progressPercent = () => Math.round((position() / SAMPLE_EPISODE.duration) * 100) + + return ( + + + + Now Playing + + + Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")} + + + + + + {SAMPLE_EPISODE.title} + + {SAMPLE_EPISODE.description} + + + + Progress: + + + + {progressPercent()}% + + + setPosition(next)} + /> + + + + setIsPlaying((value: boolean) => !value)} + onPrev={() => setPosition(0)} + onNext={() => setPosition(SAMPLE_EPISODE.duration)} + onSpeedChange={setSpeed} + onVolumeChange={setVolume} + /> + + Enter dive | Esc up | Space play/pause | Left/Right seek + + ) +} diff --git a/src/components/PreferencesPanel.tsx b/src/components/PreferencesPanel.tsx new file mode 100644 index 0000000..17860fb --- /dev/null +++ b/src/components/PreferencesPanel.tsx @@ -0,0 +1,130 @@ +import { createSignal } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { useAppStore } from "../stores/app" +import type { ThemeName } from "../types/settings" + +type FocusField = "theme" | "font" | "speed" | "explicit" | "auto" + +const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ + { value: "system", label: "System" }, + { value: "catppuccin", label: "Catppuccin" }, + { value: "gruvbox", label: "Gruvbox" }, + { value: "tokyo", label: "Tokyo" }, + { value: "nord", label: "Nord" }, + { value: "custom", label: "Custom" }, +] + +export function PreferencesPanel() { + const appStore = useAppStore() + const [focusField, setFocusField] = createSignal("theme") + + const settings = () => appStore.state().settings + const preferences = () => appStore.state().preferences + + const handleKey = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const fields: FocusField[] = ["theme", "font", "speed", "explicit", "auto"] + const idx = fields.indexOf(focusField()) + const next = key.shift + ? (idx - 1 + fields.length) % fields.length + : (idx + 1) % fields.length + setFocusField(fields[next]) + return + } + + if (key.name === "left" || key.name === "h") { + stepValue(-1) + } + if (key.name === "right" || key.name === "l") { + stepValue(1) + } + if (key.name === "space" || key.name === "enter") { + toggleValue() + } + } + + const stepValue = (delta: number) => { + const field = focusField() + if (field === "theme") { + const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme) + const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length + appStore.setTheme(THEME_LABELS[next].value) + return + } + if (field === "font") { + const next = Math.min(20, Math.max(10, settings().fontSize + delta)) + appStore.updateSettings({ fontSize: next }) + return + } + if (field === "speed") { + const next = Math.min(2, Math.max(0.5, settings().playbackSpeed + delta * 0.1)) + appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) }) + } + } + + const toggleValue = () => { + const field = focusField() + if (field === "explicit") { + appStore.updatePreferences({ showExplicit: !preferences().showExplicit }) + } + if (field === "auto") { + appStore.updatePreferences({ autoDownload: !preferences().autoDownload }) + } + } + + useKeyboard(handleKey) + + return ( + + Preferences + + + + Theme: + + {THEME_LABELS.find((t) => t.value === settings().theme)?.label} + + [Left/Right] + + + + Font Size: + + {settings().fontSize}px + + [Left/Right] + + + + Playback: + + {settings().playbackSpeed}x + + [Left/Right] + + + + Show Explicit: + + + {preferences().showExplicit ? "On" : "Off"} + + + [Space] + + + + Auto Download: + + + {preferences().autoDownload ? "On" : "Off"} + + + [Space] + + + + Tab to move focus, Left/Right to adjust + + ) +} diff --git a/src/components/SearchPage.tsx b/src/components/SearchPage.tsx index a3880ab..93f6ef8 100644 --- a/src/components/SearchPage.tsx +++ b/src/components/SearchPage.tsx @@ -13,6 +13,7 @@ type SearchPageProps = { focused: boolean onSubscribe?: (result: SearchResult) => void onInputFocusChange?: (focused: boolean) => void + onExit?: () => void } type FocusArea = "input" | "results" | "history" @@ -34,6 +35,9 @@ export function SearchPage(props: SearchPageProps) { props.onInputFocusChange?.(false) } } + if (props.focused && focusArea() === "input") { + props.onInputFocusChange?.(true) + } } const handleHistorySelect = async (query: string) => { @@ -144,10 +148,14 @@ export function SearchPage(props: SearchPageProps) { } } - // Escape goes back to input + // Escape goes back to input or up one level if (key.name === "escape") { - setFocusArea("input") - props.onInputFocusChange?.(true) + if (area === "input") { + props.onExit?.() + } else { + setFocusArea("input") + props.onInputFocusChange?.(true) + } return } @@ -261,7 +269,7 @@ export function SearchPage(props: SearchPageProps) { [Tab] Switch focus [/] Focus search [Enter] Select - [Esc] Back to search + [Esc] Up ) diff --git a/src/components/SettingsScreen.tsx b/src/components/SettingsScreen.tsx new file mode 100644 index 0000000..0bd4e83 --- /dev/null +++ b/src/components/SettingsScreen.tsx @@ -0,0 +1,94 @@ +import { createSignal } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { SourceManager } from "./SourceManager" +import { PreferencesPanel } from "./PreferencesPanel" +import { SyncPanel } from "./SyncPanel" + +type SettingsScreenProps = { + accountLabel: string + accountStatus: "signed-in" | "signed-out" + onOpenAccount?: () => void + onExit?: () => void +} + +type SectionId = "sync" | "sources" | "preferences" | "account" + +const SECTIONS: Array<{ id: SectionId; label: string }> = [ + { id: "sync", label: "Sync" }, + { id: "sources", label: "Sources" }, + { id: "preferences", label: "Preferences" }, + { id: "account", label: "Account" }, +] + +export function SettingsScreen(props: SettingsScreenProps) { + const [activeSection, setActiveSection] = createSignal("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("account") + }) + + return ( + + + + Settings + + [Tab] Switch section | 1-4 jump | Esc up + + + + {SECTIONS.map((section, index) => ( + setActiveSection(section.id)} + > + + [{index + 1}] {section.label} + + + ))} + + + + {activeSection() === "sync" && } + {activeSection() === "sources" && } + {activeSection() === "preferences" && } + {activeSection() === "account" && ( + + Account + + Status: + + {props.accountLabel} + + + props.onOpenAccount?.()}> + [A] Manage Account + + + )} + + + Enter to dive | Esc up + + ) +} diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx new file mode 100644 index 0000000..70a83aa --- /dev/null +++ b/src/components/Waveform.tsx @@ -0,0 +1,52 @@ +type WaveformProps = { + data: number[] + position: number + duration: number + isPlaying: boolean + onSeek?: (next: number) => void +} + +const bars = [".", "-", "~", "=", "#"] + +export function Waveform(props: WaveformProps) { + const playedRatio = () => (props.duration === 0 ? 0 : props.position / props.duration) + + const renderLine = () => { + const playedCount = Math.floor(props.data.length * playedRatio()) + const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" + const futureColor = "#3b4252" + const played = props.data + .map((value, index) => + index <= playedCount + ? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))] + : "" + ) + .join("") + const upcoming = props.data + .map((value, index) => + index > playedCount + ? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))] + : "" + ) + .join("") + + return ( + + {played || " "} + {upcoming || " "} + + ) + } + + const handleClick = (event: { x: number }) => { + const ratio = props.data.length === 0 ? 0 : event.x / props.data.length + const next = Math.max(0, Math.min(props.duration, Math.round(props.duration * ratio))) + props.onSeek?.(next) + } + + return ( + + {renderLine()} + + ) +} diff --git a/src/constants/themes.ts b/src/constants/themes.ts new file mode 100644 index 0000000..758be63 --- /dev/null +++ b/src/constants/themes.ts @@ -0,0 +1,67 @@ +import type { ThemeColors, ThemeName } from "../types/settings" + +export const DEFAULT_THEME: ThemeColors = { + background: "transparent", + surface: "#1b1f27", + primary: "#6fa8ff", + secondary: "#a9b1d6", + accent: "#f6c177", + text: "#e6edf3", + muted: "#7d8590", + warning: "#f0b429", + error: "#f47067", + success: "#3fb950", +} + +export const THEMES: Record = { + system: DEFAULT_THEME, + catppuccin: { + background: "transparent", + surface: "#1e1e2e", + primary: "#89b4fa", + secondary: "#cba6f7", + accent: "#f9e2af", + text: "#cdd6f4", + muted: "#7f849c", + warning: "#fab387", + error: "#f38ba8", + success: "#a6e3a1", + }, + gruvbox: { + background: "transparent", + surface: "#282828", + primary: "#fabd2f", + secondary: "#83a598", + accent: "#fe8019", + text: "#ebdbb2", + muted: "#928374", + warning: "#fabd2f", + error: "#fb4934", + success: "#b8bb26", + }, + tokyo: { + background: "transparent", + surface: "#1a1b26", + primary: "#7aa2f7", + secondary: "#bb9af7", + accent: "#e0af68", + text: "#c0caf5", + muted: "#565f89", + warning: "#e0af68", + error: "#f7768e", + success: "#9ece6a", + }, + nord: { + background: "transparent", + surface: "#2e3440", + primary: "#88c0d0", + secondary: "#81a1c1", + accent: "#ebcb8b", + text: "#eceff4", + muted: "#4c566a", + warning: "#ebcb8b", + error: "#bf616a", + success: "#a3be8c", + }, + custom: DEFAULT_THEME, +} diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 6226825..690c9e1 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -13,6 +13,7 @@ type ShortcutOptions = { onTabChange: (tab: TabId) => void onAction?: (action: string) => void inputFocused?: boolean + navigationEnabled?: boolean } export function useAppKeyboard(options: ShortcutOptions) { @@ -35,11 +36,25 @@ export function useAppKeyboard(options: ShortcutOptions) { 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 + } + + if (key.name === "enter") { + options.onAction?.("enter") + return + } + // Tab navigation with left/right arrows OR [ and ] if (key.name === "right" || key.name === "]") { options.onTabChange(getNextTab(options.activeTab)) @@ -89,8 +104,6 @@ export function useAppKeyboard(options: ShortcutOptions) { options.onAction("save") } else if (key.ctrl && key.name === "f") { options.onAction("find") - } else if (key.name === "escape") { - options.onAction("escape") } else if (key.name === "?" || (key.shift && key.name === "/")) { options.onAction("help") } diff --git a/src/hooks/useCachedData.ts b/src/hooks/useCachedData.ts new file mode 100644 index 0000000..2ca4450 --- /dev/null +++ b/src/hooks/useCachedData.ts @@ -0,0 +1,34 @@ +import { createSignal, onCleanup } from "solid-js" + +type CacheOptions = { + fetcher: () => Promise + intervalMs?: number +} + +export const useCachedData = (options: CacheOptions) => { + const [data, setData] = createSignal(null) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(null) + + const refresh = async () => { + setLoading(true) + setError(null) + try { + const value = await options.fetcher() + setData(() => value) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load data") + } finally { + setLoading(false) + } + } + + refresh() + + if (options.intervalMs) { + const interval = setInterval(refresh, options.intervalMs) + onCleanup(() => clearInterval(interval)) + } + + return { data, loading, error, refresh } +} diff --git a/src/stores/app.ts b/src/stores/app.ts new file mode 100644 index 0000000..dc26c0f --- /dev/null +++ b/src/stores/app.ts @@ -0,0 +1,109 @@ +import { createSignal } from "solid-js" +import { DEFAULT_THEME, THEMES } from "../constants/themes" +import type { AppSettings, AppState, ThemeColors, ThemeName, UserPreferences } from "../types/settings" + +const STORAGE_KEY = "podtui_app_state" + +const defaultSettings: AppSettings = { + theme: "system", + fontSize: 14, + playbackSpeed: 1, + downloadPath: "", +} + +const defaultPreferences: UserPreferences = { + showExplicit: false, + autoDownload: false, +} + +const defaultState: AppState = { + settings: defaultSettings, + preferences: defaultPreferences, + customTheme: DEFAULT_THEME, +} + +const loadState = (): AppState => { + if (typeof localStorage === "undefined") return defaultState + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return defaultState + const parsed = JSON.parse(raw) as Partial + return { + settings: { ...defaultSettings, ...parsed.settings }, + preferences: { ...defaultPreferences, ...parsed.preferences }, + customTheme: { ...DEFAULT_THEME, ...parsed.customTheme }, + } + } catch { + return defaultState + } +} + +const saveState = (state: AppState) => { + if (typeof localStorage === "undefined") return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch { + // ignore storage errors + } +} + +export function createAppStore() { + const [state, setState] = createSignal(loadState()) + + const updateState = (next: AppState) => { + setState(next) + saveState(next) + } + + const updateSettings = (updates: Partial) => { + const next = { + ...state(), + settings: { ...state().settings, ...updates }, + } + updateState(next) + } + + const updatePreferences = (updates: Partial) => { + const next = { + ...state(), + preferences: { ...state().preferences, ...updates }, + } + updateState(next) + } + + const updateCustomTheme = (updates: Partial) => { + const next = { + ...state(), + customTheme: { ...state().customTheme, ...updates }, + } + updateState(next) + } + + const setTheme = (theme: ThemeName) => { + updateSettings({ theme }) + } + + const resolveTheme = (): ThemeColors => { + const theme = state().settings.theme + if (theme === "custom") return state().customTheme + return THEMES[theme] ?? DEFAULT_THEME + } + + return { + state, + updateSettings, + updatePreferences, + updateCustomTheme, + setTheme, + resolveTheme, + } +} + +let appStoreInstance: ReturnType | null = null + +export function useAppStore() { + if (!appStoreInstance) { + appStoreInstance = createAppStore() + } + return appStoreInstance +} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..e6068b7 --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,32 @@ +export type ThemeName = "system" | "catppuccin" | "gruvbox" | "tokyo" | "nord" | "custom" + +export type ThemeColors = { + background: string + surface: string + primary: string + secondary: string + accent: string + text: string + muted: string + warning: string + error: string + success: string +} + +export type AppSettings = { + theme: ThemeName + fontSize: number + playbackSpeed: number + downloadPath: string +} + +export type UserPreferences = { + showExplicit: boolean + autoDownload: boolean +} + +export type AppState = { + settings: AppSettings + preferences: UserPreferences + customTheme: ThemeColors +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts new file mode 100644 index 0000000..4313126 --- /dev/null +++ b/src/utils/cache.ts @@ -0,0 +1,57 @@ +type CacheEntry = { + value: T + timestamp: number +} + +const CACHE_KEY = "podtui_cache" +const DEFAULT_TTL = 1000 * 60 * 60 + +const loadCache = (): Record> => { + if (typeof localStorage === "undefined") return {} + try { + const raw = localStorage.getItem(CACHE_KEY) + return raw ? (JSON.parse(raw) as Record>) : {} + } catch { + return {} + } +} + +const saveCache = (cache: Record>) => { + if (typeof localStorage === "undefined") return + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) + } catch { + // ignore + } +} + +const cache = loadCache() + +export const cacheValue = (key: string, value: T) => { + cache[key] = { value, timestamp: Date.now() } + saveCache(cache) +} + +export const getCachedValue = (key: string, ttl = DEFAULT_TTL): T | null => { + const entry = cache[key] as CacheEntry | undefined + if (!entry) return null + if (Date.now() - entry.timestamp > ttl) { + delete cache[key] + saveCache(cache) + return null + } + return entry.value +} + +export const invalidateCache = (prefix?: string) => { + if (!prefix) { + Object.keys(cache).forEach((key) => delete cache[key]) + saveCache(cache) + return + } + + Object.keys(cache) + .filter((key) => key.startsWith(prefix)) + .forEach((key) => delete cache[key]) + saveCache(cache) +} diff --git a/src/utils/data-fetcher.ts b/src/utils/data-fetcher.ts new file mode 100644 index 0000000..f4dd6bf --- /dev/null +++ b/src/utils/data-fetcher.ts @@ -0,0 +1,57 @@ +import { FeedVisibility } from "../types/feed" +import type { Feed } from "../types/feed" +import type { Episode } from "../types/episode" +import type { Podcast } from "../types/podcast" +import { cacheValue, getCachedValue } from "./cache" +import { fetchEpisodes } from "@/api/client" + +const feedKey = (feedUrl: string) => `feed:${feedUrl}` +const episodesKey = (feedUrl: string) => `episodes:${feedUrl}` +const searchKey = (query: string) => `search:${query.toLowerCase()}` + +export const fetchFeedWithCache = async (feedUrl: string): Promise => { + const cached = getCachedValue(feedKey(feedUrl)) + if (cached) return cached + try { + const episodes = await fetchEpisodes(feedUrl) + const feed: Feed = { + id: feedUrl, + podcast: { + id: feedUrl, + title: feedUrl, + description: "", + feedUrl, + lastUpdated: new Date(), + isSubscribed: true, + }, + episodes, + visibility: FeedVisibility.PUBLIC, + sourceId: "rss", + lastUpdated: new Date(), + isPinned: false, + } + cacheValue(feedKey(feedUrl), feed) + return feed + } catch { + return null + } +} + +export const fetchEpisodesWithCache = async (feedUrl: string): Promise => { + const cached = getCachedValue(episodesKey(feedUrl)) + if (cached) return cached + const episodes = await fetchEpisodes(feedUrl) + cacheValue(episodesKey(feedUrl), episodes) + return episodes +} + +export const searchWithCache = async ( + query: string, + fetcher: () => Promise +): Promise => { + const cached = getCachedValue(searchKey(query)) + if (cached) return cached + const results = await fetcher() + cacheValue(searchKey(query), results) + return results +} diff --git a/src/utils/persistence.ts b/src/utils/persistence.ts new file mode 100644 index 0000000..2d1286c --- /dev/null +++ b/src/utils/persistence.ts @@ -0,0 +1,77 @@ +import type { AppSettings, UserPreferences } from "../types/settings" +import type { Feed } from "../types/feed" + +const STORAGE_KEYS = { + settings: "podtui_settings", + preferences: "podtui_preferences", + feeds: "podtui_feeds", +} + +export const savePreference = (key: keyof UserPreferences, value: boolean) => { + const current = loadPreferences() + const next = { ...current, [key]: value } + savePreferences(next) +} + +export const loadPreference = (key: keyof UserPreferences) => { + return loadPreferences()[key] +} + +export const saveSettings = (settings: AppSettings) => { + if (typeof localStorage === "undefined") return + try { + localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings)) + } catch { + // ignore + } +} + +export const loadSettings = (): AppSettings | null => { + if (typeof localStorage === "undefined") return null + try { + const raw = localStorage.getItem(STORAGE_KEYS.settings) + return raw ? (JSON.parse(raw) as AppSettings) : null + } catch { + return null + } +} + +export const savePreferences = (preferences: UserPreferences) => { + if (typeof localStorage === "undefined") return + try { + localStorage.setItem(STORAGE_KEYS.preferences, JSON.stringify(preferences)) + } catch { + // ignore + } +} + +export const loadPreferences = (): UserPreferences => { + if (typeof localStorage === "undefined") { + return { showExplicit: false, autoDownload: false } + } + try { + const raw = localStorage.getItem(STORAGE_KEYS.preferences) + return raw ? (JSON.parse(raw) as UserPreferences) : { showExplicit: false, autoDownload: false } + } catch { + return { showExplicit: false, autoDownload: false } + } +} + +export const saveFeeds = (feeds: Feed[]) => { + if (typeof localStorage === "undefined") return + try { + localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds)) + } catch { + // ignore + } +} + +export const loadFeeds = (): Feed[] => { + if (typeof localStorage === "undefined") return [] + try { + const raw = localStorage.getItem(STORAGE_KEYS.feeds) + return raw ? (JSON.parse(raw) as Feed[]) : [] + } catch { + return [] + } +} diff --git a/src/utils/waveform.ts b/src/utils/waveform.ts new file mode 100644 index 0000000..74b854f --- /dev/null +++ b/src/utils/waveform.ts @@ -0,0 +1,8 @@ +export const createWaveform = (width: number): number[] => { + const data: number[] = [] + for (let i = 0; i < width; i += 1) { + const value = 0.2 + Math.abs(Math.sin(i / 3)) * 0.8 + data.push(Number(value.toFixed(2))) + } + return data +}