From 6b00871c326641a9c72828d955bdbf613ccfc375 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 5 Feb 2026 21:18:44 -0500 Subject: [PATCH] working playback --- src/App.tsx | 22 +++- src/api/rss-parser.ts | 86 +++++++++++++- src/components/DiscoverPage.tsx | 7 +- src/components/MyShowsPage.tsx | 1 + src/components/PreferencesPanel.tsx | 2 +- src/components/SearchPage.tsx | 32 ++---- src/context/KeybindContext.tsx | 5 + src/hooks/useAppKeyboard.ts | 2 +- src/hooks/useAudio.ts | 43 ++++++- src/stores/progress.ts | 168 ++++++++++++++++++++++++++++ 10 files changed, 331 insertions(+), 37 deletions(-) create mode 100644 src/stores/progress.ts diff --git a/src/App.tsx b/src/App.tsx index 280dc7e..ec0e963 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,12 @@ import { SettingsScreen } from "./components/SettingsScreen"; import { useAuthStore } from "./stores/auth"; import { useFeedStore } from "./stores/feed"; import { useAppStore } from "./stores/app"; +import { useAudio } from "./hooks/useAudio"; import { FeedVisibility } from "./types/feed"; import { useAppKeyboard } from "./hooks/useAppKeyboard"; import type { TabId } from "./components/Tab"; import type { AuthScreen } from "./types/auth"; +import type { Episode } from "./types/episode"; export function App() { const [activeTab, setActiveTab] = createSignal("feed"); @@ -29,12 +31,19 @@ export function App() { const auth = useAuthStore(); const feedStore = useFeedStore(); const appStore = useAppStore(); + const audio = useAudio(); + + const handlePlayEpisode = (episode: Episode) => { + audio.play(episode); + setActiveTab("player"); + setLayerDepth(1); + }; // My Shows page returns panel renderers const myShows = MyShowsPage({ get focused() { return activeTab() === "shows" && layerDepth() > 0 }, onPlayEpisode: (episode, feed) => { - // TODO: play episode + handlePlayEpisode(episode); }, onExit: () => setLayerDepth(0), }); @@ -44,9 +53,12 @@ export function App() { get activeTab() { return activeTab(); }, - onTabChange: setActiveTab, - inputFocused: inputFocused(), - navigationEnabled: layerDepth() === 0, + onTabChange: (tab: TabId) => { + setActiveTab(tab); + setInputFocused(false); + }, + get inputFocused() { return inputFocused() }, + get navigationEnabled() { return layerDepth() === 0 }, layerDepth, onLayerChange: (newDepth) => { setLayerDepth(newDepth); @@ -81,7 +93,7 @@ export function App() { 0} onPlayEpisode={(episode, feed) => { - // TODO: play episode + handlePlayEpisode(episode); }} onExit={() => setLayerDepth(0)} /> diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index 744d026..c451d05 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -1,11 +1,19 @@ import type { Podcast } from "../types/podcast" -import type { Episode } from "../types/episode" +import type { Episode, EpisodeType } from "../types/episode" const getTagValue = (xml: string, tag: string): string => { - const match = xml.match(new RegExp(`<${tag}[^>]*>([\s\S]*?)`, "i")) + const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, "i")) return match?.[1]?.trim() ?? "" } +/** Get an attribute value from a self-closing or open tag */ +const getAttr = (xml: string, tag: string, attr: string): string => { + const tagMatch = xml.match(new RegExp(`<${tag}[^>]*>`, "i")) + if (!tagMatch) return "" + const attrMatch = tagMatch[0].match(new RegExp(`${attr}\\s*=\\s*["']([^"']*)["']`, "i")) + return attrMatch?.[1] ?? "" +} + const decodeEntities = (value: string) => value .replace(/</g, "<") @@ -14,6 +22,42 @@ const decodeEntities = (value: string) => .replace(/"/g, '"') .replace(/'/g, "'") +/** + * Parse an itunes:duration value which can be: + * - "HH:MM:SS" + * - "MM:SS" + * - seconds as a plain number string (e.g. "1234") + * Returns duration in seconds, or 0 if unparseable. + */ +const parseDuration = (raw: string): number => { + if (!raw) return 0 + const trimmed = raw.trim() + + // Pure numeric (seconds) + if (/^\d+$/.test(trimmed)) { + return parseInt(trimmed, 10) + } + + // HH:MM:SS or MM:SS + const parts = trimmed.split(":").map(Number) + if (parts.some(isNaN)) return 0 + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2] + } + if (parts.length === 2) { + return parts[0] * 60 + parts[1] + } + return 0 +} + +const parseEpisodeType = (raw: string): EpisodeType | undefined => { + const lower = raw.trim().toLowerCase() + if (lower === "trailer") return "trailer" as EpisodeType + if (lower === "bonus") return "bonus" as EpisodeType + if (lower === "full") return "full" as EpisodeType + return undefined +} + 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" @@ -26,18 +70,52 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}` const epDescription = decodeEntities(getTagValue(item, "description")) const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) + + // Audio URL + file size + MIME type from const enclosure = item.match(/]*url=["']([^"']+)["'][^>]*>/i) const audioUrl = enclosure?.[1] ?? "" + const fileSizeStr = getAttr(item, "enclosure", "length") + const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined + const mimeType = getAttr(item, "enclosure", "type") || undefined - return { + // Duration from + const durationRaw = getTagValue(item, "itunes:duration") + const duration = parseDuration(durationRaw) + + // Episode & season numbers + const episodeNumRaw = getTagValue(item, "itunes:episode") + const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined + const seasonNumRaw = getTagValue(item, "itunes:season") + const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined + + // Episode type & explicit + const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType")) + const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase() + const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined + + // Episode image (itunes:image has href attribute) + const imageUrl = getAttr(item, "itunes:image", "href") || undefined + + const ep: Episode = { id: `${feedUrl}#${index}`, podcastId: feedUrl, title: epTitle, description: epDescription, audioUrl, - duration: 0, + duration, pubDate, } + + // Only set optional fields if present + if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber + if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber + if (episodeType) ep.episodeType = episodeType + if (explicit !== undefined) ep.explicit = explicit + if (imageUrl) ep.imageUrl = imageUrl + if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize + if (mimeType) ep.mimeType = mimeType + + return ep }) return { diff --git a/src/components/DiscoverPage.tsx b/src/components/DiscoverPage.tsx index 248d59e..309dbce 100644 --- a/src/components/DiscoverPage.tsx +++ b/src/components/DiscoverPage.tsx @@ -37,7 +37,7 @@ export function DiscoverPage(props: DiscoverPageProps) { return } - if (key.name === "enter" && area === "categories") { + if ((key.name === "return" || key.name === "enter") && area === "categories") { setFocusArea("shows") return } @@ -60,7 +60,7 @@ export function DiscoverPage(props: DiscoverPageProps) { setShowIndex(0) return } - if (key.name === "enter") { + if (key.name === "return" || key.name === "enter") { // Select category and move to shows setFocusArea("shows") return @@ -92,7 +92,7 @@ export function DiscoverPage(props: DiscoverPageProps) { } return } - if (key.name === "enter") { + if (key.name === "return" || key.name === "enter") { // Subscribe/unsubscribe const podcast = shows[showIndex()] if (podcast) { @@ -105,6 +105,7 @@ export function DiscoverPage(props: DiscoverPageProps) { if (key.name === "escape") { if (area === "shows") { setFocusArea("categories") + key.stopPropagation() } else { props.onExit?.() } diff --git a/src/components/MyShowsPage.tsx b/src/components/MyShowsPage.tsx index 22a5bd2..15c1051 100644 --- a/src/components/MyShowsPage.tsx +++ b/src/components/MyShowsPage.tsx @@ -136,6 +136,7 @@ export function MyShowsPage(props: MyShowsPageProps) { handleRefresh() } else if (key.name === "escape") { setFocusPane("shows") + key.stopPropagation() } } }) diff --git a/src/components/PreferencesPanel.tsx b/src/components/PreferencesPanel.tsx index 1a79bca..82968c3 100644 --- a/src/components/PreferencesPanel.tsx +++ b/src/components/PreferencesPanel.tsx @@ -40,7 +40,7 @@ export function PreferencesPanel() { if (key.name === "right" || key.name === "l") { stepValue(1) } - if (key.name === "space" || key.name === "enter") { + if (key.name === "space" || key.name === "return" || key.name === "enter") { toggleValue() } } diff --git a/src/components/SearchPage.tsx b/src/components/SearchPage.tsx index 93f6ef8..e44507c 100644 --- a/src/components/SearchPage.tsx +++ b/src/components/SearchPage.tsx @@ -2,7 +2,7 @@ * SearchPage component - Main search interface for PodTUI */ -import { createSignal, Show } from "solid-js" +import { createSignal, createEffect, Show } from "solid-js" import { useKeyboard } from "@opentui/solid" import { useSearchStore } from "../stores/search" import { SearchResults } from "./SearchResults" @@ -25,6 +25,12 @@ export function SearchPage(props: SearchPageProps) { const [resultIndex, setResultIndex] = createSignal(0) const [historyIndex, setHistoryIndex] = createSignal(0) + // Keep parent informed about input focus state + createEffect(() => { + const isInputFocused = props.focused && focusArea() === "input" + props.onInputFocusChange?.(isInputFocused) + }) + const handleSearch = async () => { const query = inputValue().trim() if (query) { @@ -32,12 +38,8 @@ export function SearchPage(props: SearchPageProps) { if (searchStore.results().length > 0) { setFocusArea("results") setResultIndex(0) - props.onInputFocusChange?.(false) } } - if (props.focused && focusArea() === "input") { - props.onInputFocusChange?.(true) - } } const handleHistorySelect = async (query: string) => { @@ -61,7 +63,7 @@ export function SearchPage(props: SearchPageProps) { const area = focusArea() // Enter to search from input - if (key.name === "enter" && area === "input") { + if ((key.name === "return" || key.name === "enter") && area === "input") { handleSearch() return } @@ -71,21 +73,17 @@ export function SearchPage(props: SearchPageProps) { if (area === "input") { if (searchStore.results().length > 0) { setFocusArea("results") - props.onInputFocusChange?.(false) } else if (searchStore.history().length > 0) { setFocusArea("history") - props.onInputFocusChange?.(false) } } else if (area === "results") { if (searchStore.history().length > 0) { setFocusArea("history") } else { setFocusArea("input") - props.onInputFocusChange?.(true) } } else { setFocusArea("input") - props.onInputFocusChange?.(true) } return } @@ -94,21 +92,17 @@ export function SearchPage(props: SearchPageProps) { if (area === "input") { if (searchStore.history().length > 0) { setFocusArea("history") - props.onInputFocusChange?.(false) } else if (searchStore.results().length > 0) { setFocusArea("results") - props.onInputFocusChange?.(false) } } else if (area === "history") { if (searchStore.results().length > 0) { setFocusArea("results") } else { setFocusArea("input") - props.onInputFocusChange?.(true) } } else { setFocusArea("input") - props.onInputFocusChange?.(true) } return } @@ -124,7 +118,7 @@ export function SearchPage(props: SearchPageProps) { setResultIndex((i) => Math.max(i - 1, 0)) return } - if (key.name === "enter") { + if (key.name === "return" || key.name === "enter") { const result = results[resultIndex()] if (result) handleResultSelect(result) return @@ -141,7 +135,7 @@ export function SearchPage(props: SearchPageProps) { setHistoryIndex((i) => Math.max(i - 1, 0)) return } - if (key.name === "enter") { + if (key.name === "return" || key.name === "enter") { const query = history[historyIndex()] if (query) handleHistorySelect(query) return @@ -154,7 +148,7 @@ export function SearchPage(props: SearchPageProps) { props.onExit?.() } else { setFocusArea("input") - props.onInputFocusChange?.(true) + key.stopPropagation() } return } @@ -162,7 +156,6 @@ export function SearchPage(props: SearchPageProps) { // "/" focuses search input if (key.name === "/" && area !== "input") { setFocusArea("input") - props.onInputFocusChange?.(true) return } }) @@ -182,9 +175,6 @@ export function SearchPage(props: SearchPageProps) { value={inputValue()} onInput={(value) => { setInputValue(value) - if (props.focused && focusArea() === "input") { - props.onInputFocusChange?.(true) - } }} placeholder="Enter podcast name, topic, or author..." focused={props.focused && focusArea() === "input"} diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index 8461674..d7fca75 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -63,6 +63,11 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex // Handle leader key useKeyboard(async (evt) => { + // Don't intercept leader key when a text-editing renderable (input/textarea) + // has focus — let it handle text input (including space for the leader key). + const focused = renderer.currentFocusedRenderable + if (focused && "insertText" in focused) return + if (!store.leader && result.match("leader", evt)) { leader(true) return diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 6c7f70a..b8f1451 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -53,7 +53,7 @@ export function useAppKeyboard(options: ShortcutOptions) { return } - if (key.name === "enter") { + if (key.name === "return") { options.onAction?.("enter") return } diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 12a4bdf..f6a9f57 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -22,6 +22,7 @@ import { } from "../utils/audio-player" import { emit, on } from "../utils/event-bus" import { useAppStore } from "../stores/app" +import { useProgressStore } from "../stores/progress" import type { Episode } from "../types/episode" export interface AudioControls { @@ -53,6 +54,7 @@ export interface AudioControls { let backend: AudioBackend | null = null let pollTimer: ReturnType | null = null let refCount = 0 +let pollCount = 0 // Counts poll ticks for throttling progress saves const [isPlaying, setIsPlaying] = createSignal(false) const [position, setPosition] = createSignal(0) @@ -76,6 +78,7 @@ function ensureBackend(): AudioBackend { function startPolling(): void { stopPolling() + pollCount = 0 pollTimer = setInterval(async () => { if (!backend || !isPlaying()) return try { @@ -84,10 +87,26 @@ function startPolling(): void { setPosition(pos) if (dur > 0) setDuration(dur) + // Save progress every ~5 seconds (10 ticks * 500ms) + pollCount++ + if (pollCount % 10 === 0) { + const ep = currentEpisode() + if (ep) { + const progressStore = useProgressStore() + progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()) + } + } + // Check if backend stopped playing (track ended) if (!backend.isPlaying() && isPlaying()) { setIsPlaying(false) stopPolling() + // Save final position on track end + const ep = currentEpisode() + if (ep) { + const progressStore = useProgressStore() + progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()) + } } } catch { // Backend may have been disposed @@ -113,18 +132,27 @@ async function play(episode: Episode): Promise { try { const appStore = useAppStore() + const progressStore = useProgressStore() const storeSpeed = appStore.state().settings.playbackSpeed const vol = volume() const spd = storeSpeed || speed() + // Resume from saved progress if available and not completed + const savedProgress = progressStore.get(episode.id) + let startPos = 0 + if (savedProgress && !progressStore.isCompleted(episode.id)) { + startPos = savedProgress.position + } + await b.play(episode.audioUrl, { volume: vol, speed: spd, + startPosition: startPos > 0 ? startPos : undefined, }) setCurrentEpisode(episode) setIsPlaying(true) - setPosition(0) + setPosition(startPos) setSpeed(spd) if (episode.duration) setDuration(episode.duration) @@ -143,7 +171,12 @@ async function pause(): Promise { setIsPlaying(false) stopPolling() const ep = currentEpisode() - if (ep) emit("player.pause", { episodeId: ep.id }) + if (ep) { + // Save progress on pause + const progressStore = useProgressStore() + progressStore.update(ep.id, position(), duration(), speed()) + emit("player.pause", { episodeId: ep.id }) + } } catch (err) { setError(err instanceof Error ? err.message : "Pause failed") } @@ -173,6 +206,12 @@ async function togglePlayback(): Promise { async function stop(): Promise { if (!backend) return try { + // Save progress before stopping + const ep = currentEpisode() + if (ep) { + const progressStore = useProgressStore() + progressStore.update(ep.id, position(), duration(), speed()) + } await backend.stop() setIsPlaying(false) setPosition(0) diff --git a/src/stores/progress.ts b/src/stores/progress.ts new file mode 100644 index 0000000..22080d3 --- /dev/null +++ b/src/stores/progress.ts @@ -0,0 +1,168 @@ +/** + * Episode progress store for PodTUI + * + * Persists per-episode playback progress to localStorage. + * Tracks position, duration, completion, and last-played timestamp. + */ + +import { createSignal } from "solid-js" +import type { Progress } from "../types/episode" + +const STORAGE_KEY = "podtui_progress" + +/** Threshold (fraction 0-1) at which an episode is considered completed */ +const COMPLETION_THRESHOLD = 0.95 + +/** Minimum seconds of progress before persisting */ +const MIN_POSITION_TO_SAVE = 5 + +// --- localStorage helpers --- + +function loadProgress(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record + const result: Record = {} + for (const [key, value] of Object.entries(parsed)) { + const p = value as Record + result[key] = { + episodeId: p.episodeId as string, + position: p.position as number, + duration: p.duration as number, + timestamp: new Date(p.timestamp as string), + playbackSpeed: p.playbackSpeed as number | undefined, + } + } + return result + } catch { + return {} + } +} + +function saveProgress(data: Record): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) + } catch { + // Quota exceeded or unavailable — silently ignore + } +} + +// --- Singleton store --- + +const [progressMap, setProgressMap] = createSignal>( + loadProgress(), +) + +function persist(): void { + saveProgress(progressMap()) +} + +function createProgressStore() { + return { + /** + * Get progress for a specific episode. + */ + get(episodeId: string): Progress | undefined { + return progressMap()[episodeId] + }, + + /** + * Get all progress entries. + */ + all(): Record { + return progressMap() + }, + + /** + * Update progress for an episode. Only persists if position is meaningful. + */ + update( + episodeId: string, + position: number, + duration: number, + playbackSpeed?: number, + ): void { + if (position < MIN_POSITION_TO_SAVE && duration > 0) return + + setProgressMap((prev) => ({ + ...prev, + [episodeId]: { + episodeId, + position, + duration, + timestamp: new Date(), + playbackSpeed, + }, + })) + persist() + }, + + /** + * Check if an episode is completed. + */ + isCompleted(episodeId: string): boolean { + const p = progressMap()[episodeId] + if (!p || p.duration <= 0) return false + return p.position / p.duration >= COMPLETION_THRESHOLD + }, + + /** + * Get progress percentage (0-100) for an episode. + */ + getPercent(episodeId: string): number { + const p = progressMap()[episodeId] + if (!p || p.duration <= 0) return 0 + return Math.min(100, Math.round((p.position / p.duration) * 100)) + }, + + /** + * Mark an episode as completed (set position to duration). + */ + markCompleted(episodeId: string): void { + const p = progressMap()[episodeId] + const duration = p?.duration ?? 0 + setProgressMap((prev) => ({ + ...prev, + [episodeId]: { + episodeId, + position: duration, + duration, + timestamp: new Date(), + playbackSpeed: p?.playbackSpeed, + }, + })) + persist() + }, + + /** + * Remove progress for an episode (e.g. "mark as new"). + */ + remove(episodeId: string): void { + setProgressMap((prev) => { + const next = { ...prev } + delete next[episodeId] + return next + }) + persist() + }, + + /** + * Clear all progress data. + */ + clear(): void { + setProgressMap({}) + persist() + }, + } +} + +// Singleton instance +let instance: ReturnType | null = null + +export function useProgressStore() { + if (!instance) { + instance = createProgressStore() + } + return instance +}