From 4a594803fd1c97a00604f6d24306f50847ec57cd Mon Sep 17 00:00:00 2001 From: xiaye Date: Fri, 5 Jun 2026 20:33:48 -0700 Subject: [PATCH] feat(player): add audio-locked attribute (force-mute + hide controls) muted alone is user-toggleable via the controls bar. audio-locked is a hard lock for host-mandated silent playback (e.g. a HyperFrames project embedded in a chat host): it forces muted on, hides the volume controls (mute button and slider) so the viewer has no UI path to turn sound on, and re-asserts mute if anything clears the muted attribute while locked. Unlocking only lifts the restriction (unhides controls); it does not auto-unmute, so callers manage muted explicitly after unlocking. - new observed attribute audio-locked plus audioLocked JS property - createControls gains an audioLocked option and setVolumeControlsHidden - attribute handling extracted into helpers to keep complexity in check - README documents the attribute and property - 7 new tests; full player suite green, typecheck and oxlint clean Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/player/README.md | 2 + packages/player/src/controls-setup.ts | 6 +- packages/player/src/controls.ts | 17 ++++ .../player/src/hyperframes-player.test.ts | 94 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 45 ++++++++- 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/packages/player/README.md b/packages/player/README.md index 494dad99a..85c3c4fce 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -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) diff --git a/packages/player/src/controls-setup.ts b/packages/player/src/controls-setup.ts index daf8cfa6c..be051cc6e 100644 --- a/packages/player/src/controls-setup.ts +++ b/packages/player/src/controls-setup.ts @@ -20,6 +20,7 @@ export function setupControls( volume: number, speedPresetsAttr: string | null, callbacks: ControlsCallbacks, + audioLocked = false, ): ReturnType { 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); diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index 4889a2a07..7351cf0bd 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -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 ``, 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 = ""; }, diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 6fa8962cc..2e377b3ea 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -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", () => { diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 39f0d2901..a57c5a0ea 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -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, ); }