mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* 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>
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { installRuntimeControlBridge } from "./bridge";
|
|
|
|
function createMockDeps() {
|
|
return {
|
|
onPlay: vi.fn(),
|
|
onPause: vi.fn(),
|
|
onSeek: vi.fn(),
|
|
onSetMuted: vi.fn(),
|
|
onSetVolume: vi.fn(),
|
|
onSetMediaOutputMuted: vi.fn(),
|
|
onSetPlaybackRate: vi.fn(),
|
|
onEnablePickMode: vi.fn(),
|
|
onDisablePickMode: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function makeControlMessage(action: string, extra?: Record<string, unknown>) {
|
|
return new MessageEvent("message", {
|
|
data: { source: "hf-parent", type: "control", action, ...extra },
|
|
});
|
|
}
|
|
|
|
describe("installRuntimeControlBridge", () => {
|
|
it("dispatches play command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("play"));
|
|
expect(deps.onPlay).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("dispatches pause command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("pause"));
|
|
expect(deps.onPause).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("dispatches seek command with frame and mode", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("seek", { frame: 150, seekMode: "drag" }));
|
|
expect(deps.onSeek).toHaveBeenCalledWith(150, "drag");
|
|
});
|
|
|
|
it("seek defaults frame to 0 and seekMode to commit", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("seek"));
|
|
expect(deps.onSeek).toHaveBeenCalledWith(0, "commit");
|
|
});
|
|
|
|
it("dispatches set-muted command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-muted", { muted: true }));
|
|
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);
|
|
handler(makeControlMessage("set-media-output-muted", { muted: true }));
|
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(true);
|
|
handler(makeControlMessage("set-media-output-muted", { muted: false }));
|
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("set-media-output-muted coerces absent flag to false", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-media-output-muted"));
|
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("dispatches set-playback-rate command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-playback-rate", { playbackRate: 2 }));
|
|
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(2);
|
|
});
|
|
|
|
it("defaults playbackRate to 1", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-playback-rate"));
|
|
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("dispatches enable-pick-mode", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("enable-pick-mode"));
|
|
expect(deps.onEnablePickMode).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("dispatches disable-pick-mode", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("disable-pick-mode"));
|
|
expect(deps.onDisablePickMode).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("ignores messages from wrong source", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(
|
|
new MessageEvent("message", {
|
|
data: { source: "other", type: "control", action: "play" },
|
|
}),
|
|
);
|
|
expect(deps.onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages with wrong type", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(
|
|
new MessageEvent("message", {
|
|
data: { source: "hf-parent", type: "state", action: "play" },
|
|
}),
|
|
);
|
|
expect(deps.onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores null data", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(new MessageEvent("message", { data: null }));
|
|
expect(deps.onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles flash-elements command without crashing", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
expect(() =>
|
|
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 })),
|
|
).not.toThrow();
|
|
});
|
|
});
|