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:
Miguel Ángel
2026-05-07 00:38:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 614f764bcd
commit a7a6648852
11 changed files with 490 additions and 2 deletions
+138 -1
View File
@@ -1,10 +1,18 @@
import { PLAY_ICON, PAUSE_ICON } from "./styles.js";
import {
PLAY_ICON,
PAUSE_ICON,
VOLUME_HIGH_ICON,
VOLUME_LOW_ICON,
VOLUME_MUTED_ICON,
} from "./styles.js";
export interface ControlsCallbacks {
onPlay: () => void;
onPause: () => void;
onSeek: (fraction: number) => void;
onSpeedChange: (speed: number) => void;
onMuteToggle: () => void;
onVolumeChange: (volume: number) => void;
}
/** Default logarithmic speed presets — each step roughly doubles/halves. */
@@ -38,6 +46,8 @@ export function createControls(
updateTime: (current: number, duration: number) => void;
updatePlaying: (playing: boolean) => void;
updateSpeed: (speed: number) => void;
updateMuted: (muted: boolean) => void;
updateVolume: (volume: number) => void;
show: () => void;
hide: () => void;
destroy: () => void;
@@ -94,23 +104,135 @@ export function createControls(
speedWrap.appendChild(speedMenu);
speedWrap.appendChild(speedBtn);
const volumeWrap = document.createElement("div");
volumeWrap.className = "hfp-volume-wrap";
const muteBtn = document.createElement("button");
muteBtn.className = "hfp-mute-btn";
muteBtn.type = "button";
muteBtn.innerHTML = VOLUME_HIGH_ICON;
muteBtn.setAttribute("aria-label", "Mute");
const volumeSliderWrap = document.createElement("div");
volumeSliderWrap.className = "hfp-volume-slider-wrap";
const volumeSlider = document.createElement("div");
volumeSlider.className = "hfp-volume-slider";
volumeSlider.setAttribute("role", "slider");
volumeSlider.setAttribute("aria-label", "Volume");
volumeSlider.setAttribute("aria-valuemin", "0");
volumeSlider.setAttribute("aria-valuemax", "100");
volumeSlider.setAttribute("aria-valuenow", "100");
volumeSlider.tabIndex = 0;
const volumeFill = document.createElement("div");
volumeFill.className = "hfp-volume-fill";
volumeFill.style.width = "100%";
volumeSlider.appendChild(volumeFill);
volumeSliderWrap.appendChild(volumeSlider);
volumeWrap.appendChild(volumeSliderWrap);
volumeWrap.appendChild(muteBtn);
controls.appendChild(playBtn);
controls.appendChild(scrubber);
controls.appendChild(time);
controls.appendChild(volumeWrap);
controls.appendChild(speedWrap);
parent.appendChild(controls);
let isPlaying = false;
let isMuted = false;
let currentVolume = 1;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let speedIndex = presets.indexOf(1); // start at 1x
if (speedIndex === -1) speedIndex = 0;
const getVolumeIcon = (muted: boolean, volume: number): string => {
if (muted) return VOLUME_MUTED_ICON;
if (volume === 0) return VOLUME_LOW_ICON;
if (volume < 0.5) return VOLUME_LOW_ICON;
return VOLUME_HIGH_ICON;
};
playBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (isPlaying) callbacks.onPause();
else callbacks.onPlay();
});
muteBtn.addEventListener("click", (e) => {
e.stopPropagation();
callbacks.onMuteToggle();
});
let volumeScrubbing = false;
const handleVolumeAt = (clientX: number) => {
const rect = volumeSlider.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
currentVolume = fraction;
volumeFill.style.width = `${fraction * 100}%`;
volumeSlider.setAttribute("aria-valuenow", String(Math.round(fraction * 100)));
if (isMuted && fraction > 0) callbacks.onMuteToggle();
muteBtn.innerHTML = getVolumeIcon(isMuted, fraction);
callbacks.onVolumeChange(fraction);
};
volumeSlider.addEventListener("mousedown", (e) => {
e.stopPropagation();
volumeScrubbing = true;
handleVolumeAt(e.clientX);
});
const onVolumeMouseMove = (e: MouseEvent) => {
if (volumeScrubbing) handleVolumeAt(e.clientX);
};
const onVolumeMouseUp = () => {
volumeScrubbing = false;
};
document.addEventListener("mousemove", onVolumeMouseMove);
document.addEventListener("mouseup", onVolumeMouseUp);
volumeSlider.addEventListener(
"touchstart",
(e) => {
volumeScrubbing = true;
const touch = e.touches[0];
if (touch) handleVolumeAt(touch.clientX);
},
{ passive: true },
);
const onVolumeTouchMove = (e: TouchEvent) => {
if (volumeScrubbing) {
const touch = e.touches[0];
if (touch) handleVolumeAt(touch.clientX);
}
};
const onVolumeTouchEnd = () => {
volumeScrubbing = false;
};
document.addEventListener("touchmove", onVolumeTouchMove, { passive: true });
document.addEventListener("touchend", onVolumeTouchEnd);
const VOLUME_STEP = 0.05;
volumeSlider.addEventListener("keydown", (e) => {
let newVol = currentVolume;
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
newVol = Math.min(1, currentVolume + VOLUME_STEP);
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
newVol = Math.max(0, currentVolume - VOLUME_STEP);
} else {
return;
}
e.preventDefault();
e.stopPropagation();
currentVolume = newVol;
volumeFill.style.width = `${newVol * 100}%`;
volumeSlider.setAttribute("aria-valuenow", String(Math.round(newVol * 100)));
if (isMuted && newVol > 0) callbacks.onMuteToggle();
muteBtn.innerHTML = getVolumeIcon(isMuted, newVol);
callbacks.onVolumeChange(newVol);
});
const setActiveOption = (speed: number) => {
for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) {
opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed));
@@ -221,6 +343,17 @@ export function createControls(
speedBtn.textContent = formatSpeed(speed);
setActiveOption(speed);
},
updateMuted(muted: boolean) {
isMuted = muted;
muteBtn.innerHTML = getVolumeIcon(muted, currentVolume);
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
},
updateVolume(volume: number) {
currentVolume = volume;
volumeFill.style.width = `${volume * 100}%`;
volumeSlider.setAttribute("aria-valuenow", String(Math.round(volume * 100)));
muteBtn.innerHTML = getVolumeIcon(isMuted, volume);
},
show() {
controls.style.display = "";
},
@@ -232,6 +365,10 @@ export function createControls(
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
document.removeEventListener("mousemove", onVolumeMouseMove);
document.removeEventListener("mouseup", onVolumeMouseUp);
document.removeEventListener("touchmove", onVolumeTouchMove);
document.removeEventListener("touchend", onVolumeTouchEnd);
document.removeEventListener("click", onDocClick);
if (hideTimeout) clearTimeout(hideTimeout);
},
@@ -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);
});
});
+29
View File
@@ -138,6 +138,7 @@ class HyperframesPlayer extends HTMLElement {
"height",
"controls",
"muted",
"volume",
"poster",
"playback-rate",
"audio-src",
@@ -166,6 +167,7 @@ class HyperframesPlayer extends HTMLElement {
private _duration = 0;
private _currentTime = 0;
private _paused = true;
private _volume = 1;
private _compositionWidth = 1920;
private _compositionHeight = 1080;
private _probeInterval: ReturnType<typeof setInterval> | null = null;
@@ -362,7 +364,18 @@ class HyperframesPlayer extends HTMLElement {
case "muted":
for (const m of this._parentMedia) m.el.muted = val !== null;
this._sendControl("set-muted", { muted: val !== null });
this.controlsApi?.updateMuted(val !== null);
this.dispatchEvent(new Event("volumechange"));
break;
case "volume": {
const v = Math.max(0, Math.min(1, parseFloat(val || "1")));
this._volume = v;
for (const m of this._parentMedia) m.el.volume = v;
this._sendControl("set-volume", { volume: v });
this.controlsApi?.updateVolume(v);
this.dispatchEvent(new Event("volumechange"));
break;
}
case "audio-src":
if (val) this._setupParentAudioFromUrl(val);
break;
@@ -517,6 +530,13 @@ class HyperframesPlayer extends HTMLElement {
else this.removeAttribute("muted");
}
get volume() {
return this._volume;
}
set volume(v: number) {
this.setAttribute("volume", String(Math.max(0, Math.min(1, v))));
}
get loop() {
return this.hasAttribute("loop");
}
@@ -1061,6 +1081,12 @@ class HyperframesPlayer extends HTMLElement {
onSpeedChange: (speed) => {
this.playbackRate = speed;
},
onMuteToggle: () => {
this.muted = !this.muted;
},
onVolumeChange: (volume) => {
this.volume = volume;
},
};
const presetsAttr = this.getAttribute("speed-presets");
const speedPresets = presetsAttr
@@ -1070,6 +1096,8 @@ class HyperframesPlayer extends HTMLElement {
.filter((n) => !isNaN(n) && n > 0)
: undefined;
this.controlsApi = createControls(this.shadow, callbacks, { speedPresets });
this.controlsApi.updateMuted(this.muted);
this.controlsApi.updateVolume(this._volume);
}
private _setupPoster() {
@@ -1242,6 +1270,7 @@ class HyperframesPlayer extends HTMLElement {
el.src = src;
el.load();
el.muted = this.muted;
el.volume = this._volume;
if (this.playbackRate !== 1) el.playbackRate = this.playbackRate;
const entry = { el, start, duration, driftSamples: 0 };
+67
View File
@@ -350,10 +350,77 @@ export const PLAYER_STYLES = /* css */ `
color: var(--hfp-accent, #fff);
font-weight: 600;
}
.hfp-volume-wrap {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0;
}
.hfp-mute-btn {
background: none;
border: none;
color: var(--hfp-color, #fff);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.hfp-mute-btn:hover {
opacity: 0.8;
}
.hfp-mute-btn svg,
.hfp-mute-btn svg * {
pointer-events: none;
}
.hfp-volume-slider-wrap {
width: 0;
overflow: hidden;
transition: width 0.2s ease;
display: flex;
align-items: center;
}
.hfp-volume-wrap:hover .hfp-volume-slider-wrap {
width: 64px;
}
.hfp-volume-slider {
width: 56px;
height: var(--hfp-scrubber-height, 4px);
background: var(--hfp-scrubber-bg, rgba(255, 255, 255, 0.3));
border-radius: var(--hfp-scrubber-radius, 2px);
cursor: pointer;
position: relative;
margin-left: 4px;
margin-right: 4px;
}
.hfp-volume-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--hfp-accent, #fff);
border-radius: var(--hfp-scrubber-radius, 2px);
pointer-events: none;
}
`;
export const PLAY_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><polygon points="4,2 16,9 4,16"/></svg>`;
export const PAUSE_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><rect x="3" y="2" width="4" height="14"/><rect x="11" y="2" width="4" height="14"/></svg>`;
export const VOLUME_HIGH_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/><path d="M14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`;
export const VOLUME_LOW_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>`;
export const VOLUME_MUTED_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" opacity="0.3"/><line x1="18" y1="7" x2="14" y2="17" stroke="currentColor" stroke-width="2"/></svg>`;
/**
* Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the