audio playback fixes

This commit is contained in:
2026-08-07 19:28:57 -04:00
parent 52e9ae0ab7
commit 91a831c5f9
8 changed files with 102 additions and 630 deletions

View File

@@ -88,7 +88,7 @@ function ensureBackend(): AudioBackend {
// ── Process-exit teardown ─────────────────────────────────────────────
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
// player (mpv/ffplay/afplay). Without this hook those child processes
// player (mpv). Without this hook those child processes
// survive the host and keep playing audio after the TUI has quit. The
// `exit` event fires synchronously on `process.exit(N)`; the signal
// handlers cover Ctrl-C / kill, which otherwise terminate without running

View File

@@ -5,43 +5,43 @@
* 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.
* Volume and speed are app-level settings — adjustable with or without
* an episode loaded (they apply to the next playback and persist). Seek
* is playback-dependent, so it still requires a loaded episode.
*/
import { useKeyboard } from "@opentui/solid"
import { emit } from "../utils/event-bus"
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"
| "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.
}
// 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
/** 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;
}
/**
@@ -49,50 +49,47 @@ export interface MultimediaKeysOptions {
* 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
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
// 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
// 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
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 "up":
emit("media.volumeUp", {});
break;
case "down":
if (!options.hasEpisode?.()) return
emit("media.volumeDown", {})
break
case "down":
emit("media.volumeDown", {});
break;
case "left":
if (!options.hasEpisode?.()) return
emit("media.seekBackward", {})
break
case "left":
if (!options.hasEpisode?.()) return;
emit("media.seekBackward", {});
break;
case "right":
if (!options.hasEpisode?.()) return
emit("media.seekForward", {})
break
case "right":
if (!options.hasEpisode?.()) return;
emit("media.seekForward", {});
break;
case "s":
if (!options.hasEpisode?.()) return
emit("media.speedCycle", {})
break
case "s":
emit("media.speedCycle", {});
break;
default:
// Not a media key — do nothing
break
}
})
default:
// Not a media key — do nothing
break;
}
});
}