checkpoint
This commit is contained in:
40
src/App.tsx
40
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<AuthScreen>("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,11 +36,21 @@ export function App() {
|
||||
},
|
||||
onTabChange: setActiveTab,
|
||||
inputFocused: inputFocused(),
|
||||
navigationEnabled: layerDepth() === 0,
|
||||
onAction: (action) => {
|
||||
if (action === "escape") {
|
||||
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 (
|
||||
<FeedList
|
||||
focused={true}
|
||||
focused={layerDepth() > 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 (
|
||||
<SyncProfile
|
||||
focused={true}
|
||||
focused={layerDepth() > 0}
|
||||
onLogout={() => {
|
||||
auth.logout();
|
||||
setShowAuthPanel(false);
|
||||
@@ -77,14 +91,14 @@ export function App() {
|
||||
case "code":
|
||||
return (
|
||||
<CodeValidation
|
||||
focused={true}
|
||||
focused={layerDepth() > 0}
|
||||
onBack={() => setAuthScreen("login")}
|
||||
/>
|
||||
);
|
||||
case "oauth":
|
||||
return (
|
||||
<OAuthPlaceholder
|
||||
focused={true}
|
||||
focused={layerDepth() > 0}
|
||||
onBack={() => setAuthScreen("login")}
|
||||
onNavigateToCode={() => setAuthScreen("code")}
|
||||
/>
|
||||
@@ -93,7 +107,7 @@ export function App() {
|
||||
default:
|
||||
return (
|
||||
<LoginScreen
|
||||
focused={true}
|
||||
focused={layerDepth() > 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 <DiscoverPage focused={!inputFocused()} />;
|
||||
return (
|
||||
<DiscoverPage
|
||||
focused={layerDepth() > 0}
|
||||
onExit={() => setLayerDepth(0)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "search":
|
||||
return (
|
||||
<SearchPage
|
||||
focused={!inputFocused()}
|
||||
focused={layerDepth() > 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 <Player focused={!inputFocused()} />;
|
||||
return (
|
||||
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
@@ -158,6 +181,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
theme={appStore.resolveTheme()}
|
||||
header={
|
||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||
}
|
||||
|
||||
73
src/api/client.ts
Normal file
73
src/api/client.ts
Normal 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
53
src/api/rss-parser.ts
Normal 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
94
src/api/source-handler.ts
Normal 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)]
|
||||
}
|
||||
@@ -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) {
|
||||
<text fg="gray">[Tab] Switch focus</text>
|
||||
<text fg="gray">[j/k] Navigate</text>
|
||||
<text fg="gray">[Enter] Subscribe</text>
|
||||
<text fg="gray">[Esc] Up</text>
|
||||
<text fg="gray">[R] Refresh</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -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 */}
|
||||
<box paddingTop={0}>
|
||||
<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>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -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 (
|
||||
<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>}
|
||||
<box style={{ flexGrow: 1 }}>{props.children}</box>
|
||||
{props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>}
|
||||
|
||||
34
src/components/PlaybackControls.tsx
Normal file
34
src/components/PlaybackControls.tsx
Normal 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
114
src/components/Player.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/components/PreferencesPanel.tsx
Normal file
130
src/components/PreferencesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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") {
|
||||
if (area === "input") {
|
||||
props.onExit?.()
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
props.onInputFocusChange?.(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -261,7 +269,7 @@ export function SearchPage(props: SearchPageProps) {
|
||||
<text fg="gray">[Tab] Switch focus</text>
|
||||
<text fg="gray">[/] Focus search</text>
|
||||
<text fg="gray">[Enter] Select</text>
|
||||
<text fg="gray">[Esc] Back to search</text>
|
||||
<text fg="gray">[Esc] Up</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
94
src/components/SettingsScreen.tsx
Normal file
94
src/components/SettingsScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/Waveform.tsx
Normal file
52
src/components/Waveform.tsx
Normal 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
67
src/constants/themes.ts
Normal 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,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
34
src/hooks/useCachedData.ts
Normal file
34
src/hooks/useCachedData.ts
Normal 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
109
src/stores/app.ts
Normal 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
32
src/types/settings.ts
Normal 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
57
src/utils/cache.ts
Normal 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
57
src/utils/data-fetcher.ts
Normal 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
77
src/utils/persistence.ts
Normal 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
8
src/utils/waveform.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user