Compare commits

...

3 Commits

Author SHA1 Message Date
0e4f47323f mulitmedia pass, downloads 2026-02-06 00:00:15 -05:00
42a1ddf458 meh 2026-02-05 23:43:19 -05:00
168e6d5a61 final feature set 2026-02-05 22:55:24 -05:00
134 changed files with 4311 additions and 4504 deletions

View File

@@ -1,4 +1,5 @@
import { createSignal, ErrorBoundary } from "solid-js";
import { useSelectionHandler } from "@opentui/solid";
import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation";
import { TabNavigation } from "./components/TabNavigation";
@@ -16,8 +17,11 @@ import { useAuthStore } from "./stores/auth";
import { useFeedStore } from "./stores/feed";
import { useAppStore } from "./stores/app";
import { useAudio } from "./hooks/useAudio";
import { useMultimediaKeys } from "./hooks/useMultimediaKeys";
import { FeedVisibility } from "./types/feed";
import { useAppKeyboard } from "./hooks/useAppKeyboard";
import { Clipboard } from "./utils/clipboard";
import { emit } from "./utils/event-bus";
import type { TabId } from "./components/Tab";
import type { AuthScreen } from "./types/auth";
import type { Episode } from "./types/episode";
@@ -33,6 +37,14 @@ export function App() {
const appStore = useAppStore();
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) => {
audio.play(episode);
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 tab = activeTab();

View File

@@ -1,5 +1,7 @@
import type { Podcast } from "../types/podcast"
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 match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"))
@@ -22,6 +24,20 @@ const decodeEntities = (value: string) =>
.replace(/&quot;/g, '"')
.replace(/&#39;/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:
* - "HH:MM:SS"
@@ -61,14 +77,14 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = decodeEntities(getTagValue(channel, "title")) || "Untitled Podcast"
const description = decodeEntities(getTagValue(channel, "description"))
const description = cleanDescription(getTagValue(channel, "description"))
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, index) => {
const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = decodeEntities(getTagValue(item, "description"))
const epDescription = cleanDescription(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>

View 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>
)
}

View File

@@ -4,9 +4,11 @@
* 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 { useFeedStore } from "../stores/feed"
import { useDownloadStore } from "../stores/download"
import { DownloadStatus } from "../types/episode"
import { format } from "date-fns"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
@@ -21,11 +23,15 @@ type FocusPane = "shows" | "episodes"
export function MyShowsPage(props: MyShowsPageProps) {
const feedStore = useFeedStore()
const downloadStore = useDownloadStore()
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
const [showIndex, setShowIndex] = createSignal(0)
const [episodeIndex, setEpisodeIndex] = createSignal(0)
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 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 => {
return format(date, "MMM d, yyyy")
}
@@ -53,6 +72,42 @@ export function MyShowsPage(props: MyShowsPageProps) {
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 show = selectedShow()
if (!show) return
@@ -128,6 +183,17 @@ export function MyShowsPage(props: MyShowsPageProps) {
const ep = eps[episodeIndex()]
const show = selectedShow()
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") {
setEpisodeIndex((i) => Math.max(0, i - 10))
} else if (key.name === "pagedown") {
@@ -227,10 +293,23 @@ export function MyShowsPage(props: MyShowsPageProps) {
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg="gray">{formatDate(episode.pubDate)}</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>
)}
</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>
</Show>
</Show>

View File

@@ -1,7 +1,6 @@
import { useKeyboard } from "@opentui/solid"
import { PlaybackControls } from "./PlaybackControls"
import { Waveform } from "./Waveform"
import { createWaveform } from "../utils/waveform"
import { MergedWaveform } from "./MergedWaveform"
import { useAudio } from "../hooks/useAudio"
import type { Episode } from "../types/episode"
@@ -24,8 +23,6 @@ const SAMPLE_EPISODE: Episode = {
export function Player(props: PlayerProps) {
const audio = useAudio()
const waveform = () => createWaveform(64)
// The episode to display — prefer a passed-in episode, then the
// currently-playing episode, then fall back to the sample.
const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE
@@ -86,7 +83,7 @@ export function Player(props: PlayerProps) {
<strong>Now Playing</strong>
</text>
<text fg="gray">
{formatTime(audio.position())} / {formatTime(dur())}
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
</text>
</box>
@@ -100,28 +97,14 @@ export function Player(props: PlayerProps) {
</text>
<text fg="gray">{episode().description}</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Progress:</text>
<box flexGrow={1} height={1} backgroundColor="#2a2f3a">
<box
width={`${progressPercent()}%`}
height={1}
backgroundColor={audio.isPlaying() ? "#6fa8ff" : "#7d8590"}
/>
</box>
<text fg="gray">{progressPercent()}%</text>
</box>
<Waveform
data={waveform()}
<MergedWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
onSeek={(next: number) => audio.seek(next)}
/>
</box>
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}

View File

@@ -23,6 +23,7 @@ import {
import { emit, on } from "../utils/event-bus"
import { useAppStore } from "../stores/app"
import { useProgressStore } from "../stores/progress"
import { useMediaRegistry } from "../utils/media-registry"
import type { Episode } from "../types/episode"
export interface AudioControls {
@@ -94,6 +95,10 @@ function startPolling(): void {
if (ep) {
const progressStore = useProgressStore()
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)
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()
emit("player.play", { episodeId: episode.id })
} catch (err) {
@@ -176,6 +191,11 @@ async function pause(): Promise<void> {
const progressStore = useProgressStore()
progressStore.update(ep.id, position(), duration(), speed())
emit("player.pause", { episodeId: ep.id })
// Update platform media controls
const media = useMediaRegistry()
media.setPlaybackState(false)
media.setPosition(position())
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed")
@@ -189,7 +209,11 @@ async function resume(): Promise<void> {
setIsPlaying(true)
startPolling()
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) {
setError(err instanceof Error ? err.message : "Resume failed")
}
@@ -218,6 +242,10 @@ async function stop(): Promise<void> {
setCurrentEpisode(null)
stopPolling()
emit("player.stop", {})
// Clear platform media controls
const media = useMediaRegistry()
media.clearNowPlaying()
} catch (err) {
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(() => {
refCount--
unsubPlay()
unsubStop()
unsubMediaToggle()
unsubMediaVolUp()
unsubMediaVolDown()
unsubMediaSeekFwd()
unsubMediaSeekBack()
unsubMediaSpeed()
if (refCount <= 0) {
stopPolling()
@@ -358,6 +418,10 @@ export function useAudio(): AudioControls {
backend.dispose()
backend = null
}
// Clear media registry on full teardown
const media = useMediaRegistry()
media.clearNowPlaying()
refCount = 0
}
})

View 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
}
})
}

View File

@@ -3,8 +3,11 @@ import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
import { resolveTheme } from "../utils/theme-resolver"
import type { ThemeJson } from "../types/theme-schema"
const STORAGE_KEY = "podtui_app_state"
import {
loadAppStateFromFile,
saveAppStateToFile,
migrateAppStateFromLocalStorage,
} from "../utils/app-persistence"
const defaultSettings: AppSettings = {
theme: "system",
@@ -24,33 +27,21 @@ const defaultState: AppState = {
customTheme: DEFAULT_THEME,
}
const loadState = (): AppState => {
if (typeof localStorage === "undefined") return defaultState
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultState
const parsed = JSON.parse(raw) as Partial<AppState>
return {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
} catch {
return defaultState
}
}
const saveState = (state: AppState) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {
// ignore storage errors
}
}
export function createAppStore() {
const [state, setState] = createSignal<AppState>(loadState())
// 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) => {
setState(next)

View File

@@ -146,7 +146,7 @@ export function createDiscoverStore() {
return podcasts().filter((p) => {
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
View 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
}

View File

@@ -11,98 +11,60 @@ import type { Episode, EpisodeStatus } from "../types/episode"
import type { PodcastSource, SourceType } from "../types/source"
import { DEFAULT_SOURCES } from "../types/source"
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
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20
/** Storage keys */
const STORAGE_KEYS = {
feeds: "podtui_feeds",
sources: "podtui_sources",
}
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
const fullEpisodeCache = new Map<string, Episode[]>()
/** Load feeds from localStorage */
function loadFeeds(): Feed[] {
if (typeof localStorage === "undefined") {
return []
}
/** Track how many episodes are currently loaded per feed */
const episodeLoadCount = new Map<string, number>()
try {
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 */
/** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
} catch {
// Ignore errors
}
saveFeedsToFile(feeds).catch(() => {})
}
/** Load sources from localStorage */
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 */
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
} catch {
// Ignore errors
}
saveSourcesToFile(sources).catch(() => {})
}
/** Create feed store */
export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
const [feeds, setFeeds] = createSignal<Feed[]>([])
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>({
visibility: "all",
sortBy: "updated" as FeedSortField,
sortDirection: "desc",
})
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
const [isLoadingMore, setIsLoadingMore] = createSignal(false)
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
@@ -179,8 +141,8 @@ export function createFeedStore() {
return allEpisodes
}
/** Fetch latest episodes from an RSS feed URL */
const fetchEpisodes = async (feedUrl: string, limit: number): Promise<Episode[]> => {
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl, {
headers: {
@@ -191,7 +153,15 @@ export function createFeedStore() {
if (!response.ok) return []
const xml = await response.text()
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 {
return []
}
@@ -199,9 +169,10 @@ export function createFeedStore() {
/** Add a new feed and auto-fetch latest 20 episodes */
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 = {
id: crypto.randomUUID(),
id: feedId,
podcast,
episodes,
visibility,
@@ -217,11 +188,33 @@ export function createFeedStore() {
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 */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId)
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) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
@@ -229,6 +222,14 @@ export function createFeedStore() {
saveFeeds(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 */
@@ -241,6 +242,8 @@ export function createFeedStore() {
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId)
episodeLoadCount.delete(feedId)
setFeeds((prev) => {
const updated = prev.filter((f) => f.id !== feedId)
saveFeeds(updated)
@@ -330,18 +333,81 @@ export function createFeedStore() {
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 {
// State
feeds,
sources,
filter,
selectedFeedId,
isLoadingMore,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
getSelectedFeed,
hasMoreEpisodes,
// Actions
setFilter,
@@ -352,10 +418,12 @@ export function createFeedStore() {
togglePinned,
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
addSource,
removeSource,
toggleSource,
updateSource,
setAutoDownload,
}
}

View File

@@ -1,14 +1,17 @@
/**
* 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.
*/
import { createSignal } from "solid-js"
import type { Progress } from "../types/episode"
const STORAGE_KEY = "podtui_progress"
import {
loadProgressFromFile,
saveProgressToFile,
migrateProgressFromLocalStorage,
} from "../utils/app-persistence"
/** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95
@@ -16,15 +19,19 @@ const COMPLETION_THRESHOLD = 0.95
/** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5
// --- localStorage helpers ---
// --- Singleton store ---
function loadProgress(): Record<string, Progress> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({})
/** Persist current progress map to file (fire-and-forget) */
function persist(): void {
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> = {}
for (const [key, value] of Object.entries(parsed)) {
for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown>
result[key] = {
episodeId: p.episodeId as string,
@@ -35,28 +42,18 @@ function loadProgress(): Record<string, Progress> {
}
}
return result
} catch {
return {}
}
}
function saveProgress(data: Record<string, Progress>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {
// Quota exceeded or unavailable — silently ignore
}
/** Async initialisation — migrate from localStorage then load from file */
async function initProgress(): Promise<void> {
await migrateProgressFromLocalStorage()
const raw = await loadProgressFromFile()
const parsed = parseProgressEntries(raw as Record<string, unknown>)
setProgressMap(parsed)
}
// --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
loadProgress(),
)
function persist(): void {
saveProgress(progressMap())
}
// Fire-and-forget init
initProgress()
function createProgressStore() {
return {

View File

@@ -84,3 +84,34 @@ export interface EpisodeListItem {
/** Progress percentage (0-100) */
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
}

View File

@@ -33,6 +33,10 @@ export interface Feed {
isPinned: boolean
/** Feed color for UI */
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 */

View 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
}
}

View File

@@ -452,12 +452,22 @@ class FfplayBackend implements AudioBackend {
async setVolume(volume: number): Promise<void> {
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> {
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> {
@@ -588,10 +598,28 @@ class AfplayBackend implements AudioBackend {
async setVolume(volume: number): Promise<void> {
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> {
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> {

149
src/utils/audio-waveform.ts Normal file
View 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()
}

View 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
View 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
}

View 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 }
}

View 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,
}
}
}

View File

@@ -107,6 +107,17 @@ export type AppEvents = {
"dialog.open": { dialogId: string }
"dialog.close": { dialogId?: string }
"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

View 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
View 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&mdash;/g, "\u2014")
.replace(/&ndash;/g, "\u2013")
.replace(/&hellip;/g, "\u2026")
.replace(/&laquo;/g, "\u00AB")
.replace(/&raquo;/g, "\u00BB")
.replace(/&ldquo;/g, "\u201C")
.replace(/&rdquo;/g, "\u201D")
.replace(/&lsquo;/g, "\u2018")
.replace(/&rsquo;/g, "\u2019")
.replace(/&bull;/g, "\u2022")
.replace(/&copy;/g, "\u00A9")
.replace(/&reg;/g, "\u00AE")
.replace(/&trade;/g, "\u2122")
.replace(/&deg;/g, "\u00B0")
.replace(/&times;/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
View 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
}

View 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 (&amp; 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 &amp; / &lt; / 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
View 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

View 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)

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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