Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion packages/player/src/controls-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ export function setupControls(
volume: number,
speedPresetsAttr: string | null,
callbacks: ControlsCallbacks,
audioLocked = false,
): ReturnType<typeof createControls> {
const speedPresets = speedPresetsAttr
? speedPresetsAttr
.split(",")
.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);
Expand Down
17 changes: 17 additions & 0 deletions packages/player/src/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = "";
},
Expand Down
94 changes: 94 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
45 changes: 41 additions & 4 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class HyperframesPlayer extends HTMLElement {
"height",
"controls",
"muted",
"audio-locked",
"volume",
"poster",
"playback-rate",
Expand Down Expand Up @@ -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")));
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -525,6 +561,7 @@ class HyperframesPlayer extends HTMLElement {
onMuteToggle: () => void (this.muted = !this.muted),
onVolumeChange: (v) => void (this.volume = v),
},
this.audioLocked,
);
}

Expand Down
Loading