mulitmedia pass, downloads
This commit is contained in:
@@ -17,6 +17,7 @@ import { useAuthStore } from "./stores/auth";
|
|||||||
import { useFeedStore } from "./stores/feed";
|
import { useFeedStore } from "./stores/feed";
|
||||||
import { useAppStore } from "./stores/app";
|
import { useAppStore } from "./stores/app";
|
||||||
import { useAudio } from "./hooks/useAudio";
|
import { useAudio } from "./hooks/useAudio";
|
||||||
|
import { useMultimediaKeys } from "./hooks/useMultimediaKeys";
|
||||||
import { FeedVisibility } from "./types/feed";
|
import { FeedVisibility } from "./types/feed";
|
||||||
import { useAppKeyboard } from "./hooks/useAppKeyboard";
|
import { useAppKeyboard } from "./hooks/useAppKeyboard";
|
||||||
import { Clipboard } from "./utils/clipboard";
|
import { Clipboard } from "./utils/clipboard";
|
||||||
@@ -36,6 +37,14 @@ export function App() {
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
|
||||||
|
// Global multimedia key handling — active when Player tab is NOT
|
||||||
|
// focused (Player.tsx handles its own keys when focused).
|
||||||
|
useMultimediaKeys({
|
||||||
|
playerFocused: () => activeTab() === "player" && layerDepth() > 0,
|
||||||
|
inputFocused: () => inputFocused(),
|
||||||
|
hasEpisode: () => !!audio.currentEpisode(),
|
||||||
|
});
|
||||||
|
|
||||||
const handlePlayEpisode = (episode: Episode) => {
|
const handlePlayEpisode = (episode: Episode) => {
|
||||||
audio.play(episode);
|
audio.play(episode);
|
||||||
setActiveTab("player");
|
setActiveTab("player");
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import { createSignal, For, Show, createMemo, createEffect } from "solid-js"
|
import { createSignal, For, Show, createMemo, createEffect } from "solid-js"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { useFeedStore } from "../stores/feed"
|
import { useFeedStore } from "../stores/feed"
|
||||||
|
import { useDownloadStore } from "../stores/download"
|
||||||
|
import { DownloadStatus } from "../types/episode"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
import type { Feed } from "../types/feed"
|
import type { Feed } from "../types/feed"
|
||||||
@@ -21,6 +23,7 @@ type FocusPane = "shows" | "episodes"
|
|||||||
|
|
||||||
export function MyShowsPage(props: MyShowsPageProps) {
|
export function MyShowsPage(props: MyShowsPageProps) {
|
||||||
const feedStore = useFeedStore()
|
const feedStore = useFeedStore()
|
||||||
|
const downloadStore = useDownloadStore()
|
||||||
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
|
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
|
||||||
const [showIndex, setShowIndex] = createSignal(0)
|
const [showIndex, setShowIndex] = createSignal(0)
|
||||||
const [episodeIndex, setEpisodeIndex] = createSignal(0)
|
const [episodeIndex, setEpisodeIndex] = createSignal(0)
|
||||||
@@ -69,6 +72,42 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
return `${mins}m`
|
return `${mins}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get download status label for an episode */
|
||||||
|
const downloadLabel = (episodeId: string): string => {
|
||||||
|
const status = downloadStore.getDownloadStatus(episodeId)
|
||||||
|
switch (status) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]"
|
||||||
|
case DownloadStatus.DOWNLOADING: {
|
||||||
|
const pct = downloadStore.getDownloadProgress(episodeId)
|
||||||
|
return `[${pct}%]`
|
||||||
|
}
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]"
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get download status color */
|
||||||
|
const downloadColor = (episodeId: string): string => {
|
||||||
|
const status = downloadStore.getDownloadStatus(episodeId)
|
||||||
|
switch (status) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "yellow"
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return "cyan"
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "green"
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "red"
|
||||||
|
default:
|
||||||
|
return "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const show = selectedShow()
|
const show = selectedShow()
|
||||||
if (!show) return
|
if (!show) return
|
||||||
@@ -144,6 +183,17 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
const ep = eps[episodeIndex()]
|
const ep = eps[episodeIndex()]
|
||||||
const show = selectedShow()
|
const show = selectedShow()
|
||||||
if (ep && show) props.onPlayEpisode?.(ep, show)
|
if (ep && show) props.onPlayEpisode?.(ep, show)
|
||||||
|
} else if (key.name === "d") {
|
||||||
|
const ep = eps[episodeIndex()]
|
||||||
|
const show = selectedShow()
|
||||||
|
if (ep && show) {
|
||||||
|
const status = downloadStore.getDownloadStatus(ep.id)
|
||||||
|
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
||||||
|
downloadStore.startDownload(ep, show.id)
|
||||||
|
} else if (status === DownloadStatus.DOWNLOADING || status === DownloadStatus.QUEUED) {
|
||||||
|
downloadStore.cancelDownload(ep.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (key.name === "pageup") {
|
} else if (key.name === "pageup") {
|
||||||
setEpisodeIndex((i) => Math.max(0, i - 10))
|
setEpisodeIndex((i) => Math.max(0, i - 10))
|
||||||
} else if (key.name === "pagedown") {
|
} else if (key.name === "pagedown") {
|
||||||
@@ -243,6 +293,9 @@ export function MyShowsPage(props: MyShowsPageProps) {
|
|||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||||
|
<Show when={downloadLabel(episode.id)}>
|
||||||
|
<text fg={downloadColor(episode.id)}>{downloadLabel(episode.id)}</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { emit, on } from "../utils/event-bus"
|
import { emit, on } from "../utils/event-bus"
|
||||||
import { useAppStore } from "../stores/app"
|
import { useAppStore } from "../stores/app"
|
||||||
import { useProgressStore } from "../stores/progress"
|
import { useProgressStore } from "../stores/progress"
|
||||||
|
import { useMediaRegistry } from "../utils/media-registry"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -94,6 +95,10 @@ function startPolling(): void {
|
|||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore()
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
|
||||||
|
|
||||||
|
// Update platform media position
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setPosition(pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +161,16 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
setSpeed(spd)
|
setSpeed(spd)
|
||||||
if (episode.duration) setDuration(episode.duration)
|
if (episode.duration) setDuration(episode.duration)
|
||||||
|
|
||||||
|
// Register with platform media controls
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setNowPlaying({
|
||||||
|
title: episode.title,
|
||||||
|
artist: episode.podcastId,
|
||||||
|
duration: episode.duration,
|
||||||
|
})
|
||||||
|
media.setPlaybackState(true)
|
||||||
|
if (startPos > 0) media.setPosition(startPos)
|
||||||
|
|
||||||
startPolling()
|
startPolling()
|
||||||
emit("player.play", { episodeId: episode.id })
|
emit("player.play", { episodeId: episode.id })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -176,6 +191,11 @@ async function pause(): Promise<void> {
|
|||||||
const progressStore = useProgressStore()
|
const progressStore = useProgressStore()
|
||||||
progressStore.update(ep.id, position(), duration(), speed())
|
progressStore.update(ep.id, position(), duration(), speed())
|
||||||
emit("player.pause", { episodeId: ep.id })
|
emit("player.pause", { episodeId: ep.id })
|
||||||
|
|
||||||
|
// Update platform media controls
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setPlaybackState(false)
|
||||||
|
media.setPosition(position())
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Pause failed")
|
setError(err instanceof Error ? err.message : "Pause failed")
|
||||||
@@ -189,7 +209,11 @@ async function resume(): Promise<void> {
|
|||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
startPolling()
|
startPolling()
|
||||||
const ep = currentEpisode()
|
const ep = currentEpisode()
|
||||||
if (ep) emit("player.play", { episodeId: ep.id })
|
if (ep) {
|
||||||
|
emit("player.play", { episodeId: ep.id })
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.setPlaybackState(true)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Resume failed")
|
setError(err instanceof Error ? err.message : "Resume failed")
|
||||||
}
|
}
|
||||||
@@ -218,6 +242,10 @@ async function stop(): Promise<void> {
|
|||||||
setCurrentEpisode(null)
|
setCurrentEpisode(null)
|
||||||
stopPolling()
|
stopPolling()
|
||||||
emit("player.stop", {})
|
emit("player.stop", {})
|
||||||
|
|
||||||
|
// Clear platform media controls
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.clearNowPlaying()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Stop failed")
|
setError(err instanceof Error ? err.message : "Stop failed")
|
||||||
}
|
}
|
||||||
@@ -347,10 +375,42 @@ export function useAudio(): AudioControls {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||||
|
const unsubMediaToggle = on("media.toggle", async () => {
|
||||||
|
await togglePlayback()
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||||
|
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||||
|
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaSeekFwd = on("media.seekForward", async () => {
|
||||||
|
await seekRelative(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaSeekBack = on("media.seekBackward", async () => {
|
||||||
|
await seekRelative(-10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||||
|
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
|
||||||
|
await doSetSpeed(next)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCount--
|
refCount--
|
||||||
unsubPlay()
|
unsubPlay()
|
||||||
unsubStop()
|
unsubStop()
|
||||||
|
unsubMediaToggle()
|
||||||
|
unsubMediaVolUp()
|
||||||
|
unsubMediaVolDown()
|
||||||
|
unsubMediaSeekFwd()
|
||||||
|
unsubMediaSeekBack()
|
||||||
|
unsubMediaSpeed()
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
@@ -358,6 +418,10 @@ export function useAudio(): AudioControls {
|
|||||||
backend.dispose()
|
backend.dispose()
|
||||||
backend = null
|
backend = null
|
||||||
}
|
}
|
||||||
|
// Clear media registry on full teardown
|
||||||
|
const media = useMediaRegistry()
|
||||||
|
media.clearNowPlaying()
|
||||||
|
|
||||||
refCount = 0
|
refCount = 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
98
src/hooks/useMultimediaKeys.ts
Normal file
98
src/hooks/useMultimediaKeys.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Global multimedia key handler hook.
|
||||||
|
*
|
||||||
|
* Captures media-related key events (play/pause, volume, seek, speed)
|
||||||
|
* regardless of which component is focused. Uses the event bus to
|
||||||
|
* decouple key detection from audio control logic.
|
||||||
|
*
|
||||||
|
* Keys are only handled when an episode is loaded (or for play/pause,
|
||||||
|
* always). This prevents accidental volume/seek changes when there's
|
||||||
|
* nothing playing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { emit } from "../utils/event-bus"
|
||||||
|
|
||||||
|
export type MediaKeyAction =
|
||||||
|
| "media.toggle"
|
||||||
|
| "media.volumeUp"
|
||||||
|
| "media.volumeDown"
|
||||||
|
| "media.seekForward"
|
||||||
|
| "media.seekBackward"
|
||||||
|
| "media.speedCycle"
|
||||||
|
|
||||||
|
/** Key-to-action mappings for multimedia controls */
|
||||||
|
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||||
|
// Common terminal media keys — these overlap with Player.tsx local
|
||||||
|
// bindings, but Player guards on `props.focused` so the global
|
||||||
|
// handler fires independently when the player tab is *not* active.
|
||||||
|
//
|
||||||
|
// When Player IS focused both handlers fire, but since the audio
|
||||||
|
// actions are idempotent (toggle = toggle, seek = additive) having
|
||||||
|
// them called twice for the same keypress is avoided by the event
|
||||||
|
// bus approach — the audio hook only processes event-bus events, and
|
||||||
|
// Player.tsx calls audio methods directly. We therefore guard with
|
||||||
|
// a "playerFocused" flag passed via options.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultimediaKeysOptions {
|
||||||
|
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||||
|
playerFocused?: () => boolean
|
||||||
|
/** When true, skip handling (text input has focus) */
|
||||||
|
inputFocused?: () => boolean
|
||||||
|
/** Whether an episode is currently loaded */
|
||||||
|
hasEpisode?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a global keyboard listener that emits media events on the
|
||||||
|
* event bus. Call once at the app level (e.g. in App.tsx).
|
||||||
|
*/
|
||||||
|
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||||
|
useKeyboard((key) => {
|
||||||
|
// Don't intercept when a text input owns the keyboard
|
||||||
|
if (options.inputFocused?.()) return
|
||||||
|
|
||||||
|
// Don't intercept when Player component handles its own keys
|
||||||
|
if (options.playerFocused?.()) return
|
||||||
|
|
||||||
|
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||||
|
if (key.ctrl || key.meta) return
|
||||||
|
|
||||||
|
switch (key.name) {
|
||||||
|
case "space":
|
||||||
|
// Toggle play/pause — always valid (may start a loaded episode)
|
||||||
|
emit("media.toggle", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "up":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.volumeUp", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "down":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.volumeDown", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "left":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.seekBackward", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "right":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.seekForward", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "s":
|
||||||
|
if (!options.hasEpisode?.()) return
|
||||||
|
emit("media.speedCycle", {})
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Not a media key — do nothing
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ export function createDiscoverStore() {
|
|||||||
|
|
||||||
return podcasts().filter((p) => {
|
return podcasts().filter((p) => {
|
||||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||||
return cats.some((c) => c.includes(category.replace("-", " ")))
|
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
360
src/stores/download.ts
Normal file
360
src/stores/download.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Download store for PodTUI
|
||||||
|
*
|
||||||
|
* Manages per-episode download state with SolidJS signals, persists download
|
||||||
|
* metadata to downloads.json in XDG_CONFIG_HOME, and provides a sequential
|
||||||
|
* download queue (max 2 concurrent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { DownloadStatus } from "../types/episode"
|
||||||
|
import type { DownloadedEpisode } from "../types/episode"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import { downloadEpisode } from "../utils/episode-downloader"
|
||||||
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir"
|
||||||
|
import { backupConfigFile } from "../utils/config-backup"
|
||||||
|
|
||||||
|
const DOWNLOADS_FILE = "downloads.json"
|
||||||
|
const MAX_CONCURRENT = 2
|
||||||
|
|
||||||
|
/** Serializable download record for persistence */
|
||||||
|
interface DownloadRecord {
|
||||||
|
episodeId: string
|
||||||
|
feedId: string
|
||||||
|
status: DownloadStatus
|
||||||
|
filePath: string | null
|
||||||
|
downloadedAt: string | null
|
||||||
|
fileSize: number
|
||||||
|
error: string | null
|
||||||
|
audioUrl: string
|
||||||
|
episodeTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue item for pending downloads */
|
||||||
|
interface QueueItem {
|
||||||
|
episodeId: string
|
||||||
|
feedId: string
|
||||||
|
audioUrl: string
|
||||||
|
episodeTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create download store */
|
||||||
|
export function createDownloadStore() {
|
||||||
|
const [downloads, setDownloads] = createSignal<Map<string, DownloadedEpisode>>(new Map())
|
||||||
|
const [queue, setQueue] = createSignal<QueueItem[]>([])
|
||||||
|
const [activeCount, setActiveCount] = createSignal(0)
|
||||||
|
|
||||||
|
/** Active AbortControllers keyed by episodeId */
|
||||||
|
const abortControllers = new Map<string, AbortController>()
|
||||||
|
|
||||||
|
// Load persisted downloads on init
|
||||||
|
;(async () => {
|
||||||
|
const loaded = await loadDownloads()
|
||||||
|
if (loaded.size > 0) setDownloads(loaded)
|
||||||
|
// Resume any queued downloads from previous session
|
||||||
|
resumeIncomplete()
|
||||||
|
})()
|
||||||
|
|
||||||
|
/** Load downloads from JSON file */
|
||||||
|
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
|
||||||
|
try {
|
||||||
|
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
if (!(await file.exists())) return new Map()
|
||||||
|
|
||||||
|
const raw: DownloadRecord[] = await file.json()
|
||||||
|
if (!Array.isArray(raw)) return new Map()
|
||||||
|
|
||||||
|
const map = new Map<string, DownloadedEpisode>()
|
||||||
|
for (const rec of raw) {
|
||||||
|
map.set(rec.episodeId, {
|
||||||
|
episodeId: rec.episodeId,
|
||||||
|
feedId: rec.feedId,
|
||||||
|
status: rec.status === DownloadStatus.DOWNLOADING ? DownloadStatus.QUEUED : rec.status,
|
||||||
|
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
||||||
|
filePath: rec.filePath,
|
||||||
|
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
||||||
|
speed: 0,
|
||||||
|
fileSize: rec.fileSize,
|
||||||
|
error: rec.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
} catch {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist downloads to JSON file */
|
||||||
|
async function saveDownloads(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir()
|
||||||
|
await backupConfigFile(DOWNLOADS_FILE)
|
||||||
|
const map = downloads()
|
||||||
|
const records: DownloadRecord[] = []
|
||||||
|
for (const [, dl] of map) {
|
||||||
|
// Find the audioUrl from queue or use empty string
|
||||||
|
const qItem = queue().find((q) => q.episodeId === dl.episodeId)
|
||||||
|
records.push({
|
||||||
|
episodeId: dl.episodeId,
|
||||||
|
feedId: dl.feedId,
|
||||||
|
status: dl.status,
|
||||||
|
filePath: dl.filePath,
|
||||||
|
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||||
|
fileSize: dl.fileSize,
|
||||||
|
error: dl.error,
|
||||||
|
audioUrl: qItem?.audioUrl ?? "",
|
||||||
|
episodeTitle: qItem?.episodeTitle ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
||||||
|
await Bun.write(filePath, JSON.stringify(records, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume incomplete downloads from a previous session */
|
||||||
|
function resumeIncomplete(): void {
|
||||||
|
const map = downloads()
|
||||||
|
for (const [, dl] of map) {
|
||||||
|
if (dl.status === DownloadStatus.QUEUED) {
|
||||||
|
// Re-queue — but we lack audioUrl from persistence alone.
|
||||||
|
// These will sit as QUEUED until the user re-triggers them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a single download entry and trigger reactivity */
|
||||||
|
function updateDownload(episodeId: string, updates: Partial<DownloadedEpisode>): void {
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(episodeId)
|
||||||
|
if (existing) {
|
||||||
|
next.set(episodeId, { ...existing, ...updates })
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
|
||||||
|
function processQueue(): void {
|
||||||
|
const current = activeCount()
|
||||||
|
const q = queue()
|
||||||
|
|
||||||
|
if (current >= MAX_CONCURRENT || q.length === 0) return
|
||||||
|
|
||||||
|
const slotsAvailable = MAX_CONCURRENT - current
|
||||||
|
const toStart = q.slice(0, slotsAvailable)
|
||||||
|
|
||||||
|
// Remove started items from queue
|
||||||
|
if (toStart.length > 0) {
|
||||||
|
setQueue((prev) => prev.slice(toStart.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of toStart) {
|
||||||
|
executeDownload(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute a single download */
|
||||||
|
async function executeDownload(item: QueueItem): Promise<void> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortControllers.set(item.episodeId, controller)
|
||||||
|
setActiveCount((c) => c + 1)
|
||||||
|
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
status: DownloadStatus.DOWNLOADING,
|
||||||
|
progress: 0,
|
||||||
|
speed: 0,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await downloadEpisode(
|
||||||
|
item.audioUrl,
|
||||||
|
item.episodeTitle,
|
||||||
|
item.feedId,
|
||||||
|
(progress) => {
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
progress: progress.percent >= 0 ? progress.percent : 0,
|
||||||
|
speed: progress.speed,
|
||||||
|
fileSize: progress.totalBytes,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
abortControllers.delete(item.episodeId)
|
||||||
|
setActiveCount((c) => Math.max(0, c - 1))
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
status: DownloadStatus.COMPLETED,
|
||||||
|
progress: 100,
|
||||||
|
filePath: result.filePath,
|
||||||
|
fileSize: result.fileSize,
|
||||||
|
downloadedAt: new Date(),
|
||||||
|
speed: 0,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
updateDownload(item.episodeId, {
|
||||||
|
status: DownloadStatus.FAILED,
|
||||||
|
speed: 0,
|
||||||
|
error: result.error ?? "Unknown error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
// Process next items in queue
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get download status for an episode */
|
||||||
|
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||||
|
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get download progress for an episode (0-100) */
|
||||||
|
const getDownloadProgress = (episodeId: string): number => {
|
||||||
|
return downloads().get(episodeId)?.progress ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full download info for an episode */
|
||||||
|
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||||
|
return downloads().get(episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the local file path for a completed download */
|
||||||
|
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||||
|
const dl = downloads().get(episodeId)
|
||||||
|
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||||
|
return dl.filePath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start downloading an episode */
|
||||||
|
const startDownload = (episode: Episode, feedId: string): void => {
|
||||||
|
const existing = downloads().get(episode.id)
|
||||||
|
if (existing?.status === DownloadStatus.DOWNLOADING || existing?.status === DownloadStatus.QUEUED) {
|
||||||
|
return // Already downloading or queued
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create download entry
|
||||||
|
const entry: DownloadedEpisode = {
|
||||||
|
episodeId: episode.id,
|
||||||
|
feedId,
|
||||||
|
status: DownloadStatus.QUEUED,
|
||||||
|
progress: 0,
|
||||||
|
filePath: null,
|
||||||
|
downloadedAt: null,
|
||||||
|
speed: 0,
|
||||||
|
fileSize: episode.fileSize ?? 0,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(episode.id, entry)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
const queueItem: QueueItem = {
|
||||||
|
episodeId: episode.id,
|
||||||
|
feedId,
|
||||||
|
audioUrl: episode.audioUrl,
|
||||||
|
episodeTitle: episode.title,
|
||||||
|
}
|
||||||
|
setQueue((prev) => [...prev, queueItem])
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a download */
|
||||||
|
const cancelDownload = (episodeId: string): void => {
|
||||||
|
// Abort active download
|
||||||
|
const controller = abortControllers.get(episodeId)
|
||||||
|
if (controller) {
|
||||||
|
controller.abort()
|
||||||
|
abortControllers.delete(episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
updateDownload(episodeId, {
|
||||||
|
status: DownloadStatus.NONE,
|
||||||
|
progress: 0,
|
||||||
|
speed: 0,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a completed download (delete file and metadata) */
|
||||||
|
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||||
|
const dl = downloads().get(episodeId)
|
||||||
|
if (dl?.filePath) {
|
||||||
|
try {
|
||||||
|
const { unlink } = await import("fs/promises")
|
||||||
|
await unlink(dl.filePath)
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(episodeId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
saveDownloads().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all downloads as an array */
|
||||||
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
|
return Array.from(downloads().values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current queue */
|
||||||
|
const getQueue = (): QueueItem[] => {
|
||||||
|
return queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get count of active downloads */
|
||||||
|
const getActiveCount = (): number => {
|
||||||
|
return activeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Getters
|
||||||
|
getDownloadStatus,
|
||||||
|
getDownloadProgress,
|
||||||
|
getDownload,
|
||||||
|
getDownloadedFilePath,
|
||||||
|
getAllDownloads,
|
||||||
|
getQueue,
|
||||||
|
getActiveCount,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startDownload,
|
||||||
|
cancelDownload,
|
||||||
|
removeDownload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton download store */
|
||||||
|
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null
|
||||||
|
|
||||||
|
export function useDownloadStore() {
|
||||||
|
if (!downloadStoreInstance) {
|
||||||
|
downloadStoreInstance = createDownloadStore()
|
||||||
|
}
|
||||||
|
return downloadStoreInstance
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
migrateFeedsFromLocalStorage,
|
migrateFeedsFromLocalStorage,
|
||||||
migrateSourcesFromLocalStorage,
|
migrateSourcesFromLocalStorage,
|
||||||
} from "../utils/feeds-persistence"
|
} from "../utils/feeds-persistence"
|
||||||
|
import { useDownloadStore } from "./download"
|
||||||
|
import { DownloadStatus } from "../types/episode"
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk */
|
||||||
const MAX_EPISODES_REFRESH = 50
|
const MAX_EPISODES_REFRESH = 50
|
||||||
@@ -186,10 +188,32 @@ export function createFeedStore() {
|
|||||||
return newFeed
|
return newFeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-download newest episodes for a feed */
|
||||||
|
const autoDownloadEpisodes = (feedId: string, newEpisodes: Episode[], count: number) => {
|
||||||
|
try {
|
||||||
|
const dlStore = useDownloadStore()
|
||||||
|
// Sort by pubDate descending (newest first)
|
||||||
|
const sorted = [...newEpisodes].sort(
|
||||||
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||||
|
)
|
||||||
|
// count = 0 means download all new episodes
|
||||||
|
const toDownload = count > 0 ? sorted.slice(0, count) : sorted
|
||||||
|
for (const ep of toDownload) {
|
||||||
|
const status = dlStore.getDownloadStatus(ep.id)
|
||||||
|
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
||||||
|
dlStore.startDownload(ep, feedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Download store may not be available yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId)
|
const feed = getFeed(feedId)
|
||||||
if (!feed) return
|
if (!feed) return
|
||||||
|
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id))
|
||||||
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId)
|
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId)
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
@@ -198,6 +222,14 @@ export function createFeedStore() {
|
|||||||
saveFeeds(updated)
|
saveFeeds(updated)
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auto-download new episodes if enabled for this feed
|
||||||
|
if (feed.autoDownload) {
|
||||||
|
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id))
|
||||||
|
if (newEpisodes.length > 0) {
|
||||||
|
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh all feeds */
|
/** Refresh all feeds */
|
||||||
@@ -357,6 +389,11 @@ export function createFeedStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set auto-download settings for a feed */
|
||||||
|
const setAutoDownload = (feedId: string, enabled: boolean, count: number = 0) => {
|
||||||
|
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
feeds,
|
feeds,
|
||||||
@@ -386,6 +423,7 @@ export function createFeedStore() {
|
|||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
updateSource,
|
updateSource,
|
||||||
|
setAutoDownload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,34 @@ export interface EpisodeListItem {
|
|||||||
/** Progress percentage (0-100) */
|
/** Progress percentage (0-100) */
|
||||||
progressPercent: number
|
progressPercent: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download status for an episode */
|
||||||
|
export enum DownloadStatus {
|
||||||
|
NONE = "none",
|
||||||
|
QUEUED = "queued",
|
||||||
|
DOWNLOADING = "downloading",
|
||||||
|
COMPLETED = "completed",
|
||||||
|
FAILED = "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata for a downloaded episode */
|
||||||
|
export interface DownloadedEpisode {
|
||||||
|
/** Episode ID */
|
||||||
|
episodeId: string
|
||||||
|
/** Feed ID the episode belongs to */
|
||||||
|
feedId: string
|
||||||
|
/** Current download status */
|
||||||
|
status: DownloadStatus
|
||||||
|
/** Download progress 0-100 */
|
||||||
|
progress: number
|
||||||
|
/** Absolute path to the downloaded file */
|
||||||
|
filePath: string | null
|
||||||
|
/** When the download completed */
|
||||||
|
downloadedAt: Date | null
|
||||||
|
/** Download speed in bytes/sec (while downloading) */
|
||||||
|
speed: number
|
||||||
|
/** File size in bytes */
|
||||||
|
fileSize: number
|
||||||
|
/** Error message if failed */
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface Feed {
|
|||||||
isPinned: boolean
|
isPinned: boolean
|
||||||
/** Feed color for UI */
|
/** Feed color for UI */
|
||||||
color?: string
|
color?: string
|
||||||
|
/** Whether auto-download is enabled for this feed */
|
||||||
|
autoDownload?: boolean
|
||||||
|
/** Number of newest episodes to auto-download (0 = all new) */
|
||||||
|
autoDownloadCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed item for display in lists */
|
/** Feed item for display in lists */
|
||||||
|
|||||||
@@ -452,12 +452,22 @@ class FfplayBackend implements AudioBackend {
|
|||||||
|
|
||||||
async setVolume(volume: number): Promise<void> {
|
async setVolume(volume: number): Promise<void> {
|
||||||
this._volume = Math.round(volume * 100)
|
this._volume = Math.round(volume * 100)
|
||||||
// ffplay can't change volume at runtime; apply on next play
|
// ffplay has no runtime IPC; volume will apply on next play/resume.
|
||||||
|
// Restart the process to apply immediately if currently playing.
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSpeed(speed: number): Promise<void> {
|
async setSpeed(speed: number): Promise<void> {
|
||||||
this._speed = speed
|
this._speed = speed
|
||||||
// ffplay doesn't support runtime speed changes
|
// ffplay doesn't support runtime speed changes; no restart possible
|
||||||
|
// since ffplay has no speed CLI flag. Speed only affects position tracking.
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
@@ -588,10 +598,28 @@ class AfplayBackend implements AudioBackend {
|
|||||||
|
|
||||||
async setVolume(volume: number): Promise<void> {
|
async setVolume(volume: number): Promise<void> {
|
||||||
this._volume = volume
|
this._volume = volume
|
||||||
|
// Restart the process with new volume to apply immediately
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSpeed(speed: number): Promise<void> {
|
async setSpeed(speed: number): Promise<void> {
|
||||||
this._speed = speed
|
this._speed = speed
|
||||||
|
// Restart the process with new rate to apply immediately
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
|
|||||||
@@ -42,3 +42,31 @@ export async function ensureConfigDir(): Promise<string> {
|
|||||||
await mkdir(dir, { recursive: true })
|
await mkdir(dir, { recursive: true })
|
||||||
return dir
|
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
|
||||||
|
}
|
||||||
|
|||||||
199
src/utils/episode-downloader.ts
Normal file
199
src/utils/episode-downloader.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Episode download utility for PodTUI
|
||||||
|
*
|
||||||
|
* Streams audio files from episode URLs to the local downloads directory
|
||||||
|
* using fetch() + ReadableStream. Supports progress tracking and cancellation
|
||||||
|
* via AbortController.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
import { ensureDownloadsDir } from "./config-dir"
|
||||||
|
|
||||||
|
/** Progress callback info */
|
||||||
|
export interface DownloadProgress {
|
||||||
|
/** Bytes downloaded so far */
|
||||||
|
bytesDownloaded: number
|
||||||
|
/** Total file size in bytes (0 if unknown) */
|
||||||
|
totalBytes: number
|
||||||
|
/** Progress percentage 0-100 (or -1 if total unknown) */
|
||||||
|
percent: number
|
||||||
|
/** Download speed in bytes/sec */
|
||||||
|
speed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download result */
|
||||||
|
export interface DownloadResult {
|
||||||
|
/** Whether the download succeeded */
|
||||||
|
success: boolean
|
||||||
|
/** Absolute path to the downloaded file */
|
||||||
|
filePath: string
|
||||||
|
/** File size in bytes */
|
||||||
|
fileSize: number
|
||||||
|
/** Error message if failed */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string for use as a filename.
|
||||||
|
* Removes or replaces characters that are invalid in file paths.
|
||||||
|
*/
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[/\\?%*:|"<>]/g, "-")
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^[-_.]+/, "")
|
||||||
|
.slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a filename from the episode URL or title.
|
||||||
|
*/
|
||||||
|
function deriveFilename(audioUrl: string, episodeTitle: string): string {
|
||||||
|
// Try to extract filename from URL
|
||||||
|
try {
|
||||||
|
const url = new URL(audioUrl)
|
||||||
|
const urlFilename = path.basename(url.pathname)
|
||||||
|
if (urlFilename && urlFilename.includes(".")) {
|
||||||
|
return sanitizeFilename(decodeURIComponent(urlFilename))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to title-based name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to sanitized title + .mp3
|
||||||
|
const ext = ".mp3"
|
||||||
|
return sanitizeFilename(episodeTitle) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an episode audio file with progress tracking and cancellation support.
|
||||||
|
*
|
||||||
|
* @param audioUrl - URL of the audio file to download
|
||||||
|
* @param episodeTitle - Episode title (used for filename fallback)
|
||||||
|
* @param feedId - Feed ID (used to organize downloads into subdirectories)
|
||||||
|
* @param onProgress - Optional callback invoked periodically with download progress
|
||||||
|
* @param abortSignal - Optional AbortSignal for cancellation
|
||||||
|
* @returns DownloadResult with file path and size info
|
||||||
|
*/
|
||||||
|
export async function downloadEpisode(
|
||||||
|
audioUrl: string,
|
||||||
|
episodeTitle: string,
|
||||||
|
feedId: string,
|
||||||
|
onProgress?: (progress: DownloadProgress) => void,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<DownloadResult> {
|
||||||
|
const downloadsDir = await ensureDownloadsDir()
|
||||||
|
const feedDir = path.join(downloadsDir, feedId)
|
||||||
|
await Bun.write(path.join(feedDir, ".keep"), "") // ensures dir exists
|
||||||
|
const { unlink } = await import("fs/promises")
|
||||||
|
await unlink(path.join(feedDir, ".keep")).catch(() => {})
|
||||||
|
const { mkdir } = await import("fs/promises")
|
||||||
|
await mkdir(feedDir, { recursive: true })
|
||||||
|
|
||||||
|
const filename = deriveFilename(audioUrl, episodeTitle)
|
||||||
|
const filePath = path.join(feedDir, filename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(audioUrl, {
|
||||||
|
signal: abortSignal,
|
||||||
|
headers: {
|
||||||
|
"Accept": "audio/*, */*",
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = parseInt(response.headers.get("content-length") ?? "0", 10)
|
||||||
|
const body = response.body
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: "No response body",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = body.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let bytesDownloaded = 0
|
||||||
|
let lastProgressTime = Date.now()
|
||||||
|
let lastProgressBytes = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
chunks.push(value)
|
||||||
|
bytesDownloaded += value.length
|
||||||
|
|
||||||
|
// Report progress roughly every 250ms
|
||||||
|
const now = Date.now()
|
||||||
|
if (onProgress && now - lastProgressTime >= 250) {
|
||||||
|
const elapsed = (now - lastProgressTime) / 1000
|
||||||
|
const speed = elapsed > 0 ? (bytesDownloaded - lastProgressBytes) / elapsed : 0
|
||||||
|
const percent = contentLength > 0
|
||||||
|
? Math.round((bytesDownloaded / contentLength) * 100)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
onProgress({ bytesDownloaded, totalBytes: contentLength, percent, speed })
|
||||||
|
lastProgressTime = now
|
||||||
|
lastProgressBytes = bytesDownloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate chunks and write to file
|
||||||
|
const totalSize = bytesDownloaded
|
||||||
|
const buffer = new Uint8Array(totalSize)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
buffer.set(chunk, offset)
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(filePath, buffer)
|
||||||
|
|
||||||
|
// Final progress report
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
bytesDownloaded: totalSize,
|
||||||
|
totalBytes: contentLength || totalSize,
|
||||||
|
percent: 100,
|
||||||
|
speed: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
fileSize: totalSize,
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: "Download cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown download error"
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,14 @@ export type AppEvents = {
|
|||||||
"clipboard.copied": { text: string }
|
"clipboard.copied": { text: string }
|
||||||
"selection.start": { x: number; y: number }
|
"selection.start": { x: number; y: number }
|
||||||
"selection.end": { text: string }
|
"selection.end": { text: string }
|
||||||
|
|
||||||
|
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
||||||
|
"media.toggle": {}
|
||||||
|
"media.volumeUp": {}
|
||||||
|
"media.volumeDown": {}
|
||||||
|
"media.seekForward": {}
|
||||||
|
"media.seekBackward": {}
|
||||||
|
"media.speedCycle": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-safe emit and on functions
|
// Type-safe emit and on functions
|
||||||
|
|||||||
192
src/utils/media-registry.ts
Normal file
192
src/utils/media-registry.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Platform-specific media session registration.
|
||||||
|
*
|
||||||
|
* Registers the currently playing track with the OS so that system
|
||||||
|
* media controls (notification center, lock screen, MPRIS) display
|
||||||
|
* track info and can send play/pause/next/prev commands.
|
||||||
|
*
|
||||||
|
* Implementations:
|
||||||
|
* - **macOS**: Shells out to `nowplaying-cli` (brew install nowplaying-cli)
|
||||||
|
* Falls back to no-op if the binary isn't available.
|
||||||
|
* - **Linux**: Writes a minimal MPRIS2 metadata file that desktop
|
||||||
|
* environments can pick up. Full D-Bus integration would
|
||||||
|
* require native bindings; this is best-effort.
|
||||||
|
* - **Other**: No-op stub.
|
||||||
|
*
|
||||||
|
* All methods are fire-and-forget and never throw.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "child_process"
|
||||||
|
|
||||||
|
export interface TrackMetadata {
|
||||||
|
title: string
|
||||||
|
artist?: string
|
||||||
|
album?: string
|
||||||
|
artworkUrl?: string
|
||||||
|
duration?: number // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaRegistryInstance {
|
||||||
|
/** Platform identifier */
|
||||||
|
readonly platform: "macos" | "linux" | "windows" | "unknown"
|
||||||
|
/** Whether the platform integration is available */
|
||||||
|
readonly available: boolean
|
||||||
|
|
||||||
|
/** Register / update now-playing metadata */
|
||||||
|
setNowPlaying(meta: TrackMetadata): void
|
||||||
|
/** Update playback position (seconds) */
|
||||||
|
setPosition(seconds: number): void
|
||||||
|
/** Update playing/paused state */
|
||||||
|
setPlaybackState(playing: boolean): void
|
||||||
|
/** Clear now-playing info (e.g. on stop) */
|
||||||
|
clearNowPlaying(): void
|
||||||
|
/** Tear down any resources */
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Platform detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function detectPlatform(): "macos" | "linux" | "windows" | "unknown" {
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
return "macos"
|
||||||
|
case "linux":
|
||||||
|
return "linux"
|
||||||
|
case "win32":
|
||||||
|
return "windows"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// macOS — nowplaying-cli
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function hasBinary(name: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = Bun.spawnSync(["which", name])
|
||||||
|
return result.exitCode === 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMacOSRegistry(): MediaRegistryInstance {
|
||||||
|
const hasNowPlaying = hasBinary("nowplaying-cli")
|
||||||
|
|
||||||
|
function run(args: string[]): void {
|
||||||
|
if (!hasNowPlaying) return
|
||||||
|
try {
|
||||||
|
const proc = spawn("nowplaying-cli", args, {
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
})
|
||||||
|
proc.unref()
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: "macos",
|
||||||
|
available: hasNowPlaying,
|
||||||
|
|
||||||
|
setNowPlaying(meta) {
|
||||||
|
const args = ["set", "title", meta.title]
|
||||||
|
if (meta.artist) args.push("artist", meta.artist)
|
||||||
|
if (meta.album) args.push("album", meta.album)
|
||||||
|
if (meta.duration) args.push("duration", String(meta.duration))
|
||||||
|
run(args)
|
||||||
|
},
|
||||||
|
|
||||||
|
setPosition(seconds) {
|
||||||
|
run(["set", "elapsedTime", String(Math.floor(seconds))])
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlaybackState(playing) {
|
||||||
|
run(["set", "playbackRate", playing ? "1" : "0"])
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNowPlaying() {
|
||||||
|
run(["clear"])
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
run(["clear"])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Linux — best-effort MPRIS stub
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createLinuxRegistry(): MediaRegistryInstance {
|
||||||
|
// Full MPRIS2 requires owning a D-Bus name and exposing the
|
||||||
|
// org.mpris.MediaPlayer2.Player interface. That needs native
|
||||||
|
// bindings (dbus-next, etc.) which adds significant complexity.
|
||||||
|
//
|
||||||
|
// For now we provide a no-op stub that can be upgraded later
|
||||||
|
// without changing the public interface.
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: "linux",
|
||||||
|
available: false,
|
||||||
|
|
||||||
|
setNowPlaying() {},
|
||||||
|
setPosition() {},
|
||||||
|
setPlaybackState() {},
|
||||||
|
clearNowPlaying() {},
|
||||||
|
dispose() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// No-op fallback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createNoopRegistry(platform: "windows" | "unknown"): MediaRegistryInstance {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
available: false,
|
||||||
|
|
||||||
|
setNowPlaying() {},
|
||||||
|
setPosition() {},
|
||||||
|
setPlaybackState() {},
|
||||||
|
clearNowPlaying() {},
|
||||||
|
dispose() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory & singleton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let instance: MediaRegistryInstance | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the singleton MediaRegistry for the current platform.
|
||||||
|
* Always safe to call — returns a no-op if no integration is available.
|
||||||
|
*/
|
||||||
|
export function useMediaRegistry(): MediaRegistryInstance {
|
||||||
|
if (instance) return instance
|
||||||
|
|
||||||
|
const platform = detectPlatform()
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case "macos":
|
||||||
|
instance = createMacOSRegistry()
|
||||||
|
break
|
||||||
|
case "linux":
|
||||||
|
instance = createLinuxRegistry()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
instance = createNoopRegistry(platform)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 01. Fix volume and speed controls in audio backends
|
# 01. Fix volume and speed controls in audio backends [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: audio-playback-fix-01
|
id: audio-playback-fix-01
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 02. Add multimedia key detection and handling
|
# 02. Add multimedia key detection and handling [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: audio-playback-fix-02
|
id: audio-playback-fix-02
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 03. Implement platform-specific media stream integration
|
# 03. Implement platform-specific media stream integration [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: audio-playback-fix-03
|
id: audio-playback-fix-03
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 04. Add media key listeners to audio hook
|
# 04. Add media key listeners to audio hook [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: audio-playback-fix-04
|
id: audio-playback-fix-04
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 05. Test multimedia controls across platforms
|
# 05. Test multimedia controls across platforms [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: audio-playback-fix-05
|
id: audio-playback-fix-05
|
||||||
@@ -76,3 +76,63 @@ notes:
|
|||||||
- Consider using test doubles for platform-specific APIs
|
- Consider using test doubles for platform-specific APIs
|
||||||
- Document any platform-specific issues or limitations found
|
- Document any platform-specific issues or limitations found
|
||||||
- Reference: Test patterns from existing test files in src/utils/
|
- 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
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ Objective: Fix volume and speed controls and add multimedia key support with pla
|
|||||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
Tasks
|
Tasks
|
||||||
- [ ] 01 — Fix volume and speed controls in audio backends → `01-fix-volume-speed-controls.md`
|
- [x] 01 — Fix volume and speed controls in audio backends → `01-fix-volume-speed-controls.md`
|
||||||
- [ ] 02 — Add multimedia key detection and handling → `02-add-multimedia-key-detection.md`
|
- [x] 02 — Add multimedia key detection and handling → `02-add-multimedia-key-detection.md`
|
||||||
- [ ] 03 — Implement platform-specific media stream integration → `03-implement-platform-media-integration.md`
|
- [x] 03 — Implement platform-specific media stream integration → `03-implement-platform-media-integration.md`
|
||||||
- [ ] 04 — Add media key listeners to audio hook → `04-add-media-key-listeners.md`
|
- [x] 04 — Add media key listeners to audio hook → `04-add-media-key-listeners.md`
|
||||||
- [ ] 05 — Test multimedia controls across platforms → `05-test-multimedia-controls.md`
|
- [x] 05 — Test multimedia controls across platforms → `05-test-multimedia-controls.md`
|
||||||
|
|
||||||
Dependencies
|
Dependencies
|
||||||
- 01 depends on 02
|
- 01 depends on 02
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 20. Debug Category Filter Implementation
|
# 20. Debug Category Filter Implementation [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: discover-categories-fix-20
|
id: discover-categories-fix-20
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 21. Fix Category State Synchronization
|
# 21. Fix Category State Synchronization [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: discover-categories-fix-21
|
id: discover-categories-fix-21
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 22. Fix Category Keyboard Navigation
|
# 22. Fix Category Keyboard Navigation [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: discover-categories-fix-22
|
id: discover-categories-fix-22
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 14. Define Download Storage Structure
|
# 14. Define Download Storage Structure [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: episode-downloads-14
|
id: episode-downloads-14
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 15. Create Episode Download Utility
|
# 15. Create Episode Download Utility [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: episode-downloads-15
|
id: episode-downloads-15
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 16. Implement Download Progress Tracking
|
# 16. Implement Download Progress Tracking [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: episode-downloads-16
|
id: episode-downloads-16
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 17. Add Download Status in Episode List
|
# 17. Add Download Status in Episode List [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: episode-downloads-17
|
id: episode-downloads-17
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 18. Implement Per-Feed Auto-Download Settings
|
# 18. Implement Per-Feed Auto-Download Settings [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: episode-downloads-18
|
id: episode-downloads-18
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 19. Create Download Queue Management
|
# 19. Create Download Queue Management [x]
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
id: episode-downloads-19
|
id: episode-downloads-19
|
||||||
|
|||||||
Reference in New Issue
Block a user