mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(player): add audio-locked attribute (force-mute + hide controls) (#1234)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user