working playback
This commit is contained in:
22
src/App.tsx
22
src/App.tsx
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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(/</g, "<")
|
.replace(/</g, "<")
|
||||||
@@ -14,6 +22,42 @@ const decodeEntities = (value: string) =>
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.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[] } => {
|
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 {
|
||||||
|
|||||||
@@ -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?.()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
168
src/stores/progress.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user