checkpoint

This commit is contained in:
Michael Freno
2026-02-04 12:10:30 -05:00
parent b8549777ba
commit cdabf2c3e0
22 changed files with 1176 additions and 18 deletions

View File

@@ -13,6 +13,7 @@ import { Player } from "./components/Player";
import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsScreen } from "./components/SettingsScreen";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "./stores/auth";
import { useFeedStore } from "./stores/feed"; import { useFeedStore } from "./stores/feed";
import { useAppStore } from "./stores/app";
import { FeedVisibility } from "./types/feed"; import { FeedVisibility } from "./types/feed";
import { useAppKeyboard } from "./hooks/useAppKeyboard"; import { useAppKeyboard } from "./hooks/useAppKeyboard";
import type { TabId } from "./components/Tab"; import type { TabId } from "./components/Tab";
@@ -23,8 +24,10 @@ export function App() {
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login"); const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
const [showAuthPanel, setShowAuthPanel] = createSignal(false); const [showAuthPanel, setShowAuthPanel] = createSignal(false);
const [inputFocused, setInputFocused] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false);
const [layerDepth, setLayerDepth] = createSignal(0);
const auth = useAuthStore(); const auth = useAuthStore();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const appStore = useAppStore();
// Centralized keyboard handler for all tab navigation and shortcuts // Centralized keyboard handler for all tab navigation and shortcuts
useAppKeyboard({ useAppKeyboard({
@@ -33,10 +36,20 @@ export function App() {
}, },
onTabChange: setActiveTab, onTabChange: setActiveTab,
inputFocused: inputFocused(), inputFocused: inputFocused(),
navigationEnabled: layerDepth() === 0,
onAction: (action) => { onAction: (action) => {
if (action === "escape") { if (action === "escape") {
setShowAuthPanel(false); if (layerDepth() > 0) {
setInputFocused(false); 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": case "feeds":
return ( return (
<FeedList <FeedList
focused={true} focused={layerDepth() > 0}
showEpisodeCount={true} showEpisodeCount={true}
showLastUpdated={true} showLastUpdated={true}
onFocusChange={() => setLayerDepth(0)}
onOpenFeed={(feed) => { onOpenFeed={(feed) => {
// Would open feed detail view // Would open feed detail view
}} }}
@@ -63,7 +77,7 @@ export function App() {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
return ( return (
<SyncProfile <SyncProfile
focused={true} focused={layerDepth() > 0}
onLogout={() => { onLogout={() => {
auth.logout(); auth.logout();
setShowAuthPanel(false); setShowAuthPanel(false);
@@ -77,14 +91,14 @@ export function App() {
case "code": case "code":
return ( return (
<CodeValidation <CodeValidation
focused={true} focused={layerDepth() > 0}
onBack={() => setAuthScreen("login")} onBack={() => setAuthScreen("login")}
/> />
); );
case "oauth": case "oauth":
return ( return (
<OAuthPlaceholder <OAuthPlaceholder
focused={true} focused={layerDepth() > 0}
onBack={() => setAuthScreen("login")} onBack={() => setAuthScreen("login")}
onNavigateToCode={() => setAuthScreen("code")} onNavigateToCode={() => setAuthScreen("code")}
/> />
@@ -93,7 +107,7 @@ export function App() {
default: default:
return ( return (
<LoginScreen <LoginScreen
focused={true} focused={layerDepth() > 0}
onNavigateToCode={() => setAuthScreen("code")} onNavigateToCode={() => setAuthScreen("code")}
onNavigateToOAuth={() => setAuthScreen("oauth")} onNavigateToOAuth={() => setAuthScreen("oauth")}
/> />
@@ -110,17 +124,24 @@ export function App() {
: "Not signed in" : "Not signed in"
} }
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"} accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
onExit={() => setLayerDepth(0)}
/> />
); );
case "discover": case "discover":
return <DiscoverPage focused={!inputFocused()} />; return (
<DiscoverPage
focused={layerDepth() > 0}
onExit={() => setLayerDepth(0)}
/>
);
case "search": case "search":
return ( return (
<SearchPage <SearchPage
focused={!inputFocused()} focused={layerDepth() > 0}
onInputFocusChange={setInputFocused} onInputFocusChange={setInputFocused}
onExit={() => setLayerDepth(0)}
onSubscribe={(result) => { onSubscribe={(result) => {
const feeds = feedStore.feeds(); const feeds = feedStore.feeds();
const alreadySubscribed = feeds.some( const alreadySubscribed = feeds.some(
@@ -141,7 +162,9 @@ export function App() {
); );
case "player": case "player":
return <Player focused={!inputFocused()} />; return (
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
);
default: default:
return ( return (
@@ -158,6 +181,7 @@ export function App() {
return ( return (
<Layout <Layout
theme={appStore.resolveTheme()}
header={ header={
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} /> <TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
} }

73
src/api/client.ts Normal file
View File

@@ -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<Episode[]> => {
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<Feed[]> => {
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<Podcast[]> => {
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
}

53
src/api/rss-parser.ts Normal file
View File

@@ -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]*?)</${tag}>`, "i"))
return match?.[1]?.trim() ?? ""
}
const decodeEntities = (value: string) =>
value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/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(/<item[\s\S]*?<\/item>/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(/<enclosure[^>]*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,
}
}

94
src/api/source-handler.ts Normal file
View File

@@ -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<Feed[]> => {
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<Feed[]> => {
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<Feed[]> => {
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)]
}

View File

@@ -10,6 +10,7 @@ import { TrendingShows } from "./TrendingShows"
type DiscoverPageProps = { type DiscoverPageProps = {
focused: boolean focused: boolean
onExit?: () => void
} }
type FocusArea = "categories" | "shows" type FocusArea = "categories" | "shows"
@@ -36,6 +37,11 @@ export function DiscoverPage(props: DiscoverPageProps) {
return return
} }
if (key.name === "enter" && area === "categories") {
setFocusArea("shows")
return
}
// Category navigation // Category navigation
if (area === "categories") { if (area === "categories") {
if (key.name === "left" || key.name === "h") { 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' // Refresh with 'r'
if (key.name === "r") { if (key.name === "r") {
discoverStore.refresh() discoverStore.refresh()
@@ -177,6 +192,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
<text fg="gray">[Tab] Switch focus</text> <text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[j/k] Navigate</text> <text fg="gray">[j/k] Navigate</text>
<text fg="gray">[Enter] Subscribe</text> <text fg="gray">[Enter] Subscribe</text>
<text fg="gray">[Esc] Up</text>
<text fg="gray">[R] Refresh</text> <text fg="gray">[R] Refresh</text>
</box> </box>
</box> </box>

View File

@@ -17,6 +17,7 @@ interface FeedListProps {
showLastUpdated?: boolean showLastUpdated?: boolean
onSelectFeed?: (feed: Feed) => void onSelectFeed?: (feed: Feed) => void
onOpenFeed?: (feed: Feed) => void onOpenFeed?: (feed: Feed) => void
onFocusChange?: (focused: boolean) => void
} }
export function FeedList(props: FeedListProps) { export function FeedList(props: FeedListProps) {
@@ -26,6 +27,10 @@ export function FeedList(props: FeedListProps) {
const filteredFeeds = () => feedStore.getFilteredFeeds() const filteredFeeds = () => feedStore.getFilteredFeeds()
const handleKeyPress = (key: { name: string }) => { const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false)
return
}
const feeds = filteredFeeds() const feeds = filteredFeeds()
if (key.name === "up" || key.name === "k") { if (key.name === "up" || key.name === "k") {
@@ -180,7 +185,7 @@ export function FeedList(props: FeedListProps) {
{/* Navigation help */} {/* Navigation help */}
<box paddingTop={0}> <box paddingTop={0}>
<text fg="gray"> <text fg="gray">
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
</text> </text>
</box> </box>
</box> </box>

View File

@@ -1,14 +1,21 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import type { ThemeColors } from "../types/settings"
type LayoutProps = { type LayoutProps = {
header?: JSX.Element header?: JSX.Element
footer?: JSX.Element footer?: JSX.Element
children?: JSX.Element children?: JSX.Element
theme?: ThemeColors
} }
export function Layout(props: LayoutProps) { export function Layout(props: LayoutProps) {
return ( return (
<box style={{ flexDirection: "column", width: "100%", height: "100%" }}> <box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={props.theme?.background}
>
{props.header ? <box style={{ height: 3 }}>{props.header}</box> : <text></text>} {props.header ? <box style={{ height: 3 }}>{props.header}</box> : <text></text>}
<box style={{ flexGrow: 1 }}>{props.children}</box> <box style={{ flexGrow: 1 }}>{props.children}</box>
{props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>} {props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>}

View File

@@ -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 (
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
<box border padding={0} onMouseDown={props.onPrev}>
<text fg="cyan">[Prev]</text>
</box>
<box border padding={0} onMouseDown={props.onToggle}>
<text fg="cyan">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box border padding={0} onMouseDown={props.onNext}>
<text fg="cyan">[Next]</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg="gray">Vol</text>
<text fg="white">{Math.round(props.volume * 100)}%</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg="gray">Speed</text>
<text fg="white">{props.speed}x</text>
</box>
</box>
)
}

114
src/components/Player.tsx Normal file
View File

@@ -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 (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Now Playing</strong>
</text>
<text fg="gray">
Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")}
</text>
</box>
<box border padding={1} flexDirection="column" gap={1}>
<text fg="white">
<strong>{SAMPLE_EPISODE.title}</strong>
</text>
<text fg="gray">{SAMPLE_EPISODE.description}</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Progress:</text>
<box flexGrow={1} height={1} backgroundColor="#2a2f3a">
<box
width={`${progressPercent()}%`}
height={1}
backgroundColor={isPlaying() ? "#6fa8ff" : "#7d8590"}
/>
</box>
<text fg="gray">{progressPercent()}%</text>
</box>
<Waveform
data={waveform()}
position={position()}
duration={SAMPLE_EPISODE.duration}
isPlaying={isPlaying()}
onSeek={(next: number) => setPosition(next)}
/>
</box>
</box>
<PlaybackControls
isPlaying={isPlaying()}
volume={volume()}
speed={speed()}
onToggle={() => setIsPlaying((value: boolean) => !value)}
onPrev={() => setPosition(0)}
onNext={() => setPosition(SAMPLE_EPISODE.duration)}
onSpeedChange={setSpeed}
onVolumeChange={setVolume}
/>
<text fg="gray">Enter dive | Esc up | Space play/pause | Left/Right seek</text>
</box>
)
}

View File

@@ -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<FocusField>("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 (
<box flexDirection="column" gap={1}>
<text fg="gray">Preferences</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "theme" ? "cyan" : "gray"}>Theme:</text>
<box border padding={0}>
<text fg="white">{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text>
</box>
<text fg="gray">[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "font" ? "cyan" : "gray"}>Font Size:</text>
<box border padding={0}>
<text fg="white">{settings().fontSize}px</text>
</box>
<text fg="gray">[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "speed" ? "cyan" : "gray"}>Playback:</text>
<box border padding={0}>
<text fg="white">{settings().playbackSpeed}x</text>
</box>
<text fg="gray">[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "explicit" ? "cyan" : "gray"}>Show Explicit:</text>
<box border padding={0}>
<text fg={preferences().showExplicit ? "green" : "gray"}>
{preferences().showExplicit ? "On" : "Off"}
</text>
</box>
<text fg="gray">[Space]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "auto" ? "cyan" : "gray"}>Auto Download:</text>
<box border padding={0}>
<text fg={preferences().autoDownload ? "green" : "gray"}>
{preferences().autoDownload ? "On" : "Off"}
</text>
</box>
<text fg="gray">[Space]</text>
</box>
</box>
<text fg="gray">Tab to move focus, Left/Right to adjust</text>
</box>
)
}

View File

@@ -13,6 +13,7 @@ type SearchPageProps = {
focused: boolean focused: boolean
onSubscribe?: (result: SearchResult) => void onSubscribe?: (result: SearchResult) => void
onInputFocusChange?: (focused: boolean) => void onInputFocusChange?: (focused: boolean) => void
onExit?: () => void
} }
type FocusArea = "input" | "results" | "history" type FocusArea = "input" | "results" | "history"
@@ -34,6 +35,9 @@ export function SearchPage(props: SearchPageProps) {
props.onInputFocusChange?.(false) props.onInputFocusChange?.(false)
} }
} }
if (props.focused && focusArea() === "input") {
props.onInputFocusChange?.(true)
}
} }
const handleHistorySelect = async (query: string) => { 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") { if (key.name === "escape") {
setFocusArea("input") if (area === "input") {
props.onInputFocusChange?.(true) props.onExit?.()
} else {
setFocusArea("input")
props.onInputFocusChange?.(true)
}
return return
} }
@@ -261,7 +269,7 @@ export function SearchPage(props: SearchPageProps) {
<text fg="gray">[Tab] Switch focus</text> <text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[/] Focus search</text> <text fg="gray">[/] Focus search</text>
<text fg="gray">[Enter] Select</text> <text fg="gray">[Enter] Select</text>
<text fg="gray">[Esc] Back to search</text> <text fg="gray">[Esc] Up</text>
</box> </box>
</box> </box>
) )

View File

@@ -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<SectionId>("sync")
useKeyboard((key) => {
if (key.name === "escape") {
props.onExit?.()
return
}
if (key.name === "tab") {
const idx = SECTIONS.findIndex((s) => s.id === activeSection())
const next = key.shift
? (idx - 1 + SECTIONS.length) % SECTIONS.length
: (idx + 1) % SECTIONS.length
setActiveSection(SECTIONS[next].id)
return
}
if (key.name === "1") setActiveSection("sync")
if (key.name === "2") setActiveSection("sources")
if (key.name === "3") setActiveSection("preferences")
if (key.name === "4") setActiveSection("account")
})
return (
<box flexDirection="column" gap={1} height="100%">
<box flexDirection="row" justifyContent="space-between" alignItems="center">
<text>
<strong>Settings</strong>
</text>
<text fg="gray">[Tab] Switch section | 1-4 jump | Esc up</text>
</box>
<box flexDirection="row" gap={1}>
{SECTIONS.map((section, index) => (
<box
border
padding={0}
backgroundColor={activeSection() === section.id ? "#2b303b" : undefined}
onMouseDown={() => setActiveSection(section.id)}
>
<text fg={activeSection() === section.id ? "cyan" : "gray"}>
[{index + 1}] {section.label}
</text>
</box>
))}
</box>
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
{activeSection() === "sync" && <SyncPanel />}
{activeSection() === "sources" && <SourceManager focused />}
{activeSection() === "preferences" && <PreferencesPanel />}
{activeSection() === "account" && (
<box flexDirection="column" gap={1}>
<text fg="gray">Account</text>
<box flexDirection="row" gap={2} alignItems="center">
<text fg="gray">Status:</text>
<text fg={props.accountStatus === "signed-in" ? "green" : "yellow"}>
{props.accountLabel}
</text>
</box>
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
<text fg="cyan">[A] Manage Account</text>
</box>
</box>
)}
</box>
<text fg="gray">Enter to dive | Esc up</text>
</box>
)
}

View File

@@ -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 (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{played || " "}</text>
<text fg={futureColor}>{upcoming || " "}</text>
</box>
)
}
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 (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
)
}

67
src/constants/themes.ts Normal file
View File

@@ -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<ThemeName, ThemeColors> = {
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,
}

View File

@@ -13,6 +13,7 @@ type ShortcutOptions = {
onTabChange: (tab: TabId) => void onTabChange: (tab: TabId) => void
onAction?: (action: string) => void onAction?: (action: string) => void
inputFocused?: boolean inputFocused?: boolean
navigationEnabled?: boolean
} }
export function useAppKeyboard(options: ShortcutOptions) { export function useAppKeyboard(options: ShortcutOptions) {
@@ -35,11 +36,25 @@ export function useAppKeyboard(options: ShortcutOptions) {
return return
} }
if (key.name === "escape") {
options.onAction?.("escape")
return
}
// Skip global shortcuts if input is focused (let input handle keys) // Skip global shortcuts if input is focused (let input handle keys)
if (options.inputFocused) { if (options.inputFocused) {
return return
} }
if (options.navigationEnabled === false) {
return
}
if (key.name === "enter") {
options.onAction?.("enter")
return
}
// Tab navigation with left/right arrows OR [ and ] // Tab navigation with left/right arrows OR [ and ]
if (key.name === "right" || key.name === "]") { if (key.name === "right" || key.name === "]") {
options.onTabChange(getNextTab(options.activeTab)) options.onTabChange(getNextTab(options.activeTab))
@@ -89,8 +104,6 @@ export function useAppKeyboard(options: ShortcutOptions) {
options.onAction("save") options.onAction("save")
} else if (key.ctrl && key.name === "f") { } else if (key.ctrl && key.name === "f") {
options.onAction("find") options.onAction("find")
} else if (key.name === "escape") {
options.onAction("escape")
} else if (key.name === "?" || (key.shift && key.name === "/")) { } else if (key.name === "?" || (key.shift && key.name === "/")) {
options.onAction("help") options.onAction("help")
} }

View File

@@ -0,0 +1,34 @@
import { createSignal, onCleanup } from "solid-js"
type CacheOptions<T> = {
fetcher: () => Promise<T>
intervalMs?: number
}
export const useCachedData = <T,>(options: CacheOptions<T>) => {
const [data, setData] = createSignal<T | null>(null)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(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 }
}

109
src/stores/app.ts Normal file
View File

@@ -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<AppState>
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<AppState>(loadState())
const updateState = (next: AppState) => {
setState(next)
saveState(next)
}
const updateSettings = (updates: Partial<AppSettings>) => {
const next = {
...state(),
settings: { ...state().settings, ...updates },
}
updateState(next)
}
const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = {
...state(),
preferences: { ...state().preferences, ...updates },
}
updateState(next)
}
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
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<typeof createAppStore> | null = null
export function useAppStore() {
if (!appStoreInstance) {
appStoreInstance = createAppStore()
}
return appStoreInstance
}

32
src/types/settings.ts Normal file
View File

@@ -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
}

57
src/utils/cache.ts Normal file
View File

@@ -0,0 +1,57 @@
type CacheEntry<T> = {
value: T
timestamp: number
}
const CACHE_KEY = "podtui_cache"
const DEFAULT_TTL = 1000 * 60 * 60
const loadCache = (): Record<string, CacheEntry<unknown>> => {
if (typeof localStorage === "undefined") return {}
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
} catch {
return {}
}
}
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
const cache = loadCache()
export const cacheValue = <T,>(key: string, value: T) => {
cache[key] = { value, timestamp: Date.now() }
saveCache(cache)
}
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
const entry = cache[key] as CacheEntry<T> | 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)
}

57
src/utils/data-fetcher.ts Normal file
View File

@@ -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<Feed | null> => {
const cached = getCachedValue<Feed>(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<Episode[]> => {
const cached = getCachedValue<Episode[]>(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<Podcast[]>
): Promise<Podcast[]> => {
const cached = getCachedValue<Podcast[]>(searchKey(query))
if (cached) return cached
const results = await fetcher()
cacheValue(searchKey(query), results)
return results
}

77
src/utils/persistence.ts Normal file
View File

@@ -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 []
}
}

8
src/utils/waveform.ts Normal file
View File

@@ -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
}