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(),
|
onPause: vi.fn(),
|
||||||
onSeek: vi.fn(),
|
onSeek: vi.fn(),
|
||||||
onSetMuted: vi.fn(),
|
onSetMuted: vi.fn(),
|
||||||
|
onSetVolume: vi.fn(),
|
||||||
onSetMediaOutputMuted: vi.fn(),
|
onSetMediaOutputMuted: vi.fn(),
|
||||||
onSetPlaybackRate: vi.fn(),
|
onSetPlaybackRate: vi.fn(),
|
||||||
onEnablePickMode: vi.fn(),
|
onEnablePickMode: vi.fn(),
|
||||||
@@ -56,6 +57,29 @@ describe("installRuntimeControlBridge", () => {
|
|||||||
expect(deps.onSetMuted).toHaveBeenCalledWith(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", () => {
|
it("dispatches set-media-output-muted command", () => {
|
||||||
const deps = createMockDeps();
|
const deps = createMockDeps();
|
||||||
const handler = installRuntimeControlBridge(deps);
|
const handler = installRuntimeControlBridge(deps);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type BridgeDeps = {
|
|||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
onSeek: (frame: number, seekMode: "drag" | "commit") => void;
|
onSeek: (frame: number, seekMode: "drag" | "commit") => void;
|
||||||
onSetMuted: (muted: boolean) => void;
|
onSetMuted: (muted: boolean) => void;
|
||||||
|
onSetVolume: (volume: number) => void;
|
||||||
onSetMediaOutputMuted: (muted: boolean) => void;
|
onSetMediaOutputMuted: (muted: boolean) => void;
|
||||||
onSetPlaybackRate: (rate: number) => void;
|
onSetPlaybackRate: (rate: number) => void;
|
||||||
onEnablePickMode: () => void;
|
onEnablePickMode: () => void;
|
||||||
@@ -40,6 +41,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
|
|||||||
deps.onSetMuted(Boolean(data.muted));
|
deps.onSetMuted(Boolean(data.muted));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "set-volume") {
|
||||||
|
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "set-media-output-muted") {
|
if (action === "set-media-output-muted") {
|
||||||
deps.onSetMediaOutputMuted(Boolean(data.muted));
|
deps.onSetMediaOutputMuted(Boolean(data.muted));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1296,6 +1296,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
playbackRate: state.playbackRate,
|
playbackRate: state.playbackRate,
|
||||||
outputMuted: state.mediaOutputMuted,
|
outputMuted: state.mediaOutputMuted,
|
||||||
userMuted: state.bridgeMuted,
|
userMuted: state.bridgeMuted,
|
||||||
|
userVolume: state.bridgeVolume,
|
||||||
onAutoplayBlocked: () => {
|
onAutoplayBlocked: () => {
|
||||||
if (state.mediaAutoplayBlockedPosted) return;
|
if (state.mediaAutoplayBlockedPosted) return;
|
||||||
state.mediaAutoplayBlockedPosted = true;
|
state.mediaAutoplayBlockedPosted = true;
|
||||||
@@ -1555,6 +1556,16 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
el.muted = effective;
|
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) => {
|
onSetMediaOutputMuted: (muted) => {
|
||||||
state.mediaOutputMuted = muted;
|
state.mediaOutputMuted = muted;
|
||||||
const effective = muted || state.bridgeMuted;
|
const effective = muted || state.bridgeMuted;
|
||||||
|
|||||||
@@ -319,6 +319,30 @@ describe("syncRuntimeMedia", () => {
|
|||||||
expect(clip.el.volume).toBe(0.7);
|
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)", () => {
|
it("hard-syncs on the first active tick (sub-composition activation, mediaStart offsets)", () => {
|
||||||
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
||||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export function syncRuntimeMedia(params: {
|
|||||||
* before the next bridge message lands.
|
* before the next bridge message lands.
|
||||||
*/
|
*/
|
||||||
userMuted?: boolean;
|
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
|
* Invoked at most once when a media element's `play()` promise rejects with
|
||||||
* `NotAllowedError`. The caller is expected to latch and post a single
|
* `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);
|
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;
|
if (shouldMute) el.muted = true;
|
||||||
// Ensure full preload for every active media element. Streaming
|
// Ensure full preload for every active media element. Streaming
|
||||||
// formats (MP3) may arrive with preload="metadata", which only
|
// formats (MP3) may arrive with preload="metadata", which only
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type RuntimeState = {
|
|||||||
parityModeEnabled: boolean;
|
parityModeEnabled: boolean;
|
||||||
canonicalFps: number;
|
canonicalFps: number;
|
||||||
bridgeMuted: boolean;
|
bridgeMuted: boolean;
|
||||||
|
bridgeVolume: number;
|
||||||
/**
|
/**
|
||||||
* Internal mute of audible media output, owned by the audio-ownership
|
* Internal mute of audible media output, owned by the audio-ownership
|
||||||
* protocol between the parent (`<hyperframes-player>`) and this runtime.
|
* protocol between the parent (`<hyperframes-player>`) and this runtime.
|
||||||
@@ -78,6 +79,7 @@ export function createRuntimeState(): RuntimeState {
|
|||||||
parityModeEnabled: true,
|
parityModeEnabled: true,
|
||||||
canonicalFps: 30,
|
canonicalFps: 30,
|
||||||
bridgeMuted: false,
|
bridgeMuted: false,
|
||||||
|
bridgeVolume: 1,
|
||||||
mediaOutputMuted: false,
|
mediaOutputMuted: false,
|
||||||
mediaAutoplayBlockedPosted: false,
|
mediaAutoplayBlockedPosted: false,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type RuntimeBridgeControlAction =
|
|||||||
| "pause"
|
| "pause"
|
||||||
| "seek"
|
| "seek"
|
||||||
| "set-muted"
|
| "set-muted"
|
||||||
|
| "set-volume"
|
||||||
| "set-media-output-muted"
|
| "set-media-output-muted"
|
||||||
| "set-playback-rate"
|
| "set-playback-rate"
|
||||||
| "enable-pick-mode"
|
| "enable-pick-mode"
|
||||||
@@ -23,6 +24,7 @@ export type RuntimeBridgeControlMessage = {
|
|||||||
action: RuntimeBridgeControlAction;
|
action: RuntimeBridgeControlAction;
|
||||||
frame?: number;
|
frame?: number;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
|
volume?: number;
|
||||||
playbackRate?: number;
|
playbackRate?: number;
|
||||||
seekMode?: "drag" | "commit";
|
seekMode?: "drag" | "commit";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
export interface ControlsCallbacks {
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
onSeek: (fraction: number) => void;
|
onSeek: (fraction: number) => void;
|
||||||
onSpeedChange: (speed: number) => void;
|
onSpeedChange: (speed: number) => void;
|
||||||
|
onMuteToggle: () => void;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default logarithmic speed presets — each step roughly doubles/halves. */
|
/** Default logarithmic speed presets — each step roughly doubles/halves. */
|
||||||
@@ -38,6 +46,8 @@ export function createControls(
|
|||||||
updateTime: (current: number, duration: number) => void;
|
updateTime: (current: number, duration: number) => void;
|
||||||
updatePlaying: (playing: boolean) => void;
|
updatePlaying: (playing: boolean) => void;
|
||||||
updateSpeed: (speed: number) => void;
|
updateSpeed: (speed: number) => void;
|
||||||
|
updateMuted: (muted: boolean) => void;
|
||||||
|
updateVolume: (volume: number) => void;
|
||||||
show: () => void;
|
show: () => void;
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
@@ -94,23 +104,135 @@ export function createControls(
|
|||||||
speedWrap.appendChild(speedMenu);
|
speedWrap.appendChild(speedMenu);
|
||||||
speedWrap.appendChild(speedBtn);
|
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(playBtn);
|
||||||
controls.appendChild(scrubber);
|
controls.appendChild(scrubber);
|
||||||
controls.appendChild(time);
|
controls.appendChild(time);
|
||||||
|
controls.appendChild(volumeWrap);
|
||||||
controls.appendChild(speedWrap);
|
controls.appendChild(speedWrap);
|
||||||
parent.appendChild(controls);
|
parent.appendChild(controls);
|
||||||
|
|
||||||
let isPlaying = false;
|
let isPlaying = false;
|
||||||
|
let isMuted = false;
|
||||||
|
let currentVolume = 1;
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let speedIndex = presets.indexOf(1); // start at 1x
|
let speedIndex = presets.indexOf(1); // start at 1x
|
||||||
if (speedIndex === -1) speedIndex = 0;
|
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) => {
|
playBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isPlaying) callbacks.onPause();
|
if (isPlaying) callbacks.onPause();
|
||||||
else callbacks.onPlay();
|
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) => {
|
const setActiveOption = (speed: number) => {
|
||||||
for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) {
|
for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) {
|
||||||
opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed));
|
opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed));
|
||||||
@@ -221,6 +343,17 @@ export function createControls(
|
|||||||
speedBtn.textContent = formatSpeed(speed);
|
speedBtn.textContent = formatSpeed(speed);
|
||||||
setActiveOption(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() {
|
show() {
|
||||||
controls.style.display = "";
|
controls.style.display = "";
|
||||||
},
|
},
|
||||||
@@ -232,6 +365,10 @@ export function createControls(
|
|||||||
document.removeEventListener("mouseup", onMouseUp);
|
document.removeEventListener("mouseup", onMouseUp);
|
||||||
document.removeEventListener("touchmove", onTouchMove);
|
document.removeEventListener("touchmove", onTouchMove);
|
||||||
document.removeEventListener("touchend", onTouchEnd);
|
document.removeEventListener("touchend", onTouchEnd);
|
||||||
|
document.removeEventListener("mousemove", onVolumeMouseMove);
|
||||||
|
document.removeEventListener("mouseup", onVolumeMouseUp);
|
||||||
|
document.removeEventListener("touchmove", onVolumeTouchMove);
|
||||||
|
document.removeEventListener("touchend", onVolumeTouchEnd);
|
||||||
document.removeEventListener("click", onDocClick);
|
document.removeEventListener("click", onDocClick);
|
||||||
if (hideTimeout) clearTimeout(hideTimeout);
|
if (hideTimeout) clearTimeout(hideTimeout);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1171,3 +1171,184 @@ describe("HyperframesPlayer srcdoc attribute", () => {
|
|||||||
player.remove();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
"height",
|
"height",
|
||||||
"controls",
|
"controls",
|
||||||
"muted",
|
"muted",
|
||||||
|
"volume",
|
||||||
"poster",
|
"poster",
|
||||||
"playback-rate",
|
"playback-rate",
|
||||||
"audio-src",
|
"audio-src",
|
||||||
@@ -166,6 +167,7 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
private _duration = 0;
|
private _duration = 0;
|
||||||
private _currentTime = 0;
|
private _currentTime = 0;
|
||||||
private _paused = true;
|
private _paused = true;
|
||||||
|
private _volume = 1;
|
||||||
private _compositionWidth = 1920;
|
private _compositionWidth = 1920;
|
||||||
private _compositionHeight = 1080;
|
private _compositionHeight = 1080;
|
||||||
private _probeInterval: ReturnType<typeof setInterval> | null = null;
|
private _probeInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -362,7 +364,18 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
case "muted":
|
case "muted":
|
||||||
for (const m of this._parentMedia) m.el.muted = val !== null;
|
for (const m of this._parentMedia) m.el.muted = val !== null;
|
||||||
this._sendControl("set-muted", { muted: val !== null });
|
this._sendControl("set-muted", { muted: val !== null });
|
||||||
|
this.controlsApi?.updateMuted(val !== null);
|
||||||
|
this.dispatchEvent(new Event("volumechange"));
|
||||||
break;
|
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":
|
case "audio-src":
|
||||||
if (val) this._setupParentAudioFromUrl(val);
|
if (val) this._setupParentAudioFromUrl(val);
|
||||||
break;
|
break;
|
||||||
@@ -517,6 +530,13 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
else this.removeAttribute("muted");
|
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() {
|
get loop() {
|
||||||
return this.hasAttribute("loop");
|
return this.hasAttribute("loop");
|
||||||
}
|
}
|
||||||
@@ -1061,6 +1081,12 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
onSpeedChange: (speed) => {
|
onSpeedChange: (speed) => {
|
||||||
this.playbackRate = speed;
|
this.playbackRate = speed;
|
||||||
},
|
},
|
||||||
|
onMuteToggle: () => {
|
||||||
|
this.muted = !this.muted;
|
||||||
|
},
|
||||||
|
onVolumeChange: (volume) => {
|
||||||
|
this.volume = volume;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const presetsAttr = this.getAttribute("speed-presets");
|
const presetsAttr = this.getAttribute("speed-presets");
|
||||||
const speedPresets = presetsAttr
|
const speedPresets = presetsAttr
|
||||||
@@ -1070,6 +1096,8 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
.filter((n) => !isNaN(n) && n > 0)
|
.filter((n) => !isNaN(n) && n > 0)
|
||||||
: undefined;
|
: undefined;
|
||||||
this.controlsApi = createControls(this.shadow, callbacks, { speedPresets });
|
this.controlsApi = createControls(this.shadow, callbacks, { speedPresets });
|
||||||
|
this.controlsApi.updateMuted(this.muted);
|
||||||
|
this.controlsApi.updateVolume(this._volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setupPoster() {
|
private _setupPoster() {
|
||||||
@@ -1242,6 +1270,7 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
el.src = src;
|
el.src = src;
|
||||||
el.load();
|
el.load();
|
||||||
el.muted = this.muted;
|
el.muted = this.muted;
|
||||||
|
el.volume = this._volume;
|
||||||
if (this.playbackRate !== 1) el.playbackRate = this.playbackRate;
|
if (this.playbackRate !== 1) el.playbackRate = this.playbackRate;
|
||||||
|
|
||||||
const entry = { el, start, duration, driftSamples: 0 };
|
const entry = { el, start, duration, driftSamples: 0 };
|
||||||
|
|||||||
@@ -350,10 +350,77 @@ export const PLAYER_STYLES = /* css */ `
|
|||||||
color: var(--hfp-accent, #fff);
|
color: var(--hfp-accent, #fff);
|
||||||
font-weight: 600;
|
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 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 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
|
* Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the
|
||||||
|
|||||||
Reference in New Issue
Block a user