mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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
@@ -1171,3 +1171,184 @@ describe("HyperframesPlayer srcdoc attribute", () => {
|
||||
player.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Volume / Mute controls ──
|
||||
|
||||
describe("HyperframesPlayer volume and mute", () => {
|
||||
let player: HTMLElement & {
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
iframeElement: HTMLIFrameElement;
|
||||
};
|
||||
let mockAudio: {
|
||||
preload: string;
|
||||
src: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
currentTime: number;
|
||||
load: ReturnType<typeof vi.fn>;
|
||||
play: ReturnType<typeof vi.fn>;
|
||||
pause: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./hyperframes-player.js");
|
||||
|
||||
mockAudio = {
|
||||
preload: "",
|
||||
src: "",
|
||||
muted: false,
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
currentTime: 0,
|
||||
load: vi.fn(),
|
||||
play: vi.fn().mockResolvedValue(undefined),
|
||||
pause: vi.fn(),
|
||||
};
|
||||
vi.spyOn(globalThis, "Audio").mockImplementation(
|
||||
() => mockAudio as unknown as HTMLAudioElement,
|
||||
);
|
||||
|
||||
player = document.createElement("hyperframes-player") as typeof player;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("defaults volume to 1", () => {
|
||||
document.body.appendChild(player);
|
||||
expect(player.volume).toBe(1);
|
||||
});
|
||||
|
||||
it("sets volume on parent media when audio-src is configured", () => {
|
||||
player.setAttribute("volume", "0.5");
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
expect(mockAudio.volume).toBe(0.5);
|
||||
});
|
||||
|
||||
it("updates parent media volume when volume attribute changes", () => {
|
||||
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
||||
document.body.appendChild(player);
|
||||
|
||||
player.setAttribute("volume", "0.3");
|
||||
expect(mockAudio.volume).toBe(0.3);
|
||||
});
|
||||
|
||||
it("clamps volume to [0, 1]", () => {
|
||||
document.body.appendChild(player);
|
||||
|
||||
player.volume = 1.5;
|
||||
expect(player.volume).toBe(1);
|
||||
|
||||
player.volume = -0.5;
|
||||
expect(player.volume).toBe(0);
|
||||
});
|
||||
|
||||
it("dispatches volumechange event when volume changes", () => {
|
||||
document.body.appendChild(player);
|
||||
|
||||
const handler = vi.fn();
|
||||
player.addEventListener("volumechange", handler);
|
||||
|
||||
player.setAttribute("volume", "0.7");
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("muted property toggles the muted attribute", () => {
|
||||
document.body.appendChild(player);
|
||||
|
||||
player.muted = true;
|
||||
expect(player.hasAttribute("muted")).toBe(true);
|
||||
|
||||
player.muted = false;
|
||||
expect(player.hasAttribute("muted")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends set-volume control to iframe", () => {
|
||||
document.body.appendChild(player);
|
||||
|
||||
const postMessageSpy = vi.fn();
|
||||
Object.defineProperty(player.iframeElement, "contentWindow", {
|
||||
value: { postMessage: postMessageSpy },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
player.setAttribute("volume", "0.6");
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-volume",
|
||||
volume: 0.6,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("controls bar shows mute button when controls are enabled", () => {
|
||||
player.setAttribute("controls", "");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const shadow = player.shadowRoot!;
|
||||
const muteBtn = shadow.querySelector(".hfp-mute-btn");
|
||||
expect(muteBtn).toBeTruthy();
|
||||
expect(muteBtn?.getAttribute("aria-label")).toBe("Mute");
|
||||
});
|
||||
|
||||
it("controls bar shows volume slider when controls are enabled", () => {
|
||||
player.setAttribute("controls", "");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const shadow = player.shadowRoot!;
|
||||
const slider = shadow.querySelector(".hfp-volume-slider");
|
||||
expect(slider).toBeTruthy();
|
||||
});
|
||||
|
||||
it("volume slider has ARIA slider attributes", () => {
|
||||
player.setAttribute("controls", "");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const shadow = player.shadowRoot!;
|
||||
const slider = shadow.querySelector(".hfp-volume-slider")!;
|
||||
expect(slider.getAttribute("role")).toBe("slider");
|
||||
expect(slider.getAttribute("aria-label")).toBe("Volume");
|
||||
expect(slider.getAttribute("aria-valuemin")).toBe("0");
|
||||
expect(slider.getAttribute("aria-valuemax")).toBe("100");
|
||||
expect(slider.getAttribute("aria-valuenow")).toBe("100");
|
||||
expect(slider.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("dispatches volumechange when muted toggles (HTML5 spec)", () => {
|
||||
document.body.appendChild(player);
|
||||
|
||||
const handler = vi.fn();
|
||||
player.addEventListener("volumechange", handler);
|
||||
|
||||
player.muted = true;
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
player.muted = false;
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("muted icon differs from volume=0 unmuted icon", () => {
|
||||
player.setAttribute("controls", "");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const shadow = player.shadowRoot!;
|
||||
const muteBtn = shadow.querySelector(".hfp-mute-btn")!;
|
||||
|
||||
player.setAttribute("volume", "0");
|
||||
const zeroVolumeHtml = muteBtn.innerHTML;
|
||||
|
||||
player.muted = true;
|
||||
const mutedHtml = muteBtn.innerHTML;
|
||||
|
||||
expect(zeroVolumeHtml).not.toBe(mutedHtml);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user