mulitmedia pass, downloads
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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 }
|
||||
"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
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
|
||||
}
|
||||
Reference in New Issue
Block a user