mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(player): add volume/mute controls (#651)
* feat(player): add volume/mute controls to the player Adds a mute toggle button and volume slider to the controls bar, positioned between the time display and speed selector. The slider expands on hover for a compact default footprint. - `volume` attribute/property (0–1, clamped) with `volumechange` event - `muted` attribute now syncs to the controls UI (icon updates) - Three volume icons: high, low, muted — updates reactively - Volume forwarded to parent-frame audio proxies and iframe runtime via `set-volume` postMessage control - 9 new tests covering volume clamping, events, controls rendering, mute toggle, and iframe message forwarding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(player): wire set-volume through runtime bridge + address review feedback Addresses the blocker from PR review: the iframe runtime now handles `set-volume` messages end-to-end (types → bridge → init → media sync). Runtime side: - Add `set-volume` to RuntimeBridgeControlAction union - Add `volume` field to RuntimeBridgeControlMessage - Handle `set-volume` in bridge.ts with [0,1] clamping - Store bridgeVolume in RuntimeState, apply to media elements - syncRuntimeMedia composes userVolume × clip author volume Player side: - Muted toggle now dispatches `volumechange` (HTML5 spec compliance) - Volume slider auto-unmutes when scrubbed above 0 while muted - Touch support on volume slider (touchstart/move/end) Tests: 5 new (3 bridge, 2 media) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(player): add ARIA keyboard controls, fix icon collision and clipVolume parity - Volume slider: role="slider", aria-label, aria-valuemin/max/now, tabindex=0, arrow key support (5% steps, auto-unmutes) - Volume=0 unmuted now shows low-volume icon instead of muted icon - Fix clipVolume divergence: init.ts uses Number.isFinite() matching media.ts semantics (preserves data-volume="0") - 3 new tests: ARIA attributes, volumechange on mute, icon collision Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
614f764bcd
commit
a7a6648852
@@ -7,6 +7,7 @@ function createMockDeps() {
|
||||
onPause: vi.fn(),
|
||||
onSeek: vi.fn(),
|
||||
onSetMuted: vi.fn(),
|
||||
onSetVolume: vi.fn(),
|
||||
onSetMediaOutputMuted: vi.fn(),
|
||||
onSetPlaybackRate: vi.fn(),
|
||||
onEnablePickMode: vi.fn(),
|
||||
@@ -56,6 +57,29 @@ describe("installRuntimeControlBridge", () => {
|
||||
expect(deps.onSetMuted).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("dispatches set-volume command", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(makeControlMessage("set-volume", { volume: 0.5 }));
|
||||
expect(deps.onSetVolume).toHaveBeenCalledWith(0.5);
|
||||
});
|
||||
|
||||
it("clamps set-volume to [0, 1]", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(makeControlMessage("set-volume", { volume: 1.5 }));
|
||||
expect(deps.onSetVolume).toHaveBeenCalledWith(1);
|
||||
handler(makeControlMessage("set-volume", { volume: -0.5 }));
|
||||
expect(deps.onSetVolume).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("defaults volume to 1 when absent", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(makeControlMessage("set-volume"));
|
||||
expect(deps.onSetVolume).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("dispatches set-media-output-muted command", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
|
||||
@@ -5,6 +5,7 @@ type BridgeDeps = {
|
||||
onPause: () => void;
|
||||
onSeek: (frame: number, seekMode: "drag" | "commit") => void;
|
||||
onSetMuted: (muted: boolean) => void;
|
||||
onSetVolume: (volume: number) => void;
|
||||
onSetMediaOutputMuted: (muted: boolean) => void;
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onEnablePickMode: () => void;
|
||||
@@ -40,6 +41,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
|
||||
deps.onSetMuted(Boolean(data.muted));
|
||||
return;
|
||||
}
|
||||
if (action === "set-volume") {
|
||||
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1))));
|
||||
return;
|
||||
}
|
||||
if (action === "set-media-output-muted") {
|
||||
deps.onSetMediaOutputMuted(Boolean(data.muted));
|
||||
return;
|
||||
|
||||
@@ -1296,6 +1296,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
playbackRate: state.playbackRate,
|
||||
outputMuted: state.mediaOutputMuted,
|
||||
userMuted: state.bridgeMuted,
|
||||
userVolume: state.bridgeVolume,
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
@@ -1555,6 +1556,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
el.muted = effective;
|
||||
}
|
||||
},
|
||||
onSetVolume: (volume) => {
|
||||
state.bridgeVolume = volume;
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
const parsed = parseFloat(el.dataset.volume ?? "");
|
||||
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
|
||||
el.volume = clipVolume * volume;
|
||||
}
|
||||
},
|
||||
onSetMediaOutputMuted: (muted) => {
|
||||
state.mediaOutputMuted = muted;
|
||||
const effective = muted || state.bridgeMuted;
|
||||
|
||||
@@ -319,6 +319,30 @@ describe("syncRuntimeMedia", () => {
|
||||
expect(clip.el.volume).toBe(0.7);
|
||||
});
|
||||
|
||||
it("applies userVolume as a multiplier on clip volume", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
userVolume: 0.5,
|
||||
});
|
||||
expect(clip.el.volume).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
it("applies userVolume to clips without explicit volume (default 1)", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10 });
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 5,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
userVolume: 0.3,
|
||||
});
|
||||
expect(clip.el.volume).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it("hard-syncs on the first active tick (sub-composition activation, mediaStart offsets)", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
|
||||
@@ -118,6 +118,11 @@ export function syncRuntimeMedia(params: {
|
||||
* before the next bridge message lands.
|
||||
*/
|
||||
userMuted?: boolean;
|
||||
/**
|
||||
* User's volume preference (0–1, set via `onSetVolume`). Multiplied with the
|
||||
* per-clip author volume so `data-volume="0.5"` at user volume 0.8 yields 0.4.
|
||||
*/
|
||||
userVolume?: number;
|
||||
/**
|
||||
* Invoked at most once when a media element's `play()` promise rejects with
|
||||
* `NotAllowedError`. The caller is expected to latch and post a single
|
||||
@@ -142,7 +147,8 @@ export function syncRuntimeMedia(params: {
|
||||
relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength);
|
||||
}
|
||||
}
|
||||
if (clip.volume != null) el.volume = clip.volume;
|
||||
const userVol = params.userVolume ?? 1;
|
||||
el.volume = (clip.volume ?? 1) * userVol;
|
||||
if (shouldMute) el.muted = true;
|
||||
// Ensure full preload for every active media element. Streaming
|
||||
// formats (MP3) may arrive with preload="metadata", which only
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RuntimeState = {
|
||||
parityModeEnabled: boolean;
|
||||
canonicalFps: number;
|
||||
bridgeMuted: boolean;
|
||||
bridgeVolume: number;
|
||||
/**
|
||||
* Internal mute of audible media output, owned by the audio-ownership
|
||||
* protocol between the parent (`<hyperframes-player>`) and this runtime.
|
||||
@@ -78,6 +79,7 @@ export function createRuntimeState(): RuntimeState {
|
||||
parityModeEnabled: true,
|
||||
canonicalFps: 30,
|
||||
bridgeMuted: false,
|
||||
bridgeVolume: 1,
|
||||
mediaOutputMuted: false,
|
||||
mediaAutoplayBlockedPosted: false,
|
||||
playbackRate: 1,
|
||||
|
||||
@@ -11,6 +11,7 @@ export type RuntimeBridgeControlAction =
|
||||
| "pause"
|
||||
| "seek"
|
||||
| "set-muted"
|
||||
| "set-volume"
|
||||
| "set-media-output-muted"
|
||||
| "set-playback-rate"
|
||||
| "enable-pick-mode"
|
||||
@@ -23,6 +24,7 @@ export type RuntimeBridgeControlMessage = {
|
||||
action: RuntimeBridgeControlAction;
|
||||
frame?: number;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
playbackRate?: number;
|
||||
seekMode?: "drag" | "commit";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user