mulitmedia pass, downloads

This commit is contained in:
2026-02-06 00:00:15 -05:00
parent 42a1ddf458
commit 0e4f47323f
29 changed files with 1195 additions and 23 deletions

View File

@@ -17,6 +17,7 @@ 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";
@@ -36,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");

View File

@@ -7,6 +7,8 @@
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,6 +23,7 @@ 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)
@@ -69,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
@@ -144,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") {
@@ -243,6 +293,9 @@ 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>
)}

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

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

@@ -19,6 +19,8 @@ import {
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "../utils/feeds-persistence"
import { useDownloadStore } from "./download"
import { DownloadStatus } from "../types/episode"
/** Max episodes to load per page/chunk */
const MAX_EPISODES_REFRESH = 50
@@ -186,10 +188,32 @@ 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 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) =>
@@ -198,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 */
@@ -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 {
// State
feeds,
@@ -386,6 +423,7 @@ export function createFeedStore() {
removeSource,
toggleSource,
updateSource,
setAutoDownload,
}
}

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

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

View File

@@ -42,3 +42,31 @@ export async function ensureConfigDir(): Promise<string> {
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,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

@@ -110,6 +110,14 @@ export type AppEvents = {
"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

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
}