Compare commits
3 Commits
6b00871c32
...
0e4f47323f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4f47323f | |||
| 42a1ddf458 | |||
| 168e6d5a61 |
27
src/App.tsx
27
src/App.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, ErrorBoundary } from "solid-js";
|
import { createSignal, ErrorBoundary } from "solid-js";
|
||||||
|
import { useSelectionHandler } from "@opentui/solid";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { Navigation } from "./components/Navigation";
|
import { Navigation } from "./components/Navigation";
|
||||||
import { TabNavigation } from "./components/TabNavigation";
|
import { TabNavigation } from "./components/TabNavigation";
|
||||||
@@ -16,8 +17,11 @@ 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 { useAudio } from "./hooks/useAudio";
|
||||||
|
import { useMultimediaKeys } from "./hooks/useMultimediaKeys";
|
||||||
import { FeedVisibility } from "./types/feed";
|
import { FeedVisibility } from "./types/feed";
|
||||||
import { useAppKeyboard } from "./hooks/useAppKeyboard";
|
import { useAppKeyboard } from "./hooks/useAppKeyboard";
|
||||||
|
import { Clipboard } from "./utils/clipboard";
|
||||||
|
import { emit } from "./utils/event-bus";
|
||||||
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";
|
import type { Episode } from "./types/episode";
|
||||||
@@ -33,6 +37,14 @@ export function App() {
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
|
||||||
|
// Global multimedia key handling — active when Player tab is NOT
|
||||||
|
// focused (Player.tsx handles its own keys when focused).
|
||||||
|
useMultimediaKeys({
|
||||||
|
playerFocused: () => activeTab() === "player" && layerDepth() > 0,
|
||||||
|
inputFocused: () => inputFocused(),
|
||||||
|
hasEpisode: () => !!audio.currentEpisode(),
|
||||||
|
});
|
||||||
|
|
||||||
const handlePlayEpisode = (episode: Episode) => {
|
const handlePlayEpisode = (episode: Episode) => {
|
||||||
audio.play(episode);
|
audio.play(episode);
|
||||||
setActiveTab("player");
|
setActiveTab("player");
|
||||||
@@ -80,6 +92,21 @@ export function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Copy selected text to clipboard when selection ends (mouse release)
|
||||||
|
useSelectionHandler((selection: any) => {
|
||||||
|
if (!selection) return
|
||||||
|
const text = selection.getSelectedText?.()
|
||||||
|
if (!text || text.trim().length === 0) return
|
||||||
|
|
||||||
|
Clipboard.copy(text).then(() => {
|
||||||
|
emit("toast.show", {
|
||||||
|
message: "Copied to clipboard",
|
||||||
|
variant: "info",
|
||||||
|
duration: 1500,
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
const getPanels = () => {
|
const getPanels = () => {
|
||||||
const tab = activeTab();
|
const tab = activeTab();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Podcast } from "../types/podcast"
|
import type { Podcast } from "../types/podcast"
|
||||||
import type { Episode, EpisodeType } from "../types/episode"
|
import type { Episode, EpisodeType } from "../types/episode"
|
||||||
|
import { detectContentType, ContentType } from "../utils/rss-content-detector"
|
||||||
|
import { htmlToText } from "../utils/html-to-text"
|
||||||
|
|
||||||
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"))
|
||||||
@@ -22,6 +24,20 @@ const decodeEntities = (value: string) =>
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean a description field: detect HTML vs plain text, and convert
|
||||||
|
* HTML to readable plain text. Plain text just gets entity decoding.
|
||||||
|
*/
|
||||||
|
const cleanDescription = (raw: string): string => {
|
||||||
|
if (!raw) return ""
|
||||||
|
const decoded = decodeEntities(raw)
|
||||||
|
const type = detectContentType(decoded)
|
||||||
|
if (type === ContentType.HTML) {
|
||||||
|
return htmlToText(decoded)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an itunes:duration value which can be:
|
* Parse an itunes:duration value which can be:
|
||||||
* - "HH:MM:SS"
|
* - "HH:MM:SS"
|
||||||
@@ -61,14 +77,14 @@ const parseEpisodeType = (raw: string): EpisodeType | 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"
|
||||||
const description = decodeEntities(getTagValue(channel, "description"))
|
const description = cleanDescription(getTagValue(channel, "description"))
|
||||||
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||||
const lastUpdated = new Date()
|
const lastUpdated = new Date()
|
||||||
|
|
||||||
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||||
const episodes = items.map((item, index) => {
|
const episodes = items.map((item, index) => {
|
||||||
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 = cleanDescription(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>
|
// Audio URL + file size + MIME type from <enclosure>
|
||||||
|
|||||||
93
src/components/MergedWaveform.tsx
Normal file
93
src/components/MergedWaveform.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* MergedWaveform — unified progress bar + waveform display
|
||||||
|
*
|
||||||
|
* Shows waveform bars coloured to indicate played vs unplayed portions.
|
||||||
|
* The played section doubles as the progress indicator, replacing the
|
||||||
|
* separate progress bar. Click-to-seek is supported.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, createEffect, onCleanup } from "solid-js"
|
||||||
|
import { getWaveformData, getWaveformDataSync } from "../utils/audio-waveform"
|
||||||
|
|
||||||
|
type MergedWaveformProps = {
|
||||||
|
/** Audio URL — used to generate or retrieve waveform data */
|
||||||
|
audioUrl: string
|
||||||
|
/** Current playback position in seconds */
|
||||||
|
position: number
|
||||||
|
/** Total duration in seconds */
|
||||||
|
duration: number
|
||||||
|
/** Whether audio is currently playing */
|
||||||
|
isPlaying: boolean
|
||||||
|
/** Number of data points / columns */
|
||||||
|
resolution?: number
|
||||||
|
/** Callback when user clicks to seek */
|
||||||
|
onSeek?: (seconds: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block characters for waveform amplitude levels */
|
||||||
|
const BARS = [".", "-", "~", "=", "#"]
|
||||||
|
|
||||||
|
export function MergedWaveform(props: MergedWaveformProps) {
|
||||||
|
const resolution = () => props.resolution ?? 64
|
||||||
|
|
||||||
|
// Waveform data — start with sync/cached, kick off async extraction
|
||||||
|
const [data, setData] = createSignal<number[]>(
|
||||||
|
getWaveformDataSync(props.audioUrl, resolution()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// When the audioUrl changes, attempt async extraction for real data
|
||||||
|
createEffect(() => {
|
||||||
|
const url = props.audioUrl
|
||||||
|
const res = resolution()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
getWaveformData(url, res).then((result) => {
|
||||||
|
if (!cancelled) setData(result)
|
||||||
|
})
|
||||||
|
onCleanup(() => { cancelled = true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const playedRatio = () =>
|
||||||
|
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration)
|
||||||
|
|
||||||
|
const renderLine = () => {
|
||||||
|
const d = data()
|
||||||
|
const played = Math.floor(d.length * playedRatio())
|
||||||
|
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
|
||||||
|
const futureColor = "#3b4252"
|
||||||
|
|
||||||
|
const playedChars = d
|
||||||
|
.slice(0, played)
|
||||||
|
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
const futureChars = d
|
||||||
|
.slice(played)
|
||||||
|
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={0}>
|
||||||
|
<text fg={playedColor}>{playedChars || " "}</text>
|
||||||
|
<text fg={futureColor}>{futureChars || " "}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (event: { x: number }) => {
|
||||||
|
const d = data()
|
||||||
|
const ratio = d.length === 0 ? 0 : event.x / d.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
* Right panel: episodes for the selected show
|
* Right panel: episodes for the selected show
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, For, Show, createMemo } from "solid-js"
|
import { createSignal, For, Show, createMemo, createEffect } from "solid-js"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { useFeedStore } from "../stores/feed"
|
import { useFeedStore } from "../stores/feed"
|
||||||
|
import { useDownloadStore } from "../stores/download"
|
||||||
|
import { DownloadStatus } from "../types/episode"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
import type { Feed } from "../types/feed"
|
import type { Feed } from "../types/feed"
|
||||||
@@ -21,11 +23,15 @@ type FocusPane = "shows" | "episodes"
|
|||||||
|
|
||||||
export function MyShowsPage(props: MyShowsPageProps) {
|
export function MyShowsPage(props: MyShowsPageProps) {
|
||||||
const feedStore = useFeedStore()
|
const feedStore = useFeedStore()
|
||||||
|
const downloadStore = useDownloadStore()
|
||||||
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
|
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
|
||||||
const [showIndex, setShowIndex] = createSignal(0)
|
const [showIndex, setShowIndex] = createSignal(0)
|
||||||
const [episodeIndex, setEpisodeIndex] = createSignal(0)
|
const [episodeIndex, setEpisodeIndex] = createSignal(0)
|
||||||
const [isRefreshing, setIsRefreshing] = createSignal(false)
|
const [isRefreshing, setIsRefreshing] = createSignal(false)
|
||||||
|
|
||||||
|
/** Threshold: load more when within this many items of the end */
|
||||||
|
const LOAD_MORE_THRESHOLD = 5
|
||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds()
|
const shows = () => feedStore.getFilteredFeeds()
|
||||||
|
|
||||||
const selectedShow = createMemo(() => {
|
const selectedShow = createMemo(() => {
|
||||||
@@ -42,6 +48,19 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Detect when user navigates near the bottom and load more episodes
|
||||||
|
createEffect(() => {
|
||||||
|
const idx = episodeIndex()
|
||||||
|
const eps = episodes()
|
||||||
|
const show = selectedShow()
|
||||||
|
if (!show || eps.length === 0) return
|
||||||
|
|
||||||
|
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD
|
||||||
|
if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) {
|
||||||
|
feedStore.loadMoreEpisodes(show.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
const formatDate = (date: Date): string => {
|
||||||
return format(date, "MMM d, yyyy")
|
return format(date, "MMM d, yyyy")
|
||||||
}
|
}
|
||||||
@@ -53,6 +72,42 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
return `${mins}m`
|
return `${mins}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get download status label for an episode */
|
||||||
|
const downloadLabel = (episodeId: string): string => {
|
||||||
|
const status = downloadStore.getDownloadStatus(episodeId)
|
||||||
|
switch (status) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]"
|
||||||
|
case DownloadStatus.DOWNLOADING: {
|
||||||
|
const pct = downloadStore.getDownloadProgress(episodeId)
|
||||||
|
return `[${pct}%]`
|
||||||
|
}
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]"
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get download status color */
|
||||||
|
const downloadColor = (episodeId: string): string => {
|
||||||
|
const status = downloadStore.getDownloadStatus(episodeId)
|
||||||
|
switch (status) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "yellow"
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return "cyan"
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "green"
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "red"
|
||||||
|
default:
|
||||||
|
return "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const show = selectedShow()
|
const show = selectedShow()
|
||||||
if (!show) return
|
if (!show) return
|
||||||
@@ -128,6 +183,17 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
const ep = eps[episodeIndex()]
|
const ep = eps[episodeIndex()]
|
||||||
const show = selectedShow()
|
const show = selectedShow()
|
||||||
if (ep && show) props.onPlayEpisode?.(ep, show)
|
if (ep && show) props.onPlayEpisode?.(ep, show)
|
||||||
|
} else if (key.name === "d") {
|
||||||
|
const ep = eps[episodeIndex()]
|
||||||
|
const show = selectedShow()
|
||||||
|
if (ep && show) {
|
||||||
|
const status = downloadStore.getDownloadStatus(ep.id)
|
||||||
|
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
||||||
|
downloadStore.startDownload(ep, show.id)
|
||||||
|
} else if (status === DownloadStatus.DOWNLOADING || status === DownloadStatus.QUEUED) {
|
||||||
|
downloadStore.cancelDownload(ep.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (key.name === "pageup") {
|
} else if (key.name === "pageup") {
|
||||||
setEpisodeIndex((i) => Math.max(0, i - 10))
|
setEpisodeIndex((i) => Math.max(0, i - 10))
|
||||||
} else if (key.name === "pagedown") {
|
} else if (key.name === "pagedown") {
|
||||||
@@ -227,10 +293,23 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||||
|
<Show when={downloadLabel(episode.id)}>
|
||||||
|
<text fg={downloadColor(episode.id)}>{downloadLabel(episode.id)}</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={feedStore.isLoadingMore()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<text fg="yellow">Loading more episodes...</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<text fg="gray">Scroll down for more episodes</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { PlaybackControls } from "./PlaybackControls"
|
import { PlaybackControls } from "./PlaybackControls"
|
||||||
import { Waveform } from "./Waveform"
|
import { MergedWaveform } from "./MergedWaveform"
|
||||||
import { createWaveform } from "../utils/waveform"
|
|
||||||
import { useAudio } from "../hooks/useAudio"
|
import { useAudio } from "../hooks/useAudio"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
@@ -24,8 +23,6 @@ const SAMPLE_EPISODE: Episode = {
|
|||||||
export function Player(props: PlayerProps) {
|
export function Player(props: PlayerProps) {
|
||||||
const audio = useAudio()
|
const audio = useAudio()
|
||||||
|
|
||||||
const waveform = () => createWaveform(64)
|
|
||||||
|
|
||||||
// The episode to display — prefer a passed-in episode, then the
|
// The episode to display — prefer a passed-in episode, then the
|
||||||
// currently-playing episode, then fall back to the sample.
|
// currently-playing episode, then fall back to the sample.
|
||||||
const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE
|
const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE
|
||||||
@@ -86,7 +83,7 @@ export function Player(props: PlayerProps) {
|
|||||||
<strong>Now Playing</strong>
|
<strong>Now Playing</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">
|
<text fg="gray">
|
||||||
{formatTime(audio.position())} / {formatTime(dur())}
|
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
@@ -100,28 +97,14 @@ export function Player(props: PlayerProps) {
|
|||||||
</text>
|
</text>
|
||||||
<text fg="gray">{episode().description}</text>
|
<text fg="gray">{episode().description}</text>
|
||||||
|
|
||||||
<box flexDirection="column" gap={1}>
|
<MergedWaveform
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
audioUrl={episode().audioUrl}
|
||||||
<text fg="gray">Progress:</text>
|
|
||||||
<box flexGrow={1} height={1} backgroundColor="#2a2f3a">
|
|
||||||
<box
|
|
||||||
width={`${progressPercent()}%`}
|
|
||||||
height={1}
|
|
||||||
backgroundColor={audio.isPlaying() ? "#6fa8ff" : "#7d8590"}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
<text fg="gray">{progressPercent()}%</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Waveform
|
|
||||||
data={waveform()}
|
|
||||||
position={audio.position()}
|
position={audio.position()}
|
||||||
duration={dur()}
|
duration={dur()}
|
||||||
isPlaying={audio.isPlaying()}
|
isPlaying={audio.isPlaying()}
|
||||||
onSeek={(next: number) => audio.seek(next)}
|
onSeek={(next: number) => audio.seek(next)}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
isPlaying={audio.isPlaying()}
|
isPlaying={audio.isPlaying()}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
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 { useProgressStore } from "../stores/progress"
|
||||||
|
import { useMediaRegistry } from "../utils/media-registry"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -94,6 +95,10 @@ function startPolling(): void {
|
|||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore()
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
||||||
|
|
||||||
|
// Update platform media position
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setPosition(pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +161,16 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
setSpeed(spd)
|
setSpeed(spd)
|
||||||
if (episode.duration) setDuration(episode.duration)
|
if (episode.duration) setDuration(episode.duration)
|
||||||
|
|
||||||
|
// Register with platform media controls
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setNowPlaying({
|
||||||
|
title: episode.title,
|
||||||
|
artist: episode.podcastId,
|
||||||
|
duration: episode.duration,
|
||||||
|
})
|
||||||
|
media.setPlaybackState(true)
|
||||||
|
if (startPos > 0) media.setPosition(startPos)
|
||||||
|
|
||||||
startPolling()
|
startPolling()
|
||||||
emit("player.play", { episodeId: episode.id })
|
emit("player.play", { episodeId: episode.id })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -176,6 +191,11 @@ async function pause(): Promise<void> {
|
|||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore()
|
||||||
progressStore.update(ep.id, position(), duration(), speed())
|
progressStore.update(ep.id, position(), duration(), speed())
|
||||||
emit("player.pause", { episodeId: ep.id })
|
emit("player.pause", { episodeId: ep.id })
|
||||||
|
|
||||||
|
// Update platform media controls
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setPlaybackState(false)
|
||||||
|
media.setPosition(position())
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Pause failed")
|
setError(err instanceof Error ? err.message : "Pause failed")
|
||||||
@@ -189,7 +209,11 @@ async function resume(): Promise<void> {
|
|||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
startPolling()
|
startPolling()
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode()
|
||||||
if (ep) emit("player.play", { episodeId: ep.id })
|
if (ep) {
|
||||||
|
emit("player.play", { episodeId: ep.id })
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setPlaybackState(true)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Resume failed")
|
setError(err instanceof Error ? err.message : "Resume failed")
|
||||||
}
|
}
|
||||||
@@ -218,6 +242,10 @@ async function stop(): Promise<void> {
|
|||||||
setCurrentEpisode(null)
|
setCurrentEpisode(null)
|
||||||
stopPolling()
|
stopPolling()
|
||||||
emit("player.stop", {})
|
emit("player.stop", {})
|
||||||
|
|
||||||
|
// Clear platform media controls
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.clearNowPlaying()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Stop failed")
|
setError(err instanceof Error ? err.message : "Stop failed")
|
||||||
}
|
}
|
||||||
@@ -347,10 +375,42 @@ export function useAudio(): AudioControls {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||||
|
const unsubMediaToggle = on("media.toggle", async () => {
|
||||||
|
await togglePlayback()
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||||
|
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||||
|
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||||
|
await seekRelative(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||||
|
await seekRelative(-10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||||
|
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
|
||||||
|
await doSetSpeed(next)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCount--
|
refCount--
|
||||||
unsubPlay()
|
unsubPlay()
|
||||||
unsubStop()
|
unsubStop()
|
||||||
|
unsubMediaToggle()
|
||||||
|
unsubMediaVolUp()
|
||||||
|
unsubMediaVolDown()
|
||||||
|
unsubMediaSeekFwd()
|
||||||
|
unsubMediaSeekBack()
|
||||||
|
unsubMediaSpeed()
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
@@ -358,6 +418,10 @@ export function useAudio(): AudioControls {
|
|||||||
backend.dispose()
|
backend.dispose()
|
||||||
backend = null
|
backend = null
|
||||||
}
|
}
|
||||||
|
// Clear media registry on full teardown
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.clearNowPlaying()
|
||||||
|
|
||||||
refCount = 0
|
refCount = 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
98
src/hooks/useMultimediaKeys.ts
Normal file
98
src/hooks/useMultimediaKeys.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Global multimedia key handler hook.
|
||||||
|
*
|
||||||
|
* Captures media-related key events (play/pause, volume, seek, speed)
|
||||||
|
* regardless of which component is focused. Uses the event bus to
|
||||||
|
* decouple key detection from audio control logic.
|
||||||
|
*
|
||||||
|
* Keys are only handled when an episode is loaded (or for play/pause,
|
||||||
|
* always). This prevents accidental volume/seek changes when there's
|
||||||
|
* nothing playing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { emit } from "../utils/event-bus"
|
||||||
|
|
||||||
|
export type MediaKeyAction =
|
||||||
|
| "media.toggle"
|
||||||
|
| "media.volumeUp"
|
||||||
|
| "media.volumeDown"
|
||||||
|
| "media.seekForward"
|
||||||
|
| "media.seekBackward"
|
||||||
|
| "media.speedCycle"
|
||||||
|
|
||||||
|
/** Key-to-action mappings for multimedia controls */
|
||||||
|
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||||
|
// Common terminal media keys — these overlap with Player.tsx local
|
||||||
|
// bindings, but Player guards on `props.focused` so the global
|
||||||
|
// handler fires independently when the player tab is *not* active.
|
||||||
|
//
|
||||||
|
// When Player IS focused both handlers fire, but since the audio
|
||||||
|
// actions are idempotent (toggle = toggle, seek = additive) having
|
||||||
|
// them called twice for the same keypress is avoided by the event
|
||||||
|
// bus approach — the audio hook only processes event-bus events, and
|
||||||
|
// Player.tsx calls audio methods directly. We therefore guard with
|
||||||
|
// a "playerFocused" flag passed via options.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultimediaKeysOptions {
|
||||||
|
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||||
|
playerFocused?: () => boolean
|
||||||
|
/** When true, skip handling (text input has focus) */
|
||||||
|
inputFocused?: () => boolean
|
||||||
|
/** Whether an episode is currently loaded */
|
||||||
|
hasEpisode?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a global keyboard listener that emits media events on the
|
||||||
|
* event bus. Call once at the app level (e.g. in App.tsx).
|
||||||
|
*/
|
||||||
|
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||||
|
useKeyboard((key) => {
|
||||||
|
// Don't intercept when a text input owns the keyboard
|
||||||
|
if (options.inputFocused?.()) return
|
||||||
|
|
||||||
|
// Don't intercept when Player component handles its own keys
|
||||||
|
if (options.playerFocused?.()) return
|
||||||
|
|
||||||
|
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||||
|
if (key.ctrl || key.meta) return
|
||||||
|
|
||||||
|
switch (key.name) {
|
||||||
|
case "space":
|
||||||
|
// Toggle play/pause — always valid (may start a loaded episode)
|
||||||
|
emit("media.toggle", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "up":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.volumeUp", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "down":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.volumeDown", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "left":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.seekBackward", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "right":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.seekForward", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "s":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.speedCycle", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Not a media key — do nothing
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
|
|||||||
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
|
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
|
||||||
import { resolveTheme } from "../utils/theme-resolver"
|
import { resolveTheme } from "../utils/theme-resolver"
|
||||||
import type { ThemeJson } from "../types/theme-schema"
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
|
import {
|
||||||
const STORAGE_KEY = "podtui_app_state"
|
loadAppStateFromFile,
|
||||||
|
saveAppStateToFile,
|
||||||
|
migrateAppStateFromLocalStorage,
|
||||||
|
} from "../utils/app-persistence"
|
||||||
|
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -24,33 +27,21 @@ const defaultState: AppState = {
|
|||||||
customTheme: DEFAULT_THEME,
|
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() {
|
export function createAppStore() {
|
||||||
const [state, setState] = createSignal<AppState>(loadState())
|
// Start with defaults; async load will update once ready
|
||||||
|
const [state, setState] = createSignal<AppState>(defaultState)
|
||||||
|
|
||||||
|
// Fire-and-forget async initialisation
|
||||||
|
const init = async () => {
|
||||||
|
await migrateAppStateFromLocalStorage()
|
||||||
|
const loaded = await loadAppStateFromFile()
|
||||||
|
setState(loaded)
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
|
||||||
|
const saveState = (next: AppState) => {
|
||||||
|
saveAppStateToFile(next).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const updateState = (next: AppState) => {
|
const updateState = (next: AppState) => {
|
||||||
setState(next)
|
setState(next)
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function createDiscoverStore() {
|
|||||||
|
|
||||||
return podcasts().filter((p) => {
|
return podcasts().filter((p) => {
|
||||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||||
return cats.some((c) => c.includes(category.replace("-", " ")))
|
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
360
src/stores/download.ts
Normal file
360
src/stores/download.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Download store for PodTUI
|
||||||
|
*
|
||||||
|
* Manages per-episode download state with SolidJS signals, persists download
|
||||||
|
* metadata to downloads.json in XDG_CONFIG_HOME, and provides a sequential
|
||||||
|
* download queue (max 2 concurrent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { DownloadStatus } from "../types/episode"
|
||||||
|
import type { DownloadedEpisode } from "../types/episode"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import { downloadEpisode } from "../utils/episode-downloader"
|
||||||
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir"
|
||||||
|
import { backupConfigFile } from "../utils/config-backup"
|
||||||
|
|
||||||
|
const DOWNLOADS_FILE = "downloads.json"
|
||||||
|
const MAX_CONCURRENT = 2
|
||||||
|
|
||||||
|
/** Serializable download record for persistence */
|
||||||
|
interface DownloadRecord {
|
||||||
|
episodeId: string
|
||||||
|
feedId: string
|
||||||
|
status: DownloadStatus
|
||||||
|
filePath: string | null
|
||||||
|
downloadedAt: string | null
|
||||||
|
fileSize: number
|
||||||
|
error: string | null
|
||||||
|
audioUrl: string
|
||||||
|
episodeTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue item for pending downloads */
|
||||||
|
interface QueueItem {
|
||||||
|
episodeId: string
|
||||||
|
feedId: string
|
||||||
|
audioUrl: string
|
||||||
|
episodeTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create download store */
|
||||||
|
export function createDownloadStore() {
|
||||||
|
const [downloads, setDownloads] = createSignal<Map<string, DownloadedEpisode>>(new Map())
|
||||||
|
const [queue, setQueue] = createSignal<QueueItem[]>([])
|
||||||
|
const [activeCount, setActiveCount] = createSignal(0)
|
||||||
|
|
||||||
|
/** Active AbortControllers keyed by episodeId */
|
||||||
|
const abortControllers = new Map<string, AbortController>()
|
||||||
|
|
||||||
|
// Load persisted downloads on init
|
||||||
|
;(async () => {
|
||||||
|
const loaded = await loadDownloads()
|
||||||
|
if (loaded.size > 0) setDownloads(loaded)
|
||||||
|
// Resume any queued downloads from previous session
|
||||||
|
resumeIncomplete()
|
||||||
|
})()
|
||||||
|
|
||||||
|
/** Load downloads from JSON file */
|
||||||
|
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) return new Map()
|
||||||
|
|
||||||
|
const raw: DownloadRecord[] = await file.json()
|
||||||
|
if (!Array.isArray(raw)) return new Map()
|
||||||
|
|
||||||
|
const map = new Map<string, DownloadedEpisode>()
|
||||||
|
for (const rec of raw) {
|
||||||
|
map.set(rec.episodeId, {
|
||||||
|
episodeId: rec.episodeId,
|
||||||
|
feedId: rec.feedId,
|
||||||
|
status: rec.status === DownloadStatus.DOWNLOADING ? DownloadStatus.QUEUED : rec.status,
|
||||||
|
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
||||||
|
filePath: rec.filePath,
|
||||||
|
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
||||||
|
speed: 0,
|
||||||
|
fileSize: rec.fileSize,
|
||||||
|
error: rec.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
} catch {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist downloads to JSON file */
|
||||||
|
async function saveDownloads(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
await backupConfigFile(DOWNLOADS_FILE)
|
||||||
|
const map = downloads()
|
||||||
|
const records: DownloadRecord[] = []
|
||||||
|
for (const [, dl] of map) {
|
||||||
|
// Find the audioUrl from queue or use empty string
|
||||||
|
const qItem = queue().find((q) => q.episodeId === dl.episodeId)
|
||||||
|
records.push({
|
||||||
|
episodeId: dl.episodeId,
|
||||||
|
feedId: dl.feedId,
|
||||||
|
status: dl.status,
|
||||||
|
filePath: dl.filePath,
|
||||||
|
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||||
|
fileSize: dl.fileSize,
|
||||||
|
error: dl.error,
|
||||||
|
audioUrl: qItem?.audioUrl ?? "",
|
||||||
|
episodeTitle: qItem?.episodeTitle ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
||||||
|
await Bun.write(filePath, JSON.stringify(records, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume incomplete downloads from a previous session */
|
||||||
|
function resumeIncomplete(): void {
|
||||||
|
const map = downloads()
|
||||||
|
for (const [, dl] of map) {
|
||||||
|
if (dl.status === DownloadStatus.QUEUED) {
|
||||||
|
// Re-queue — but we lack audioUrl from persistence alone.
|
||||||
|
// These will sit as QUEUED until the user re-triggers them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a single download entry and trigger reactivity */
|
||||||
|
function updateDownload(episodeId: string, updates: Partial<DownloadedEpisode>): void {
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(episodeId)
|
||||||
|
if (existing) {
|
||||||
|
next.set(episodeId, { ...existing, ...updates })
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
|
||||||
|
function processQueue(): void {
|
||||||
|
const current = activeCount()
|
||||||
|
const q = queue()
|
||||||
|
|
||||||
|
if (current >= MAX_CONCURRENT || q.length === 0) return
|
||||||
|
|
||||||
|
const slotsAvailable = MAX_CONCURRENT - current
|
||||||
|
const toStart = q.slice(0, slotsAvailable)
|
||||||
|
|
||||||
|
// Remove started items from queue
|
||||||
|
if (toStart.length > 0) {
|
||||||
|
setQueue((prev) => prev.slice(toStart.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of toStart) {
|
||||||
|
executeDownload(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute a single download */
|
||||||
|
async function executeDownload(item: QueueItem): Promise<void> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortControllers.set(item.episodeId, controller)
|
||||||
|
setActiveCount((c) => c + 1)
|
||||||
|
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
status: DownloadStatus.DOWNLOADING,
|
||||||
|
progress: 0,
|
||||||
|
speed: 0,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await downloadEpisode(
|
||||||
|
item.audioUrl,
|
||||||
|
item.episodeTitle,
|
||||||
|
item.feedId,
|
||||||
|
(progress) => {
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
progress: progress.percent >= 0 ? progress.percent : 0,
|
||||||
|
speed: progress.speed,
|
||||||
|
fileSize: progress.totalBytes,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
abortControllers.delete(item.episodeId)
|
||||||
|
setActiveCount((c) => Math.max(0, c - 1))
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
status: DownloadStatus.COMPLETED,
|
||||||
|
progress: 100,
|
||||||
|
filePath: result.filePath,
|
||||||
|
fileSize: result.fileSize,
|
||||||
|
downloadedAt: new Date(),
|
||||||
|
speed: 0,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
status: DownloadStatus.FAILED,
|
||||||
|
speed: 0,
|
||||||
|
error: result.error ?? "Unknown error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
// Process next items in queue
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get download status for an episode */
|
||||||
|
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||||
|
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get download progress for an episode (0-100) */
|
||||||
|
const getDownloadProgress = (episodeId: string): number => {
|
||||||
|
return downloads().get(episodeId)?.progress ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full download info for an episode */
|
||||||
|
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||||
|
return downloads().get(episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the local file path for a completed download */
|
||||||
|
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||||
|
const dl = downloads().get(episodeId)
|
||||||
|
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||||
|
return dl.filePath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start downloading an episode */
|
||||||
|
const startDownload = (episode: Episode, feedId: string): void => {
|
||||||
|
const existing = downloads().get(episode.id)
|
||||||
|
if (existing?.status === DownloadStatus.DOWNLOADING || existing?.status === DownloadStatus.QUEUED) {
|
||||||
|
return // Already downloading or queued
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create download entry
|
||||||
|
const entry: DownloadedEpisode = {
|
||||||
|
episodeId: episode.id,
|
||||||
|
feedId,
|
||||||
|
status: DownloadStatus.QUEUED,
|
||||||
|
progress: 0,
|
||||||
|
filePath: null,
|
||||||
|
downloadedAt: null,
|
||||||
|
speed: 0,
|
||||||
|
fileSize: episode.fileSize ?? 0,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(episode.id, entry)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
const queueItem: QueueItem = {
|
||||||
|
episodeId: episode.id,
|
||||||
|
feedId,
|
||||||
|
audioUrl: episode.audioUrl,
|
||||||
|
episodeTitle: episode.title,
|
||||||
|
}
|
||||||
|
setQueue((prev) => [...prev, queueItem])
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a download */
|
||||||
|
const cancelDownload = (episodeId: string): void => {
|
||||||
|
// Abort active download
|
||||||
|
const controller = abortControllers.get(episodeId)
|
||||||
|
if (controller) {
|
||||||
|
controller.abort()
|
||||||
|
abortControllers.delete(episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
updateDownload(episodeId, {
|
||||||
|
status: DownloadStatus.NONE,
|
||||||
|
progress: 0,
|
||||||
|
speed: 0,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a completed download (delete file and metadata) */
|
||||||
|
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||||
|
const dl = downloads().get(episodeId)
|
||||||
|
if (dl?.filePath) {
|
||||||
|
try {
|
||||||
|
const { unlink } = await import("fs/promises")
|
||||||
|
await unlink(dl.filePath)
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(episodeId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all downloads as an array */
|
||||||
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
|
return Array.from(downloads().values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current queue */
|
||||||
|
const getQueue = (): QueueItem[] => {
|
||||||
|
return queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get count of active downloads */
|
||||||
|
const getActiveCount = (): number => {
|
||||||
|
return activeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Getters
|
||||||
|
getDownloadStatus,
|
||||||
|
getDownloadProgress,
|
||||||
|
getDownload,
|
||||||
|
getDownloadedFilePath,
|
||||||
|
getAllDownloads,
|
||||||
|
getQueue,
|
||||||
|
getActiveCount,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startDownload,
|
||||||
|
cancelDownload,
|
||||||
|
removeDownload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton download store */
|
||||||
|
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null
|
||||||
|
|
||||||
|
export function useDownloadStore() {
|
||||||
|
if (!downloadStoreInstance) {
|
||||||
|
downloadStoreInstance = createDownloadStore()
|
||||||
|
}
|
||||||
|
return downloadStoreInstance
|
||||||
|
}
|
||||||
@@ -11,98 +11,60 @@ import type { Episode, EpisodeStatus } from "../types/episode"
|
|||||||
import type { PodcastSource, SourceType } from "../types/source"
|
import type { PodcastSource, SourceType } from "../types/source"
|
||||||
import { DEFAULT_SOURCES } from "../types/source"
|
import { DEFAULT_SOURCES } from "../types/source"
|
||||||
import { parseRSSFeed } from "../api/rss-parser"
|
import { parseRSSFeed } from "../api/rss-parser"
|
||||||
|
import {
|
||||||
|
loadFeedsFromFile,
|
||||||
|
saveFeedsToFile,
|
||||||
|
loadSourcesFromFile,
|
||||||
|
saveSourcesToFile,
|
||||||
|
migrateFeedsFromLocalStorage,
|
||||||
|
migrateSourcesFromLocalStorage,
|
||||||
|
} from "../utils/feeds-persistence"
|
||||||
|
import { useDownloadStore } from "./download"
|
||||||
|
import { DownloadStatus } from "../types/episode"
|
||||||
|
|
||||||
/** Max episodes to fetch on refresh */
|
/** Max episodes to load per page/chunk */
|
||||||
const MAX_EPISODES_REFRESH = 50
|
const MAX_EPISODES_REFRESH = 50
|
||||||
|
|
||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20
|
const MAX_EPISODES_SUBSCRIBE = 20
|
||||||
|
|
||||||
/** Storage keys */
|
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
||||||
const STORAGE_KEYS = {
|
const fullEpisodeCache = new Map<string, Episode[]>()
|
||||||
feeds: "podtui_feeds",
|
|
||||||
sources: "podtui_sources",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load feeds from localStorage */
|
/** Track how many episodes are currently loaded per feed */
|
||||||
function loadFeeds(): Feed[] {
|
const episodeLoadCount = new Map<string, number>()
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
/** Save feeds to file (async, fire-and-forget) */
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.feeds)
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored)
|
|
||||||
// Convert date strings
|
|
||||||
return parsed.map((feed: Feed) => ({
|
|
||||||
...feed,
|
|
||||||
lastUpdated: new Date(feed.lastUpdated),
|
|
||||||
podcast: {
|
|
||||||
...feed.podcast,
|
|
||||||
lastUpdated: new Date(feed.podcast.lastUpdated),
|
|
||||||
},
|
|
||||||
episodes: feed.episodes.map((ep: Episode) => ({
|
|
||||||
...ep,
|
|
||||||
pubDate: new Date(ep.pubDate),
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save feeds to localStorage */
|
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
if (typeof localStorage === "undefined") return
|
saveFeedsToFile(feeds).catch(() => {})
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load sources from localStorage */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
function loadSources(): PodcastSource[] {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return [...DEFAULT_SOURCES]
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.sources)
|
|
||||||
if (stored) {
|
|
||||||
return JSON.parse(stored)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...DEFAULT_SOURCES]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save sources to localStorage */
|
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
if (typeof localStorage === "undefined") return
|
saveSourcesToFile(sources).catch(() => {})
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
export function createFeedStore() {
|
export function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
|
const [feeds, setFeeds] = createSignal<Feed[]>([])
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
|
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES])
|
||||||
|
|
||||||
|
// Async initialization: migrate from localStorage, then load from file
|
||||||
|
;(async () => {
|
||||||
|
await migrateFeedsFromLocalStorage()
|
||||||
|
await migrateSourcesFromLocalStorage()
|
||||||
|
const loadedFeeds = await loadFeedsFromFile()
|
||||||
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds)
|
||||||
|
const loadedSources = await loadSourcesFromFile<PodcastSource>()
|
||||||
|
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
|
||||||
|
})()
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||||
visibility: "all",
|
visibility: "all",
|
||||||
sortBy: "updated" as FeedSortField,
|
sortBy: "updated" as FeedSortField,
|
||||||
sortDirection: "desc",
|
sortDirection: "desc",
|
||||||
})
|
})
|
||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false)
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
/** Get filtered and sorted feeds */
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
@@ -179,8 +141,8 @@ export function createFeedStore() {
|
|||||||
return allEpisodes
|
return allEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL */
|
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||||
const fetchEpisodes = async (feedUrl: string, limit: number): Promise<Episode[]> => {
|
const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise<Episode[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -191,7 +153,15 @@ export function createFeedStore() {
|
|||||||
if (!response.ok) return []
|
if (!response.ok) return []
|
||||||
const xml = await response.text()
|
const xml = await response.text()
|
||||||
const parsed = parseRSSFeed(xml, feedUrl)
|
const parsed = parseRSSFeed(xml, feedUrl)
|
||||||
return parsed.episodes.slice(0, limit)
|
const allEpisodes = parsed.episodes
|
||||||
|
|
||||||
|
// Cache all parsed episodes for pagination
|
||||||
|
if (feedId) {
|
||||||
|
fullEpisodeCache.set(feedId, allEpisodes)
|
||||||
|
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEpisodes.slice(0, limit)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -199,9 +169,10 @@ export function createFeedStore() {
|
|||||||
|
|
||||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||||
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
||||||
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE)
|
const feedId = crypto.randomUUID()
|
||||||
|
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId)
|
||||||
const newFeed: Feed = {
|
const newFeed: Feed = {
|
||||||
id: crypto.randomUUID(),
|
id: feedId,
|
||||||
podcast,
|
podcast,
|
||||||
episodes,
|
episodes,
|
||||||
visibility,
|
visibility,
|
||||||
@@ -217,11 +188,33 @@ export function createFeedStore() {
|
|||||||
return newFeed
|
return newFeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-download newest episodes for a feed */
|
||||||
|
const autoDownloadEpisodes = (feedId: string, newEpisodes: Episode[], count: number) => {
|
||||||
|
try {
|
||||||
|
const dlStore = useDownloadStore()
|
||||||
|
// Sort by pubDate descending (newest first)
|
||||||
|
const sorted = [...newEpisodes].sort(
|
||||||
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||||
|
)
|
||||||
|
// count = 0 means download all new episodes
|
||||||
|
const toDownload = count > 0 ? sorted.slice(0, count) : sorted
|
||||||
|
for (const ep of toDownload) {
|
||||||
|
const status = dlStore.getDownloadStatus(ep.id)
|
||||||
|
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
||||||
|
dlStore.startDownload(ep, feedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Download store may not be available yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId)
|
const feed = getFeed(feedId)
|
||||||
if (!feed) return
|
if (!feed) return
|
||||||
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH)
|
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id))
|
||||||
|
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId)
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
|
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
|
||||||
@@ -229,6 +222,14 @@ export function createFeedStore() {
|
|||||||
saveFeeds(updated)
|
saveFeeds(updated)
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auto-download new episodes if enabled for this feed
|
||||||
|
if (feed.autoDownload) {
|
||||||
|
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id))
|
||||||
|
if (newEpisodes.length > 0) {
|
||||||
|
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh all feeds */
|
/** Refresh all feeds */
|
||||||
@@ -241,6 +242,8 @@ export function createFeedStore() {
|
|||||||
|
|
||||||
/** Remove a feed */
|
/** Remove a feed */
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
|
fullEpisodeCache.delete(feedId)
|
||||||
|
episodeLoadCount.delete(feedId)
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.filter((f) => f.id !== feedId)
|
const updated = prev.filter((f) => f.id !== feedId)
|
||||||
saveFeeds(updated)
|
saveFeeds(updated)
|
||||||
@@ -330,18 +333,81 @@ export function createFeedStore() {
|
|||||||
return id ? getFeed(id) : undefined
|
return id ? getFeed(id) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||||
|
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
|
const cached = fullEpisodeCache.get(feedId)
|
||||||
|
if (!cached) return false
|
||||||
|
const loaded = episodeLoadCount.get(feedId) ?? 0
|
||||||
|
return loaded < cached.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the next chunk of episodes for a feed from the cache.
|
||||||
|
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||||
|
const loadMoreEpisodes = async (feedId: string) => {
|
||||||
|
if (isLoadingMore()) return
|
||||||
|
const feed = getFeed(feedId)
|
||||||
|
if (!feed) return
|
||||||
|
|
||||||
|
setIsLoadingMore(true)
|
||||||
|
try {
|
||||||
|
let cached = fullEpisodeCache.get(feedId)
|
||||||
|
|
||||||
|
// If no cache, re-fetch and parse the full feed
|
||||||
|
if (!cached) {
|
||||||
|
const response = await fetch(feed.podcast.feedUrl, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) return
|
||||||
|
const xml = await response.text()
|
||||||
|
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl)
|
||||||
|
cached = parsed.episodes
|
||||||
|
fullEpisodeCache.set(feedId, cached)
|
||||||
|
// Set current load count to match what's already displayed
|
||||||
|
episodeLoadCount.set(feedId, feed.episodes.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length
|
||||||
|
const newCount = Math.min(currentCount + MAX_EPISODES_REFRESH, cached.length)
|
||||||
|
|
||||||
|
if (newCount <= currentCount) return // nothing more to load
|
||||||
|
|
||||||
|
episodeLoadCount.set(feedId, newCount)
|
||||||
|
const episodes = cached.slice(0, newCount)
|
||||||
|
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, episodes } : f
|
||||||
|
)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set auto-download settings for a feed */
|
||||||
|
const setAutoDownload = (feedId: string, enabled: boolean, count: number = 0) => {
|
||||||
|
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
feeds,
|
feeds,
|
||||||
sources,
|
sources,
|
||||||
filter,
|
filter,
|
||||||
selectedFeedId,
|
selectedFeedId,
|
||||||
|
isLoadingMore,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
getFilteredFeeds,
|
getFilteredFeeds,
|
||||||
getAllEpisodesChronological,
|
getAllEpisodesChronological,
|
||||||
getFeed,
|
getFeed,
|
||||||
getSelectedFeed,
|
getSelectedFeed,
|
||||||
|
hasMoreEpisodes,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setFilter,
|
setFilter,
|
||||||
@@ -352,10 +418,12 @@ export function createFeedStore() {
|
|||||||
togglePinned,
|
togglePinned,
|
||||||
refreshFeed,
|
refreshFeed,
|
||||||
refreshAllFeeds,
|
refreshAllFeeds,
|
||||||
|
loadMoreEpisodes,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
updateSource,
|
updateSource,
|
||||||
|
setAutoDownload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Episode progress store for PodTUI
|
* Episode progress store for PodTUI
|
||||||
*
|
*
|
||||||
* Persists per-episode playback progress to localStorage.
|
* Persists per-episode playback progress to a JSON file in XDG_CONFIG_HOME.
|
||||||
* Tracks position, duration, completion, and last-played timestamp.
|
* Tracks position, duration, completion, and last-played timestamp.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import type { Progress } from "../types/episode"
|
import type { Progress } from "../types/episode"
|
||||||
|
import {
|
||||||
const STORAGE_KEY = "podtui_progress"
|
loadProgressFromFile,
|
||||||
|
saveProgressToFile,
|
||||||
|
migrateProgressFromLocalStorage,
|
||||||
|
} from "../utils/app-persistence"
|
||||||
|
|
||||||
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
||||||
const COMPLETION_THRESHOLD = 0.95
|
const COMPLETION_THRESHOLD = 0.95
|
||||||
@@ -16,15 +19,19 @@ const COMPLETION_THRESHOLD = 0.95
|
|||||||
/** Minimum seconds of progress before persisting */
|
/** Minimum seconds of progress before persisting */
|
||||||
const MIN_POSITION_TO_SAVE = 5
|
const MIN_POSITION_TO_SAVE = 5
|
||||||
|
|
||||||
// --- localStorage helpers ---
|
// --- Singleton store ---
|
||||||
|
|
||||||
function loadProgress(): Record<string, Progress> {
|
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({})
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
/** Persist current progress map to file (fire-and-forget) */
|
||||||
if (!raw) return {}
|
function persist(): void {
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
saveProgressToFile(progressMap()).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse raw progress entries from file, reviving Date objects */
|
||||||
|
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> {
|
||||||
const result: Record<string, Progress> = {}
|
const result: Record<string, Progress> = {}
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
const p = value as Record<string, unknown>
|
const p = value as Record<string, unknown>
|
||||||
result[key] = {
|
result[key] = {
|
||||||
episodeId: p.episodeId as string,
|
episodeId: p.episodeId as string,
|
||||||
@@ -35,28 +42,18 @@ function loadProgress(): Record<string, Progress> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveProgress(data: Record<string, Progress>): void {
|
/** Async initialisation — migrate from localStorage then load from file */
|
||||||
try {
|
async function initProgress(): Promise<void> {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
await migrateProgressFromLocalStorage()
|
||||||
} catch {
|
const raw = await loadProgressFromFile()
|
||||||
// Quota exceeded or unavailable — silently ignore
|
const parsed = parseProgressEntries(raw as Record<string, unknown>)
|
||||||
}
|
setProgressMap(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Singleton store ---
|
// Fire-and-forget init
|
||||||
|
initProgress()
|
||||||
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
|
|
||||||
loadProgress(),
|
|
||||||
)
|
|
||||||
|
|
||||||
function persist(): void {
|
|
||||||
saveProgress(progressMap())
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProgressStore() {
|
function createProgressStore() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -84,3 +84,34 @@ export interface EpisodeListItem {
|
|||||||
/** Progress percentage (0-100) */
|
/** Progress percentage (0-100) */
|
||||||
progressPercent: number
|
progressPercent: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download status for an episode */
|
||||||
|
export enum DownloadStatus {
|
||||||
|
NONE = "none",
|
||||||
|
QUEUED = "queued",
|
||||||
|
DOWNLOADING = "downloading",
|
||||||
|
COMPLETED = "completed",
|
||||||
|
FAILED = "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata for a downloaded episode */
|
||||||
|
export interface DownloadedEpisode {
|
||||||
|
/** Episode ID */
|
||||||
|
episodeId: string
|
||||||
|
/** Feed ID the episode belongs to */
|
||||||
|
feedId: string
|
||||||
|
/** Current download status */
|
||||||
|
status: DownloadStatus
|
||||||
|
/** Download progress 0-100 */
|
||||||
|
progress: number
|
||||||
|
/** Absolute path to the downloaded file */
|
||||||
|
filePath: string | null
|
||||||
|
/** When the download completed */
|
||||||
|
downloadedAt: Date | null
|
||||||
|
/** Download speed in bytes/sec (while downloading) */
|
||||||
|
speed: number
|
||||||
|
/** File size in bytes */
|
||||||
|
fileSize: number
|
||||||
|
/** Error message if failed */
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface Feed {
|
|||||||
isPinned: boolean
|
isPinned: boolean
|
||||||
/** Feed color for UI */
|
/** Feed color for UI */
|
||||||
color?: string
|
color?: string
|
||||||
|
/** Whether auto-download is enabled for this feed */
|
||||||
|
autoDownload?: boolean
|
||||||
|
/** Number of newest episodes to auto-download (0 = all new) */
|
||||||
|
autoDownloadCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed item for display in lists */
|
/** Feed item for display in lists */
|
||||||
|
|||||||
163
src/utils/app-persistence.ts
Normal file
163
src/utils/app-persistence.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* App state persistence via JSON file in XDG_CONFIG_HOME
|
||||||
|
*
|
||||||
|
* Reads and writes app settings, preferences, and custom theme to a JSON file
|
||||||
|
* instead of localStorage. Provides migration from localStorage on first run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
|
||||||
|
import { backupConfigFile } from "./config-backup"
|
||||||
|
import type { AppState, AppSettings, UserPreferences, ThemeColors } from "../types/settings"
|
||||||
|
import { DEFAULT_THEME } from "../constants/themes"
|
||||||
|
|
||||||
|
const APP_STATE_FILE = "app-state.json"
|
||||||
|
const PROGRESS_FILE = "progress.json"
|
||||||
|
|
||||||
|
const LEGACY_APP_STATE_KEY = "podtui_app_state"
|
||||||
|
const LEGACY_PROGRESS_KEY = "podtui_progress"
|
||||||
|
|
||||||
|
// --- Defaults ---
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- App State ---
|
||||||
|
|
||||||
|
/** Load app state from JSON file */
|
||||||
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(APP_STATE_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) return defaultState
|
||||||
|
|
||||||
|
const raw = await file.json()
|
||||||
|
if (!raw || typeof raw !== "object") return defaultState
|
||||||
|
|
||||||
|
const parsed = raw as Partial<AppState>
|
||||||
|
return {
|
||||||
|
settings: { ...defaultSettings, ...parsed.settings },
|
||||||
|
preferences: { ...defaultPreferences, ...parsed.preferences },
|
||||||
|
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return defaultState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save app state to JSON file */
|
||||||
|
export async function saveAppStateToFile(state: AppState): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
await backupConfigFile(APP_STATE_FILE)
|
||||||
|
const filePath = getConfigFilePath(APP_STATE_FILE)
|
||||||
|
await Bun.write(filePath, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate app state from localStorage to file.
|
||||||
|
* Only runs once — if the state file already exists, it's a no-op.
|
||||||
|
*/
|
||||||
|
export async function migrateAppStateFromLocalStorage(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(APP_STATE_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (await file.exists()) return false
|
||||||
|
|
||||||
|
if (typeof localStorage === "undefined") return false
|
||||||
|
|
||||||
|
const raw = localStorage.getItem(LEGACY_APP_STATE_KEY)
|
||||||
|
if (!raw) return false
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AppState>
|
||||||
|
const state: AppState = {
|
||||||
|
settings: { ...defaultSettings, ...parsed.settings },
|
||||||
|
preferences: { ...defaultPreferences, ...parsed.preferences },
|
||||||
|
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveAppStateToFile(state)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Progress ---
|
||||||
|
|
||||||
|
interface ProgressEntry {
|
||||||
|
episodeId: string
|
||||||
|
position: number
|
||||||
|
duration: number
|
||||||
|
timestamp: string | Date
|
||||||
|
playbackSpeed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load progress map from JSON file */
|
||||||
|
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(PROGRESS_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) return {}
|
||||||
|
|
||||||
|
const raw = await file.json()
|
||||||
|
if (!raw || typeof raw !== "object") return {}
|
||||||
|
return raw as Record<string, ProgressEntry>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save progress map to JSON file */
|
||||||
|
export async function saveProgressToFile(data: Record<string, unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
await backupConfigFile(PROGRESS_FILE)
|
||||||
|
const filePath = getConfigFilePath(PROGRESS_FILE)
|
||||||
|
await Bun.write(filePath, JSON.stringify(data, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate progress from localStorage to file.
|
||||||
|
* Only runs once — if the progress file already exists, it's a no-op.
|
||||||
|
*/
|
||||||
|
export async function migrateProgressFromLocalStorage(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(PROGRESS_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (await file.exists()) return false
|
||||||
|
|
||||||
|
if (typeof localStorage === "undefined") return false
|
||||||
|
|
||||||
|
const raw = localStorage.getItem(LEGACY_PROGRESS_KEY)
|
||||||
|
if (!raw) return false
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") return false
|
||||||
|
|
||||||
|
await saveProgressToFile(parsed as Record<string, unknown>)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -452,12 +452,22 @@ class FfplayBackend implements AudioBackend {
|
|||||||
|
|
||||||
async setVolume(volume: number): Promise<void> {
|
async setVolume(volume: number): Promise<void> {
|
||||||
this._volume = Math.round(volume * 100)
|
this._volume = Math.round(volume * 100)
|
||||||
// ffplay can't change volume at runtime; apply on next play
|
// ffplay has no runtime IPC; volume will apply on next play/resume.
|
||||||
|
// Restart the process to apply immediately if currently playing.
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSpeed(speed: number): Promise<void> {
|
async setSpeed(speed: number): Promise<void> {
|
||||||
this._speed = speed
|
this._speed = speed
|
||||||
// ffplay doesn't support runtime speed changes
|
// ffplay doesn't support runtime speed changes; no restart possible
|
||||||
|
// since ffplay has no speed CLI flag. Speed only affects position tracking.
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
@@ -588,10 +598,28 @@ class AfplayBackend implements AudioBackend {
|
|||||||
|
|
||||||
async setVolume(volume: number): Promise<void> {
|
async setVolume(volume: number): Promise<void> {
|
||||||
this._volume = volume
|
this._volume = volume
|
||||||
|
// Restart the process with new volume to apply immediately
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSpeed(speed: number): Promise<void> {
|
async setSpeed(speed: number): Promise<void> {
|
||||||
this._speed = speed
|
this._speed = speed
|
||||||
|
// Restart the process with new rate to apply immediately
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
|
|||||||
149
src/utils/audio-waveform.ts
Normal file
149
src/utils/audio-waveform.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Audio waveform analysis for PodTUI
|
||||||
|
*
|
||||||
|
* Extracts amplitude data from audio files using ffmpeg (when available)
|
||||||
|
* or generates procedural waveform data as a fallback. Results are cached
|
||||||
|
* in-memory keyed by audio URL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Number of amplitude data points to generate */
|
||||||
|
const DEFAULT_RESOLUTION = 128
|
||||||
|
|
||||||
|
/** In-memory cache: audioUrl -> amplitude data */
|
||||||
|
const waveformCache = new Map<string, number[]>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract real waveform data from an audio URL using ffmpeg.
|
||||||
|
* Returns null if ffmpeg is not available or the extraction fails.
|
||||||
|
*/
|
||||||
|
async function extractWithFfmpeg(audioUrl: string, resolution: number): Promise<number[] | null> {
|
||||||
|
try {
|
||||||
|
if (!Bun.which("ffmpeg")) return null
|
||||||
|
|
||||||
|
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
|
||||||
|
// -t 300: read at most 5 minutes (enough data to fill the waveform)
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", audioUrl,
|
||||||
|
"-t", "300",
|
||||||
|
"-ac", "1", // mono
|
||||||
|
"-ar", "8000", // low sample rate to keep data small
|
||||||
|
"-f", "s16le", // raw signed 16-bit PCM
|
||||||
|
"-v", "quiet",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "ignore" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const output = await new Response(proc.stdout).arrayBuffer()
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
|
if (output.byteLength === 0) return null
|
||||||
|
|
||||||
|
const samples = new Int16Array(output)
|
||||||
|
if (samples.length === 0) return null
|
||||||
|
|
||||||
|
// Downsample to `resolution` buckets by taking the max absolute amplitude
|
||||||
|
// in each bucket.
|
||||||
|
const bucketSize = Math.max(1, Math.floor(samples.length / resolution))
|
||||||
|
const data: number[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < resolution; i++) {
|
||||||
|
const start = i * bucketSize
|
||||||
|
const end = Math.min(start + bucketSize, samples.length)
|
||||||
|
let maxAbs = 0
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const abs = Math.abs(samples[j])
|
||||||
|
if (abs > maxAbs) maxAbs = abs
|
||||||
|
}
|
||||||
|
// Normalise to 0-1
|
||||||
|
data.push(Number((maxAbs / 32768).toFixed(3)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a procedural (fake) waveform that looks plausible.
|
||||||
|
* Uses a combination of sine waves with different frequencies to
|
||||||
|
* simulate varying audio energy.
|
||||||
|
*/
|
||||||
|
function generateProcedural(resolution: number, seed: number): number[] {
|
||||||
|
const data: number[] = []
|
||||||
|
for (let i = 0; i < resolution; i++) {
|
||||||
|
const t = i + seed
|
||||||
|
const value =
|
||||||
|
0.15 +
|
||||||
|
Math.abs(Math.sin(t / 3.7)) * 0.35 +
|
||||||
|
Math.abs(Math.sin(t / 7.3)) * 0.25 +
|
||||||
|
Math.abs(Math.sin(t / 13.1)) * 0.15 +
|
||||||
|
(Math.random() * 0.1)
|
||||||
|
data.push(Number(Math.min(1, value).toFixed(3)))
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple numeric hash of a string, used to seed procedural generation
|
||||||
|
* so the same URL always produces the same waveform.
|
||||||
|
*/
|
||||||
|
function hashString(s: string): number {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h = (h * 31 + s.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
return Math.abs(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get waveform data for an audio URL.
|
||||||
|
*
|
||||||
|
* Returns cached data if available, otherwise attempts ffmpeg extraction
|
||||||
|
* and falls back to procedural generation.
|
||||||
|
*/
|
||||||
|
export async function getWaveformData(
|
||||||
|
audioUrl: string,
|
||||||
|
resolution: number = DEFAULT_RESOLUTION,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const cacheKey = `${audioUrl}:${resolution}`
|
||||||
|
const cached = waveformCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
// Try real extraction first
|
||||||
|
const real = await extractWithFfmpeg(audioUrl, resolution)
|
||||||
|
if (real) {
|
||||||
|
waveformCache.set(cacheKey, real)
|
||||||
|
return real
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to procedural
|
||||||
|
const procedural = generateProcedural(resolution, hashString(audioUrl))
|
||||||
|
waveformCache.set(cacheKey, procedural)
|
||||||
|
return procedural
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous fallback: get a waveform immediately (from cache or procedural).
|
||||||
|
* Use this when you need data without waiting for async extraction.
|
||||||
|
*/
|
||||||
|
export function getWaveformDataSync(
|
||||||
|
audioUrl: string,
|
||||||
|
resolution: number = DEFAULT_RESOLUTION,
|
||||||
|
): number[] {
|
||||||
|
const cacheKey = `${audioUrl}:${resolution}`
|
||||||
|
const cached = waveformCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const procedural = generateProcedural(resolution, hashString(audioUrl))
|
||||||
|
waveformCache.set(cacheKey, procedural)
|
||||||
|
return procedural
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the waveform cache (for memory management) */
|
||||||
|
export function clearWaveformCache(): void {
|
||||||
|
waveformCache.clear()
|
||||||
|
}
|
||||||
96
src/utils/config-backup.ts
Normal file
96
src/utils/config-backup.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Config file backup utility for PodTUI
|
||||||
|
*
|
||||||
|
* Creates timestamped backups of config files before updates.
|
||||||
|
* Keeps the most recent N backups and cleans up older ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdir, unlink } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { getConfigDir, ensureConfigDir } from "./config-dir"
|
||||||
|
|
||||||
|
/** Maximum number of backup files to keep per config file */
|
||||||
|
const MAX_BACKUPS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a timestamped backup filename.
|
||||||
|
* Example: feeds.json -> feeds.json.2026-02-05T120000.backup
|
||||||
|
*/
|
||||||
|
function backupFilename(originalName: string): string {
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||||
|
return `${originalName}.${ts}.backup`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a backup of a config file before overwriting it.
|
||||||
|
* No-op if the source file does not exist.
|
||||||
|
*/
|
||||||
|
export async function backupConfigFile(filename: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
const dir = getConfigDir()
|
||||||
|
const srcPath = path.join(dir, filename)
|
||||||
|
const srcFile = Bun.file(srcPath)
|
||||||
|
|
||||||
|
if (!(await srcFile.exists())) return false
|
||||||
|
|
||||||
|
const content = await srcFile.text()
|
||||||
|
if (!content || content.trim().length === 0) return false
|
||||||
|
|
||||||
|
const backupName = backupFilename(filename)
|
||||||
|
const backupPath = path.join(dir, backupName)
|
||||||
|
await Bun.write(backupPath, content)
|
||||||
|
|
||||||
|
// Clean up old backups
|
||||||
|
await pruneBackups(filename)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the most recent MAX_BACKUPS backup files for a given config file.
|
||||||
|
*/
|
||||||
|
async function pruneBackups(filename: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dir = getConfigDir()
|
||||||
|
const entries = await readdir(dir)
|
||||||
|
|
||||||
|
// Match pattern: filename.*.backup
|
||||||
|
const prefix = `${filename}.`
|
||||||
|
const suffix = ".backup"
|
||||||
|
const backups = entries
|
||||||
|
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
|
||||||
|
.sort() // Lexicographic sort works because timestamps are ISO-like
|
||||||
|
|
||||||
|
if (backups.length <= MAX_BACKUPS) return
|
||||||
|
|
||||||
|
const toRemove = backups.slice(0, backups.length - MAX_BACKUPS)
|
||||||
|
for (const name of toRemove) {
|
||||||
|
await unlink(path.join(dir, name)).catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List existing backup files for a given config file, newest first.
|
||||||
|
*/
|
||||||
|
export async function listBackups(filename: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const dir = getConfigDir()
|
||||||
|
const entries = await readdir(dir)
|
||||||
|
|
||||||
|
const prefix = `${filename}.`
|
||||||
|
const suffix = ".backup"
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/utils/config-dir.ts
Normal file
72
src/utils/config-dir.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* XDG_CONFIG_HOME directory setup for PodTUI
|
||||||
|
*
|
||||||
|
* Handles config directory detection and creation following the XDG Base
|
||||||
|
* Directory Specification. Falls back to ~/.config when XDG_CONFIG_HOME
|
||||||
|
* is not set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
/** Application config directory name */
|
||||||
|
const APP_DIR_NAME = "podtui"
|
||||||
|
|
||||||
|
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
||||||
|
export function getXdgConfigHome(): string {
|
||||||
|
const xdg = process.env.XDG_CONFIG_HOME
|
||||||
|
if (xdg) return xdg
|
||||||
|
|
||||||
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
|
||||||
|
if (!home) throw new Error("Cannot determine home directory")
|
||||||
|
|
||||||
|
return path.join(home, ".config")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the application-specific config directory path */
|
||||||
|
export function getConfigDir(): string {
|
||||||
|
return path.join(getXdgConfigHome(), APP_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the path for a specific config file */
|
||||||
|
export function getConfigFilePath(filename: string): string {
|
||||||
|
return path.join(getConfigDir(), filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the application config directory exists.
|
||||||
|
* Creates it recursively if needed.
|
||||||
|
*/
|
||||||
|
export async function ensureConfigDir(): Promise<string> {
|
||||||
|
const dir = getConfigDir()
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
||||||
|
export function getXdgDataHome(): string {
|
||||||
|
const xdg = process.env.XDG_DATA_HOME
|
||||||
|
if (xdg) return xdg
|
||||||
|
|
||||||
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
|
||||||
|
if (!home) throw new Error("Cannot determine home directory")
|
||||||
|
|
||||||
|
return path.join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the application-specific data directory path */
|
||||||
|
export function getDataDir(): string {
|
||||||
|
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the downloads directory path */
|
||||||
|
export function getDownloadsDir(): string {
|
||||||
|
return path.join(getDataDir(), "downloads")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the downloads directory exists */
|
||||||
|
export async function ensureDownloadsDir(): Promise<string> {
|
||||||
|
const dir = getDownloadsDir()
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
166
src/utils/config-validation.ts
Normal file
166
src/utils/config-validation.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Config file validation and migration for PodTUI
|
||||||
|
*
|
||||||
|
* Validates JSON structure of config files, handles corrupted files
|
||||||
|
* gracefully (falling back to defaults), and provides a single
|
||||||
|
* entry-point to migrate all localStorage data to XDG config files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConfigFilePath } from "./config-dir"
|
||||||
|
import {
|
||||||
|
migrateAppStateFromLocalStorage,
|
||||||
|
migrateProgressFromLocalStorage,
|
||||||
|
} from "./app-persistence"
|
||||||
|
import {
|
||||||
|
migrateFeedsFromLocalStorage,
|
||||||
|
migrateSourcesFromLocalStorage,
|
||||||
|
} from "./feeds-persistence"
|
||||||
|
|
||||||
|
// --- Validation helpers ---
|
||||||
|
|
||||||
|
/** Check that a value is a non-null object */
|
||||||
|
function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return v !== null && typeof v === "object" && !Array.isArray(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate AppState JSON structure */
|
||||||
|
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
if (!isObject(data)) {
|
||||||
|
return { valid: false, errors: ["app-state.json is not an object"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings
|
||||||
|
if (data.settings !== undefined) {
|
||||||
|
if (!isObject(data.settings)) {
|
||||||
|
errors.push("settings must be an object")
|
||||||
|
} else {
|
||||||
|
const s = data.settings as Record<string, unknown>
|
||||||
|
if (s.theme !== undefined && typeof s.theme !== "string") errors.push("settings.theme must be a string")
|
||||||
|
if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number")
|
||||||
|
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number")
|
||||||
|
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferences
|
||||||
|
if (data.preferences !== undefined) {
|
||||||
|
if (!isObject(data.preferences)) {
|
||||||
|
errors.push("preferences must be an object")
|
||||||
|
} else {
|
||||||
|
const p = data.preferences as Record<string, unknown>
|
||||||
|
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") errors.push("preferences.showExplicit must be a boolean")
|
||||||
|
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// customTheme
|
||||||
|
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
||||||
|
errors.push("customTheme must be an object")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate feeds JSON structure */
|
||||||
|
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return { valid: false, errors: ["feeds.json is not an array"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const feed = data[i]
|
||||||
|
if (!isObject(feed)) {
|
||||||
|
errors.push(`feeds[${i}] is not an object`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`)
|
||||||
|
if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`)
|
||||||
|
if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate progress JSON structure */
|
||||||
|
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
if (!isObject(data)) {
|
||||||
|
return { valid: false, errors: ["progress.json is not an object"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (!isObject(value)) {
|
||||||
|
errors.push(`progress["${key}"] is not an object`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const p = value as Record<string, unknown>
|
||||||
|
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`)
|
||||||
|
if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`)
|
||||||
|
if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Safe config file reading ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely read and validate a config file.
|
||||||
|
* Returns the parsed data if valid, or null if the file is missing/corrupt.
|
||||||
|
*/
|
||||||
|
export async function safeReadConfigFile<T>(
|
||||||
|
filename: string,
|
||||||
|
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
||||||
|
): Promise<{ data: T | null; errors: string[] }> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(filename)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
return { data: null, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await file.text()
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { data: null, errors: [`${filename}: invalid JSON`] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validator(parsed)
|
||||||
|
if (!result.valid) {
|
||||||
|
return { data: null, errors: result.errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: parsed as T, errors: [] }
|
||||||
|
} catch (err) {
|
||||||
|
return { data: null, errors: [`${filename}: ${String(err)}`] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Unified migration ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all localStorage -> file migrations.
|
||||||
|
* Safe to call multiple times; each migration is a no-op if the target
|
||||||
|
* file already exists.
|
||||||
|
*
|
||||||
|
* Returns a summary of what was migrated.
|
||||||
|
*/
|
||||||
|
export async function migrateAllFromLocalStorage(): Promise<{
|
||||||
|
appState: boolean
|
||||||
|
progress: boolean
|
||||||
|
feeds: boolean
|
||||||
|
sources: boolean
|
||||||
|
}> {
|
||||||
|
const [appState, progress, feeds, sources] = await Promise.all([
|
||||||
|
migrateAppStateFromLocalStorage(),
|
||||||
|
migrateProgressFromLocalStorage(),
|
||||||
|
migrateFeedsFromLocalStorage(),
|
||||||
|
migrateSourcesFromLocalStorage(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { appState, progress, feeds, sources }
|
||||||
|
}
|
||||||
199
src/utils/episode-downloader.ts
Normal file
199
src/utils/episode-downloader.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Episode download utility for PodTUI
|
||||||
|
*
|
||||||
|
* Streams audio files from episode URLs to the local downloads directory
|
||||||
|
* using fetch() + ReadableStream. Supports progress tracking and cancellation
|
||||||
|
* via AbortController.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
import { ensureDownloadsDir } from "./config-dir"
|
||||||
|
|
||||||
|
/** Progress callback info */
|
||||||
|
export interface DownloadProgress {
|
||||||
|
/** Bytes downloaded so far */
|
||||||
|
bytesDownloaded: number
|
||||||
|
/** Total file size in bytes (0 if unknown) */
|
||||||
|
totalBytes: number
|
||||||
|
/** Progress percentage 0-100 (or -1 if total unknown) */
|
||||||
|
percent: number
|
||||||
|
/** Download speed in bytes/sec */
|
||||||
|
speed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download result */
|
||||||
|
export interface DownloadResult {
|
||||||
|
/** Whether the download succeeded */
|
||||||
|
success: boolean
|
||||||
|
/** Absolute path to the downloaded file */
|
||||||
|
filePath: string
|
||||||
|
/** File size in bytes */
|
||||||
|
fileSize: number
|
||||||
|
/** Error message if failed */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string for use as a filename.
|
||||||
|
* Removes or replaces characters that are invalid in file paths.
|
||||||
|
*/
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[/\\?%*:|"<>]/g, "-")
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^[-_.]+/, "")
|
||||||
|
.slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a filename from the episode URL or title.
|
||||||
|
*/
|
||||||
|
function deriveFilename(audioUrl: string, episodeTitle: string): string {
|
||||||
|
// Try to extract filename from URL
|
||||||
|
try {
|
||||||
|
const url = new URL(audioUrl)
|
||||||
|
const urlFilename = path.basename(url.pathname)
|
||||||
|
if (urlFilename && urlFilename.includes(".")) {
|
||||||
|
return sanitizeFilename(decodeURIComponent(urlFilename))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to title-based name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to sanitized title + .mp3
|
||||||
|
const ext = ".mp3"
|
||||||
|
return sanitizeFilename(episodeTitle) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an episode audio file with progress tracking and cancellation support.
|
||||||
|
*
|
||||||
|
* @param audioUrl - URL of the audio file to download
|
||||||
|
* @param episodeTitle - Episode title (used for filename fallback)
|
||||||
|
* @param feedId - Feed ID (used to organize downloads into subdirectories)
|
||||||
|
* @param onProgress - Optional callback invoked periodically with download progress
|
||||||
|
* @param abortSignal - Optional AbortSignal for cancellation
|
||||||
|
* @returns DownloadResult with file path and size info
|
||||||
|
*/
|
||||||
|
export async function downloadEpisode(
|
||||||
|
audioUrl: string,
|
||||||
|
episodeTitle: string,
|
||||||
|
feedId: string,
|
||||||
|
onProgress?: (progress: DownloadProgress) => void,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<DownloadResult> {
|
||||||
|
const downloadsDir = await ensureDownloadsDir()
|
||||||
|
const feedDir = path.join(downloadsDir, feedId)
|
||||||
|
await Bun.write(path.join(feedDir, ".keep"), "") // ensures dir exists
|
||||||
|
const { unlink } = await import("fs/promises")
|
||||||
|
await unlink(path.join(feedDir, ".keep")).catch(() => {})
|
||||||
|
const { mkdir } = await import("fs/promises")
|
||||||
|
await mkdir(feedDir, { recursive: true })
|
||||||
|
|
||||||
|
const filename = deriveFilename(audioUrl, episodeTitle)
|
||||||
|
const filePath = path.join(feedDir, filename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(audioUrl, {
|
||||||
|
signal: abortSignal,
|
||||||
|
headers: {
|
||||||
|
"Accept": "audio/*, */*",
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = parseInt(response.headers.get("content-length") ?? "0", 10)
|
||||||
|
const body = response.body
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: "No response body",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = body.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let bytesDownloaded = 0
|
||||||
|
let lastProgressTime = Date.now()
|
||||||
|
let lastProgressBytes = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
chunks.push(value)
|
||||||
|
bytesDownloaded += value.length
|
||||||
|
|
||||||
|
// Report progress roughly every 250ms
|
||||||
|
const now = Date.now()
|
||||||
|
if (onProgress && now - lastProgressTime >= 250) {
|
||||||
|
const elapsed = (now - lastProgressTime) / 1000
|
||||||
|
const speed = elapsed > 0 ? (bytesDownloaded - lastProgressBytes) / elapsed : 0
|
||||||
|
const percent = contentLength > 0
|
||||||
|
? Math.round((bytesDownloaded / contentLength) * 100)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
onProgress({ bytesDownloaded, totalBytes: contentLength, percent, speed })
|
||||||
|
lastProgressTime = now
|
||||||
|
lastProgressBytes = bytesDownloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate chunks and write to file
|
||||||
|
const totalSize = bytesDownloaded
|
||||||
|
const buffer = new Uint8Array(totalSize)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
buffer.set(chunk, offset)
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(filePath, buffer)
|
||||||
|
|
||||||
|
// Final progress report
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
bytesDownloaded: totalSize,
|
||||||
|
totalBytes: contentLength || totalSize,
|
||||||
|
percent: 100,
|
||||||
|
speed: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
fileSize: totalSize,
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: "Download cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown download error"
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,17 @@ export type AppEvents = {
|
|||||||
"dialog.open": { dialogId: string }
|
"dialog.open": { dialogId: string }
|
||||||
"dialog.close": { dialogId?: string }
|
"dialog.close": { dialogId?: string }
|
||||||
"command.execute": { command: string; args?: unknown }
|
"command.execute": { command: string; args?: unknown }
|
||||||
|
"clipboard.copied": { text: string }
|
||||||
|
"selection.start": { x: number; y: number }
|
||||||
|
"selection.end": { text: string }
|
||||||
|
|
||||||
|
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
||||||
|
"media.toggle": {}
|
||||||
|
"media.volumeUp": {}
|
||||||
|
"media.volumeDown": {}
|
||||||
|
"media.seekForward": {}
|
||||||
|
"media.seekBackward": {}
|
||||||
|
"media.speedCycle": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-safe emit and on functions
|
// Type-safe emit and on functions
|
||||||
|
|||||||
132
src/utils/feeds-persistence.ts
Normal file
132
src/utils/feeds-persistence.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
||||||
|
*
|
||||||
|
* Reads and writes feeds to a JSON file instead of localStorage.
|
||||||
|
* Provides migration from localStorage on first run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
|
||||||
|
import { backupConfigFile } from "./config-backup"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
|
||||||
|
const FEEDS_FILE = "feeds.json"
|
||||||
|
const SOURCES_FILE = "sources.json"
|
||||||
|
|
||||||
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
|
function reviveDates(feed: Feed): Feed {
|
||||||
|
return {
|
||||||
|
...feed,
|
||||||
|
lastUpdated: new Date(feed.lastUpdated),
|
||||||
|
podcast: {
|
||||||
|
...feed.podcast,
|
||||||
|
lastUpdated: new Date(feed.podcast.lastUpdated),
|
||||||
|
},
|
||||||
|
episodes: feed.episodes.map((ep) => ({
|
||||||
|
...ep,
|
||||||
|
pubDate: new Date(ep.pubDate),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load feeds from JSON file */
|
||||||
|
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(FEEDS_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) return []
|
||||||
|
|
||||||
|
const raw = await file.json()
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.map(reviveDates)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save feeds to JSON file */
|
||||||
|
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
await backupConfigFile(FEEDS_FILE)
|
||||||
|
const filePath = getConfigFilePath(FEEDS_FILE)
|
||||||
|
await Bun.write(filePath, JSON.stringify(feeds, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load sources from JSON file */
|
||||||
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(SOURCES_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) return null
|
||||||
|
|
||||||
|
const raw = await file.json()
|
||||||
|
if (!Array.isArray(raw)) return null
|
||||||
|
return raw as T[]
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save sources to JSON file */
|
||||||
|
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
await backupConfigFile(SOURCES_FILE)
|
||||||
|
const filePath = getConfigFilePath(SOURCES_FILE)
|
||||||
|
await Bun.write(filePath, JSON.stringify(sources, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate feeds from localStorage to file.
|
||||||
|
* Only runs once — if the feeds file already exists, it's a no-op.
|
||||||
|
*/
|
||||||
|
export async function migrateFeedsFromLocalStorage(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(FEEDS_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (await file.exists()) return false // Already migrated
|
||||||
|
|
||||||
|
if (typeof localStorage === "undefined") return false
|
||||||
|
|
||||||
|
const raw = localStorage.getItem("podtui_feeds")
|
||||||
|
if (!raw) return false
|
||||||
|
|
||||||
|
const feeds = JSON.parse(raw) as Feed[]
|
||||||
|
if (!Array.isArray(feeds) || feeds.length === 0) return false
|
||||||
|
|
||||||
|
await saveFeedsToFile(feeds)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate sources from localStorage to file.
|
||||||
|
*/
|
||||||
|
export async function migrateSourcesFromLocalStorage(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(SOURCES_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (await file.exists()) return false
|
||||||
|
|
||||||
|
if (typeof localStorage === "undefined") return false
|
||||||
|
|
||||||
|
const raw = localStorage.getItem("podtui_sources")
|
||||||
|
if (!raw) return false
|
||||||
|
|
||||||
|
const sources = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(sources) || sources.length === 0) return false
|
||||||
|
|
||||||
|
await saveSourcesToFile(sources)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/utils/html-to-text.ts
Normal file
111
src/utils/html-to-text.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* HTML-to-text conversion for PodTUI
|
||||||
|
*
|
||||||
|
* Converts HTML content from RSS feed descriptions into clean plain text
|
||||||
|
* suitable for display in the terminal. Preserves paragraph structure,
|
||||||
|
* converts lists to bulleted text, and strips all tags.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML content to readable plain text.
|
||||||
|
*
|
||||||
|
* - Block elements (<p>, <div>, <br>, headings, <li>) become line breaks
|
||||||
|
* - <li> items get a bullet prefix
|
||||||
|
* - <a href="...">text</a> becomes "text (url)"
|
||||||
|
* - All other tags are stripped
|
||||||
|
* - HTML entities are decoded
|
||||||
|
* - Excessive whitespace is collapsed
|
||||||
|
*/
|
||||||
|
export function htmlToText(html: string): string {
|
||||||
|
if (!html) return ""
|
||||||
|
|
||||||
|
let text = html
|
||||||
|
|
||||||
|
// Strip CDATA wrappers
|
||||||
|
text = text.replace(/<!\[CDATA\[([\s\S]*?)]]>/gi, "$1")
|
||||||
|
|
||||||
|
// Replace <br> / <br/> with newline
|
||||||
|
text = text.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
|
||||||
|
// Replace <hr> with a separator line
|
||||||
|
text = text.replace(/<hr\s*\/?>/gi, "\n---\n")
|
||||||
|
|
||||||
|
// Block-level elements get newlines before/after
|
||||||
|
text = text.replace(/<\/?(p|div|blockquote|pre|h[1-6]|table|tr|section|article|header|footer)[\s>][^>]*>/gi, "\n")
|
||||||
|
|
||||||
|
// List items get bullet prefix
|
||||||
|
text = text.replace(/<li[^>]*>/gi, "\n - ")
|
||||||
|
text = text.replace(/<\/li>/gi, "")
|
||||||
|
|
||||||
|
// Strip list wrappers
|
||||||
|
text = text.replace(/<\/?(ul|ol|dl|dt|dd)[^>]*>/gi, "\n")
|
||||||
|
|
||||||
|
// Convert links: <a href="url">text</a> -> text (url)
|
||||||
|
text = text.replace(/<a\s[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, linkText) => {
|
||||||
|
const cleanText = stripTags(linkText).trim()
|
||||||
|
if (!cleanText) return href
|
||||||
|
// Don't duplicate if the link text IS the URL
|
||||||
|
if (cleanText === href || cleanText === href.replace(/^https?:\/\//, "")) return cleanText
|
||||||
|
return `${cleanText} (${href})`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strip all remaining tags
|
||||||
|
text = stripTags(text)
|
||||||
|
|
||||||
|
// Decode HTML entities
|
||||||
|
text = decodeHtmlEntities(text)
|
||||||
|
|
||||||
|
// Collapse multiple blank lines into at most two newlines
|
||||||
|
text = text.replace(/\n{3,}/g, "\n\n")
|
||||||
|
|
||||||
|
// Collapse runs of spaces/tabs (but not newlines) on each line
|
||||||
|
text = text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
return text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip all HTML/XML tags from a string */
|
||||||
|
function stripTags(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode common HTML entities */
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
// Named entities
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/—/g, "\u2014")
|
||||||
|
.replace(/–/g, "\u2013")
|
||||||
|
.replace(/…/g, "\u2026")
|
||||||
|
.replace(/«/g, "\u00AB")
|
||||||
|
.replace(/»/g, "\u00BB")
|
||||||
|
.replace(/“/g, "\u201C")
|
||||||
|
.replace(/”/g, "\u201D")
|
||||||
|
.replace(/‘/g, "\u2018")
|
||||||
|
.replace(/’/g, "\u2019")
|
||||||
|
.replace(/•/g, "\u2022")
|
||||||
|
.replace(/©/g, "\u00A9")
|
||||||
|
.replace(/®/g, "\u00AE")
|
||||||
|
.replace(/™/g, "\u2122")
|
||||||
|
.replace(/°/g, "\u00B0")
|
||||||
|
.replace(/×/g, "\u00D7")
|
||||||
|
// Numeric entities (decimal)
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => {
|
||||||
|
const n = parseInt(code, 10)
|
||||||
|
return n > 0 && n < 0x10ffff ? String.fromCodePoint(n) : ""
|
||||||
|
})
|
||||||
|
// Numeric entities (hex)
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||||
|
const n = parseInt(hex, 16)
|
||||||
|
return n > 0 && n < 0x10ffff ? String.fromCodePoint(n) : ""
|
||||||
|
})
|
||||||
|
}
|
||||||
192
src/utils/media-registry.ts
Normal file
192
src/utils/media-registry.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Platform-specific media session registration.
|
||||||
|
*
|
||||||
|
* Registers the currently playing track with the OS so that system
|
||||||
|
* media controls (notification center, lock screen, MPRIS) display
|
||||||
|
* track info and can send play/pause/next/prev commands.
|
||||||
|
*
|
||||||
|
* Implementations:
|
||||||
|
* - **macOS**: Shells out to `nowplaying-cli` (brew install nowplaying-cli)
|
||||||
|
* Falls back to no-op if the binary isn't available.
|
||||||
|
* - **Linux**: Writes a minimal MPRIS2 metadata file that desktop
|
||||||
|
* environments can pick up. Full D-Bus integration would
|
||||||
|
* require native bindings; this is best-effort.
|
||||||
|
* - **Other**: No-op stub.
|
||||||
|
*
|
||||||
|
* All methods are fire-and-forget and never throw.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "child_process"
|
||||||
|
|
||||||
|
export interface TrackMetadata {
|
||||||
|
title: string
|
||||||
|
artist?: string
|
||||||
|
album?: string
|
||||||
|
artworkUrl?: string
|
||||||
|
duration?: number // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaRegistryInstance {
|
||||||
|
/** Platform identifier */
|
||||||
|
readonly platform: "macos" | "linux" | "windows" | "unknown"
|
||||||
|
/** Whether the platform integration is available */
|
||||||
|
readonly available: boolean
|
||||||
|
|
||||||
|
/** Register / update now-playing metadata */
|
||||||
|
setNowPlaying(meta: TrackMetadata): void
|
||||||
|
/** Update playback position (seconds) */
|
||||||
|
setPosition(seconds: number): void
|
||||||
|
/** Update playing/paused state */
|
||||||
|
setPlaybackState(playing: boolean): void
|
||||||
|
/** Clear now-playing info (e.g. on stop) */
|
||||||
|
clearNowPlaying(): void
|
||||||
|
/** Tear down any resources */
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Platform detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function detectPlatform(): "macos" | "linux" | "windows" | "unknown" {
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
return "macos"
|
||||||
|
case "linux":
|
||||||
|
return "linux"
|
||||||
|
case "win32":
|
||||||
|
return "windows"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// macOS — nowplaying-cli
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function hasBinary(name: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = Bun.spawnSync(["which", name])
|
||||||
|
return result.exitCode === 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMacOSRegistry(): MediaRegistryInstance {
|
||||||
|
const hasNowPlaying = hasBinary("nowplaying-cli")
|
||||||
|
|
||||||
|
function run(args: string[]): void {
|
||||||
|
if (!hasNowPlaying) return
|
||||||
|
try {
|
||||||
|
const proc = spawn("nowplaying-cli", args, {
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
})
|
||||||
|
proc.unref()
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: "macos",
|
||||||
|
available: hasNowPlaying,
|
||||||
|
|
||||||
|
setNowPlaying(meta) {
|
||||||
|
const args = ["set", "title", meta.title]
|
||||||
|
if (meta.artist) args.push("artist", meta.artist)
|
||||||
|
if (meta.album) args.push("album", meta.album)
|
||||||
|
if (meta.duration) args.push("duration", String(meta.duration))
|
||||||
|
run(args)
|
||||||
|
},
|
||||||
|
|
||||||
|
setPosition(seconds) {
|
||||||
|
run(["set", "elapsedTime", String(Math.floor(seconds))])
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlaybackState(playing) {
|
||||||
|
run(["set", "playbackRate", playing ? "1" : "0"])
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNowPlaying() {
|
||||||
|
run(["clear"])
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
run(["clear"])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Linux — best-effort MPRIS stub
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createLinuxRegistry(): MediaRegistryInstance {
|
||||||
|
// Full MPRIS2 requires owning a D-Bus name and exposing the
|
||||||
|
// org.mpris.MediaPlayer2.Player interface. That needs native
|
||||||
|
// bindings (dbus-next, etc.) which adds significant complexity.
|
||||||
|
//
|
||||||
|
// For now we provide a no-op stub that can be upgraded later
|
||||||
|
// without changing the public interface.
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: "linux",
|
||||||
|
available: false,
|
||||||
|
|
||||||
|
setNowPlaying() {},
|
||||||
|
setPosition() {},
|
||||||
|
setPlaybackState() {},
|
||||||
|
clearNowPlaying() {},
|
||||||
|
dispose() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// No-op fallback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createNoopRegistry(platform: "windows" | "unknown"): MediaRegistryInstance {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
available: false,
|
||||||
|
|
||||||
|
setNowPlaying() {},
|
||||||
|
setPosition() {},
|
||||||
|
setPlaybackState() {},
|
||||||
|
clearNowPlaying() {},
|
||||||
|
dispose() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory & singleton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let instance: MediaRegistryInstance | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the singleton MediaRegistry for the current platform.
|
||||||
|
* Always safe to call — returns a no-op if no integration is available.
|
||||||
|
*/
|
||||||
|
export function useMediaRegistry(): MediaRegistryInstance {
|
||||||
|
if (instance) return instance
|
||||||
|
|
||||||
|
const platform = detectPlatform()
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case "macos":
|
||||||
|
instance = createMacOSRegistry()
|
||||||
|
break
|
||||||
|
case "linux":
|
||||||
|
instance = createLinuxRegistry()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
instance = createNoopRegistry(platform)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
40
src/utils/rss-content-detector.ts
Normal file
40
src/utils/rss-content-detector.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* RSS content type detection for PodTUI
|
||||||
|
*
|
||||||
|
* Determines whether RSS feed content (description, etc.) is HTML or plain
|
||||||
|
* text so the appropriate parsing path can be selected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum ContentType {
|
||||||
|
HTML = "html",
|
||||||
|
PLAIN_TEXT = "plain_text",
|
||||||
|
UNKNOWN = "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Common HTML tags found in RSS descriptions */
|
||||||
|
const HTML_TAG_RE = /<\s*\/?\s*(div|p|br|a|b|i|em|strong|ul|ol|li|span|h[1-6]|img|table|tr|td|blockquote|pre|code|hr)\b[^>]*\/?>/i
|
||||||
|
|
||||||
|
/** HTML entity patterns beyond the basic five (& etc.) */
|
||||||
|
const HTML_ENTITY_RE = /&(nbsp|mdash|ndash|hellip|laquo|raquo|ldquo|rdquo|lsquo|rsquo|bull|#\d{2,5}|#x[0-9a-fA-F]{2,4});/
|
||||||
|
|
||||||
|
/** CDATA wrapper — content inside is almost always HTML */
|
||||||
|
const CDATA_RE = /^\s*<!\[CDATA\[/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether a string contains HTML markup or is plain text.
|
||||||
|
*/
|
||||||
|
export function detectContentType(content: string): ContentType {
|
||||||
|
if (!content || content.trim().length === 0) return ContentType.UNKNOWN
|
||||||
|
|
||||||
|
// CDATA-wrapped content is nearly always HTML
|
||||||
|
if (CDATA_RE.test(content)) return ContentType.HTML
|
||||||
|
|
||||||
|
// Check for standard HTML tags
|
||||||
|
if (HTML_TAG_RE.test(content)) return ContentType.HTML
|
||||||
|
|
||||||
|
// Check for extended HTML entities (basic & / < / etc. can appear in
|
||||||
|
// plain text too, so we only look for the less common ones)
|
||||||
|
if (HTML_ENTITY_RE.test(content)) return ContentType.HTML
|
||||||
|
|
||||||
|
return ContentType.PLAIN_TEXT
|
||||||
|
}
|
||||||
128
tasks/INDEX.md
Normal file
128
tasks/INDEX.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# PodTUI Task Index
|
||||||
|
|
||||||
|
This directory contains all task files for the PodTUI project feature implementation.
|
||||||
|
|
||||||
|
## Task Structure
|
||||||
|
|
||||||
|
Each feature has its own directory with:
|
||||||
|
- `README.md` - Feature overview and task list
|
||||||
|
- `{seq}-{task-description}.md` - Individual task files
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
### 1. Text Selection Copy to Clipboard
|
||||||
|
**Feature:** Text selection copy to clipboard
|
||||||
|
**Tasks:** 2 tasks
|
||||||
|
**Directory:** `tasks/text-selection-copy/`
|
||||||
|
|
||||||
|
### 2. HTML vs Plain Text RSS Parsing
|
||||||
|
**Feature:** Detect and handle both HTML and plain text content in RSS feeds
|
||||||
|
**Tasks:** 3 tasks
|
||||||
|
**Directory:** `tasks/rss-content-parsing/`
|
||||||
|
|
||||||
|
### 3. Merged Waveform Progress Bar
|
||||||
|
**Feature:** Create a real-time waveform visualization that expands from a progress bar during playback
|
||||||
|
**Tasks:** 4 tasks
|
||||||
|
**Directory:** `tasks/merged-waveform/`
|
||||||
|
|
||||||
|
### 4. Episode List Infinite Scroll
|
||||||
|
**Feature:** Implement scroll-to-bottom loading for episode lists with MAX_EPISODES_REFRESH limit
|
||||||
|
**Tasks:** 4 tasks
|
||||||
|
**Directory:** `tasks/episode-infinite-scroll/`
|
||||||
|
|
||||||
|
### 5. Episode Downloads
|
||||||
|
**Feature:** Add per-episode download and per-feed auto-download settings
|
||||||
|
**Tasks:** 6 tasks
|
||||||
|
**Directory:** `tasks/episode-downloads/`
|
||||||
|
|
||||||
|
### 6. Discover Categories Shortcuts Fix
|
||||||
|
**Feature:** Fix broken discover category filter functionality
|
||||||
|
**Tasks:** 3 tasks
|
||||||
|
**Directory:** `tasks/discover-categories-fix/`
|
||||||
|
|
||||||
|
### 7. Config Persistence to XDG_CONFIG_HOME
|
||||||
|
**Feature:** Move feeds and themes persistence from localStorage to XDG_CONFIG_HOME directory
|
||||||
|
**Tasks:** 5 tasks
|
||||||
|
**Directory:** `tasks/config-persistence/`
|
||||||
|
|
||||||
|
### 8. Audio Playback Fix
|
||||||
|
**Feature:** Fix non-functional volume/speed controls and add multimedia key support
|
||||||
|
**Tasks:** 5 tasks
|
||||||
|
**Directory:** `tasks/audio-playback-fix/`
|
||||||
|
|
||||||
|
## Task Summary
|
||||||
|
|
||||||
|
**Total Features:** 8
|
||||||
|
**Total Tasks:** 32
|
||||||
|
**Critical Path:** Feature 7 (Config Persistence) - 5 tasks, Feature 8 (Audio Playback Fix) - 5 tasks
|
||||||
|
|
||||||
|
## Task Dependencies
|
||||||
|
|
||||||
|
### Feature 1: Text Selection Copy to Clipboard
|
||||||
|
- 01 → 02
|
||||||
|
|
||||||
|
### Feature 2: HTML vs Plain Text RSS Parsing
|
||||||
|
- 03 → 04
|
||||||
|
- 03 → 05
|
||||||
|
|
||||||
|
### Feature 3: Merged Waveform Progress Bar
|
||||||
|
- 06 → 07
|
||||||
|
- 07 → 08
|
||||||
|
- 08 → 09
|
||||||
|
|
||||||
|
### Feature 4: Episode List Infinite Scroll
|
||||||
|
- 10 → 11
|
||||||
|
- 11 → 12
|
||||||
|
- 12 → 13
|
||||||
|
|
||||||
|
### Feature 5: Episode Downloads
|
||||||
|
- 14 → 15
|
||||||
|
- 15 → 16
|
||||||
|
- 16 → 17
|
||||||
|
- 17 → 18
|
||||||
|
- 18 → 19
|
||||||
|
|
||||||
|
### Feature 6: Discover Categories Shortcuts Fix
|
||||||
|
- 20 → 21
|
||||||
|
- 21 → 22
|
||||||
|
|
||||||
|
### Feature 7: Config Persistence to XDG_CONFIG_HOME
|
||||||
|
- 23 -> 24
|
||||||
|
- 23 -> 25
|
||||||
|
- 24 -> 26
|
||||||
|
- 25 -> 26
|
||||||
|
- 26 -> 27
|
||||||
|
|
||||||
|
### Feature 8: Audio Playback Fix
|
||||||
|
- 28 -> 29
|
||||||
|
- 29 -> 30
|
||||||
|
- 30 -> 31
|
||||||
|
- 31 -> 32
|
||||||
|
|
||||||
|
## Priority Overview
|
||||||
|
|
||||||
|
**P1 (Critical):**
|
||||||
|
- 23: Implement XDG_CONFIG_HOME directory setup
|
||||||
|
- 24: Refactor feeds persistence to JSON file
|
||||||
|
- 25: Refactor theme persistence to JSON file
|
||||||
|
- 26: Add config file validation and migration
|
||||||
|
- 28: Fix volume and speed controls in audio backends
|
||||||
|
- 32: Test multimedia controls across platforms
|
||||||
|
|
||||||
|
**P2 (High):**
|
||||||
|
- All other tasks (01-22, 27, 29-31)
|
||||||
|
|
||||||
|
**P3 (Medium):**
|
||||||
|
- 09: Optimize waveform rendering performance
|
||||||
|
- 13: Add loading indicator for pagination
|
||||||
|
- 19: Create download queue management
|
||||||
|
- 30: Add multimedia key detection and handling
|
||||||
|
- 31: Implement platform-specific media stream integration
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review all task files for accuracy
|
||||||
|
2. Confirm task dependencies
|
||||||
|
3. Start with P1 tasks (Feature 7 or Feature 8)
|
||||||
|
4. Follow dependency order within each feature
|
||||||
|
5. Mark tasks complete as they're finished
|
||||||
65
tasks/audio-playback-fix/01-fix-volume-speed-controls.md
Normal file
65
tasks/audio-playback-fix/01-fix-volume-speed-controls.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 01. Fix volume and speed controls in audio backends [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: audio-playback-fix-01
|
||||||
|
feature: audio-playback-fix
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, backend-fix, testing-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Fix non-functional volume and speed controls in audio player backends (mpv, ffplay, afplay)
|
||||||
|
- Implement proper error handling and validation for volume/speed commands
|
||||||
|
- Ensure commands are successfully received and applied by the audio player
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Fixed `MpvBackend.setVolume()` and `MpvBackend.setSpeed()` methods with proper IPC command validation
|
||||||
|
- Enhanced `AfplayBackend.setVolume()` and `AfplayBackend.setSpeed()` for runtime changes
|
||||||
|
- Added command response validation in all backends
|
||||||
|
- Unit tests for volume and speed control methods
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 1: Analyze current IPC implementation in MpvBackend (lines 206-223)
|
||||||
|
- Step 2: Implement proper response validation for setVolume and setSpeed IPC commands
|
||||||
|
- Step 3: Fix afplay backend to apply volume/speed changes at runtime (currently only on next play)
|
||||||
|
- Step 4: Add error handling and logging for failed volume/speed commands
|
||||||
|
- Step 5: Add unit tests in `src/utils/audio-player.test.ts` for volume/speed methods
|
||||||
|
- Step 6: Verify volume changes apply immediately and persist across playback
|
||||||
|
- Step 7: Verify speed changes apply immediately and persist across playback
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test MpvBackend.setVolume() sends correct IPC command and receives valid response
|
||||||
|
- Test MpvBackend.setSpeed() sends correct IPC command and receives valid response
|
||||||
|
- Test AfplayBackend.setVolume() applies volume immediately
|
||||||
|
- Test AfplayBackend.setSpeed() applies speed immediately
|
||||||
|
- Test volume clamp values (0-1 range)
|
||||||
|
- Test speed clamp values (0.25-3 range)
|
||||||
|
- Integration:
|
||||||
|
- Test volume control through Player component UI
|
||||||
|
- Test speed control through Player component UI
|
||||||
|
- Test volume/speed changes persist across pause/resume cycles
|
||||||
|
- Test volume/speed changes persist across track changes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Volume slider in Player component changes volume in real-time
|
||||||
|
- Speed controls in Player component change playback speed in real-time
|
||||||
|
- Volume changes are visible in system audio output
|
||||||
|
- Speed changes are immediately reflected in playback rate
|
||||||
|
- No errors logged when changing volume or speed
|
||||||
|
- Volume/speed settings persist when restarting the app
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run `bun test src/utils/audio-player.test.ts` to verify unit tests pass
|
||||||
|
- Test volume control using Up/Down arrow keys in Player
|
||||||
|
- Test speed control using 'S' key in Player
|
||||||
|
- Verify volume level is visible in PlaybackControls component
|
||||||
|
- Verify speed level is visible in PlaybackControls component
|
||||||
|
- Check console logs for any IPC errors
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- mpv backend uses JSON IPC over Unix socket - need to validate response format
|
||||||
|
- afplay backend needs to restart process for volume/speed changes (current behavior)
|
||||||
|
- ffplay backend doesn't support runtime volume/speed changes (document limitation)
|
||||||
|
- Volume and speed state is stored in backend class properties and should be updated on successful commands
|
||||||
|
- Reference: src/utils/audio-player.ts lines 206-223 (mpv send method), lines 789-791 (afplay setVolume), lines 793-795 (afplay setSpeed)
|
||||||
61
tasks/audio-playback-fix/02-add-multimedia-key-detection.md
Normal file
61
tasks/audio-playback-fix/02-add-multimedia-key-detection.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 02. Add multimedia key detection and handling [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: audio-playback-fix-02
|
||||||
|
feature: audio-playback-fix
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, keyboard, multimedia]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement detection and handling of multimedia keys (Play/Pause, Next/Previous, Volume Up/Down)
|
||||||
|
- Create reusable multimedia key handler hook
|
||||||
|
- Map multimedia keys to audio playback actions
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- New `useMultimediaKeys()` hook in `src/hooks/useMultimediaKeys.ts`
|
||||||
|
- Integration with existing audio hook to handle multimedia key events
|
||||||
|
- Documentation of supported multimedia keys and their mappings
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 1: Research @opentui/solid keyboard event types for multimedia key detection
|
||||||
|
- Step 2: Create `useMultimediaKeys()` hook with event listener for multimedia keys
|
||||||
|
- Step 3: Define multimedia key mappings (Play/Pause, Next, Previous, Volume Up, Volume Down)
|
||||||
|
- Step 4: Integrate hook with audio hook to trigger playback actions
|
||||||
|
- Step 5: Add keyboard event filtering to prevent conflicts with other shortcuts
|
||||||
|
- Step 6: Test multimedia key detection across different platforms
|
||||||
|
- Step 7: Add help text to Player component showing multimedia key bindings
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test multimedia key events are detected correctly
|
||||||
|
- Test key mapping functions return correct audio actions
|
||||||
|
- Test hook cleanup removes event listeners
|
||||||
|
- Integration:
|
||||||
|
- Test Play/Pause key toggles playback
|
||||||
|
- Test Next/Previous keys skip tracks (placeholder for future)
|
||||||
|
- Test Volume Up/Down keys adjust volume
|
||||||
|
- Test keys don't trigger when input is focused
|
||||||
|
- Test keys don't trigger when player is not focused
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Multimedia keys are detected and logged when pressed
|
||||||
|
- Play/Pause key toggles audio playback
|
||||||
|
- Volume Up/Down keys adjust volume level
|
||||||
|
- Keys work when Player component is focused
|
||||||
|
- Keys don't interfere with other keyboard shortcuts
|
||||||
|
- Help text displays multimedia key bindings
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Press multimedia keys while Player is focused and verify playback responds
|
||||||
|
- Check console logs for detected multimedia key events
|
||||||
|
- Verify Up/Down keys adjust volume display in Player component
|
||||||
|
- Verify Space key still works for play/pause
|
||||||
|
- Test in different terminal emulators (iTerm2, Terminal.app, etc.)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Multimedia key detection may vary by platform and terminal emulator
|
||||||
|
- Common multimedia keys: Space (Play/Pause), ArrowUp (Volume Up), ArrowDown (Volume Down)
|
||||||
|
- Some terminals don't pass multimedia keys to application
|
||||||
|
- May need to use platform-specific APIs or terminal emulator-specific key codes
|
||||||
|
- Reference: @opentui/solid keyboard event types and existing useKeyboard hook patterns
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 03. Implement platform-specific media stream integration [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: audio-playback-fix-03
|
||||||
|
feature: audio-playback-fix
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, platform-integration, media-apis]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Register audio player with platform-specific media frameworks
|
||||||
|
- Enable OS media controls (notification center, lock screen, multimedia keys)
|
||||||
|
- Support macOS AVFoundation, Windows Media Foundation, and Linux PulseAudio/GStreamer
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Platform-specific media registration module in `src/utils/media-registry.ts`
|
||||||
|
- Integration with audio hook to register/unregister media streams
|
||||||
|
- Platform detection and conditional registration logic
|
||||||
|
- Documentation of supported platforms and media APIs
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 1: Research platform-specific media API integration options
|
||||||
|
- Step 2: Create `MediaRegistry` class with platform detection
|
||||||
|
- Step 3: Implement macOS AVFoundation integration (AVPlayer + AVAudioSession)
|
||||||
|
- Step 4: Implement Windows Media Foundation integration (MediaSession + PlaybackInfo)
|
||||||
|
- Step 5: Implement Linux PulseAudio/GStreamer integration (Mpris or libpulse)
|
||||||
|
- Step 6: Integrate with audio hook to register media stream on play
|
||||||
|
- Step 7: Unregister media stream on stop or dispose
|
||||||
|
- Step 8: Handle platform-specific limitations and fallbacks
|
||||||
|
- Step 9: Test media registration across platforms
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test platform detection returns correct platform name
|
||||||
|
- Test MediaRegistry.register() calls platform-specific APIs
|
||||||
|
- Test MediaRegistry.unregister() cleans up platform resources
|
||||||
|
- Integration:
|
||||||
|
- Test audio player appears in macOS notification center
|
||||||
|
- Test audio player appears in Windows media controls
|
||||||
|
- Test audio player appears in Linux media player notifications
|
||||||
|
- Test media controls update with playback position
|
||||||
|
- Test multimedia keys control playback through media APIs
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Audio player appears in platform media controls (notification center, lock screen)
|
||||||
|
- Media controls update with current track info and playback position
|
||||||
|
- Multimedia keys work through media APIs (not just terminal)
|
||||||
|
- Media registration works on macOS, Windows, and Linux
|
||||||
|
- Media unregistration properly cleans up resources
|
||||||
|
- No memory leaks from media stream registration
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- On macOS: Check notification center for audio player notification
|
||||||
|
- On Windows: Check media controls in taskbar/notification area
|
||||||
|
- On Linux: Check media player notifications in desktop environment
|
||||||
|
- Test multimedia keys work with system media player (not just terminal)
|
||||||
|
- Monitor memory usage for leaks
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Platform-specific media APIs are complex and may have limitations
|
||||||
|
- macOS AVFoundation: Use AVPlayer with AVAudioSession for media registration
|
||||||
|
- Windows Media Foundation: Use MediaSession API and PlaybackInfo for media controls
|
||||||
|
- Linux: Use Mpris (Media Player Remote Interface Specification) or libpulse
|
||||||
|
- May need additional platform-specific dependencies or native code
|
||||||
|
- Fallback to terminal multimedia key handling if platform APIs unavailable
|
||||||
|
- Reference: Platform-specific media API documentation and examples
|
||||||
63
tasks/audio-playback-fix/04-add-media-key-listeners.md
Normal file
63
tasks/audio-playback-fix/04-add-media-key-listeners.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 04. Add media key listeners to audio hook [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: audio-playback-fix-04
|
||||||
|
feature: audio-playback-fix
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, integration, event-handling]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Integrate multimedia key handling with existing audio hook
|
||||||
|
- Route multimedia key events to appropriate audio control actions
|
||||||
|
- Ensure proper cleanup of event listeners
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Updated `useAudio()` hook with multimedia key event handling
|
||||||
|
- Media key event listener registration in audio hook
|
||||||
|
- Integration with multimedia key detection hook
|
||||||
|
- Proper cleanup of event listeners on component unmount
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 1: Import multimedia key detection hook into audio hook
|
||||||
|
- Step 2: Register multimedia key event listener in audio hook
|
||||||
|
- Step 3: Map multimedia key events to audio control actions (play/pause, seek, volume)
|
||||||
|
- Step 4: Add event listener cleanup on hook dispose
|
||||||
|
- Step 5: Test event listener cleanup with multiple component instances
|
||||||
|
- Step 6: Add error handling for failed multimedia key events
|
||||||
|
- Step 7: Test multimedia key events trigger correct audio actions
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test multimedia key events are captured in audio hook
|
||||||
|
- Test events are mapped to correct audio control actions
|
||||||
|
- Test event listeners are properly cleaned up
|
||||||
|
- Test multiple audio hook instances don't conflict
|
||||||
|
- Integration:
|
||||||
|
- Test multimedia keys control playback from any component
|
||||||
|
- Test multimedia keys work when player is not focused
|
||||||
|
- Test multimedia keys don't interfere with other keyboard shortcuts
|
||||||
|
- Test event listeners are removed when audio hook is disposed
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Multimedia key events are captured by audio hook
|
||||||
|
- Multimedia keys trigger correct audio control actions
|
||||||
|
- Event listeners are properly cleaned up on unmount
|
||||||
|
- No duplicate event listeners when components re-render
|
||||||
|
- No memory leaks from event listeners
|
||||||
|
- Error handling prevents crashes from invalid events
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Use multimedia keys and verify audio responds correctly
|
||||||
|
- Unmount and remount audio hook to test cleanup
|
||||||
|
- Check for memory leaks with browser dev tools or system monitoring
|
||||||
|
- Verify event listener count is correct after cleanup
|
||||||
|
- Test with multiple Player components to ensure no conflicts
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Audio hook is a singleton, so event listeners should be registered once
|
||||||
|
- Multimedia key detection hook should be reused to avoid duplicate listeners
|
||||||
|
- Event listener cleanup should use onCleanup from solid-js
|
||||||
|
- Reference: src/hooks/useAudio.ts for event listener patterns
|
||||||
|
- Multimedia keys may only work when terminal is focused (platform limitation)
|
||||||
|
- Consider adding platform-specific key codes for better compatibility
|
||||||
138
tasks/audio-playback-fix/05-test-multimedia-controls.md
Normal file
138
tasks/audio-playback-fix/05-test-multimedia-controls.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 05. Test multimedia controls across platforms [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: audio-playback-fix-05
|
||||||
|
feature: audio-playback-fix
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [testing, integration, cross-platform]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Comprehensive testing of volume/speed controls and multimedia key support
|
||||||
|
- Verify platform-specific media integration works correctly
|
||||||
|
- Validate all controls across different audio backends
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Test suite for volume/speed controls in `src/utils/audio-player.test.ts`
|
||||||
|
- Integration tests for multimedia key handling in `src/hooks/useMultimediaKeys.test.ts`
|
||||||
|
- Platform-specific integration tests in `src/utils/media-registry.test.ts`
|
||||||
|
- Test coverage report showing all features tested
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 1: Run existing unit tests for audio player backends
|
||||||
|
- Step 2: Add volume control tests (setVolume, volume clamp, persistence)
|
||||||
|
- Step 3: Add speed control tests (setSpeed, speed clamp, persistence)
|
||||||
|
- Step 4: Create integration test for multimedia key handling
|
||||||
|
- Step 5: Test volume/speed controls with Player component UI
|
||||||
|
- Step 6: Test multimedia keys with Player component UI
|
||||||
|
- Step 7: Test platform-specific media integration on each platform
|
||||||
|
- Step 8: Test all controls across mpv, ffplay, and afplay backends
|
||||||
|
- Step 9: Document any platform-specific limitations or workarounds
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test volume control methods in all backends
|
||||||
|
- Test speed control methods in all backends
|
||||||
|
- Test volume clamp logic (0-1 range)
|
||||||
|
- Test speed clamp logic (0.25-3 range)
|
||||||
|
- Test multimedia key detection
|
||||||
|
- Test event listener cleanup
|
||||||
|
- Integration:
|
||||||
|
- Test volume control via Player component UI
|
||||||
|
- Test speed control via Player component UI
|
||||||
|
- Test multimedia keys via keyboard
|
||||||
|
- Test volume/speed persistence across pause/resume
|
||||||
|
- Test volume/speed persistence across track changes
|
||||||
|
- Cross-platform:
|
||||||
|
- Test volume/speed controls on macOS
|
||||||
|
- Test volume/speed controls on Linux
|
||||||
|
- Test volume/speed controls on Windows
|
||||||
|
- Test multimedia keys on each platform
|
||||||
|
- Test media registration on each platform
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All unit tests pass with >90% code coverage
|
||||||
|
- All integration tests pass
|
||||||
|
- Volume controls work correctly on all platforms
|
||||||
|
- Speed controls work correctly on all platforms
|
||||||
|
- Multimedia keys work on all platforms
|
||||||
|
- Media controls appear on all supported platforms
|
||||||
|
- All audio backends (mpv, ffplay, afplay) work correctly
|
||||||
|
- No regressions in existing audio functionality
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run full test suite: `bun test`
|
||||||
|
- Check test coverage: `bun test --coverage`
|
||||||
|
- Manually test volume controls on each platform
|
||||||
|
- Manually test speed controls on each platform
|
||||||
|
- Manually test multimedia keys on each platform
|
||||||
|
- Verify media controls appear on each platform
|
||||||
|
- Check for any console errors or warnings
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Test suite should cover all audio backend implementations
|
||||||
|
- Integration tests should verify UI controls work correctly
|
||||||
|
- Platform-specific tests should run on actual platform if possible
|
||||||
|
- Consider using test doubles for platform-specific APIs
|
||||||
|
- Document any platform-specific issues or limitations found
|
||||||
|
- Reference: Test patterns from existing test files in src/utils/
|
||||||
|
|
||||||
|
## Implementation Notes (Completed)
|
||||||
|
|
||||||
|
### Manual Validation Steps
|
||||||
|
|
||||||
|
1. **Volume controls (all backends)**
|
||||||
|
- Launch app, load an episode, press Up/Down arrows on Player tab
|
||||||
|
- Volume indicator in PlaybackControls should update (0.00 - 1.00)
|
||||||
|
- Audio output volume should change audibly
|
||||||
|
- Test on non-Player tabs: Up/Down should still adjust volume via global media keys
|
||||||
|
|
||||||
|
2. **Speed controls (mpv, afplay)**
|
||||||
|
- Press `S` to cycle speed: 1.0 -> 1.25 -> 1.5 -> 1.75 -> 2.0 -> 0.5
|
||||||
|
- Speed indicator should update in PlaybackControls
|
||||||
|
- Audible pitch/speed change on mpv and afplay
|
||||||
|
- ffplay: speed changes require track restart (documented limitation)
|
||||||
|
|
||||||
|
3. **Seek controls**
|
||||||
|
- Press Left/Right arrows to seek -10s / +10s
|
||||||
|
- Position indicator should update
|
||||||
|
- Works on Player tab (local) and other tabs (global media keys)
|
||||||
|
|
||||||
|
4. **Global media keys (non-Player tabs)**
|
||||||
|
- Navigate to Feed, Shows, or Discover tab
|
||||||
|
- Start playing an episode from Player tab first
|
||||||
|
- Switch to another tab
|
||||||
|
- Press Space to toggle play/pause
|
||||||
|
- Press Up/Down to adjust volume
|
||||||
|
- Press Left/Right to seek
|
||||||
|
- Press S to cycle speed
|
||||||
|
|
||||||
|
5. **Platform media integration (macOS)**
|
||||||
|
- Install `nowplaying-cli`: `brew install nowplaying-cli`
|
||||||
|
- Track info should appear in macOS Now Playing widget
|
||||||
|
- If `nowplaying-cli` is not installed, graceful no-op (no errors)
|
||||||
|
|
||||||
|
### Platform Limitations
|
||||||
|
|
||||||
|
| Backend | Volume | Speed | Seek | Notes |
|
||||||
|
|---------|--------|-------|------|-------|
|
||||||
|
| **mpv** | Runtime (IPC) | Runtime (IPC) | Runtime (IPC) | Best support, uses Unix socket |
|
||||||
|
| **afplay** | Restart required | Restart required | Not supported | Process restarts with new args |
|
||||||
|
| **ffplay** | Restart required | Not supported | Not supported | No runtime speed flag |
|
||||||
|
| **system** | Depends on OS | Depends on OS | Depends on OS | Uses `open`/`xdg-open` |
|
||||||
|
| **noop** | No-op | No-op | No-op | Silent fallback |
|
||||||
|
|
||||||
|
### Media Registry Platform Support
|
||||||
|
|
||||||
|
| Platform | Integration | Status |
|
||||||
|
|----------|------------|--------|
|
||||||
|
| **macOS** | `nowplaying-cli` | Works if binary installed |
|
||||||
|
| **Linux** | MPRIS D-Bus | Stub (no-op), upgradable |
|
||||||
|
| **Windows** | None | No-op stub |
|
||||||
|
|
||||||
|
### Key Architecture Decisions
|
||||||
|
- Global media keys use event bus (`media.*` events) for decoupling
|
||||||
|
- `useMultimediaKeys` hook is called once in App.tsx
|
||||||
|
- Guards prevent double-handling when Player tab is focused (Player.tsx handles locally)
|
||||||
|
- Guards prevent interference when text input is focused
|
||||||
|
- MediaRegistry is a singleton, fire-and-forget, never throws
|
||||||
26
tasks/audio-playback-fix/README.md
Normal file
26
tasks/audio-playback-fix/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Audio Playback Fix
|
||||||
|
|
||||||
|
Objective: Fix volume and speed controls and add multimedia key support with platform media stream integration
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [x] 01 — Fix volume and speed controls in audio backends → `01-fix-volume-speed-controls.md`
|
||||||
|
- [x] 02 — Add multimedia key detection and handling → `02-add-multimedia-key-detection.md`
|
||||||
|
- [x] 03 — Implement platform-specific media stream integration → `03-implement-platform-media-integration.md`
|
||||||
|
- [x] 04 — Add media key listeners to audio hook → `04-add-media-key-listeners.md`
|
||||||
|
- [x] 05 — Test multimedia controls across platforms → `05-test-multimedia-controls.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 01 depends on 02
|
||||||
|
- 02 depends on 03
|
||||||
|
- 03 depends on 04
|
||||||
|
- 04 depends on 05
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Volume controls change playback volume in real-time
|
||||||
|
- Speed controls change playback speed in real-time
|
||||||
|
- Multimedia keys (Space, Arrow keys, Volume keys, Media keys) control playback
|
||||||
|
- Audio player appears in system media controls
|
||||||
|
- System multimedia keys trigger appropriate playback actions
|
||||||
|
- All controls work across mpv, ffplay, and afplay backends
|
||||||
50
tasks/config-persistence/23-config-directory-setup.md
Normal file
50
tasks/config-persistence/23-config-directory-setup.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 23. Implement XDG_CONFIG_HOME Directory Setup
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: config-persistence-23
|
||||||
|
feature: config-persistence
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [configuration, file-system, directory-setup]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement XDG_CONFIG_HOME directory detection and creation
|
||||||
|
- Create application-specific config directory
|
||||||
|
- Handle XDG_CONFIG_HOME environment variable
|
||||||
|
- Provide fallback to ~/.config if XDG_CONFIG_HOME not set
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Config directory detection utility
|
||||||
|
- Directory creation logic
|
||||||
|
- Environment variable handling
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create `src/utils/config-dir.ts`
|
||||||
|
2. Implement XDG_CONFIG_HOME detection
|
||||||
|
3. Create fallback to HOME/.config
|
||||||
|
4. Create application-specific directory (podcast-tui-app)
|
||||||
|
5. Add directory creation with error handling
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test XDG_CONFIG_HOME detection
|
||||||
|
- Unit: Test config directory creation
|
||||||
|
- Manual: Verify directory exists at expected path
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Config directory is created at correct path
|
||||||
|
- XDG_CONFIG_HOME is respected if set
|
||||||
|
- Falls back to ~/.config if XDG_CONFIG_HOME not set
|
||||||
|
- Directory is created with correct permissions
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run app and check config directory exists
|
||||||
|
- Test with XDG_CONFIG_HOME=/custom/path
|
||||||
|
- Test with XDG_CONFIG_HOME not set
|
||||||
|
- Verify directory is created in both cases
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- XDG_CONFIG_HOME default: ~/.config
|
||||||
|
- App name from package.json: podcast-tui-app
|
||||||
|
- Use Bun.file() and file operations for directory creation
|
||||||
|
- Handle permission errors gracefully
|
||||||
|
- Use mkdir -p for recursive creation
|
||||||
51
tasks/config-persistence/24-feeds-persistence-refactor.md
Normal file
51
tasks/config-persistence/24-feeds-persistence-refactor.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 24. Refactor Feeds Persistence to JSON File
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: config-persistence-24
|
||||||
|
feature: config-persistence
|
||||||
|
priority: P1
|
||||||
|
depends_on: [config-persistence-23]
|
||||||
|
tags: [persistence, feeds, file-io]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Move feeds persistence from localStorage to JSON file
|
||||||
|
- Load feeds from XDG_CONFIG_HOME directory
|
||||||
|
- Save feeds to JSON file
|
||||||
|
- Maintain backward compatibility
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Feeds JSON file I/O functions
|
||||||
|
- Updated feed store persistence
|
||||||
|
- Migration from localStorage
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create `src/utils/feeds-persistence.ts`
|
||||||
|
2. Implement loadFeedsFromFile() function
|
||||||
|
3. Implement saveFeedsToFile() function
|
||||||
|
4. Update feed store to use file-based persistence
|
||||||
|
5. Add migration from localStorage to file
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test file I/O functions
|
||||||
|
- Integration: Test feed persistence with file
|
||||||
|
- Migration: Test migration from localStorage
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Feeds are loaded from JSON file
|
||||||
|
- Feeds are saved to JSON file
|
||||||
|
- Backward compatibility maintained
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Start app with no config file
|
||||||
|
- Subscribe to feeds
|
||||||
|
- Verify feeds saved to file
|
||||||
|
- Restart app and verify feeds loaded
|
||||||
|
- Test migration from localStorage
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- File path: XDG_CONFIG_HOME/podcast-tui-app/feeds.json
|
||||||
|
- Use JSON.stringify/parse for serialization
|
||||||
|
- Handle file not found (empty initial load)
|
||||||
|
- Handle file write errors
|
||||||
|
- Add timestamp to file for versioning
|
||||||
|
- Maintain Feed type structure
|
||||||
52
tasks/config-persistence/25-theme-persistence-refactor.md
Normal file
52
tasks/config-persistence/25-theme-persistence-refactor.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 25. Refactor Theme Persistence to JSON File
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: config-persistence-25
|
||||||
|
feature: config-persistence
|
||||||
|
priority: P1
|
||||||
|
depends_on: [config-persistence-23]
|
||||||
|
tags: [persistence, themes, file-io]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Move theme persistence from localStorage to JSON file
|
||||||
|
- Load custom themes from XDG_CONFIG_HOME directory
|
||||||
|
- Save custom themes to JSON file
|
||||||
|
- Maintain backward compatibility
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Themes JSON file I/O functions
|
||||||
|
- Updated theme persistence
|
||||||
|
- Migration from localStorage
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create `src/utils/themes-persistence.ts`
|
||||||
|
2. Implement loadThemesFromFile() function
|
||||||
|
3. Implement saveThemesToFile() function
|
||||||
|
4. Update theme store to use file-based persistence
|
||||||
|
5. Add migration from localStorage to file
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test file I/O functions
|
||||||
|
- Integration: Test theme persistence with file
|
||||||
|
- Migration: Test migration from localStorage
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Custom themes are loaded from JSON file
|
||||||
|
- Custom themes are saved to JSON file
|
||||||
|
- Backward compatibility maintained
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Start app with no theme file
|
||||||
|
- Load custom theme
|
||||||
|
- Verify theme saved to file
|
||||||
|
- Restart app and verify theme loaded
|
||||||
|
- Test migration from localStorage
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- File path: XDG_CONFIG_HOME/podcast-tui-app/themes.json
|
||||||
|
- Use JSON.stringify/parse for serialization
|
||||||
|
- Handle file not found (use default themes)
|
||||||
|
- Handle file write errors
|
||||||
|
- Add timestamp to file for versioning
|
||||||
|
- Maintain theme type structure
|
||||||
|
- Include all theme files in directory
|
||||||
51
tasks/config-persistence/26-config-file-validation.md
Normal file
51
tasks/config-persistence/26-config-file-validation.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 26. Add Config File Validation and Migration
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: config-persistence-26
|
||||||
|
feature: config-persistence
|
||||||
|
priority: P1
|
||||||
|
depends_on: [config-persistence-24, config-persistence-25]
|
||||||
|
tags: [validation, migration, data-integrity]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Validate config file structure and data integrity
|
||||||
|
- Migrate data from localStorage to file
|
||||||
|
- Provide migration on first run
|
||||||
|
- Handle config file corruption
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Config file validation function
|
||||||
|
- Migration utility from localStorage
|
||||||
|
- Error handling for corrupted files
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create config file schema validation
|
||||||
|
2. Implement migration from localStorage to file
|
||||||
|
3. Add config file backup before migration
|
||||||
|
4. Handle corrupted JSON files
|
||||||
|
5. Test migration scenarios
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test validation function
|
||||||
|
- Integration: Test migration from localStorage
|
||||||
|
- Error: Test corrupted file handling
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Config files are validated before use
|
||||||
|
- Migration from localStorage works seamlessly
|
||||||
|
- Corrupted files are handled gracefully
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Start app with localStorage data
|
||||||
|
- Verify migration to file
|
||||||
|
- Corrupt file and verify handling
|
||||||
|
- Test migration on app restart
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Validate Feed type structure
|
||||||
|
- Validate theme structure
|
||||||
|
- Create backup before migration
|
||||||
|
- Log migration events
|
||||||
|
- Provide error messages for corrupted files
|
||||||
|
- Add config file versioning
|
||||||
|
- Test with both new and old data formats
|
||||||
50
tasks/config-persistence/27-config-file-backup.md
Normal file
50
tasks/config-persistence/27-config-file-backup.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 27. Implement Config File Backup on Update
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: config-persistence-27
|
||||||
|
feature: config-persistence
|
||||||
|
priority: P2
|
||||||
|
depends_on: [config-persistence-26]
|
||||||
|
tags: [backup, data-safety, migration]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Create backups of config files before updates
|
||||||
|
- Handle config file changes during app updates
|
||||||
|
- Provide rollback capability if needed
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Config backup utility
|
||||||
|
- Backup on config changes
|
||||||
|
- Config version history
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create config backup function
|
||||||
|
2. Implement backup on config save
|
||||||
|
3. Add config version history management
|
||||||
|
4. Test backup and restore scenarios
|
||||||
|
5. Add config file version display
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test backup function
|
||||||
|
- Integration: Test backup on config save
|
||||||
|
- Manual: Test restore from backup
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Config files are backed up before updates
|
||||||
|
- Backup preserves data integrity
|
||||||
|
- Config version history is maintained
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Make config changes
|
||||||
|
- Verify backup created
|
||||||
|
- Restart app and check backup
|
||||||
|
- Test restore from backup
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Backup file naming: feeds.json.backup, themes.json.backup
|
||||||
|
- Keep last N backups (e.g., 5)
|
||||||
|
- Backup timestamp in filename
|
||||||
|
- Use atomic file operations
|
||||||
|
- Test with large config files
|
||||||
|
- Add config file size tracking
|
||||||
|
- Consider automatic cleanup of old backups
|
||||||
25
tasks/config-persistence/README.md
Normal file
25
tasks/config-persistence/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Config Persistence to XDG_CONFIG_HOME
|
||||||
|
|
||||||
|
Objective: Move feeds and themes persistence from localStorage to XDG_CONFIG_HOME directory
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [ ] 23 — Implement XDG_CONFIG_HOME directory setup → `23-config-directory-setup.md`
|
||||||
|
- [ ] 24 — Refactor feeds persistence to JSON file → `24-feeds-persistence-refactor.md`
|
||||||
|
- [ ] 25 — Refactor theme persistence to JSON file → `25-theme-persistence-refactor.md`
|
||||||
|
- [ ] 26 — Add config file validation and migration → `26-config-file-validation.md`
|
||||||
|
- [ ] 27 — Implement config file backup on update → `27-config-file-backup.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 23 -> 24
|
||||||
|
- 23 -> 25
|
||||||
|
- 24 -> 26
|
||||||
|
- 25 -> 26
|
||||||
|
- 26 -> 27
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Feeds are persisted to XDG_CONFIG_HOME/podcast-tui-app/feeds.json
|
||||||
|
- Themes are persisted to XDG_CONFIG_HOME/podcast-tui-app/themes.json
|
||||||
|
- Config file validation ensures data integrity
|
||||||
|
- Migration from localStorage works seamlessly
|
||||||
47
tasks/discover-categories-fix/20-category-filter-debug.md
Normal file
47
tasks/discover-categories-fix/20-category-filter-debug.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 20. Debug Category Filter Implementation [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: discover-categories-fix-20
|
||||||
|
feature: discover-categories-fix
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [debugging, discover, categories]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Identify why category filter is not working
|
||||||
|
- Analyze CategoryFilter component behavior
|
||||||
|
- Trace state flow from category selection to show filtering
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Debugged category filter logic
|
||||||
|
- Identified root cause of issue
|
||||||
|
- Test cases to verify fix
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Review CategoryFilter component implementation
|
||||||
|
2. Review DiscoverPage category selection handler
|
||||||
|
3. Review discover store category filtering logic
|
||||||
|
4. Add console logging to trace state changes
|
||||||
|
5. Test with various category selections
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Debug: Test category selection in UI
|
||||||
|
- Debug: Verify state updates in console
|
||||||
|
- Manual: Select different categories and observe behavior
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Root cause of category filter issue identified
|
||||||
|
- State flow from category to shows is traced
|
||||||
|
- Specific code causing issue identified
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run app and select categories
|
||||||
|
- Check console for state updates
|
||||||
|
- Verify which component is not responding correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Check if categoryIndex signal is updated
|
||||||
|
- Verify discoverStore.setSelectedCategory() is called
|
||||||
|
- Check if filteredPodcasts() is recalculated
|
||||||
|
- Look for race conditions or state sync issues
|
||||||
|
- Add temporary logging to trace state changes
|
||||||
47
tasks/discover-categories-fix/21-category-state-sync.md
Normal file
47
tasks/discover-categories-fix/21-category-state-sync.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 21. Fix Category State Synchronization [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: discover-categories-fix-21
|
||||||
|
feature: discover-categories-fix
|
||||||
|
priority: P2
|
||||||
|
depends_on: [discover-categories-fix-20]
|
||||||
|
tags: [state-management, discover, categories]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Ensure category state is properly synchronized across components
|
||||||
|
- Fix state updates not triggering re-renders
|
||||||
|
- Ensure category selection persists correctly
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Fixed state synchronization logic
|
||||||
|
- Updated category selection handlers
|
||||||
|
- Verified state propagation
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Fix category state update handlers in DiscoverPage
|
||||||
|
2. Ensure discoverStore.setSelectedCategory() is called correctly
|
||||||
|
3. Fix signal updates to trigger component re-renders
|
||||||
|
4. Test state synchronization across component updates
|
||||||
|
5. Verify category state persists on navigation
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test state update handlers
|
||||||
|
- Integration: Test category selection and state updates
|
||||||
|
- Manual: Navigate between tabs and verify category state
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Category state updates propagate correctly
|
||||||
|
- Component re-renders when category changes
|
||||||
|
- Category selection persists across navigation
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Select category and verify show list updates
|
||||||
|
- Switch tabs and back, verify category still selected
|
||||||
|
- Test category navigation with keyboard
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Check if signals are properly created and updated
|
||||||
|
- Verify discoverStore state is reactive
|
||||||
|
- Ensure CategoryFilter and TrendingShows receive updated data
|
||||||
|
- Test with multiple category selections
|
||||||
|
- Add state persistence if needed
|
||||||
47
tasks/discover-categories-fix/22-category-navigation-fix.md
Normal file
47
tasks/discover-categories-fix/22-category-navigation-fix.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 22. Fix Category Keyboard Navigation [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: discover-categories-fix-22
|
||||||
|
feature: discover-categories-fix
|
||||||
|
priority: P2
|
||||||
|
depends_on: [discover-categories-fix-21]
|
||||||
|
tags: [keyboard, navigation, discover]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Fix keyboard navigation for categories
|
||||||
|
- Ensure category selection works with arrow keys
|
||||||
|
- Fix category index tracking during navigation
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Fixed keyboard navigation handlers
|
||||||
|
- Updated category index tracking
|
||||||
|
- Verified navigation works correctly
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Review keyboard navigation in DiscoverPage
|
||||||
|
2. Fix category index signal updates
|
||||||
|
3. Ensure categoryIndex signal is updated on arrow key presses
|
||||||
|
4. Test category navigation with arrow keys
|
||||||
|
5. Fix category selection on Enter key
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Integration: Test category navigation with keyboard
|
||||||
|
- Manual: Navigate categories with arrow keys
|
||||||
|
- Edge case: Test category navigation from shows list
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Arrow keys navigate categories correctly
|
||||||
|
- Category index updates on navigation
|
||||||
|
- Enter key selects category and updates shows list
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Use arrow keys to navigate categories
|
||||||
|
- Verify category highlight moves correctly
|
||||||
|
- Press Enter to select category and verify show list updates
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Check if categoryIndex signal is bound correctly
|
||||||
|
- Ensure arrow keys update categoryIndex signal
|
||||||
|
- Verify categoryIndex is used in filteredPodcasts()
|
||||||
|
- Test category navigation from shows list back to categories
|
||||||
|
- Add keyboard hints in UI
|
||||||
19
tasks/discover-categories-fix/README.md
Normal file
19
tasks/discover-categories-fix/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Discover Categories Shortcuts Fix
|
||||||
|
|
||||||
|
Objective: Fix broken discover category filter functionality
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [ ] 20 — Debug category filter implementation → `20-category-filter-debug.md`
|
||||||
|
- [ ] 21 — Fix category state synchronization → `21-category-state-sync.md`
|
||||||
|
- [ ] 22 — Fix category keyboard navigation → `22-category-navigation-fix.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 20 -> 21
|
||||||
|
- 21 -> 22
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Category filter correctly updates show list
|
||||||
|
- Keyboard navigation works for categories
|
||||||
|
- Category selection persists during navigation
|
||||||
46
tasks/episode-downloads/14-download-storage-structure.md
Normal file
46
tasks/episode-downloads/14-download-storage-structure.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 14. Define Download Storage Structure [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-downloads-14
|
||||||
|
feature: episode-downloads
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [storage, types, data-model]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Define data structures for downloaded episodes
|
||||||
|
- Create download state tracking
|
||||||
|
- Design download history and metadata storage
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- DownloadedEpisode type definition
|
||||||
|
- Download state interface
|
||||||
|
- Storage schema for download metadata
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Add DownloadedEpisode type to types/episode.ts
|
||||||
|
2. Define download state structure (status, progress, timestamp)
|
||||||
|
3. Create download metadata interface
|
||||||
|
4. Add download-related fields to Feed type
|
||||||
|
5. Design database-like storage structure
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test type definitions
|
||||||
|
- Integration: Test storage schema
|
||||||
|
- Validation: Verify structure supports all download scenarios
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- DownloadedEpisode type properly defines download metadata
|
||||||
|
- Download state interface tracks all necessary information
|
||||||
|
- Storage schema supports history and progress tracking
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Review type definitions for completeness
|
||||||
|
- Verify storage structure can hold all download data
|
||||||
|
- Test with mock download scenarios
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Add fields: status (downloading, completed, failed), progress (0-100), filePath, downloadedAt
|
||||||
|
- Include download speed and estimated time remaining
|
||||||
|
- Store download history with timestamps
|
||||||
|
- Consider adding resume capability
|
||||||
47
tasks/episode-downloads/15-episode-download-utility.md
Normal file
47
tasks/episode-downloads/15-episode-download-utility.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 15. Create Episode Download Utility [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-downloads-15
|
||||||
|
feature: episode-downloads
|
||||||
|
priority: P2
|
||||||
|
depends_on: [episode-downloads-14]
|
||||||
|
tags: [downloads, utilities, file-io]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement episode download functionality
|
||||||
|
- Download audio files from episode URLs
|
||||||
|
- Handle download errors and edge cases
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Download utility function
|
||||||
|
- File download handler
|
||||||
|
- Error handling for download failures
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create `src/utils/episode-downloader.ts`
|
||||||
|
2. Implement download function using Bun.file() or fetch
|
||||||
|
3. Add progress tracking during download
|
||||||
|
4. Handle download cancellation
|
||||||
|
5. Add error handling for network and file system errors
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test download function with mock URLs
|
||||||
|
- Integration: Test with real audio file URLs
|
||||||
|
- Error handling: Test download failure scenarios
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Episodes can be downloaded successfully
|
||||||
|
- Download progress is tracked
|
||||||
|
- Errors are handled gracefully
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Download test episode from real podcast
|
||||||
|
- Verify file is saved correctly
|
||||||
|
- Check download progress tracking
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use Bun's built-in file download capabilities
|
||||||
|
- Support resuming interrupted downloads
|
||||||
|
- Handle large files with streaming
|
||||||
|
- Add download speed tracking
|
||||||
|
- Consider download location in downloadPath setting
|
||||||
47
tasks/episode-downloads/16-download-progress-tracking.md
Normal file
47
tasks/episode-downloads/16-download-progress-tracking.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 16. Implement Download Progress Tracking [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-downloads-16
|
||||||
|
feature: episode-downloads
|
||||||
|
priority: P2
|
||||||
|
depends_on: [episode-downloads-15]
|
||||||
|
tags: [progress, state-management, downloads]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Track download progress for each episode
|
||||||
|
- Update download state in real-time
|
||||||
|
- Store download progress in persistent storage
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Download progress state in app store
|
||||||
|
- Progress update utility
|
||||||
|
- Integration with download utility
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Add download state to app store
|
||||||
|
2. Update progress during download
|
||||||
|
3. Save progress to persistent storage
|
||||||
|
4. Handle download completion
|
||||||
|
5. Test progress tracking accuracy
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test progress update logic
|
||||||
|
- Integration: Test progress tracking with download
|
||||||
|
- Persistence: Verify progress saved and restored
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Download progress is tracked accurately
|
||||||
|
- Progress updates in real-time
|
||||||
|
- Progress persists across app restarts
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Download a large file and watch progress
|
||||||
|
- Verify progress updates at intervals
|
||||||
|
- Restart app and verify progress restored
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing progress store for episode playback
|
||||||
|
- Create separate download progress store
|
||||||
|
- Update progress every 1-2 seconds
|
||||||
|
- Handle download cancellation by resetting progress
|
||||||
|
- Store progress in XDG_CONFIG_HOME directory
|
||||||
47
tasks/episode-downloads/17-download-ui-component.md
Normal file
47
tasks/episode-downloads/17-download-ui-component.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 17. Add Download Status in Episode List [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-downloads-17
|
||||||
|
feature: episode-downloads
|
||||||
|
priority: P2
|
||||||
|
depends_on: [episode-downloads-16]
|
||||||
|
tags: [ui, downloads, display]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Display download status for episodes
|
||||||
|
- Add download button to episode list
|
||||||
|
- Show download progress visually
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Download status indicator component
|
||||||
|
- Download button in episode list
|
||||||
|
- Progress bar for downloading episodes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Add download status field to EpisodeListItem
|
||||||
|
2. Create download button in MyShowsPage episodes panel
|
||||||
|
3. Display download status (none, queued, downloading, completed, failed)
|
||||||
|
4. Add download progress bar for downloading episodes
|
||||||
|
5. Test download status display
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Integration: Test download status display
|
||||||
|
- Visual: Verify download button and progress bar
|
||||||
|
- UX: Test download status changes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Download status is visible in episode list
|
||||||
|
- Download button is accessible
|
||||||
|
- Progress bar shows download progress
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- View episode list with download button
|
||||||
|
- Start download and watch status change
|
||||||
|
- Verify progress bar updates
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Reuse existing episode list UI from MyShowsPage
|
||||||
|
- Add download icon button next to episode title
|
||||||
|
- Show status text: "DL", "DWN", "DONE", "ERR"
|
||||||
|
- Use existing progress bar component for download progress
|
||||||
|
- Position download button in episode header
|
||||||
48
tasks/episode-downloads/18-auto-download-settings.md
Normal file
48
tasks/episode-downloads/18-auto-download-settings.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 18. Implement Per-Feed Auto-Download Settings [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-downloads-18
|
||||||
|
feature: episode-downloads
|
||||||
|
priority: P2
|
||||||
|
depends_on: [episode-downloads-17]
|
||||||
|
tags: [settings, automation, downloads]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Add per-feed auto-download settings
|
||||||
|
- Configure number of episodes to auto-download per feed
|
||||||
|
- Enable/disable auto-download per feed
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Auto-download settings in feed store
|
||||||
|
- Settings UI for per-feed configuration
|
||||||
|
- Auto-download trigger logic
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Add autoDownload field to Feed type
|
||||||
|
2. Add autoDownloadCount field to Feed type
|
||||||
|
3. Add settings UI in FeedPage or MyShowsPage
|
||||||
|
4. Implement auto-download trigger logic
|
||||||
|
5. Test auto-download functionality
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test auto-download trigger logic
|
||||||
|
- Integration: Test with multiple feeds
|
||||||
|
- Edge case: Test with feeds having fewer episodes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Auto-download settings are configurable per feed
|
||||||
|
- Settings are saved to persistent storage
|
||||||
|
- Auto-download works correctly when enabled
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Configure auto-download for a feed
|
||||||
|
- Subscribe to new episodes and verify auto-download
|
||||||
|
- Test with multiple feeds
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Add settings in FeedPage or MyShowsPage
|
||||||
|
- Default: autoDownload = false, autoDownloadCount = 0
|
||||||
|
- Only download newest episodes (by pubDate)
|
||||||
|
- Respect MAX_EPISODES_REFRESH limit
|
||||||
|
- Add settings in feed detail or feed list
|
||||||
|
- Consider adding "auto-download all new episodes" setting
|
||||||
48
tasks/episode-downloads/19-download-queue-management.md
Normal file
48
tasks/episode-downloads/19-download-queue-management.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 19. Create Download Queue Management [x]
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-downloads-19
|
||||||
|
feature: episode-downloads
|
||||||
|
priority: P3
|
||||||
|
depends_on: [episode-downloads-18]
|
||||||
|
tags: [queue, downloads, management]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Manage download queue for multiple episodes
|
||||||
|
- Handle concurrent downloads
|
||||||
|
- Provide queue UI for managing downloads
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Download queue data structure
|
||||||
|
- Download queue manager
|
||||||
|
- Download queue UI
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create download queue data structure
|
||||||
|
2. Implement download queue manager (add, remove, process)
|
||||||
|
3. Handle concurrent downloads (limit to 1-2 at a time)
|
||||||
|
4. Create download queue UI component
|
||||||
|
5. Test queue management
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test queue management logic
|
||||||
|
- Integration: Test with multiple downloads
|
||||||
|
- Edge case: Test queue with 50+ episodes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Download queue manages multiple downloads
|
||||||
|
- Concurrent downloads are limited
|
||||||
|
- Queue UI shows download status
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Add 10 episodes to download queue
|
||||||
|
- Verify queue processes sequentially
|
||||||
|
- Check queue UI displays correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use queue data structure (array of episodes)
|
||||||
|
- Limit concurrent downloads to 2 for performance
|
||||||
|
- Add queue UI in Settings or separate tab
|
||||||
|
- Show queue in SettingsScreen or new Downloads tab
|
||||||
|
- Allow removing items from queue
|
||||||
|
- Add pause/resume for downloads
|
||||||
26
tasks/episode-downloads/README.md
Normal file
26
tasks/episode-downloads/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Episode Downloads
|
||||||
|
|
||||||
|
Objective: Add per-episode download and per-feed auto-download settings
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [ ] 14 — Define download storage structure → `14-download-storage-structure.md`
|
||||||
|
- [ ] 15 — Create episode download utility → `15-episode-download-utility.md`
|
||||||
|
- [ ] 16 — Implement download progress tracking → `16-download-progress-tracking.md`
|
||||||
|
- [ ] 17 — Add download status in episode list → `17-download-ui-component.md`
|
||||||
|
- [ ] 18 — Implement per-feed auto-download settings → `18-auto-download-settings.md`
|
||||||
|
- [ ] 19 — Create download queue management → `19-download-queue-management.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 14 -> 15
|
||||||
|
- 15 -> 16
|
||||||
|
- 16 -> 17
|
||||||
|
- 17 -> 18
|
||||||
|
- 18 -> 19
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Episodes can be downloaded individually
|
||||||
|
- Per-feed auto-download settings are configurable
|
||||||
|
- Download progress is tracked and displayed
|
||||||
|
- Download queue can be managed
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 10. Add Scroll Event Listener to Episodes Panel
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-infinite-scroll-10
|
||||||
|
feature: episode-infinite-scroll
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [ui, events, scroll]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Detect when user scrolls to bottom of episodes list
|
||||||
|
- Add scroll event listener to episodes panel
|
||||||
|
- Track scroll position and trigger pagination when needed
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Scroll event handler function
|
||||||
|
- Scroll position tracking
|
||||||
|
- Integration with episodes panel
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Modify MyShowsPage to add scroll event listener
|
||||||
|
2. Detect scroll-to-bottom event (when scrollHeight - scrollTop <= clientHeight)
|
||||||
|
3. Track current scroll position
|
||||||
|
4. Add debouncing for scroll events
|
||||||
|
5. Test scroll detection accuracy
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test scroll detection logic
|
||||||
|
- Integration: Test scroll events in episodes panel
|
||||||
|
- Manual: Scroll to bottom and verify detection
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Scroll-to-bottom is detected accurately
|
||||||
|
- Debouncing prevents excessive event firing
|
||||||
|
- Scroll position is tracked correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Scroll through episodes list
|
||||||
|
- Verify bottom detection works
|
||||||
|
- Test with different terminal sizes
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use scrollbox component's scroll event if available
|
||||||
|
- Debounce scroll events to 100ms
|
||||||
|
- Handle both manual scroll and programmatic scroll
|
||||||
|
- Consider virtual scrolling if episode count is large
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 11. Implement Paginated Episode Fetching
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-infinite-scroll-11
|
||||||
|
feature: episode-infinite-scroll
|
||||||
|
priority: P2
|
||||||
|
depends_on: [episode-infinite-scroll-10]
|
||||||
|
tags: [rss, pagination, data-fetching]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Fetch episodes in chunks with MAX_EPISODES_REFRESH limit
|
||||||
|
- Merge new episodes with existing list
|
||||||
|
- Maintain episode ordering (newest first)
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Paginated episode fetch function
|
||||||
|
- Episode list merging logic
|
||||||
|
- Integration with feed store
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create paginated fetch function in feed store
|
||||||
|
2. Implement chunk-based episode fetching (50 episodes at a time)
|
||||||
|
3. Add logic to merge new episodes with existing list
|
||||||
|
4. Maintain reverse chronological order (newest first)
|
||||||
|
5. Deduplicate episodes by title or URL
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test paginated fetch logic
|
||||||
|
- Integration: Test with real RSS feeds
|
||||||
|
- Edge case: Test with feeds having < 50 episodes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Episodes fetched in chunks of MAX_EPISODES_REFRESH
|
||||||
|
- New episodes merged correctly with existing list
|
||||||
|
- Episode ordering maintained (newest first)
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Test with RSS feed having 100+ episodes
|
||||||
|
- Verify pagination works correctly
|
||||||
|
- Check episode ordering after merge
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing `MAX_EPISODES_REFRESH = 50` constant
|
||||||
|
- Add episode deduplication logic
|
||||||
|
- Preserve episode metadata during merge
|
||||||
|
- Handle cases where feed has fewer episodes
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 12. Manage Episode List Pagination State
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-infinite-scroll-12
|
||||||
|
feature: episode-infinite-scroll
|
||||||
|
priority: P2
|
||||||
|
depends_on: [episode-infinite-scroll-11]
|
||||||
|
tags: [state-management, pagination]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Track pagination state (current page, loaded count, has more episodes)
|
||||||
|
- Manage episode list state changes
|
||||||
|
- Handle pagination state across component renders
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Pagination state in feed store
|
||||||
|
- Episode list state management
|
||||||
|
- Integration with scroll events
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Add pagination state to feed store (currentPage, loadedCount, hasMore)
|
||||||
|
2. Update episode list when new episodes are loaded
|
||||||
|
3. Manage loading state for pagination
|
||||||
|
4. Handle empty episode list case
|
||||||
|
5. Test pagination state transitions
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test pagination state updates
|
||||||
|
- Integration: Test state transitions with scroll
|
||||||
|
- Edge case: Test with no episodes in feed
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Pagination state accurately tracks loaded episodes
|
||||||
|
- Episode list updates correctly with new episodes
|
||||||
|
- Loading state properly managed
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Load episodes and verify state updates
|
||||||
|
- Scroll to bottom and verify pagination triggers
|
||||||
|
- Test with feed having many episodes
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing feed store from `src/stores/feed.ts`
|
||||||
|
- Add pagination state to Feed interface
|
||||||
|
- Consider loading indicator visibility
|
||||||
|
- Handle rapid scroll events gracefully
|
||||||
46
tasks/episode-infinite-scroll/13-load-more-indicator.md
Normal file
46
tasks/episode-infinite-scroll/13-load-more-indicator.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 13. Add Loading Indicator for Pagination
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: episode-infinite-scroll-13
|
||||||
|
feature: episode-infinite-scroll
|
||||||
|
priority: P3
|
||||||
|
depends_on: [episode-infinite-scroll-12]
|
||||||
|
tags: [ui, feedback, loading]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Display loading indicator when fetching more episodes
|
||||||
|
- Show loading state in episodes panel
|
||||||
|
- Hide indicator when pagination complete
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Loading indicator component
|
||||||
|
- Loading state display logic
|
||||||
|
- Integration with pagination events
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Add loading state to episodes panel state
|
||||||
|
2. Create loading indicator UI (spinner or text)
|
||||||
|
3. Display indicator when fetching episodes
|
||||||
|
4. Hide indicator when pagination complete
|
||||||
|
5. Test loading state visibility
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Integration: Test loading indicator during fetch
|
||||||
|
- Visual: Verify loading state doesn't block interaction
|
||||||
|
- UX: Test loading state disappears when done
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Loading indicator displays during fetch
|
||||||
|
- Indicator is visible but doesn't block scrolling
|
||||||
|
- Indicator disappears when pagination complete
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Scroll to bottom and watch loading indicator
|
||||||
|
- Verify indicator shows/hides correctly
|
||||||
|
- Test with slow RSS feeds
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Reuse existing loading indicator pattern from MyShowsPage
|
||||||
|
- Use spinner or "Loading..." text
|
||||||
|
- Position indicator at bottom of scrollbox
|
||||||
|
- Don't block user interaction while loading
|
||||||
21
tasks/episode-infinite-scroll/README.md
Normal file
21
tasks/episode-infinite-scroll/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Episode List Infinite Scroll
|
||||||
|
|
||||||
|
Objective: Implement scroll-to-bottom loading for episode lists with MAX_EPISODES_REFRESH limit
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [ ] 10 — Add scroll event listener to episodes panel → `10-episode-list-scroll-handler.md`
|
||||||
|
- [ ] 11 — Implement paginated episode fetching → `11-paginated-episode-loading.md`
|
||||||
|
- [ ] 12 — Manage episode list pagination state → `12-episode-list-state-management.md`
|
||||||
|
- [ ] 13 — Add loading indicator for pagination → `13-load-more-indicator.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 10 -> 11
|
||||||
|
- 11 -> 12
|
||||||
|
- 12 -> 13
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Episode list automatically loads more episodes when scrolling to bottom
|
||||||
|
- MAX_EPISODES_REFRESH is respected per fetch
|
||||||
|
- Loading state is properly displayed during pagination
|
||||||
46
tasks/merged-waveform/06-waveform-audio-analysis.md
Normal file
46
tasks/merged-waveform/06-waveform-audio-analysis.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 06. Implement Audio Waveform Analysis
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: merged-waveform-06
|
||||||
|
feature: merged-waveform
|
||||||
|
priority: P2
|
||||||
|
depends_on: []
|
||||||
|
tags: [audio, waveform, analysis]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Analyze audio data to extract waveform information
|
||||||
|
- Create real-time waveform data from audio streams
|
||||||
|
- Generate waveform data points for visualization
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Audio analysis utility
|
||||||
|
- Waveform data extraction function
|
||||||
|
- Integration with audio backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Research and select audio waveform analysis library (e.g., `audiowaveform`)
|
||||||
|
2. Create `src/utils/audio-waveform.ts`
|
||||||
|
3. Implement audio data extraction from backend
|
||||||
|
4. Generate waveform data points (amplitude values)
|
||||||
|
5. Add sample rate and duration normalization
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test waveform generation from sample audio
|
||||||
|
- Integration: Test with real audio playback
|
||||||
|
- Performance: Measure waveform generation overhead
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Waveform data is generated from audio content
|
||||||
|
- Data points represent audio amplitude accurately
|
||||||
|
- Generation works with real-time audio streams
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Generate waveform from sample MP3 file
|
||||||
|
- Verify amplitude data matches audio peaks
|
||||||
|
- Test with different audio formats
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Consider using `ffmpeg` or `sox` for offline analysis
|
||||||
|
- For real-time: analyze audio chunks during playback
|
||||||
|
- Waveform resolution: 64-256 data points for TUI display
|
||||||
|
- Normalize amplitude to 0-1 range
|
||||||
46
tasks/merged-waveform/07-merged-waveform-component.md
Normal file
46
tasks/merged-waveform/07-merged-waveform-component.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 07. Create Merged Progress-Waveform Component
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: merged-waveform-07
|
||||||
|
feature: merged-waveform
|
||||||
|
priority: P2
|
||||||
|
depends_on: [merged-waveform-06]
|
||||||
|
tags: [ui, waveform, component]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Design and implement a single component that shows progress bar and waveform
|
||||||
|
- Component starts as progress bar, expands to waveform when playing
|
||||||
|
- Provide smooth transitions between states
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- MergedWaveform component
|
||||||
|
- State management for progress vs waveform display
|
||||||
|
- Visual styling for progress bar and waveform
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Create `src/components/MergedWaveform.tsx`
|
||||||
|
2. Design component state machine (progress bar → waveform)
|
||||||
|
3. Implement progress bar visualization
|
||||||
|
4. Add waveform expansion animation
|
||||||
|
5. Style progress bar and waveform with theme colors
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit: Test component state transitions
|
||||||
|
- Integration: Test component in Player
|
||||||
|
- Visual: Verify smooth expansion animation
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Component displays progress bar when paused
|
||||||
|
- Component smoothly expands to waveform when playing
|
||||||
|
- Visual styles match theme and existing UI
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Test with paused and playing states
|
||||||
|
- Verify expansion is smooth and visually appealing
|
||||||
|
- Check theme color integration
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing Waveform component as base
|
||||||
|
- Add CSS transitions for smooth expansion
|
||||||
|
- Keep component size manageable (fit in progress bar area)
|
||||||
|
- Consider responsive to terminal width changes
|
||||||
46
tasks/merged-waveform/08-realtime-waveform-rendering.md
Normal file
46
tasks/merged-waveform/08-realtime-waveform-rendering.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 08. Implement Real-Time Waveform Rendering During Playback
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: merged-waveform-08
|
||||||
|
feature: merged-waveform
|
||||||
|
priority: P2
|
||||||
|
depends_on: [merged-waveform-07]
|
||||||
|
tags: [audio, realtime, rendering]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Update waveform in real-time during audio playback
|
||||||
|
- Highlight waveform based on current playback position
|
||||||
|
- Sync waveform with audio backend position updates
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Real-time waveform update logic
|
||||||
|
- Playback position highlighting
|
||||||
|
- Integration with audio backend position tracking
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Subscribe to audio backend position updates
|
||||||
|
2. Update waveform data points based on playback position
|
||||||
|
3. Implement playback position highlighting
|
||||||
|
4. Add animation for progress indicator
|
||||||
|
5. Test synchronization with audio playback
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Integration: Test waveform sync with audio playback
|
||||||
|
- Performance: Measure real-time update overhead
|
||||||
|
- Visual: Verify progress highlighting matches audio position
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Waveform updates in real-time during playback
|
||||||
|
- Playback position is accurately highlighted
|
||||||
|
- No lag or desynchronization with audio
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Play audio and watch waveform update
|
||||||
|
- Verify progress bar matches audio position
|
||||||
|
- Test with different playback speeds
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use existing audio position polling in `useAudio.ts`
|
||||||
|
- Update waveform every ~100ms for smooth visuals
|
||||||
|
- Consider reducing waveform resolution during playback for performance
|
||||||
|
- Ensure highlighting doesn't flicker
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 09. Optimize Waveform Rendering Performance
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: merged-waveform-09
|
||||||
|
feature: merged-waveform
|
||||||
|
priority: P3
|
||||||
|
depends_on: [merged-waveform-08]
|
||||||
|
tags: [performance, optimization]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Ensure waveform rendering doesn't cause performance issues
|
||||||
|
- Optimize for terminal TUI environment
|
||||||
|
- Minimize CPU and memory usage
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Performance optimizations
|
||||||
|
- Memory management for waveform data
|
||||||
|
- Performance monitoring and testing
|
||||||
|
|
||||||
|
steps:
|
||||||
|
1. Profile waveform rendering performance
|
||||||
|
2. Optimize data point generation and updates
|
||||||
|
3. Implement waveform data caching
|
||||||
|
4. Add performance monitoring
|
||||||
|
5. Test with long audio files
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Performance: Measure CPU usage during playback
|
||||||
|
- Performance: Measure memory usage over time
|
||||||
|
- Load test: Test with 30+ minute audio files
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Waveform rendering < 16ms per frame
|
||||||
|
- No memory leaks during extended playback
|
||||||
|
- Smooth playback even with waveform rendering
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Profile CPU usage during playback
|
||||||
|
- Monitor memory over 30-minute playback session
|
||||||
|
- Test with multiple simultaneous audio files
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Consider reducing waveform resolution during playback
|
||||||
|
- Cache waveform data to avoid regeneration
|
||||||
|
- Use efficient data structures for waveform points
|
||||||
|
- Test on slower terminals (e.g., tmux)
|
||||||
21
tasks/merged-waveform/README.md
Normal file
21
tasks/merged-waveform/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Merged Waveform Progress Bar
|
||||||
|
|
||||||
|
Objective: Create a real-time waveform visualization that expands from a progress bar during playback
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
- [ ] 06 — Implement audio waveform analysis → `06-waveform-audio-analysis.md`
|
||||||
|
- [ ] 07 — Create merged progress-waveform component → `07-merged-waveform-component.md`
|
||||||
|
- [ ] 08 — Implement real-time waveform rendering during playback → `08-realtime-waveform-rendering.md`
|
||||||
|
- [ ] 09 — Optimize waveform rendering performance → `09-waveform-performance-optimization.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
- 06 -> 07
|
||||||
|
- 07 -> 08
|
||||||
|
- 08 -> 09
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
- Waveform smoothly expands from progress bar during playback
|
||||||
|
- Waveform is highlighted based on current playback position
|
||||||
|
- No performance degradation during playback
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 01. Initialize SolidJS OpenTUI Project with Bun
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-01
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: []
|
|
||||||
tags: [project-setup, solidjs, opentui, bun]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Initialize a new SolidJS-based OpenTUI project using Bun
|
|
||||||
- Set up project structure with all necessary dependencies
|
|
||||||
- Configure TypeScript and development environment
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `/podcast-tui-app/` directory created
|
|
||||||
- `package.json` with all dependencies
|
|
||||||
- `tsconfig.json` configured for TypeScript
|
|
||||||
- `bunfig.toml` with Bun configuration
|
|
||||||
- `.gitignore` for Git version control
|
|
||||||
- `README.md` with project description
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Run `bunx create-tui@latest -t solid podcast-tui-app` to initialize the project
|
|
||||||
- Verify project structure is created correctly
|
|
||||||
- Install additional dependencies: `bun add @opentui/solid @opentui/core solid-js zustand`
|
|
||||||
- Install dev dependencies: `bun add -d @opentui/testing @opentui/react @opentui/solid`
|
|
||||||
- Configure TypeScript in `tsconfig.json` with SolidJS and OpenTUI settings
|
|
||||||
- Create `.gitignore` with Node and Bun specific files
|
|
||||||
- Initialize Git repository: `git init`
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Verify `create-tui` command works without errors
|
|
||||||
- Integration: Test that application can be started with `bun run src/index.tsx`
|
|
||||||
- Environment: Confirm all dependencies are installed correctly
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Project directory `podcast-tui-app/` exists
|
|
||||||
- `package.json` contains `@opentui/solid` and `@opentui/core` dependencies
|
|
||||||
- `bun run src/index.tsx` starts the application without errors
|
|
||||||
- TypeScript compilation works with `bun run build`
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run build` to verify TypeScript compilation
|
|
||||||
- Run `bun run src/index.tsx` to start the application
|
|
||||||
- Run `bun pm ls` to verify all dependencies are installed
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- OpenTUI is already installed in the system
|
|
||||||
- Use `-t solid` flag for SolidJS template
|
|
||||||
- All commands should be run from the project root
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 02. Create Main App Shell with Tab Navigation
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-02
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [01]
|
|
||||||
tags: [layout, navigation, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create the main application shell with a responsive layout
|
|
||||||
- Implement tab-based navigation system
|
|
||||||
- Set up the root component structure
|
|
||||||
- Configure Flexbox layout for terminal UI
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/App.tsx` with main app shell
|
|
||||||
- `src/components/Navigation.tsx` with tab navigation
|
|
||||||
- `src/components/Layout.tsx` with responsive layout
|
|
||||||
- Tab navigation component with 5 tabs: Discover, My Feeds, Search, Player, Settings
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/App.tsx` with main component that renders Navigation
|
|
||||||
- Create `src/components/Navigation.tsx` with tab navigation
|
|
||||||
- Use `<box>` with `<scrollbox>` for navigation
|
|
||||||
- Implement tab selection state with `createSignal`
|
|
||||||
- Add keyboard navigation (arrow keys)
|
|
||||||
- Add tab labels: "Discover", "My Feeds", "Search", "Player", "Settings"
|
|
||||||
- Create `src/components/Layout.tsx` with responsive layout
|
|
||||||
- Use Flexbox with `flexDirection="column"`
|
|
||||||
- Create top navigation bar
|
|
||||||
- Create main content area
|
|
||||||
- Handle terminal resizing
|
|
||||||
- Update `src/index.tsx` to render the app
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test Navigation component renders with correct tabs
|
|
||||||
- Integration: Test keyboard navigation moves between tabs
|
|
||||||
- Component: Verify layout adapts to terminal size changes
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Navigation component displays 5 tabs correctly
|
|
||||||
- Tab selection is visually indicated
|
|
||||||
- Arrow keys navigate between tabs
|
|
||||||
- Layout fits within terminal bounds
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and verify navigation appears
|
|
||||||
- Press arrow keys to test navigation
|
|
||||||
- Resize terminal and verify layout adapts
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for state management
|
|
||||||
- Follow OpenTUI layout patterns from `layout/REFERENCE.md`
|
|
||||||
- Navigation should be persistent across all screens
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 03. Implement Direct File Sync (JSON/XML Import/Export)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-03
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [02]
|
|
||||||
tags: [file-sync, json, xml, import-export, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create data models for JSON and XML sync formats
|
|
||||||
- Implement import functionality to load feeds and settings from files
|
|
||||||
- Implement export functionality to save feeds and settings to files
|
|
||||||
- Add file picker UI for selecting files
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/types/sync.ts` with sync data models
|
|
||||||
- `src/utils/sync.ts` with import/export functions
|
|
||||||
- `src/components/SyncPanel.tsx` with sync UI
|
|
||||||
- File picker component for file selection
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/types/sync.ts` with data models:
|
|
||||||
- `SyncData` interface for JSON format
|
|
||||||
- `SyncDataXML` interface for XML format
|
|
||||||
- Include fields: feeds, sources, settings, preferences
|
|
||||||
- Create `src/utils/sync.ts` with functions:
|
|
||||||
- `exportToJSON(data: SyncData): string`
|
|
||||||
- `importFromJSON(json: string): SyncData`
|
|
||||||
- `exportToXML(data: SyncDataXML): string`
|
|
||||||
- `importFromXML(xml: string): SyncDataXML`
|
|
||||||
- Handle validation and error checking
|
|
||||||
- Create `src/components/SyncPanel.tsx` with:
|
|
||||||
- Import button
|
|
||||||
- Export button
|
|
||||||
- File picker UI using `<input>` component
|
|
||||||
- Sync status indicator
|
|
||||||
- Add sync functionality to Settings screen
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test JSON import/export with sample data
|
|
||||||
- Unit: Test XML import/export with sample data
|
|
||||||
- Integration: Test file picker selects correct files
|
|
||||||
- Integration: Test sync panel buttons work correctly
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Export creates valid JSON file with all data
|
|
||||||
- Export creates valid XML file with all data
|
|
||||||
- Import loads data from JSON file
|
|
||||||
- Import loads data from XML file
|
|
||||||
- File picker allows selecting files from disk
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run src/index.tsx`
|
|
||||||
- Go to Settings > Sync
|
|
||||||
- Click Export, verify file created
|
|
||||||
- Click Import, select file, verify data loaded
|
|
||||||
- Test with both JSON and XML formats
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- JSON format: Simple, human-readable
|
|
||||||
- XML format: More structured, better for complex data
|
|
||||||
- Use `FileReader` API for file operations
|
|
||||||
- Handle file not found and invalid format errors
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 04. Build Optional Authentication System
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-04
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P2
|
|
||||||
depends_on: [03]
|
|
||||||
tags: [authentication, optional, solidjs, security]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create authentication state management (disabled by default)
|
|
||||||
- Build simple login screen with email/password
|
|
||||||
- Implement 8-character code validation flow
|
|
||||||
- Add OAuth placeholder screens
|
|
||||||
- Create sync-only user profile
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/stores/auth.ts` with authentication state store
|
|
||||||
- `src/components/LoginScreen.tsx` with login form
|
|
||||||
- `src/components/CodeValidation.tsx` with 8-character code input
|
|
||||||
- `src/components/OAuthPlaceholder.tsx` with OAuth info
|
|
||||||
- `src/components/SyncProfile.tsx` with sync-only profile
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/stores/auth.ts` with Zustand store:
|
|
||||||
- `user` state (initially null)
|
|
||||||
- `isAuthenticated` state (initially false)
|
|
||||||
- `login()` function
|
|
||||||
- `logout()` function
|
|
||||||
- `validateCode()` function
|
|
||||||
- Create `src/components/LoginScreen.tsx`:
|
|
||||||
- Email input field
|
|
||||||
- Password input field
|
|
||||||
- Login button
|
|
||||||
- Link to code validation flow
|
|
||||||
- Link to OAuth placeholder
|
|
||||||
- Create `src/components/CodeValidation.tsx`:
|
|
||||||
- 8-character code input
|
|
||||||
- Validation logic (alphanumeric)
|
|
||||||
- Submit button
|
|
||||||
- Error message display
|
|
||||||
- Create `src/components/OAuthPlaceholder.tsx`:
|
|
||||||
- Display OAuth information
|
|
||||||
- Explain terminal limitations
|
|
||||||
- Link to browser redirect flow
|
|
||||||
- Create `src/components/SyncProfile.tsx`:
|
|
||||||
- User profile display
|
|
||||||
- Sync status indicator
|
|
||||||
- Profile management options
|
|
||||||
- Add auth screens to Navigation (hidden by default)
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test authentication state management
|
|
||||||
- Unit: Test code validation logic
|
|
||||||
- Integration: Test login flow completes
|
|
||||||
- Integration: Test logout clears state
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Authentication is disabled by default (isAuthenticated = false)
|
|
||||||
- Login screen accepts email and password
|
|
||||||
- Code validation accepts 8-character codes
|
|
||||||
- OAuth placeholder displays limitations
|
|
||||||
- Sync profile shows user info
|
|
||||||
- Login state persists across sessions
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and verify login screen is accessible
|
|
||||||
- Try to log in with valid credentials
|
|
||||||
- Try to log in with invalid credentials
|
|
||||||
- Test code validation with valid/invalid codes
|
|
||||||
- Verify authentication state persists
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Authentication is optional and disabled by default
|
|
||||||
- Focus on file sync, not user accounts
|
|
||||||
- Use simple validation (no real backend)
|
|
||||||
- Store authentication state in localStorage
|
|
||||||
- OAuth not feasible in terminal, document limitation
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 05. Create Feed Data Models and Types
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-05
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [04]
|
|
||||||
tags: [types, data-models, solidjs, typescript]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Define TypeScript interfaces for all podcast-related data types
|
|
||||||
- Create models for feeds, episodes, sources, and user preferences
|
|
||||||
- Set up type definitions for sync functionality
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/types/podcast.ts` with all data models
|
|
||||||
- `src/types/episode.ts` with episode-specific types
|
|
||||||
- `src/types/source.ts` with podcast source types
|
|
||||||
- `src/types/preference.ts` with user preference types
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/types/podcast.ts` with core types:
|
|
||||||
- `Podcast` interface (id, title, description, coverUrl, feedUrl, lastUpdated)
|
|
||||||
- `Episode` interface (id, title, description, audioUrl, duration, pubDate, episodeNumber)
|
|
||||||
- `Feed` interface (id, podcast, episodes[], isPublic, sourceId)
|
|
||||||
- `FeedItem` interface (represents a single episode in a feed)
|
|
||||||
- Create `src/types/episode.ts` with episode types:
|
|
||||||
- `Episode` interface
|
|
||||||
- `EpisodeStatus` enum (playing, paused, completed)
|
|
||||||
- `Progress` interface (episodeId, position, duration)
|
|
||||||
- Create `src/types/source.ts` with source types:
|
|
||||||
- `PodcastSource` interface (id, name, baseUrl, type, apiKey)
|
|
||||||
- `SourceType` enum (rss, api, custom)
|
|
||||||
- `SearchQuery` interface (query, sourceIds, filters)
|
|
||||||
- Create `src/types/preference.ts` with preference types:
|
|
||||||
- `UserPreference` interface (theme, fontSize, playbackSpeed, autoDownload)
|
|
||||||
- `SyncPreference` interface (autoSync, backupInterval, syncMethod)
|
|
||||||
- Add type exports in `src/index.ts`
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Verify all interfaces compile correctly
|
|
||||||
- Unit: Test enum values are correct
|
|
||||||
- Integration: Test type definitions match expected data structures
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- All TypeScript interfaces compile without errors
|
|
||||||
- Types are exported for use across the application
|
|
||||||
- Type definitions cover all podcast-related data
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run build` to verify TypeScript compilation
|
|
||||||
- Check `src/types/` directory contains all required files
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use strict TypeScript mode
|
|
||||||
- Include JSDoc comments for complex types
|
|
||||||
- Keep types simple and focused
|
|
||||||
- Ensure types are compatible with sync JSON/XML formats
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 06. Build Feed List Component (Public/Private Feeds)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-06
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [05]
|
|
||||||
tags: [feed-list, components, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create a scrollable feed list component
|
|
||||||
- Display public and private feeds
|
|
||||||
- Implement feed selection and display
|
|
||||||
- Add reverse chronological ordering
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/FeedList.tsx` with feed list component
|
|
||||||
- `src/components/FeedItem.tsx` with individual feed item
|
|
||||||
- `src/components/FeedFilter.tsx` with public/private toggle
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/FeedList.tsx`:
|
|
||||||
- Use `<scrollbox>` for scrollable list
|
|
||||||
- Accept feeds array as prop
|
|
||||||
- Implement feed rendering with `createSignal` for selection
|
|
||||||
- Add keyboard navigation (arrow keys, enter)
|
|
||||||
- Display feed title, description, episode count
|
|
||||||
- Create `src/components/FeedItem.tsx`:
|
|
||||||
- Display feed information
|
|
||||||
- Show public/private indicator
|
|
||||||
- Highlight selected feed
|
|
||||||
- Add hover effects
|
|
||||||
- Create `src/components/FeedFilter.tsx`:
|
|
||||||
- Toggle button for public/private feeds
|
|
||||||
- Filter logic implementation
|
|
||||||
- Update parent FeedList when filtered
|
|
||||||
- Add feed list to "My Feeds" navigation tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test FeedList renders with feeds
|
|
||||||
- Unit: Test FeedItem displays correctly
|
|
||||||
- Integration: Test public/private filtering
|
|
||||||
- Integration: Test keyboard navigation in feed list
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Feed list displays all feeds correctly
|
|
||||||
- Public/private toggle filters feeds
|
|
||||||
- Feed selection is visually indicated
|
|
||||||
- Keyboard navigation works (arrow keys, enter)
|
|
||||||
- List scrolls properly when many feeds
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "My Feeds"
|
|
||||||
- Add some feeds and verify they appear
|
|
||||||
- Test public/private toggle
|
|
||||||
- Use arrow keys to navigate feeds
|
|
||||||
- Scroll list with many feeds
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for selection state
|
|
||||||
- Follow OpenTUI component patterns from `components/REFERENCE.md`
|
|
||||||
- Feed list should be scrollable with many items
|
|
||||||
- Use Flexbox for layout
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 07. Implement Multi-Source Search Interface
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-07
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [06]
|
|
||||||
tags: [search, multi-source, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create search input component
|
|
||||||
- Implement multi-source search functionality
|
|
||||||
- Display search results with sources
|
|
||||||
- Add search history with persistent storage
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SearchBar.tsx` with search input
|
|
||||||
- `src/components/SearchResults.tsx` with results display
|
|
||||||
- `src/components/SearchHistory.tsx` with history list
|
|
||||||
- `src/utils/search.ts` with search logic
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/SearchBar.tsx`:
|
|
||||||
- Search input field using `<input>` component
|
|
||||||
- Search button
|
|
||||||
- Clear history button
|
|
||||||
- Enter key handler
|
|
||||||
- Create `src/utils/search.ts`:
|
|
||||||
- `searchPodcasts(query: string, sourceIds: string[]): Promise<Podcast[]>`
|
|
||||||
- `searchEpisodes(query: string, feedId: string): Promise<Episode[]>`
|
|
||||||
- Handle multiple sources
|
|
||||||
- Cache search results
|
|
||||||
- Create `src/components/SearchResults.tsx`:
|
|
||||||
- Display search results with source indicators
|
|
||||||
- Show podcast/episode info
|
|
||||||
- Add click to add to feeds
|
|
||||||
- Keyboard navigation through results
|
|
||||||
- Create `src/components/SearchHistory.tsx`:
|
|
||||||
- Display recent search queries
|
|
||||||
- Click to re-run search
|
|
||||||
- Delete individual history items
|
|
||||||
- Persist to localStorage
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test search logic returns correct results
|
|
||||||
- Unit: Test search history persistence
|
|
||||||
- Integration: Test search bar accepts input
|
|
||||||
- Integration: Test results display correctly
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Search bar accepts and processes queries
|
|
||||||
- Multi-source search works across all enabled sources
|
|
||||||
- Search results display with source information
|
|
||||||
- Search history persists across sessions
|
|
||||||
- Keyboard navigation works in results
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Search"
|
|
||||||
- Type a query and press Enter
|
|
||||||
- Verify results appear
|
|
||||||
- Click a result to add to feed
|
|
||||||
- Restart app and verify history persists
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use localStorage for search history
|
|
||||||
- Implement basic caching to avoid repeated searches
|
|
||||||
- Handle empty results gracefully
|
|
||||||
- Add loading state during search
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 08. Build Discover Feed with Popular Shows
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-08
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [07]
|
|
||||||
tags: [discover, popular-shows, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create popular shows data structure
|
|
||||||
- Build discover page component
|
|
||||||
- Display trending shows with categories
|
|
||||||
- Implement category filtering
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/data/popular-shows.ts` with trending shows data
|
|
||||||
- `src/components/DiscoverPage.tsx` with discover UI
|
|
||||||
- `src/components/CategoryFilter.tsx` with category buttons
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/data/popular-shows.ts`:
|
|
||||||
- Array of popular podcasts with metadata
|
|
||||||
- Categories: Technology, Business, Science, Entertainment
|
|
||||||
- Reverse chronological ordering (newest first)
|
|
||||||
- Include feed URLs and descriptions
|
|
||||||
- Create `src/components/DiscoverPage.tsx`:
|
|
||||||
- Title header
|
|
||||||
- Category filter buttons
|
|
||||||
- Grid/list display of popular shows
|
|
||||||
- Show details on selection
|
|
||||||
- Add to feed button
|
|
||||||
- Create `src/components/CategoryFilter.tsx`:
|
|
||||||
- Category button group
|
|
||||||
- Active category highlighting
|
|
||||||
- Filter logic implementation
|
|
||||||
- Add discover page to "Discover" navigation tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test popular shows data structure
|
|
||||||
- Unit: Test category filtering
|
|
||||||
- Integration: Test discover page displays correctly
|
|
||||||
- Integration: Test add to feed functionality
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Discover page displays popular shows
|
|
||||||
- Category filtering works correctly
|
|
||||||
- Shows are ordered reverse chronologically
|
|
||||||
- Clicking a show shows details
|
|
||||||
- Add to feed button works
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Discover"
|
|
||||||
- Verify popular shows appear
|
|
||||||
- Click different categories
|
|
||||||
- Click a show and verify details
|
|
||||||
- Try add to feed
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Popular shows data can be static or fetched from sources
|
|
||||||
- If sources don't provide trending, use curated list
|
|
||||||
- Categories help users find shows by topic
|
|
||||||
- Use Flexbox for category filter layout
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 09. Create Player UI with Waveform Visualization
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-09
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [08]
|
|
||||||
tags: [player, waveform, visualization, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create player UI layout
|
|
||||||
- Implement playback controls (play/pause, skip, progress)
|
|
||||||
- Build ASCII waveform visualization
|
|
||||||
- Add progress tracking and seek functionality
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/Player.tsx` with player UI
|
|
||||||
- `src/components/PlaybackControls.tsx` with controls
|
|
||||||
- `src/components/Waveform.tsx` with ASCII waveform
|
|
||||||
- `src/utils/waveform.ts` with visualization logic
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/Player.tsx`:
|
|
||||||
- Player header with episode info
|
|
||||||
- Progress bar with seek functionality
|
|
||||||
- Waveform visualization area
|
|
||||||
- Playback controls
|
|
||||||
- Create `src/components/PlaybackControls.tsx`:
|
|
||||||
- Play/Pause button
|
|
||||||
- Previous/Next episode buttons
|
|
||||||
- Volume control
|
|
||||||
- Speed control
|
|
||||||
- Keyboard shortcuts (space, arrows)
|
|
||||||
- Create `src/components/Waveform.tsx`:
|
|
||||||
- ASCII waveform visualization
|
|
||||||
- Click to seek
|
|
||||||
- Color-coded for played/paused
|
|
||||||
- Use frame buffer for drawing
|
|
||||||
- Create `src/utils/waveform.ts`:
|
|
||||||
- Generate waveform data from audio
|
|
||||||
- Convert to ASCII characters
|
|
||||||
- Handle audio duration and position
|
|
||||||
- Add player to "Player" navigation tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test waveform generation
|
|
||||||
- Unit: Test playback controls
|
|
||||||
- Integration: Test player UI displays correctly
|
|
||||||
- Integration: Test keyboard shortcuts work
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Player UI displays episode information
|
|
||||||
- Playback controls work (play/pause, skip)
|
|
||||||
- Waveform visualization shows audio waveform
|
|
||||||
- Progress bar updates during playback
|
|
||||||
- Clicking waveform seeks to position
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Player"
|
|
||||||
- Select an episode to play
|
|
||||||
- Test playback controls
|
|
||||||
- Verify waveform visualization
|
|
||||||
- Test seeking by clicking waveform
|
|
||||||
- Test keyboard shortcuts
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- ASCII waveform: Use `#` for peaks, `.` for valleys
|
|
||||||
- Audio integration: Trigger system player or use Web Audio API
|
|
||||||
- Waveform data needs to be cached for performance
|
|
||||||
- Use SolidJS `useTimeline` for animation
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 10. Build Settings Screen and Preferences
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-10
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [09]
|
|
||||||
tags: [settings, preferences, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create settings screen component
|
|
||||||
- Add source management UI
|
|
||||||
- Build user preferences panel
|
|
||||||
- Implement data persistence
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SettingsScreen.tsx` with settings UI
|
|
||||||
- `src/components/SourceManager.tsx` with source management
|
|
||||||
- `src/components/PreferencesPanel.tsx` with user preferences
|
|
||||||
- `src/utils/persistence.ts` with localStorage utilities
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/SettingsScreen.tsx`:
|
|
||||||
- Settings menu with sections
|
|
||||||
- Navigation between settings sections
|
|
||||||
- Save/Cancel buttons
|
|
||||||
- Create `src/components/SourceManager.tsx`:
|
|
||||||
- List of enabled sources
|
|
||||||
- Add source button
|
|
||||||
- Remove source button
|
|
||||||
- Enable/disable toggle
|
|
||||||
- Create `src/components/PreferencesPanel.tsx`:
|
|
||||||
- Theme selection (light/dark)
|
|
||||||
- Font size control
|
|
||||||
- Playback speed control
|
|
||||||
- Auto-download settings
|
|
||||||
- Create `src/utils/persistence.ts`:
|
|
||||||
- `savePreference(key, value)`
|
|
||||||
- `loadPreference(key)`
|
|
||||||
- `saveFeeds(feeds)`
|
|
||||||
- `loadFeeds()`
|
|
||||||
- `saveSettings(settings)`
|
|
||||||
- `loadSettings()`
|
|
||||||
- Add settings screen to "Settings" navigation tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test persistence functions
|
|
||||||
- Unit: Test source management
|
|
||||||
- Integration: Test settings screen navigation
|
|
||||||
- Integration: Test preferences save/load
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Settings screen displays all sections
|
|
||||||
- Source management adds/removes sources
|
|
||||||
- Preferences save correctly
|
|
||||||
- Data persists across sessions
|
|
||||||
- Settings screen is accessible
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Settings"
|
|
||||||
- Test source management
|
|
||||||
- Change preferences and verify save
|
|
||||||
- Restart app and verify preferences persist
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use localStorage for simple persistence
|
|
||||||
- Settings are application-level, not user-specific
|
|
||||||
- Source management requires API keys if needed
|
|
||||||
- Preferences are per-user
|
|
||||||
- Add error handling for persistence failures
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 11. Create Global State Store and Data Layer
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-11
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [10]
|
|
||||||
tags: [state-management, global-store, signals, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create global state store using Signals
|
|
||||||
- Implement data fetching and caching
|
|
||||||
- Build file-based storage for sync
|
|
||||||
- Connect all components to shared state
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/stores/appStore.ts` with global state store
|
|
||||||
- `src/stores/feedStore.ts` with feed state
|
|
||||||
- `src/stores/playerStore.ts` with player state
|
|
||||||
- `src/stores/searchStore.ts` with search state
|
|
||||||
- `src/utils/storage.ts` with file-based storage
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/stores/appStore.ts`:
|
|
||||||
- Use SolidJS signals for global state
|
|
||||||
- Store application state: currentTab, isAuthEnabled, settings
|
|
||||||
- Provide state to all child components
|
|
||||||
- Create `src/stores/feedStore.ts`:
|
|
||||||
- Signals for feeds array
|
|
||||||
- Signals for selectedFeed
|
|
||||||
- Methods: addFeed, removeFeed, updateFeed
|
|
||||||
- Create `src/stores/playerStore.ts`:
|
|
||||||
- Signals for currentEpisode
|
|
||||||
- Signals for playbackState
|
|
||||||
- Methods: play, pause, seek, setSpeed
|
|
||||||
- Create `src/stores/searchStore.ts`:
|
|
||||||
- Signals for searchResults
|
|
||||||
- Signals for searchHistory
|
|
||||||
- Methods: search, addToHistory, clearHistory
|
|
||||||
- Create `src/utils/storage.ts`:
|
|
||||||
- `saveToLocal()`
|
|
||||||
- `loadFromLocal()`
|
|
||||||
- File-based sync for feeds and settings
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test store methods update signals correctly
|
|
||||||
- Unit: Test storage functions
|
|
||||||
- Integration: Test state persists across components
|
|
||||||
- Integration: Test data sync with file storage
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Global state store manages all app state
|
|
||||||
- Store methods update signals correctly
|
|
||||||
- State persists across component re-renders
|
|
||||||
- File-based storage works for sync
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and verify state is initialized
|
|
||||||
- Modify state and verify UI updates
|
|
||||||
- Restart app and verify state persistence
|
|
||||||
- Test sync functionality
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for reactivity
|
|
||||||
- Store should be singleton pattern
|
|
||||||
- Use Zustand if complex state management needed
|
|
||||||
- Keep store simple and focused
|
|
||||||
- File-based storage for sync with JSON/XML
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 12. Set Up Testing Framework and Write Tests
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-12
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [11]
|
|
||||||
tags: [testing, snapshot-testing, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Set up OpenTUI testing framework
|
|
||||||
- Write component tests for all major components
|
|
||||||
- Add keyboard interaction tests
|
|
||||||
- Implement error handling tests
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `tests/` directory with test files
|
|
||||||
- `tests/components/` with component tests
|
|
||||||
- `tests/integration/` with integration tests
|
|
||||||
- `tests/utils/` with utility tests
|
|
||||||
- Test coverage for all components
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Set up OpenTUI testing framework:
|
|
||||||
- Install testing dependencies
|
|
||||||
- Configure test runner
|
|
||||||
- Set up snapshot testing
|
|
||||||
- Write component tests:
|
|
||||||
- `tests/components/Navigation.test.tsx`
|
|
||||||
- `tests/components/FeedList.test.tsx`
|
|
||||||
- `tests/components/SearchBar.test.tsx`
|
|
||||||
- `tests/components/Player.test.tsx`
|
|
||||||
- `tests/components/SettingsScreen.test.tsx`
|
|
||||||
- Write integration tests:
|
|
||||||
- `tests/integration/navigation.test.tsx`
|
|
||||||
- `tests/integration/feed-management.test.tsx`
|
|
||||||
- `tests/integration/search.test.tsx`
|
|
||||||
- Write utility tests:
|
|
||||||
- `tests/utils/sync.test.ts`
|
|
||||||
- `tests/utils/search.test.ts`
|
|
||||||
- `tests/utils/storage.test.ts`
|
|
||||||
- Add error handling tests:
|
|
||||||
- Test invalid file imports
|
|
||||||
- Test network errors
|
|
||||||
- Test malformed data
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Run all unit tests
|
|
||||||
- Integration: Run all integration tests
|
|
||||||
- Coverage: Verify all components tested
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- All tests pass
|
|
||||||
- Test coverage > 80%
|
|
||||||
- Snapshot tests match expected output
|
|
||||||
- Error handling tests verify proper behavior
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun test` to execute all tests
|
|
||||||
- Run `bun test --coverage` for coverage report
|
|
||||||
- Fix any failing tests
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use OpenTUI's testing framework for snapshot testing
|
|
||||||
- Test keyboard interactions separately
|
|
||||||
- Mock external dependencies (API calls)
|
|
||||||
- Keep tests fast and focused
|
|
||||||
- Add CI/CD integration for automated testing
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 13. Set Up TypeScript Configuration and Build System
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-13
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [01]
|
|
||||||
tags: [typescript, build-system, configuration, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Configure TypeScript for SolidJS and OpenTUI
|
|
||||||
- Set up build system for production
|
|
||||||
- Configure bundler for optimal output
|
|
||||||
- Add development and production scripts
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `tsconfig.json` with TypeScript configuration
|
|
||||||
- `bunfig.toml` with Bun configuration
|
|
||||||
- `package.json` with build scripts
|
|
||||||
- `.bunfig.toml` with build settings
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Configure TypeScript in `tsconfig.json`:
|
|
||||||
- Set target to ES2020
|
|
||||||
- Configure paths for SolidJS
|
|
||||||
- Add OpenTUI type definitions
|
|
||||||
- Enable strict mode
|
|
||||||
- Configure module resolution
|
|
||||||
- Configure Bun in `bunfig.toml`:
|
|
||||||
- Set up dependencies
|
|
||||||
- Configure build output
|
|
||||||
- Add optimization flags
|
|
||||||
- Update `package.json`:
|
|
||||||
- Add build script: `bun run build`
|
|
||||||
- Add dev script: `bun run dev`
|
|
||||||
- Add test script: `bun run test`
|
|
||||||
- Add lint script: `bun run lint`
|
|
||||||
- Configure bundler for production:
|
|
||||||
- Optimize bundle size
|
|
||||||
- Minify output
|
|
||||||
- Tree-shake unused code
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Verify TypeScript configuration
|
|
||||||
- Integration: Test build process
|
|
||||||
- Integration: Test dev script
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- TypeScript compiles without errors
|
|
||||||
- Build script creates optimized bundle
|
|
||||||
- Dev script runs development server
|
|
||||||
- All scripts work correctly
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run build` to verify build
|
|
||||||
- Run `bun run dev` to verify dev server
|
|
||||||
- Check bundle size is reasonable
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use `bun build` for production builds
|
|
||||||
- Enable source maps for debugging
|
|
||||||
- Configure TypeScript to match OpenTUI requirements
|
|
||||||
- Add path aliases for cleaner imports
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 14. Create Project Directory Structure and Dependencies
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-14
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [01]
|
|
||||||
tags: [project-structure, organization, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create organized project directory structure
|
|
||||||
- Set up all necessary folders and files
|
|
||||||
- Install and configure all dependencies
|
|
||||||
- Create placeholder files for future implementation
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/` directory with organized structure
|
|
||||||
- `src/components/` with component folders
|
|
||||||
- `src/stores/` with store files
|
|
||||||
- `src/types/` with type definitions
|
|
||||||
- `src/utils/` with utility functions
|
|
||||||
- `src/data/` with static data
|
|
||||||
- `tests/` with test structure
|
|
||||||
- `public/` for static assets
|
|
||||||
- `docs/` for documentation
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create directory structure:
|
|
||||||
- `src/components/` (all components)
|
|
||||||
- `src/stores/` (state management)
|
|
||||||
- `src/types/` (TypeScript types)
|
|
||||||
- `src/utils/` (utility functions)
|
|
||||||
- `src/data/` (static data)
|
|
||||||
- `src/hooks/` (custom hooks)
|
|
||||||
- `src/api/` (API clients)
|
|
||||||
- `tests/` (test files)
|
|
||||||
- `public/` (static assets)
|
|
||||||
- `docs/` (documentation)
|
|
||||||
- Create placeholder files:
|
|
||||||
- `src/index.tsx` (main entry)
|
|
||||||
- `src/App.tsx` (app shell)
|
|
||||||
- `src/main.ts` (Bun entry)
|
|
||||||
- Install dependencies:
|
|
||||||
- Core: `@opentui/solid`, `@opentui/core`, `solid-js`
|
|
||||||
- State: `zustand`
|
|
||||||
- Testing: `@opentui/testing`
|
|
||||||
- Utilities: `date-fns`, `uuid`
|
|
||||||
- Optional: `react`, `@opentui/react` (for testing)
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Verify directory structure exists
|
|
||||||
- Integration: Verify all dependencies installed
|
|
||||||
- Component: Verify placeholder files exist
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- All directories created
|
|
||||||
- All placeholder files exist
|
|
||||||
- All dependencies installed
|
|
||||||
- Project structure follows conventions
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `ls -R src/` to verify structure
|
|
||||||
- Run `bun pm ls` to verify dependencies
|
|
||||||
- Check all placeholder files exist
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Follow OpenTUI project structure conventions
|
|
||||||
- Keep structure organized and scalable
|
|
||||||
- Add comments to placeholder files
|
|
||||||
- Consider adding `eslint`, `prettier` for code quality
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 15. Build Responsive Layout System (Flexbox)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-15
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [14]
|
|
||||||
tags: [layout, flexbox, responsive, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create reusable Flexbox layout components
|
|
||||||
- Handle terminal resizing gracefully
|
|
||||||
- Ensure responsive design across different terminal sizes
|
|
||||||
- Build layout patterns for common UI patterns
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/BoxLayout.tsx` with Flexbox container
|
|
||||||
- `src/components/Row.tsx` with horizontal layout
|
|
||||||
- `src/components/Column.tsx` with vertical layout
|
|
||||||
- `src/components/ResponsiveContainer.tsx` with resizing logic
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/BoxLayout.tsx`:
|
|
||||||
- Generic Flexbox container
|
|
||||||
- Props: flexDirection, justifyContent, alignItems, gap
|
|
||||||
- Use OpenTUI layout patterns
|
|
||||||
- Create `src/components/Row.tsx`:
|
|
||||||
- Horizontal layout (flexDirection="row")
|
|
||||||
- Props for spacing and alignment
|
|
||||||
- Handle overflow
|
|
||||||
- Create `src/components/Column.tsx`:
|
|
||||||
- Vertical layout (flexDirection="column")
|
|
||||||
- Props for spacing and alignment
|
|
||||||
- Scrollable content area
|
|
||||||
- Create `src/components/ResponsiveContainer.tsx`:
|
|
||||||
- Listen to terminal resize events
|
|
||||||
- Adjust layout based on width/height
|
|
||||||
- Handle different screen sizes
|
|
||||||
- Add usage examples and documentation
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test BoxLayout with different props
|
|
||||||
- Unit: Test Row and Column layouts
|
|
||||||
- Integration: Test responsive behavior on resize
|
|
||||||
- Integration: Test layouts fit within terminal bounds
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- BoxLayout renders with correct Flexbox properties
|
|
||||||
- Row and Column layouts work correctly
|
|
||||||
- ResponsiveContainer adapts to terminal resize
|
|
||||||
- All layouts fit within terminal bounds
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and test layouts
|
|
||||||
- Resize terminal and verify responsive behavior
|
|
||||||
- Test with different terminal sizes
|
|
||||||
- Check layouts don't overflow
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use OpenTUI Flexbox patterns from `layout/REFERENCE.md`
|
|
||||||
- Terminal dimensions: width (columns) x height (rows)
|
|
||||||
- Handle edge cases (very small screens)
|
|
||||||
- Add comments explaining layout decisions
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# 16. Implement Tab Navigation Component
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-16
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [15]
|
|
||||||
tags: [navigation, tabs, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create reusable tab navigation component
|
|
||||||
- Implement tab selection state
|
|
||||||
- Add keyboard navigation for tabs
|
|
||||||
- Handle active tab highlighting
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/TabNavigation.tsx` with tab navigation
|
|
||||||
- `src/components/Tab.tsx` with individual tab component
|
|
||||||
- `src/hooks/useTabNavigation.ts` with tab logic
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/TabNavigation.tsx`:
|
|
||||||
- Accept tabs array as prop
|
|
||||||
- Render tab buttons
|
|
||||||
- Manage selected tab state
|
|
||||||
- Update parent component on tab change
|
|
||||||
- Create `src/components/Tab.tsx`:
|
|
||||||
- Individual tab button
|
|
||||||
- Highlight selected tab
|
|
||||||
- Handle click and keyboard events
|
|
||||||
- Show tab icon if needed
|
|
||||||
- Create `src/hooks/useTabNavigation.ts`:
|
|
||||||
- Manage tab selection state
|
|
||||||
- Handle keyboard navigation (arrow keys)
|
|
||||||
- Update parent component
|
|
||||||
- Persist selected tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test Tab component renders correctly
|
|
||||||
- Unit: Test tab selection updates state
|
|
||||||
- Integration: Test keyboard navigation
|
|
||||||
- Integration: Test tab persists across renders
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- TabNavigation displays all tabs correctly
|
|
||||||
- Tab selection is visually indicated
|
|
||||||
- Keyboard navigation works (arrow keys, enter)
|
|
||||||
- Active tab persists
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and verify tabs appear
|
|
||||||
- Click tabs to test selection
|
|
||||||
- Use arrow keys to navigate
|
|
||||||
- Restart app and verify active tab persists
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for tab state
|
|
||||||
- Follow OpenTUI component patterns
|
|
||||||
- Tabs: Discover, My Feeds, Search, Player, Settings
|
|
||||||
- Add keyboard shortcuts documentation
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 17. Add Keyboard Shortcuts and Navigation Handling
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-17
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [16]
|
|
||||||
tags: [keyboard, shortcuts, navigation, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Implement global keyboard shortcuts
|
|
||||||
- Add shortcut documentation
|
|
||||||
- Handle keyboard events across components
|
|
||||||
- Prevent conflicts with input fields
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/KeyboardHandler.tsx` with global shortcuts
|
|
||||||
- `src/components/ShortcutHelp.tsx` with shortcut list
|
|
||||||
- `src/hooks/useKeyboard.ts` with keyboard utilities
|
|
||||||
- `src/config/shortcuts.ts` with shortcut definitions
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/config/shortcuts.ts`:
|
|
||||||
- Define all keyboard shortcuts
|
|
||||||
- Map keys to actions
|
|
||||||
- Document each shortcut
|
|
||||||
- Create `src/hooks/useKeyboard.ts`:
|
|
||||||
- Global keyboard event listener
|
|
||||||
- Filter events based on focused element
|
|
||||||
- Handle modifier keys (Ctrl, Shift, Alt)
|
|
||||||
- Prevent default browser behavior
|
|
||||||
- Create `src/components/KeyboardHandler.tsx`:
|
|
||||||
- Wrap application with keyboard handler
|
|
||||||
- Handle escape to close modals
|
|
||||||
- Handle Ctrl+Q to quit
|
|
||||||
- Handle Ctrl+S to save
|
|
||||||
- Create `src/components/ShortcutHelp.tsx`:
|
|
||||||
- Display all shortcuts
|
|
||||||
- Organize by category
|
|
||||||
- Click to copy shortcut
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test keyboard hook handles events
|
|
||||||
- Unit: Test modifier key combinations
|
|
||||||
- Integration: Test shortcuts work globally
|
|
||||||
- Integration: Test shortcuts don't interfere with inputs
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- All shortcuts work as defined
|
|
||||||
- Shortcuts help displays correctly
|
|
||||||
- Shortcuts don't interfere with input fields
|
|
||||||
- Escape closes modals
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and test each shortcut
|
|
||||||
- Try shortcuts in input fields (shouldn't trigger)
|
|
||||||
- Check ShortcutHelp displays all shortcuts
|
|
||||||
- Test Ctrl+Q quits app
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use OpenTUI keyboard patterns from `keyboard/REFERENCE.md`
|
|
||||||
- Common shortcuts:
|
|
||||||
- Ctrl+Q: Quit
|
|
||||||
- Ctrl+S: Save
|
|
||||||
- Escape: Close modal/exit input
|
|
||||||
- Arrow keys: Navigate
|
|
||||||
- Space: Play/Pause
|
|
||||||
- Document shortcuts in README
|
|
||||||
- Test on different terminals
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 18. Create Sync Data Models (JSON/XML Formats)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-18
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [17]
|
|
||||||
tags: [data-models, json, xml, sync, typescript]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Define TypeScript interfaces for JSON sync format
|
|
||||||
- Define TypeScript interfaces for XML sync format
|
|
||||||
- Ensure compatibility between formats
|
|
||||||
- Add validation logic
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/types/sync-json.ts` with JSON sync types
|
|
||||||
- `src/types/sync-xml.ts` with XML sync types
|
|
||||||
- `src/utils/sync-validation.ts` with validation logic
|
|
||||||
- `src/constants/sync-formats.ts` with format definitions
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/types/sync-json.ts`:
|
|
||||||
- `SyncData` interface with all required fields
|
|
||||||
- Include feeds, sources, settings, preferences
|
|
||||||
- Add version field for format compatibility
|
|
||||||
- Add timestamp for last sync
|
|
||||||
- Create `src/types/sync-xml.ts`:
|
|
||||||
- `SyncDataXML` interface
|
|
||||||
- XML-compatible type definitions
|
|
||||||
- Root element and child elements
|
|
||||||
- Attributes for metadata
|
|
||||||
- Create `src/utils/sync-validation.ts`:
|
|
||||||
- `validateJSONSync(data: unknown): SyncData`
|
|
||||||
- `validateXMLSync(data: unknown): SyncDataXML`
|
|
||||||
- Field validation functions
|
|
||||||
- Type checking
|
|
||||||
- Create `src/constants/sync-formats.ts`:
|
|
||||||
- JSON format version
|
|
||||||
- XML format version
|
|
||||||
- Supported versions list
|
|
||||||
- Format extensions
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test JSON validation with valid/invalid data
|
|
||||||
- Unit: Test XML validation with valid/invalid data
|
|
||||||
- Integration: Test format compatibility
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- JSON sync types compile without errors
|
|
||||||
- XML sync types compile without errors
|
|
||||||
- Validation rejects invalid data
|
|
||||||
- Format versions are tracked
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run build` to verify TypeScript
|
|
||||||
- Test validation with sample data
|
|
||||||
- Test with invalid data to verify rejection
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- JSON format: Simple, human-readable
|
|
||||||
- XML format: More structured, better for complex data
|
|
||||||
- Include all necessary fields for complete sync
|
|
||||||
- Add comments explaining each field
|
|
||||||
- Ensure backward compatibility
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 19. Build Import/Export Functionality
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-19
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [18]
|
|
||||||
tags: [import-export, file-io, sync, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Implement JSON export functionality
|
|
||||||
- Implement JSON import functionality
|
|
||||||
- Implement XML export functionality
|
|
||||||
- Implement XML import functionality
|
|
||||||
- Handle file operations and errors
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/utils/sync.ts` with import/export functions
|
|
||||||
- `src/components/ExportDialog.tsx` with export UI
|
|
||||||
- `src/components/ImportDialog.tsx` with import UI
|
|
||||||
- Error handling components
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Implement JSON export in `src/utils/sync.ts`:
|
|
||||||
- `exportFeedsToJSON(feeds: Feed[]): string`
|
|
||||||
- `exportSettingsToJSON(settings: Settings): string`
|
|
||||||
- Combine into `exportToJSON(data: SyncData): string`
|
|
||||||
- Implement JSON import in `src/utils/sync.ts`:
|
|
||||||
- `importFeedsFromJSON(json: string): Feed[]`
|
|
||||||
- `importSettingsFromJSON(json: string): Settings`
|
|
||||||
- Combine into `importFromJSON(json: string): SyncData`
|
|
||||||
- Implement XML export in `src/utils/sync.ts`:
|
|
||||||
- `exportToXML(data: SyncDataXML): string`
|
|
||||||
- XML serialization
|
|
||||||
- Implement XML import in `src/utils/sync.ts`:
|
|
||||||
- `importFromXML(xml: string): SyncDataXML`
|
|
||||||
- XML parsing
|
|
||||||
- Create `src/components/ExportDialog.tsx`:
|
|
||||||
- File name input
|
|
||||||
- Export format selection
|
|
||||||
- Export button
|
|
||||||
- Success message
|
|
||||||
- Create `src/components/ImportDialog.tsx`:
|
|
||||||
- File picker
|
|
||||||
- Format detection
|
|
||||||
- Import button
|
|
||||||
- Error message display
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test JSON import/export with sample data
|
|
||||||
- Unit: Test XML import/export with sample data
|
|
||||||
- Unit: Test error handling
|
|
||||||
- Integration: Test file operations
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Export creates valid files
|
|
||||||
- Import loads data correctly
|
|
||||||
- Errors are handled gracefully
|
|
||||||
- Files can be opened in text editors
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and test export/import
|
|
||||||
- Open exported files in text editor
|
|
||||||
- Test with different data sizes
|
|
||||||
- Test error cases (invalid files)
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use `FileReader` API for file operations
|
|
||||||
- Handle file not found, invalid format, permission errors
|
|
||||||
- Add progress indicator for large files
|
|
||||||
- Support both JSON and XML formats
|
|
||||||
- Ensure data integrity during import
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 20. Create File Picker UI for Import
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-20
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [19]
|
|
||||||
tags: [file-picker, input, ui, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create file picker component for selecting import files
|
|
||||||
- Implement file format detection
|
|
||||||
- Display file information
|
|
||||||
- Handle file selection and validation
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/FilePicker.tsx` with file picker UI
|
|
||||||
- `src/components/FileInfo.tsx` with file details
|
|
||||||
- `src/utils/file-detector.ts` with format detection
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/utils/file-detector.ts`:
|
|
||||||
- `detectFormat(file: File): 'json' | 'xml' | 'unknown'`
|
|
||||||
- Read file header
|
|
||||||
- Check file extension
|
|
||||||
- Validate format
|
|
||||||
- Create `src/components/FilePicker.tsx`:
|
|
||||||
- File input using `<input>` component
|
|
||||||
- Accept JSON and XML files
|
|
||||||
- File selection handler
|
|
||||||
- Clear button
|
|
||||||
- Create `src/components/FileInfo.tsx`:
|
|
||||||
- Display file name
|
|
||||||
- Display file size
|
|
||||||
- Display file format
|
|
||||||
- Display last modified date
|
|
||||||
- Add file picker to import dialog
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test format detection
|
|
||||||
- Unit: Test file picker selection
|
|
||||||
- Integration: Test file validation
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- File picker allows selecting files
|
|
||||||
- Format detection works correctly
|
|
||||||
- File information is displayed
|
|
||||||
- Invalid files are rejected
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and test file picker
|
|
||||||
- Select valid files
|
|
||||||
- Select invalid files
|
|
||||||
- Verify format detection
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use OpenTUI `<input>` component for file picker
|
|
||||||
- Accept `.json` and `.xml` extensions
|
|
||||||
- Check file size limit (e.g., 10MB)
|
|
||||||
- Add file type validation
|
|
||||||
- Handle file selection errors
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# 21. Build Sync Status Indicator
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-21
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [20]
|
|
||||||
tags: [status-indicator, sync, ui, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create sync status indicator component
|
|
||||||
- Display sync state (idle, syncing, complete, error)
|
|
||||||
- Show sync progress
|
|
||||||
- Provide sync status in settings
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SyncStatus.tsx` with status indicator
|
|
||||||
- `src/components/SyncProgress.tsx` with progress bar
|
|
||||||
- `src/components/SyncError.tsx` with error display
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/SyncStatus.tsx`:
|
|
||||||
- Display current sync state
|
|
||||||
- Show status icon (spinner, check, error)
|
|
||||||
- Show status message
|
|
||||||
- Auto-update based on state
|
|
||||||
- Create `src/components/SyncProgress.tsx`:
|
|
||||||
- Progress bar visualization
|
|
||||||
- Percentage display
|
|
||||||
- Step indicators
|
|
||||||
- Animation
|
|
||||||
- Create `src/components/SyncError.tsx`:
|
|
||||||
- Error message display
|
|
||||||
- Retry button
|
|
||||||
- Error details (expandable)
|
|
||||||
- Add sync status to settings screen
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test status indicator updates correctly
|
|
||||||
- Unit: Test progress bar visualization
|
|
||||||
- Unit: Test error display
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Status indicator shows correct state
|
|
||||||
- Progress bar updates during sync
|
|
||||||
- Error message is displayed on errors
|
|
||||||
- Status persists across sync operations
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and test sync operations
|
|
||||||
- Trigger export/import
|
|
||||||
- Verify status indicator updates
|
|
||||||
- Test error cases
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS signals for state
|
|
||||||
- Status states: idle, syncing, complete, error
|
|
||||||
- Use ASCII icons for status indicators
|
|
||||||
- Add animation for syncing state
|
|
||||||
- Make status component reusable
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 22. Add Backup/Restore Functionality
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-22
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [21]
|
|
||||||
tags: [backup-restore, sync, data-protection, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Implement backup functionality for all user data
|
|
||||||
- Implement restore functionality
|
|
||||||
- Create scheduled backups
|
|
||||||
- Add backup management UI
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/utils/backup.ts` with backup functions
|
|
||||||
- `src/utils/restore.ts` with restore functions
|
|
||||||
- `src/components/BackupManager.tsx` with backup UI
|
|
||||||
- `src/components/ScheduledBackups.tsx` with backup settings
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/utils/backup.ts`:
|
|
||||||
- `createBackup(): Promise<string>`
|
|
||||||
- `backupFeeds(feeds: Feed[]): string`
|
|
||||||
- `backupSettings(settings: Settings): string`
|
|
||||||
- Include all user data
|
|
||||||
- Create `src/utils/restore.ts`:
|
|
||||||
- `restoreFromBackup(backupData: string): Promise<void>`
|
|
||||||
- `restoreFeeds(backupData: string): void`
|
|
||||||
- `restoreSettings(backupData: string): void`
|
|
||||||
- Validate backup data
|
|
||||||
- Create `src/components/BackupManager.tsx`:
|
|
||||||
- List of backup files
|
|
||||||
- Restore button
|
|
||||||
- Delete backup button
|
|
||||||
- Create new backup button
|
|
||||||
- Create `src/components/ScheduledBackups.tsx`:
|
|
||||||
- Enable/disable scheduled backups
|
|
||||||
- Backup interval selection
|
|
||||||
- Last backup time display
|
|
||||||
- Manual backup button
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test backup creates valid files
|
|
||||||
- Unit: Test restore loads data correctly
|
|
||||||
- Unit: Test backup validation
|
|
||||||
- Integration: Test backup/restore workflow
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Backup creates complete backup file
|
|
||||||
- Restore loads all data correctly
|
|
||||||
- Scheduled backups work as configured
|
|
||||||
- Backup files can be managed
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and create backup
|
|
||||||
- Restore from backup
|
|
||||||
- Test scheduled backups
|
|
||||||
- Verify data integrity
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Backup file format: JSON with timestamp
|
|
||||||
- Include version info for compatibility
|
|
||||||
- Store backups in `backups/` directory
|
|
||||||
- Add backup encryption option (optional)
|
|
||||||
- Test with large data sets
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 23. Create Authentication State (Disabled by Default)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-23
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P2
|
|
||||||
depends_on: [22]
|
|
||||||
tags: [authentication, state, solidjs, security]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create authentication state management
|
|
||||||
- Ensure authentication is disabled by default
|
|
||||||
- Set up user state structure
|
|
||||||
- Implement auth state persistence
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/stores/auth.ts` with authentication store
|
|
||||||
- `src/types/auth.ts` with auth types
|
|
||||||
- `src/config/auth.ts` with auth configuration
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/types/auth.ts`:
|
|
||||||
- `User` interface (id, email, name, createdAt)
|
|
||||||
- `AuthState` interface (user, isAuthenticated, isLoading)
|
|
||||||
- `AuthError` interface (code, message)
|
|
||||||
- Create `src/config/auth.ts`:
|
|
||||||
- `DEFAULT_AUTH_ENABLED = false`
|
|
||||||
- `AUTH_CONFIG` with settings
|
|
||||||
- `OAUTH_PROVIDERS` with provider info
|
|
||||||
- Create `src/stores/auth.ts`:
|
|
||||||
- `createAuthStore()` with Zustand
|
|
||||||
- `user` signal (initially null)
|
|
||||||
- `isAuthenticated` signal (initially false)
|
|
||||||
- `login()` function (placeholder)
|
|
||||||
- `logout()` function
|
|
||||||
- `validateCode()` function
|
|
||||||
- Persist state to localStorage
|
|
||||||
- Set up auth state in global store
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test auth state initializes correctly
|
|
||||||
- Unit: Test auth is disabled by default
|
|
||||||
- Unit: Test persistence
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Authentication is disabled by default
|
|
||||||
- Auth store manages state correctly
|
|
||||||
- State persists across sessions
|
|
||||||
- Auth is optional and not required
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and verify auth is disabled
|
|
||||||
- Check localStorage for auth state
|
|
||||||
- Test login flow (should not work without backend)
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Authentication is secondary to file sync
|
|
||||||
- No real backend, just UI/UX
|
|
||||||
- Focus on sync features
|
|
||||||
- User can choose to enable auth later
|
|
||||||
- Store auth state in localStorage
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 24. Build Simple Login Screen (Email/Password)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-24
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P2
|
|
||||||
depends_on: [23]
|
|
||||||
tags: [login, auth, form, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create login screen component
|
|
||||||
- Implement email input field
|
|
||||||
- Implement password input field
|
|
||||||
- Add login validation and error handling
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/LoginScreen.tsx` with login form
|
|
||||||
- `src/components/EmailInput.tsx` with email field
|
|
||||||
- `src/components/PasswordInput.tsx` with password field
|
|
||||||
- `src/components/LoginButton.tsx` with submit button
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/EmailInput.tsx`:
|
|
||||||
- Email input field using `<input>` component
|
|
||||||
- Email validation regex
|
|
||||||
- Error message display
|
|
||||||
- Focus state styling
|
|
||||||
- Create `src/components/PasswordInput.tsx`:
|
|
||||||
- Password input field
|
|
||||||
- Show/hide password toggle
|
|
||||||
- Password validation rules
|
|
||||||
- Error message display
|
|
||||||
- Create `src/components/LoginButton.tsx`:
|
|
||||||
- Submit button
|
|
||||||
- Loading state
|
|
||||||
- Disabled state
|
|
||||||
- Error state
|
|
||||||
- Create `src/components/LoginScreen.tsx`:
|
|
||||||
- Combine inputs and button
|
|
||||||
- Login form validation
|
|
||||||
- Error handling
|
|
||||||
- Link to code validation
|
|
||||||
- Link to OAuth placeholder
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test email validation
|
|
||||||
- Unit: Test password validation
|
|
||||||
- Unit: Test login form submission
|
|
||||||
- Integration: Test login screen displays correctly
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Login screen accepts email and password
|
|
||||||
- Validation works correctly
|
|
||||||
- Error messages display properly
|
|
||||||
- Form submission handled
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to login
|
|
||||||
- Enter valid credentials
|
|
||||||
- Enter invalid credentials
|
|
||||||
- Test error handling
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use OpenTUI `<input>` component
|
|
||||||
- Email validation: regex pattern
|
|
||||||
- Password validation: minimum length
|
|
||||||
- No real authentication, just UI
|
|
||||||
- Link to code validation for sync
|
|
||||||
- Link to OAuth placeholder
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# 25. Implement 8-Character Code Validation Flow
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-25
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P2
|
|
||||||
depends_on: [24]
|
|
||||||
tags: [code-validation, auth, sync, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create 8-character code input component
|
|
||||||
- Implement code validation logic
|
|
||||||
- Handle code submission
|
|
||||||
- Show validation results
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/CodeInput.tsx` with code field
|
|
||||||
- `src/utils/code-validator.ts` with validation logic
|
|
||||||
- `src/components/CodeValidationResult.tsx` with result display
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/utils/code-validator.ts`:
|
|
||||||
- `validateCode(code: string): boolean`
|
|
||||||
- Check length (8 characters)
|
|
||||||
- Check format (alphanumeric)
|
|
||||||
- Validate against stored codes
|
|
||||||
- Create `src/components/CodeInput.tsx`:
|
|
||||||
- 8-character code input
|
|
||||||
- Auto-formatting
|
|
||||||
- Clear button
|
|
||||||
- Validation error display
|
|
||||||
- Create `src/components/CodeValidationResult.tsx`:
|
|
||||||
- Success message
|
|
||||||
- Error message
|
|
||||||
- Retry button
|
|
||||||
- Link to alternative auth
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test code validation logic
|
|
||||||
- Unit: Test code input formatting
|
|
||||||
- Unit: Test validation result display
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Code input accepts 8 characters
|
|
||||||
- Validation checks length and format
|
|
||||||
- Validation results display correctly
|
|
||||||
- Error handling works
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and test code validation
|
|
||||||
- Enter valid 8-character code
|
|
||||||
- Enter invalid code
|
|
||||||
- Test validation error display
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Code format: alphanumeric (A-Z, 0-9)
|
|
||||||
- No real backend validation
|
|
||||||
- Store codes in localStorage for testing
|
|
||||||
- Link to OAuth placeholder
|
|
||||||
- Link to email/password login
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 26. Add OAuth Placeholder Screens (Document Limitations)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-26
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P2
|
|
||||||
depends_on: [25]
|
|
||||||
tags: [oauth, documentation, placeholders, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create OAuth placeholder screens
|
|
||||||
- Document terminal limitations for OAuth
|
|
||||||
- Provide alternative authentication methods
|
|
||||||
- Explain browser redirect flow
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/OAuthPlaceholder.tsx` with OAuth info
|
|
||||||
- `src/components/BrowserRedirect.tsx` with redirect flow
|
|
||||||
- `src/docs/oauth-limitations.md` with documentation
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/OAuthPlaceholder.tsx`:
|
|
||||||
- Display OAuth information
|
|
||||||
- Explain terminal limitations
|
|
||||||
- Show supported providers (Google, Apple)
|
|
||||||
- Link to browser redirect flow
|
|
||||||
- Create `src/components/BrowserRedirect.tsx`:
|
|
||||||
- Display QR code for mobile
|
|
||||||
- Display 8-character code
|
|
||||||
- Instructions for browser flow
|
|
||||||
- Link to website
|
|
||||||
- Create `src/docs/oauth-limitations.md`:
|
|
||||||
- Detailed explanation of OAuth in terminal
|
|
||||||
- Why OAuth is not feasible
|
|
||||||
- Alternative authentication methods
|
|
||||||
- Browser redirect flow instructions
|
|
||||||
- Add OAuth placeholder to login screen
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test OAuth placeholder displays correctly
|
|
||||||
- Unit: Test browser redirect flow displays
|
|
||||||
- Documentation review
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- OAuth placeholder screens display correctly
|
|
||||||
- Limitations are clearly documented
|
|
||||||
- Alternative methods are provided
|
|
||||||
- Browser redirect flow is explained
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to OAuth placeholder
|
|
||||||
- Read documentation
|
|
||||||
- Verify flow instructions are clear
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- OAuth in terminal is not feasible
|
|
||||||
- Terminal cannot handle OAuth flows
|
|
||||||
- Document this limitation clearly
|
|
||||||
- Provide browser redirect as alternative
|
|
||||||
- User can still use file sync
|
|
||||||
- Google and Apple OAuth are supported by browser
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 27. Create Sync-Only User Profile
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-27
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P2
|
|
||||||
depends_on: [26]
|
|
||||||
tags: [profile, sync, user-info, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create user profile component for sync-only users
|
|
||||||
- Display user information
|
|
||||||
- Show sync status
|
|
||||||
- Provide profile management options
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SyncProfile.tsx` with user profile
|
|
||||||
- `src/components/SyncStatus.tsx` with sync status
|
|
||||||
- `src/components/ProfileSettings.tsx` with profile settings
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/SyncProfile.tsx`:
|
|
||||||
- User avatar/icon
|
|
||||||
- User name display
|
|
||||||
- Email display
|
|
||||||
- Sync status indicator
|
|
||||||
- Profile actions
|
|
||||||
- Create `src/components/SyncStatus.tsx`:
|
|
||||||
- Sync status (last sync time)
|
|
||||||
- Sync method (file-based)
|
|
||||||
- Sync frequency
|
|
||||||
- Sync history
|
|
||||||
- Create `src/components/ProfileSettings.tsx`:
|
|
||||||
- Edit profile
|
|
||||||
- Change password
|
|
||||||
- Manage sync settings
|
|
||||||
- Export data
|
|
||||||
- Add profile to settings screen
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test profile displays correctly
|
|
||||||
- Unit: Test sync status updates
|
|
||||||
- Integration: Test profile settings
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Profile displays user information
|
|
||||||
- Sync status is shown
|
|
||||||
- Profile settings work correctly
|
|
||||||
- Profile is accessible from settings
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to profile
|
|
||||||
- View profile information
|
|
||||||
- Test profile settings
|
|
||||||
- Verify sync status
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Profile for sync-only users
|
|
||||||
- No authentication required
|
|
||||||
- Profile data stored in localStorage
|
|
||||||
- Sync status shows last sync time
|
|
||||||
- Profile is optional
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 28. Create Feed Data Models and Types
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-28
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [27]
|
|
||||||
tags: [types, data-models, solidjs, typescript]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Define TypeScript interfaces for all podcast-related data
|
|
||||||
- Create models for feeds, episodes, sources
|
|
||||||
- Set up type definitions for sync functionality
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/types/podcast.ts` with core types
|
|
||||||
- `src/types/episode.ts` with episode types
|
|
||||||
- `src/types/source.ts` with source types
|
|
||||||
- `src/types/feed.ts` with feed types
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/types/podcast.ts`:
|
|
||||||
- `Podcast` interface (id, title, description, coverUrl, feedUrl, lastUpdated)
|
|
||||||
- `PodcastWithEpisodes` interface (podcast + episodes array)
|
|
||||||
- Create `src/types/episode.ts`:
|
|
||||||
- `Episode` interface (id, title, description, audioUrl, duration, pubDate, episodeNumber)
|
|
||||||
- `EpisodeStatus` enum (playing, paused, completed, not_started)
|
|
||||||
- `Progress` interface (episodeId, position, duration, timestamp)
|
|
||||||
- Create `src/types/source.ts`:
|
|
||||||
- `PodcastSource` interface (id, name, baseUrl, type, apiKey, enabled)
|
|
||||||
- `SourceType` enum (rss, api, custom)
|
|
||||||
- `SearchQuery` interface (query, sourceIds, filters)
|
|
||||||
- Create `src/types/feed.ts`:
|
|
||||||
- `Feed` interface (id, podcast, episodes[], isPublic, sourceId, lastUpdated)
|
|
||||||
- `FeedItem` interface (represents a single episode in a feed)
|
|
||||||
- `FeedFilter` interface (public, private, sourceId)
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Verify all interfaces compile correctly
|
|
||||||
- Unit: Test enum values are correct
|
|
||||||
- Integration: Test type definitions match expected data structures
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- All TypeScript interfaces compile without errors
|
|
||||||
- Types are exported for use across the application
|
|
||||||
- Type definitions cover all podcast-related data
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run build` to verify TypeScript compilation
|
|
||||||
- Check `src/types/` directory contains all required files
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use strict TypeScript mode
|
|
||||||
- Include JSDoc comments for complex types
|
|
||||||
- Keep types simple and focused
|
|
||||||
- Ensure types are compatible with sync JSON/XML formats
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 29. Build Feed List Component (Public/Private Feeds)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-29
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [28]
|
|
||||||
tags: [feed-list, components, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create a scrollable feed list component
|
|
||||||
- Display public and private feeds
|
|
||||||
- Implement feed selection and display
|
|
||||||
- Add reverse chronological ordering
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/FeedList.tsx` with feed list component
|
|
||||||
- `src/components/FeedItem.tsx` with individual feed item
|
|
||||||
- `src/components/FeedFilter.tsx` with public/private toggle
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/FeedList.tsx`:
|
|
||||||
- Use `<scrollbox>` for scrollable list
|
|
||||||
- Accept feeds array as prop
|
|
||||||
- Implement feed rendering with `createSignal` for selection
|
|
||||||
- Add keyboard navigation (arrow keys, enter)
|
|
||||||
- Display feed title, description, episode count
|
|
||||||
- Create `src/components/FeedItem.tsx`:
|
|
||||||
- Display feed information
|
|
||||||
- Show public/private indicator
|
|
||||||
- Highlight selected feed
|
|
||||||
- Add hover effects
|
|
||||||
- Create `src/components/FeedFilter.tsx`:
|
|
||||||
- Toggle button for public/private feeds
|
|
||||||
- Filter logic implementation
|
|
||||||
- Update parent FeedList when filtered
|
|
||||||
- Add feed list to "My Feeds" navigation tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test FeedList renders with feeds
|
|
||||||
- Unit: Test FeedItem displays correctly
|
|
||||||
- Integration: Test public/private filtering
|
|
||||||
- Integration: Test keyboard navigation in feed list
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Feed list displays all feeds correctly
|
|
||||||
- Public/private toggle filters feeds
|
|
||||||
- Feed selection is visually indicated
|
|
||||||
- Keyboard navigation works (arrow keys, enter)
|
|
||||||
- List scrolls properly when many feeds
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "My Feeds"
|
|
||||||
- Add some feeds and verify they appear
|
|
||||||
- Test public/private toggle
|
|
||||||
- Use arrow keys to navigate feeds
|
|
||||||
- Scroll list with many feeds
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for selection state
|
|
||||||
- Follow OpenTUI component patterns from `components/REFERENCE.md`
|
|
||||||
- Feed list should be scrollable with many items
|
|
||||||
- Use Flexbox for layout
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 30. Implement Feed Source Management (Add/Remove Sources)
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-30
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [29]
|
|
||||||
tags: [source-management, feeds, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create source management component
|
|
||||||
- Implement add new source functionality
|
|
||||||
- Implement remove source functionality
|
|
||||||
- Manage enabled/disabled sources
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SourceManager.tsx` with source management UI
|
|
||||||
- `src/components/AddSourceForm.tsx` with add source form
|
|
||||||
- `src/components/SourceListItem.tsx` with individual source
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/SourceManager.tsx`:
|
|
||||||
- List of enabled sources
|
|
||||||
- Add source button
|
|
||||||
- Remove source button
|
|
||||||
- Enable/disable toggle
|
|
||||||
- Source count display
|
|
||||||
- Create `src/components/AddSourceForm.tsx`:
|
|
||||||
- Source name input
|
|
||||||
- Source URL input
|
|
||||||
- Source type selection
|
|
||||||
- API key input (if required)
|
|
||||||
- Submit button
|
|
||||||
- Validation
|
|
||||||
- Create `src/components/SourceListItem.tsx`:
|
|
||||||
- Display source info
|
|
||||||
- Enable/disable toggle
|
|
||||||
- Remove button
|
|
||||||
- Status indicator
|
|
||||||
- Add source manager to settings screen
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test source list displays correctly
|
|
||||||
- Unit: Test add source form validation
|
|
||||||
- Unit: Test remove source functionality
|
|
||||||
- Integration: Test source management workflow
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Source list displays all sources
|
|
||||||
- Add source form validates input
|
|
||||||
- Remove source works correctly
|
|
||||||
- Enable/disable toggles work
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to settings
|
|
||||||
- Test add source
|
|
||||||
- Test remove source
|
|
||||||
- Test enable/disable toggle
|
|
||||||
- Verify feeds from new sources appear
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Source types: RSS, API, Custom
|
|
||||||
- RSS sources: feed URLs
|
|
||||||
- API sources: require API key
|
|
||||||
- Custom sources: user-defined
|
|
||||||
- Add validation for source URLs
|
|
||||||
- Store sources in localStorage
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 31. Add Reverse Chronological Ordering
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-31
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [30]
|
|
||||||
tags: [ordering, feeds, episodes, solidjs, sorting]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Implement reverse chronological ordering for feeds
|
|
||||||
- Order episodes by publication date (newest first)
|
|
||||||
- Order feeds by last updated (newest first)
|
|
||||||
- Provide sort option toggle
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/utils/ordering.ts` with sorting functions
|
|
||||||
- `src/components/FeedSortToggle.tsx` with sort option
|
|
||||||
- `src/components/EpisodeList.tsx` with ordered episode list
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/utils/ordering.ts`:
|
|
||||||
- `orderEpisodesByDate(episodes: Episode[]): Episode[]`
|
|
||||||
- Order by pubDate descending
|
|
||||||
- Handle missing dates
|
|
||||||
- `orderFeedsByDate(feeds: Feed[]): Feed[]`
|
|
||||||
- Order by lastUpdated descending
|
|
||||||
- Handle missing updates
|
|
||||||
- Create `src/components/FeedSortToggle.tsx`:
|
|
||||||
- Toggle button for date ordering
|
|
||||||
- Display current sort order
|
|
||||||
- Update parent component
|
|
||||||
- Create `src/components/EpisodeList.tsx`:
|
|
||||||
- Accept episodes array
|
|
||||||
- Apply ordering
|
|
||||||
- Display episodes in reverse chronological order
|
|
||||||
- Add keyboard navigation
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test episode ordering
|
|
||||||
- Unit: Test feed ordering
|
|
||||||
- Unit: Test sort toggle
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Episodes ordered by date (newest first)
|
|
||||||
- Feeds ordered by last updated (newest first)
|
|
||||||
- Sort toggle works correctly
|
|
||||||
- Ordering persists across sessions
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and check episode order
|
|
||||||
- Check feed order
|
|
||||||
- Toggle sort order
|
|
||||||
- Restart app and verify ordering persists
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use JavaScript `sort()` with date comparison
|
|
||||||
- Handle timezone differences
|
|
||||||
- Add loading state during sort
|
|
||||||
- Cache ordered results
|
|
||||||
- Consider adding custom sort options
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 32. Create Feed Detail View
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-32
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [31]
|
|
||||||
tags: [feed-detail, episodes, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create feed detail view component
|
|
||||||
- Display podcast information
|
|
||||||
- List episodes in reverse chronological order
|
|
||||||
- Provide episode playback controls
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/FeedDetail.tsx` with feed detail view
|
|
||||||
- `src/components/EpisodeList.tsx` with episode list
|
|
||||||
- `src/components/EpisodeItem.tsx` with individual episode
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/FeedDetail.tsx`:
|
|
||||||
- Podcast cover image
|
|
||||||
- Podcast title and description
|
|
||||||
- Episode count
|
|
||||||
- Subscribe/unsubscribe button
|
|
||||||
- Episode list container
|
|
||||||
- Create `src/components/EpisodeList.tsx`:
|
|
||||||
- Scrollable episode list
|
|
||||||
- Display episode title, date, duration
|
|
||||||
- Playback status indicator
|
|
||||||
- Add keyboard navigation
|
|
||||||
- Create `src/components/EpisodeItem.tsx`:
|
|
||||||
- Episode information
|
|
||||||
- Play button
|
|
||||||
- Mark as complete button
|
|
||||||
- Progress bar
|
|
||||||
- Hover effects
|
|
||||||
- Add feed detail to "My Feeds" navigation tab
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test FeedDetail displays correctly
|
|
||||||
- Unit: Test EpisodeList rendering
|
|
||||||
- Unit: Test EpisodeItem interaction
|
|
||||||
- Integration: Test feed detail navigation
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Feed detail displays podcast info
|
|
||||||
- Episode list shows all episodes
|
|
||||||
- Episodes ordered reverse chronological
|
|
||||||
- Play buttons work
|
|
||||||
- Mark as complete works
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to feed detail
|
|
||||||
- View podcast information
|
|
||||||
- Check episode order
|
|
||||||
- Test play button
|
|
||||||
- Test mark as complete
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for episode selection
|
|
||||||
- Display episode status (playing, completed, not started)
|
|
||||||
- Show progress for completed episodes
|
|
||||||
- Add episode filtering (all, completed, not completed)
|
|
||||||
- Use Flexbox for layout
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 33. Create Search Interface
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-33
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [32]
|
|
||||||
tags: [search-interface, input, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create search input component
|
|
||||||
- Implement search functionality
|
|
||||||
- Display search results
|
|
||||||
- Handle search state
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SearchBar.tsx` with search input
|
|
||||||
- `src/components/SearchResults.tsx` with results display
|
|
||||||
- `src/components/SearchHistory.tsx` with history list
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/SearchBar.tsx`:
|
|
||||||
- Search input field using `<input>` component
|
|
||||||
- Search button
|
|
||||||
- Clear button
|
|
||||||
- Enter key handler
|
|
||||||
- Loading state
|
|
||||||
- Create `src/utils/search.ts`:
|
|
||||||
- `searchPodcasts(query: string, sourceIds: string[]): Promise<Podcast[]>`
|
|
||||||
- `searchEpisodes(query: string, feedId: string): Promise<Episode[]>`
|
|
||||||
- Handle multiple sources
|
|
||||||
- Cache search results
|
|
||||||
- Create `src/components/SearchResults.tsx`:
|
|
||||||
- Display search results with source indicators
|
|
||||||
- Show podcast/episode info
|
|
||||||
- Add to feed button
|
|
||||||
- Keyboard navigation through results
|
|
||||||
- Create `src/components/SearchHistory.tsx`:
|
|
||||||
- Display recent search queries
|
|
||||||
- Click to re-run search
|
|
||||||
- Delete individual history items
|
|
||||||
- Persist to localStorage
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test search logic returns correct results
|
|
||||||
- Unit: Test search history persistence
|
|
||||||
- Integration: Test search bar accepts input
|
|
||||||
- Integration: Test results display correctly
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Search bar accepts and processes queries
|
|
||||||
- Search results display with source information
|
|
||||||
- Search history persists across sessions
|
|
||||||
- Keyboard navigation works in results
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Search"
|
|
||||||
- Type a query and press Enter
|
|
||||||
- Verify results appear
|
|
||||||
- Click a result to add to feed
|
|
||||||
- Restart app and verify history persists
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use localStorage for search history
|
|
||||||
- Implement basic caching to avoid repeated searches
|
|
||||||
- Handle empty results gracefully
|
|
||||||
- Add loading state during search
|
|
||||||
- Search both podcasts and episodes
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 34. Implement Multi-Source Search
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-34
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [33]
|
|
||||||
tags: [multi-source, search, solidjs, api]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Implement search across multiple podcast sources
|
|
||||||
- Handle different source types (RSS, API, Custom)
|
|
||||||
- Display source information in results
|
|
||||||
- Cache search results
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/utils/search.ts` with multi-source search logic
|
|
||||||
- `src/utils/source-searcher.ts` with source-specific searchers
|
|
||||||
- `src/components/SourceBadge.tsx` with source indicator
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/utils/source-searcher.ts`:
|
|
||||||
- `searchRSSSource(query: string, source: PodcastSource): Promise<Podcast[]>`
|
|
||||||
- `searchAPISource(query: string, source: PodcastSource): Promise<Podcast[]>`
|
|
||||||
- `searchCustomSource(query: string, source: PodcastSource): Promise<Podcast[]>`
|
|
||||||
- Handle source-specific search logic
|
|
||||||
- Create `src/utils/search.ts`:
|
|
||||||
- `searchPodcasts(query: string, sourceIds: string[]): Promise<Podcast[]>`
|
|
||||||
- Aggregate results from multiple sources
|
|
||||||
- Deduplicate results
|
|
||||||
- Cache results by query
|
|
||||||
- Handle source errors gracefully
|
|
||||||
- Create `src/components/SourceBadge.tsx`:
|
|
||||||
- Display source type (RSS, API, Custom)
|
|
||||||
- Show source name
|
|
||||||
- Color-coded for different types
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test RSS source search
|
|
||||||
- Unit: Test API source search
|
|
||||||
- Unit: Test custom source search
|
|
||||||
- Unit: Test result aggregation
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Search works across all enabled sources
|
|
||||||
- Source information displayed correctly
|
|
||||||
- Results aggregated from multiple sources
|
|
||||||
- Errors handled gracefully
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and perform search
|
|
||||||
- Verify results from multiple sources
|
|
||||||
- Test with different source types
|
|
||||||
- Test error handling for failed sources
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- RSS sources: Parse feed XML
|
|
||||||
- API sources: Call API endpoints
|
|
||||||
- Custom sources: User-defined search logic
|
|
||||||
- Handle rate limiting
|
|
||||||
- Cache results to avoid repeated searches
|
|
||||||
- Show loading state for each source
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 35. Add Search Results Display
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-35
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [34]
|
|
||||||
tags: [search-results, display, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Display search results with rich information
|
|
||||||
- Show podcast/episode details
|
|
||||||
- Add to feed functionality
|
|
||||||
- Keyboard navigation through results
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SearchResults.tsx` with results display
|
|
||||||
- `src/components/ResultCard.tsx` with individual result
|
|
||||||
- `src/components/ResultDetail.tsx` with detailed view
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/ResultCard.tsx`:
|
|
||||||
- Display result title
|
|
||||||
- Display source information
|
|
||||||
- Display description/snippet
|
|
||||||
- Add to feed button
|
|
||||||
- Click to view details
|
|
||||||
- Create `src/components/ResultDetail.tsx`:
|
|
||||||
- Full result details
|
|
||||||
- Podcast/episode information
|
|
||||||
- Episode list (if podcast)
|
|
||||||
- Subscribe button
|
|
||||||
- Close button
|
|
||||||
- Create `src/components/SearchResults.tsx`:
|
|
||||||
- Scrollable results list
|
|
||||||
- Empty state display
|
|
||||||
- Loading state display
|
|
||||||
- Error state display
|
|
||||||
- Keyboard navigation
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test ResultCard displays correctly
|
|
||||||
- Unit: Test ResultDetail displays correctly
|
|
||||||
- Unit: Test search results list
|
|
||||||
- Integration: Test add to feed
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Search results display with all information
|
|
||||||
- Result cards show source and details
|
|
||||||
- Add to feed button works
|
|
||||||
- Keyboard navigation works
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and perform search
|
|
||||||
- Verify results display correctly
|
|
||||||
- Click result to view details
|
|
||||||
- Test add to feed
|
|
||||||
- Test keyboard navigation
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use SolidJS `createSignal` for result selection
|
|
||||||
- Display result type (podcast/episode)
|
|
||||||
- Show source name and type
|
|
||||||
- Add loading state during search
|
|
||||||
- Handle empty results
|
|
||||||
- Add pagination for large result sets
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 36. Build Search History with Persistent Storage
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-36
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [35]
|
|
||||||
tags: [search-history, persistence, storage, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Implement search history functionality
|
|
||||||
- Store search queries in localStorage
|
|
||||||
- Display recent searches
|
|
||||||
- Add search to history
|
|
||||||
- Clear history
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/SearchHistory.tsx` with history list
|
|
||||||
- `src/utils/history.ts` with history management
|
|
||||||
- `src/hooks/useSearchHistory.ts` with history hook
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/utils/history.ts`:
|
|
||||||
- `addToHistory(query: string): void`
|
|
||||||
- `getHistory(): string[]`
|
|
||||||
- `removeFromHistory(query: string): void`
|
|
||||||
- `clearHistory(): void`
|
|
||||||
- `maxHistorySize = 50`
|
|
||||||
- Create `src/hooks/useSearchHistory.ts`:
|
|
||||||
- `createSignal` for history array
|
|
||||||
- Update history on search
|
|
||||||
- Persist to localStorage
|
|
||||||
- Methods to manage history
|
|
||||||
- Create `src/components/SearchHistory.tsx`:
|
|
||||||
- Display recent search queries
|
|
||||||
- Click to re-run search
|
|
||||||
- Delete individual history items
|
|
||||||
- Clear all history button
|
|
||||||
- Persistent across sessions
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test history management functions
|
|
||||||
- Unit: Test history persistence
|
|
||||||
- Integration: Test history display
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Search queries added to history
|
|
||||||
- History persists across sessions
|
|
||||||
- History displays correctly
|
|
||||||
- Clear history works
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and perform searches
|
|
||||||
- Check search history persists
|
|
||||||
- Test clearing history
|
|
||||||
- Restart app and verify
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use localStorage for persistence
|
|
||||||
- Limit history to 50 items
|
|
||||||
- Remove duplicates
|
|
||||||
- Store timestamps (optional)
|
|
||||||
- Clear history button in search screen
|
|
||||||
- Add delete button on individual items
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 37. Create Popular Shows Data Structure
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-37
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [36]
|
|
||||||
tags: [popular-shows, data, discovery, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create data structure for popular shows
|
|
||||||
- Define podcast metadata
|
|
||||||
- Categorize shows by topic
|
|
||||||
- Include feed URLs and descriptions
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/data/popular-shows.ts` with popular podcasts data
|
|
||||||
- `src/types/popular-shows.ts` with data types
|
|
||||||
- `src/constants/categories.ts` with category definitions
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/types/popular-shows.ts`:
|
|
||||||
- `PopularPodcast` interface (id, title, description, coverUrl, feedUrl, category, tags)
|
|
||||||
- `Category` enum (Technology, Business, Science, Entertainment, Health, Education)
|
|
||||||
- Create `src/constants/categories.ts`:
|
|
||||||
- List of all categories
|
|
||||||
- Category descriptions
|
|
||||||
- Sample podcasts per category
|
|
||||||
- Create `src/data/popular-shows.ts`:
|
|
||||||
- Array of popular podcasts
|
|
||||||
- Categorized by topic
|
|
||||||
- Reverse chronological ordering
|
|
||||||
- Include metadata for each show
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test data structure compiles
|
|
||||||
- Unit: Test category definitions
|
|
||||||
- Integration: Test popular shows display
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Popular shows data structure defined
|
|
||||||
- Categories defined correctly
|
|
||||||
- Shows categorized properly
|
|
||||||
- Data ready for display
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run `bun run build` to verify TypeScript
|
|
||||||
- Check data structure compiles
|
|
||||||
- Review data for completeness
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Popular shows data can be static or fetched
|
|
||||||
- If sources don't provide trending, use curated list
|
|
||||||
- Categories help users find shows by topic
|
|
||||||
- Include diverse range of shows
|
|
||||||
- Add RSS feed URLs for easy subscription
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 38. Build Discover Page Component
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-38
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [37]
|
|
||||||
tags: [discover-page, popular-shows, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create discover page component
|
|
||||||
- Display popular shows
|
|
||||||
- Implement category filtering
|
|
||||||
- Add to feed functionality
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/DiscoverPage.tsx` with discover UI
|
|
||||||
- `src/components/PopularShows.tsx` with shows grid
|
|
||||||
- `src/components/CategoryFilter.tsx` with category buttons
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/DiscoverPage.tsx`:
|
|
||||||
- Page header with title
|
|
||||||
- Category filter buttons
|
|
||||||
- Popular shows grid
|
|
||||||
- Show details view
|
|
||||||
- Add to feed button
|
|
||||||
- Create `src/components/PopularShows.tsx`:
|
|
||||||
- Grid display of popular podcasts
|
|
||||||
- Show cover image
|
|
||||||
- Show title and description
|
|
||||||
- Add to feed button
|
|
||||||
- Click to view details
|
|
||||||
- Create `src/components/CategoryFilter.tsx`:
|
|
||||||
- Category button group
|
|
||||||
- Active category highlighting
|
|
||||||
- Filter logic implementation
|
|
||||||
- Update parent DiscoverPage
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test PopularShows displays correctly
|
|
||||||
- Unit: Test CategoryFilter functionality
|
|
||||||
- Integration: Test discover page navigation
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Discover page displays popular shows
|
|
||||||
- Category filtering works correctly
|
|
||||||
- Shows are ordered reverse chronologically
|
|
||||||
- Clicking a show shows details
|
|
||||||
- Add to feed button works
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Discover"
|
|
||||||
- Verify popular shows appear
|
|
||||||
- Click different categories
|
|
||||||
- Click a show and verify details
|
|
||||||
- Try add to feed
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use Flexbox for category filter layout
|
|
||||||
- Use Grid for shows display
|
|
||||||
- Add loading state if fetching from API
|
|
||||||
- Handle empty categories
|
|
||||||
- Add hover effects for interactivity
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 39. Add Trending Shows Display
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-39
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P1
|
|
||||||
depends_on: [38]
|
|
||||||
tags: [trending-shows, display, solidjs]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Display trending shows section
|
|
||||||
- Show top podcasts by popularity
|
|
||||||
- Implement trend indicators
|
|
||||||
- Display show rankings
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/TrendingShows.tsx` with trending section
|
|
||||||
- `src/components/ShowRanking.tsx` with ranking display
|
|
||||||
- `src/components/TrendIndicator.tsx` with trend icon
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/TrendingShows.tsx`:
|
|
||||||
- Trending section header
|
|
||||||
- Top shows list
|
|
||||||
- Show ranking (1, 2, 3...)
|
|
||||||
- Trending indicator
|
|
||||||
- Add to feed button
|
|
||||||
- Create `src/components/ShowRanking.tsx`:
|
|
||||||
- Display ranking number
|
|
||||||
- Show cover image
|
|
||||||
- Show title
|
|
||||||
- Trending score display
|
|
||||||
- Create `src/components/TrendIndicator.tsx`:
|
|
||||||
- Display trend icon (up arrow, down arrow, flat)
|
|
||||||
- Color-coded for trend direction
|
|
||||||
- Show trend percentage
|
|
||||||
- Add trending section to Discover page
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test TrendingShows displays correctly
|
|
||||||
- Unit: Test ranking display
|
|
||||||
- Unit: Test trend indicator
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Trending shows section displays correctly
|
|
||||||
- Rankings shown for top shows
|
|
||||||
- Trend indicators display correctly
|
|
||||||
- Add to feed buttons work
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Discover"
|
|
||||||
- View trending shows section
|
|
||||||
- Check rankings and indicators
|
|
||||||
- Test add to feed
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Trending shows: Top 10 podcasts
|
|
||||||
- Trending score: Based on downloads, listens, or engagement
|
|
||||||
- Trend indicators: Up/down/flat arrows
|
|
||||||
- Color-coded: Green for up, red for down, gray for flat
|
|
||||||
- Update trend scores periodically
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 41. Create Player UI Layout
|
|
||||||
|
|
||||||
meta:
|
|
||||||
id: podcast-tui-app-41
|
|
||||||
feature: podcast-tui-app
|
|
||||||
priority: P0
|
|
||||||
depends_on: [40]
|
|
||||||
tags: [player, layout, solidjs, opentui]
|
|
||||||
|
|
||||||
objective:
|
|
||||||
- Create player UI layout component
|
|
||||||
- Display episode information
|
|
||||||
- Position player controls and waveform
|
|
||||||
- Handle player state
|
|
||||||
|
|
||||||
deliverables:
|
|
||||||
- `src/components/Player.tsx` with player layout
|
|
||||||
- `src/components/PlayerHeader.tsx` with episode info
|
|
||||||
- `src/components/PlayerControls.tsx` with controls area
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- Create `src/components/Player.tsx`:
|
|
||||||
- Player container with borders
|
|
||||||
- Episode information header
|
|
||||||
- Waveform visualization area
|
|
||||||
- Playback controls area
|
|
||||||
- Progress bar area
|
|
||||||
- Create `src/components/PlayerHeader.tsx`:
|
|
||||||
- Episode title
|
|
||||||
- Podcast name
|
|
||||||
- Episode duration
|
|
||||||
- Close player button
|
|
||||||
- Create `src/components/PlayerControls.tsx`:
|
|
||||||
- Play/Pause button
|
|
||||||
- Previous/Next episode buttons
|
|
||||||
- Volume control
|
|
||||||
- Speed control
|
|
||||||
- Keyboard shortcuts display
|
|
||||||
|
|
||||||
tests:
|
|
||||||
- Unit: Test Player layout renders correctly
|
|
||||||
- Unit: Test PlayerHeader displays correctly
|
|
||||||
- Unit: Test PlayerControls layout
|
|
||||||
|
|
||||||
acceptance_criteria:
|
|
||||||
- Player UI displays episode information
|
|
||||||
- Controls positioned correctly
|
|
||||||
- Player fits within terminal bounds
|
|
||||||
- Layout is responsive
|
|
||||||
|
|
||||||
validation:
|
|
||||||
- Run application and navigate to "Player"
|
|
||||||
- Select an episode to play
|
|
||||||
- Verify player UI displays
|
|
||||||
- Check layout and positioning
|
|
||||||
|
|
||||||
notes:
|
|
||||||
- Use Flexbox for player layout
|
|
||||||
- Player should be at bottom or overlay
|
|
||||||
- Use `<scrollbox>` for waveform area
|
|
||||||
- Add loading state when no episode
|
|
||||||
- Use SolidJS signals for player state
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user