working playback

This commit is contained in:
2026-02-05 21:18:44 -05:00
parent e0fa76fb32
commit 6b00871c32
10 changed files with 331 additions and 37 deletions

View File

@@ -15,10 +15,12 @@ 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 { useAppStore } from "./stores/app";
import { useAudio } from "./hooks/useAudio";
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";
import type { AuthScreen } from "./types/auth"; import type { AuthScreen } from "./types/auth";
import type { Episode } from "./types/episode";
export function App() { export function App() {
const [activeTab, setActiveTab] = createSignal<TabId>("feed"); const [activeTab, setActiveTab] = createSignal<TabId>("feed");
@@ -29,12 +31,19 @@ export function App() {
const auth = useAuthStore(); const auth = useAuthStore();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const appStore = useAppStore(); const appStore = useAppStore();
const audio = useAudio();
const handlePlayEpisode = (episode: Episode) => {
audio.play(episode);
setActiveTab("player");
setLayerDepth(1);
};
// My Shows page returns panel renderers // My Shows page returns panel renderers
const myShows = MyShowsPage({ const myShows = MyShowsPage({
get focused() { return activeTab() === "shows" && layerDepth() > 0 }, get focused() { return activeTab() === "shows" && layerDepth() > 0 },
onPlayEpisode: (episode, feed) => { onPlayEpisode: (episode, feed) => {
// TODO: play episode handlePlayEpisode(episode);
}, },
onExit: () => setLayerDepth(0), onExit: () => setLayerDepth(0),
}); });
@@ -44,9 +53,12 @@ export function App() {
get activeTab() { get activeTab() {
return activeTab(); return activeTab();
}, },
onTabChange: setActiveTab, onTabChange: (tab: TabId) => {
inputFocused: inputFocused(), setActiveTab(tab);
navigationEnabled: layerDepth() === 0, setInputFocused(false);
},
get inputFocused() { return inputFocused() },
get navigationEnabled() { return layerDepth() === 0 },
layerDepth, layerDepth,
onLayerChange: (newDepth) => { onLayerChange: (newDepth) => {
setLayerDepth(newDepth); setLayerDepth(newDepth);
@@ -81,7 +93,7 @@ export function App() {
<FeedPage <FeedPage
focused={layerDepth() > 0} focused={layerDepth() > 0}
onPlayEpisode={(episode, feed) => { onPlayEpisode={(episode, feed) => {
// TODO: play episode handlePlayEpisode(episode);
}} }}
onExit={() => setLayerDepth(0)} onExit={() => setLayerDepth(0)}
/> />

View File

@@ -1,11 +1,19 @@
import type { Podcast } from "../types/podcast" 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 getTagValue = (xml: string, tag: string): string => {
const match = xml.match(new RegExp(`<${tag}[^>]*>([\s\S]*?)</${tag}>`, "i")) const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"))
return match?.[1]?.trim() ?? "" 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) => const decodeEntities = (value: string) =>
value value
.replace(/&lt;/g, "<") .replace(/&lt;/g, "<")
@@ -14,6 +22,42 @@ const decodeEntities = (value: string) =>
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#39;/g, "'") .replace(/&#39;/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[] } => { export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = decodeEntities(getTagValue(channel, "title")) || "Untitled Podcast" 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 epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = decodeEntities(getTagValue(item, "description")) const epDescription = decodeEntities(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i) const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
const audioUrl = enclosure?.[1] ?? "" 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 <itunes:duration>
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}`, id: `${feedUrl}#${index}`,
podcastId: feedUrl, podcastId: feedUrl,
title: epTitle, title: epTitle,
description: epDescription, description: epDescription,
audioUrl, audioUrl,
duration: 0, duration,
pubDate, 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 { return {

View File

@@ -37,7 +37,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
return return
} }
if (key.name === "enter" && area === "categories") { if ((key.name === "return" || key.name === "enter") && area === "categories") {
setFocusArea("shows") setFocusArea("shows")
return return
} }
@@ -60,7 +60,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
setShowIndex(0) setShowIndex(0)
return return
} }
if (key.name === "enter") { if (key.name === "return" || key.name === "enter") {
// Select category and move to shows // Select category and move to shows
setFocusArea("shows") setFocusArea("shows")
return return
@@ -92,7 +92,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
} }
return return
} }
if (key.name === "enter") { if (key.name === "return" || key.name === "enter") {
// Subscribe/unsubscribe // Subscribe/unsubscribe
const podcast = shows[showIndex()] const podcast = shows[showIndex()]
if (podcast) { if (podcast) {
@@ -105,6 +105,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
if (key.name === "escape") { if (key.name === "escape") {
if (area === "shows") { if (area === "shows") {
setFocusArea("categories") setFocusArea("categories")
key.stopPropagation()
} else { } else {
props.onExit?.() props.onExit?.()
} }

View File

@@ -136,6 +136,7 @@ export function MyShowsPage(props: MyShowsPageProps) {
handleRefresh() handleRefresh()
} else if (key.name === "escape") { } else if (key.name === "escape") {
setFocusPane("shows") setFocusPane("shows")
key.stopPropagation()
} }
} }
}) })

View File

@@ -40,7 +40,7 @@ export function PreferencesPanel() {
if (key.name === "right" || key.name === "l") { if (key.name === "right" || key.name === "l") {
stepValue(1) stepValue(1)
} }
if (key.name === "space" || key.name === "enter") { if (key.name === "space" || key.name === "return" || key.name === "enter") {
toggleValue() toggleValue()
} }
} }

View File

@@ -2,7 +2,7 @@
* SearchPage component - Main search interface for PodTUI * 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 { useKeyboard } from "@opentui/solid"
import { useSearchStore } from "../stores/search" import { useSearchStore } from "../stores/search"
import { SearchResults } from "./SearchResults" import { SearchResults } from "./SearchResults"
@@ -25,6 +25,12 @@ export function SearchPage(props: SearchPageProps) {
const [resultIndex, setResultIndex] = createSignal(0) const [resultIndex, setResultIndex] = createSignal(0)
const [historyIndex, setHistoryIndex] = 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 handleSearch = async () => {
const query = inputValue().trim() const query = inputValue().trim()
if (query) { if (query) {
@@ -32,12 +38,8 @@ export function SearchPage(props: SearchPageProps) {
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results")
setResultIndex(0) setResultIndex(0)
props.onInputFocusChange?.(false)
} }
} }
if (props.focused && focusArea() === "input") {
props.onInputFocusChange?.(true)
}
} }
const handleHistorySelect = async (query: string) => { const handleHistorySelect = async (query: string) => {
@@ -61,7 +63,7 @@ export function SearchPage(props: SearchPageProps) {
const area = focusArea() const area = focusArea()
// Enter to search from input // Enter to search from input
if (key.name === "enter" && area === "input") { if ((key.name === "return" || key.name === "enter") && area === "input") {
handleSearch() handleSearch()
return return
} }
@@ -71,21 +73,17 @@ export function SearchPage(props: SearchPageProps) {
if (area === "input") { if (area === "input") {
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results")
props.onInputFocusChange?.(false)
} else if (searchStore.history().length > 0) { } else if (searchStore.history().length > 0) {
setFocusArea("history") setFocusArea("history")
props.onInputFocusChange?.(false)
} }
} else if (area === "results") { } else if (area === "results") {
if (searchStore.history().length > 0) { if (searchStore.history().length > 0) {
setFocusArea("history") setFocusArea("history")
} else { } else {
setFocusArea("input") setFocusArea("input")
props.onInputFocusChange?.(true)
} }
} else { } else {
setFocusArea("input") setFocusArea("input")
props.onInputFocusChange?.(true)
} }
return return
} }
@@ -94,21 +92,17 @@ export function SearchPage(props: SearchPageProps) {
if (area === "input") { if (area === "input") {
if (searchStore.history().length > 0) { if (searchStore.history().length > 0) {
setFocusArea("history") setFocusArea("history")
props.onInputFocusChange?.(false)
} else if (searchStore.results().length > 0) { } else if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results")
props.onInputFocusChange?.(false)
} }
} else if (area === "history") { } else if (area === "history") {
if (searchStore.results().length > 0) { if (searchStore.results().length > 0) {
setFocusArea("results") setFocusArea("results")
} else { } else {
setFocusArea("input") setFocusArea("input")
props.onInputFocusChange?.(true)
} }
} else { } else {
setFocusArea("input") setFocusArea("input")
props.onInputFocusChange?.(true)
} }
return return
} }
@@ -124,7 +118,7 @@ export function SearchPage(props: SearchPageProps) {
setResultIndex((i) => Math.max(i - 1, 0)) setResultIndex((i) => Math.max(i - 1, 0))
return return
} }
if (key.name === "enter") { if (key.name === "return" || key.name === "enter") {
const result = results[resultIndex()] const result = results[resultIndex()]
if (result) handleResultSelect(result) if (result) handleResultSelect(result)
return return
@@ -141,7 +135,7 @@ export function SearchPage(props: SearchPageProps) {
setHistoryIndex((i) => Math.max(i - 1, 0)) setHistoryIndex((i) => Math.max(i - 1, 0))
return return
} }
if (key.name === "enter") { if (key.name === "return" || key.name === "enter") {
const query = history[historyIndex()] const query = history[historyIndex()]
if (query) handleHistorySelect(query) if (query) handleHistorySelect(query)
return return
@@ -154,7 +148,7 @@ export function SearchPage(props: SearchPageProps) {
props.onExit?.() props.onExit?.()
} else { } else {
setFocusArea("input") setFocusArea("input")
props.onInputFocusChange?.(true) key.stopPropagation()
} }
return return
} }
@@ -162,7 +156,6 @@ export function SearchPage(props: SearchPageProps) {
// "/" focuses search input // "/" focuses search input
if (key.name === "/" && area !== "input") { if (key.name === "/" && area !== "input") {
setFocusArea("input") setFocusArea("input")
props.onInputFocusChange?.(true)
return return
} }
}) })
@@ -182,9 +175,6 @@ export function SearchPage(props: SearchPageProps) {
value={inputValue()} value={inputValue()}
onInput={(value) => { onInput={(value) => {
setInputValue(value) setInputValue(value)
if (props.focused && focusArea() === "input") {
props.onInputFocusChange?.(true)
}
}} }}
placeholder="Enter podcast name, topic, or author..." placeholder="Enter podcast name, topic, or author..."
focused={props.focused && focusArea() === "input"} focused={props.focused && focusArea() === "input"}

View File

@@ -63,6 +63,11 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
// Handle leader key // Handle leader key
useKeyboard(async (evt) => { 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)) { if (!store.leader && result.match("leader", evt)) {
leader(true) leader(true)
return return

View File

@@ -53,7 +53,7 @@ export function useAppKeyboard(options: ShortcutOptions) {
return return
} }
if (key.name === "enter") { if (key.name === "return") {
options.onAction?.("enter") options.onAction?.("enter")
return return
} }

View File

@@ -22,6 +22,7 @@ import {
} from "../utils/audio-player" } from "../utils/audio-player"
import { emit, on } from "../utils/event-bus" import { emit, on } from "../utils/event-bus"
import { useAppStore } from "../stores/app" import { useAppStore } from "../stores/app"
import { useProgressStore } from "../stores/progress"
import type { Episode } from "../types/episode" import type { Episode } from "../types/episode"
export interface AudioControls { export interface AudioControls {
@@ -53,6 +54,7 @@ export interface AudioControls {
let backend: AudioBackend | null = null let backend: AudioBackend | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
let refCount = 0 let refCount = 0
let pollCount = 0 // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false) const [isPlaying, setIsPlaying] = createSignal(false)
const [position, setPosition] = createSignal(0) const [position, setPosition] = createSignal(0)
@@ -76,6 +78,7 @@ function ensureBackend(): AudioBackend {
function startPolling(): void { function startPolling(): void {
stopPolling() stopPolling()
pollCount = 0
pollTimer = setInterval(async () => { pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return if (!backend || !isPlaying()) return
try { try {
@@ -84,10 +87,26 @@ function startPolling(): void {
setPosition(pos) setPosition(pos)
if (dur > 0) setDuration(dur) 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) // Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) { if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false) setIsPlaying(false)
stopPolling() 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 { } catch {
// Backend may have been disposed // Backend may have been disposed
@@ -113,18 +132,27 @@ async function play(episode: Episode): Promise<void> {
try { try {
const appStore = useAppStore() const appStore = useAppStore()
const progressStore = useProgressStore()
const storeSpeed = appStore.state().settings.playbackSpeed const storeSpeed = appStore.state().settings.playbackSpeed
const vol = volume() const vol = volume()
const spd = storeSpeed || speed() 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, { await b.play(episode.audioUrl, {
volume: vol, volume: vol,
speed: spd, speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
}) })
setCurrentEpisode(episode) setCurrentEpisode(episode)
setIsPlaying(true) setIsPlaying(true)
setPosition(0) setPosition(startPos)
setSpeed(spd) setSpeed(spd)
if (episode.duration) setDuration(episode.duration) if (episode.duration) setDuration(episode.duration)
@@ -143,7 +171,12 @@ async function pause(): Promise<void> {
setIsPlaying(false) setIsPlaying(false)
stopPolling() stopPolling()
const ep = currentEpisode() 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) { } catch (err) {
setError(err instanceof Error ? err.message : "Pause failed") setError(err instanceof Error ? err.message : "Pause failed")
} }
@@ -173,6 +206,12 @@ async function togglePlayback(): Promise<void> {
async function stop(): Promise<void> { async function stop(): Promise<void> {
if (!backend) return if (!backend) return
try { try {
// Save progress before stopping
const ep = currentEpisode()
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, position(), duration(), speed())
}
await backend.stop() await backend.stop()
setIsPlaying(false) setIsPlaying(false)
setPosition(0) setPosition(0)

168
src/stores/progress.ts Normal file
View File

@@ -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<string, Progress> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
const result: Record<string, Progress> = {}
for (const [key, value] of Object.entries(parsed)) {
const p = value as Record<string, unknown>
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<string, Progress>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {
// Quota exceeded or unavailable — silently ignore
}
}
// --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
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<string, Progress> {
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<typeof createProgressStore> | null = null
export function useProgressStore() {
if (!instance) {
instance = createProgressStore()
}
return instance
}