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

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