feat(player): add audio-locked attribute (force-mute + hide controls) (#1234)

This commit is contained in:
xiayewang-heygen
2026-06-06 00:17:03 -04:00
committed by GitHub
parent bb9dbfdebd
commit f39b5988cc
5 changed files with 159 additions and 5 deletions
+2
View File
@@ -60,6 +60,7 @@ Show a static image before playback starts:
| `height` | number | 1080 | Composition height in pixels (aspect ratio) |
| `controls` | boolean | false | Show play/pause, scrubber, and time display |
| `muted` | boolean | false | Mute audio playback |
| `audio-locked` | boolean | false | Force-mute and hide the volume controls so the viewer cannot turn sound on |
| `poster` | string | — | Image URL shown before playback starts |
| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) |
| `autoplay` | boolean | false | Start playing when ready |
@@ -114,6 +115,7 @@ player.paused; // boolean (read-only)
player.ready; // boolean (read-only)
player.playbackRate; // number (read/write)
player.muted; // boolean (read/write)
player.audioLocked; // boolean (read/write) — force-mute + hide volume controls
player.loop; // boolean (read/write)
player.shaderCaptureScale; // number (read/write)
player.shaderLoading; // "composition" | "player" | "none" (read/write)
+5 -1
View File
@@ -20,6 +20,7 @@ export function setupControls(
volume: number,
speedPresetsAttr: string | null,
callbacks: ControlsCallbacks,
audioLocked = false,
): ReturnType<typeof createControls> {
const speedPresets = speedPresetsAttr
? speedPresetsAttr
@@ -27,7 +28,10 @@ export function setupControls(
.map(Number)
.filter((n) => !isNaN(n) && n > 0)
: undefined;
const options: ControlsOptions = speedPresets ? { speedPresets } : {};
const options: ControlsOptions = {
...(speedPresets ? { speedPresets } : {}),
audioLocked,
};
const api = createControls(parent, callbacks, options);
api.updateMuted(muted);
api.updateVolume(volume);
+17
View File
@@ -21,6 +21,14 @@ export const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
export interface ControlsOptions {
/** Speed presets shown in the menu. Defaults to SPEED_PRESETS. */
speedPresets?: readonly number[];
/**
* When true, the volume controls (mute button + volume slider) are hidden so
* the viewer cannot change the audio state. Backs the `audio-locked`
* attribute on `<hyperframes-player>`, which enforces host-mandated silent
* playback (e.g. a HyperFrames project embedded in a chat host). Toggleable
* at runtime via the returned `setVolumeControlsHidden`.
*/
audioLocked?: boolean;
}
export function formatSpeed(speed: number): string {
@@ -48,6 +56,7 @@ export function createControls(
updateSpeed: (speed: number) => void;
updateMuted: (muted: boolean) => void;
updateVolume: (volume: number) => void;
setVolumeControlsHidden: (hidden: boolean) => void;
show: () => void;
hide: () => void;
destroy: () => void;
@@ -133,6 +142,11 @@ export function createControls(
volumeWrap.appendChild(volumeSliderWrap);
volumeWrap.appendChild(muteBtn);
// Audio-locked: hide the whole volume control (mute toggle + slider) so the
// viewer has no UI path to turn sound on. The player still mutes the media
// independently via the `muted` attribute; this only removes the controls.
if (options.audioLocked) volumeWrap.style.display = "none";
controls.appendChild(playBtn);
controls.appendChild(scrubber);
controls.appendChild(time);
@@ -356,6 +370,9 @@ export function createControls(
volumeSlider.setAttribute("aria-valuenow", String(Math.round(volume * 100)));
muteBtn.innerHTML = getVolumeIcon(isMuted, volume);
},
setVolumeControlsHidden(hidden: boolean) {
volumeWrap.style.display = hidden ? "none" : "";
},
show() {
controls.style.display = "";
},
@@ -1497,6 +1497,100 @@ describe("HyperframesPlayer volume and mute", () => {
});
});
// ── Audio lock ──
describe("HyperframesPlayer audio lock", () => {
let player: HTMLElement & { muted: boolean; audioLocked: boolean };
beforeEach(async () => {
await import("./hyperframes-player.js");
player = document.createElement("hyperframes-player") as typeof player;
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
it("audioLocked property toggles the audio-locked attribute", () => {
document.body.appendChild(player);
player.audioLocked = true;
expect(player.hasAttribute("audio-locked")).toBe(true);
player.audioLocked = false;
expect(player.hasAttribute("audio-locked")).toBe(false);
});
it("forces muted when audio-locked is set", () => {
document.body.appendChild(player);
expect(player.hasAttribute("muted")).toBe(false);
player.setAttribute("audio-locked", "");
expect(player.muted).toBe(true);
expect(player.hasAttribute("muted")).toBe(true);
});
it("re-asserts mute when something tries to unmute while locked", () => {
document.body.appendChild(player);
player.setAttribute("audio-locked", "");
// Direct property unmute
player.muted = false;
expect(player.hasAttribute("muted")).toBe(true);
// Raw attribute removal
player.removeAttribute("muted");
expect(player.hasAttribute("muted")).toBe(true);
});
it("allows unmute again once unlocked", () => {
document.body.appendChild(player);
player.setAttribute("audio-locked", "");
expect(player.muted).toBe(true);
// Unlock does NOT auto-unmute — it only lifts the restriction.
player.removeAttribute("audio-locked");
expect(player.muted).toBe(true);
// Now the viewer/host can unmute.
player.muted = false;
expect(player.hasAttribute("muted")).toBe(false);
});
it("hides the volume controls when locked after controls exist", () => {
player.setAttribute("controls", "");
document.body.appendChild(player);
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
expect(volumeWrap.style.display).not.toBe("none");
player.setAttribute("audio-locked", "");
expect(volumeWrap.style.display).toBe("none");
});
it("hides the volume controls when controls are created while already locked", () => {
player.setAttribute("audio-locked", "");
player.setAttribute("controls", "");
document.body.appendChild(player);
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
expect(volumeWrap.style.display).toBe("none");
});
it("restores the volume controls when unlocked", () => {
player.setAttribute("controls", "");
player.setAttribute("audio-locked", "");
document.body.appendChild(player);
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
expect(volumeWrap.style.display).toBe("none");
player.removeAttribute("audio-locked");
expect(volumeWrap.style.display).not.toBe("none");
});
});
// ── Playback rate ──
describe("HyperframesPlayer playback rate", () => {
+41 -4
View File
@@ -42,6 +42,7 @@ class HyperframesPlayer extends HTMLElement {
"height",
"controls",
"muted",
"audio-locked",
"volume",
"poster",
"playback-rate",
@@ -198,10 +199,10 @@ class HyperframesPlayer extends HTMLElement {
break;
}
case "muted":
this._media.updateMuted(val !== null);
this._sendControl("set-muted", { muted: val !== null });
this.controlsApi?.updateMuted(val !== null);
this.dispatchEvent(new Event("volumechange"));
this._handleMutedChange(val);
break;
case "audio-locked":
this._applyAudioLock(val !== null);
break;
case "volume": {
const v = Math.max(0, Math.min(1, parseFloat(val || "1")));
@@ -340,6 +341,41 @@ class HyperframesPlayer extends HTMLElement {
else this.removeAttribute("muted");
}
get audioLocked() {
return this.hasAttribute("audio-locked");
}
set audioLocked(locked: boolean) {
if (locked) this.setAttribute("audio-locked", "");
else this.removeAttribute("audio-locked");
}
/** Apply a change to the `muted` attribute: re-assert under an audio lock,
* else mute/unmute the media, sync the controls, and fire `volumechange`. */
private _handleMutedChange(val: string | null): void {
// While audio is locked, ignore any attempt to clear `muted` (host control,
// stray script, raw `removeAttribute`) and re-assert it. The re-set fires
// this callback again with val="" (not null) so it mutes normally — no loop.
if (val === null && this.hasAttribute("audio-locked")) {
this.setAttribute("muted", "");
return;
}
this._media.updateMuted(val !== null);
this._sendControl("set-muted", { muted: val !== null });
this.controlsApi?.updateMuted(val !== null);
this.dispatchEvent(new Event("volumechange"));
}
/**
* Host-mandated silent playback (e.g. embedded in a chat host): force mute
* and hide the volume controls so the viewer cannot turn sound on. Unlocking
* only unhides the controls — it does not auto-unmute; callers manage `muted`
* explicitly after unlocking.
*/
private _applyAudioLock(locked: boolean): void {
if (locked) this.muted = true;
this.controlsApi?.setVolumeControlsHidden(locked);
}
get volume() {
return this._volume;
}
@@ -525,6 +561,7 @@ class HyperframesPlayer extends HTMLElement {
onMuteToggle: () => void (this.muted = !this.muted),
onVolumeChange: (v) => void (this.volume = v),
},
this.audioLocked,
);
}