mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(player): force audio lock on Claude desktop via UA fallback
The Claude desktop Electron client appears to strip the `audio-locked` custom-element attribute before it reaches the DOM, so chat-host audio still plays even though Claude web (which preserves the attribute) correctly mutes. Verified via DevTools: web renders `<hyperframes-player audio-locked>` and is silent; desktop omits the attribute and plays sound. Self-impose the same restriction when `navigator.userAgent` matches the Claude desktop UA (Claude/<ver> + Electron). Internally route everything through a new `_isAudioLocked()` helper — attribute OR host fallback — and apply the lock from `connectedCallback` since `attributeChangedCallback` never fires when the attribute is missing. The public `audioLocked` property still reflects only the attribute, so external consumers (e.g. pacific widget mirroring state) are unaffected by the safety net. Tests: 6 new (forces mute on Claude desktop UA, re-asserts on unmute, hides controls, no-op for regular browsers, no-op for non-Claude Electron apps, public property remains attribute-only). Player suite green: 132 tests. Refs: pacific #28773, experiment-framework #38809. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,14 @@ When a composition uses `@hyperframes/shader-transitions`, the player can own pr
|
||||
|
||||
`shader-loading="player"` shows the player-owned transition-prep overlay from shader progress messages. `composition` leaves direct composition fallback behavior alone, and `none` suppresses the loader.
|
||||
|
||||
### Audio lock (host-mandated silent playback)
|
||||
|
||||
`audio-locked` forces `muted` on and hides the volume controls, with no UI path for the viewer to turn sound back on. Use it when embedding in a chat host (Claude.ai, ChatGPT, etc.) where audio must stay off regardless of viewer intent. Setting `muted` directly is _not_ enough — viewers can flip it back via the controls bar.
|
||||
|
||||
Removing `audio-locked` only unhides the controls; it does **not** auto-unmute. Callers manage `muted` explicitly after unlocking.
|
||||
|
||||
**Host-environment fallback.** Some host renderers — notably the Claude desktop Electron client — strip unknown custom-element attributes before they reach the DOM, defeating the attribute. As a safety net, the player also self-imposes the lock when it detects such an environment via `navigator.userAgent`, so audio stays muted even if the attribute never arrives. The public `audioLocked` property still reflects only the attribute, so external consumers (e.g. host widgets that mirror state) are not affected by the fallback.
|
||||
|
||||
### Mobile audio
|
||||
|
||||
Mobile browsers block `audio.play()` inside iframes when the user gesture happened in the parent frame (the [User Activation spec](https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation) does not propagate activation across frame boundaries via `postMessage`).
|
||||
|
||||
@@ -1591,6 +1591,107 @@ describe("HyperframesPlayer audio lock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => {
|
||||
// Some host renderers (observed on the Claude desktop Electron client) strip
|
||||
// unknown custom-element attributes before they reach the DOM, so the
|
||||
// `audio-locked` attribute is lost. The player self-imposes the lock based
|
||||
// on UA detection so chat-host audio stays muted even without the attribute.
|
||||
let player: HTMLElement & { muted: boolean; audioLocked: boolean };
|
||||
let originalUserAgent: PropertyDescriptor | undefined;
|
||||
|
||||
function stubUserAgent(ua: string) {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
value: ua,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
originalUserAgent = Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(navigator),
|
||||
"userAgent",
|
||||
);
|
||||
await import("./hyperframes-player.js");
|
||||
player = document.createElement("hyperframes-player") as typeof player;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUserAgent) {
|
||||
Object.defineProperty(Object.getPrototypeOf(navigator), "userAgent", originalUserAgent);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("forces muted on Claude desktop UA even without the audio-locked attribute", () => {
|
||||
stubUserAgent(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
|
||||
);
|
||||
|
||||
document.body.appendChild(player);
|
||||
|
||||
expect(player.hasAttribute("audio-locked")).toBe(false);
|
||||
expect(player.muted).toBe(true);
|
||||
expect(player.hasAttribute("muted")).toBe(true);
|
||||
});
|
||||
|
||||
it("re-asserts mute on Claude desktop when something tries to unmute", () => {
|
||||
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
|
||||
document.body.appendChild(player);
|
||||
|
||||
player.muted = false;
|
||||
expect(player.hasAttribute("muted")).toBe(true);
|
||||
|
||||
player.removeAttribute("muted");
|
||||
expect(player.hasAttribute("muted")).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the volume controls on Claude desktop without the attribute", () => {
|
||||
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
|
||||
player.setAttribute("controls", "");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
|
||||
expect(volumeWrap.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("does NOT force mute on a regular browser UA", () => {
|
||||
stubUserAgent(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
);
|
||||
|
||||
document.body.appendChild(player);
|
||||
|
||||
expect(player.hasAttribute("audio-locked")).toBe(false);
|
||||
expect(player.muted).toBe(false);
|
||||
expect(player.hasAttribute("muted")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT force mute on Electron apps that aren't Claude desktop", () => {
|
||||
// Other Electron clients (e.g. VS Code embedded view) shouldn't be muted.
|
||||
stubUserAgent(
|
||||
"Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
|
||||
);
|
||||
|
||||
document.body.appendChild(player);
|
||||
|
||||
expect(player.muted).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps `audioLocked` property reflecting only the attribute, not the UA fallback", () => {
|
||||
// External consumers (pacific widget, etc.) read `audioLocked` to mirror
|
||||
// their own state. The UA fallback is an internal safety net and must not
|
||||
// leak into the public property — otherwise unsetting `audioLocked` would
|
||||
// appear to have no effect from the consumer's perspective.
|
||||
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
|
||||
document.body.appendChild(player);
|
||||
|
||||
expect(player.audioLocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Playback rate ──
|
||||
|
||||
describe("HyperframesPlayer playback rate", () => {
|
||||
|
||||
@@ -143,6 +143,13 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc")!);
|
||||
if (this.hasAttribute("src"))
|
||||
this.iframe.src = prepareSrcForElement(this, this.getAttribute("src")!);
|
||||
|
||||
// Host-environment audio lock: when the embedding host (e.g. Claude
|
||||
// desktop) drops the `audio-locked` attribute, attributeChangedCallback
|
||||
// never fires for it, so apply the lock here based on UA detection.
|
||||
if (!this.hasAttribute("audio-locked") && this._isLockedHostEnvironment()) {
|
||||
this._applyAudioLock(true);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -349,13 +356,32 @@ class HyperframesPlayer extends HTMLElement {
|
||||
else this.removeAttribute("audio-locked");
|
||||
}
|
||||
|
||||
/**
|
||||
* Host renderers that strip unknown custom-element attributes before they
|
||||
* reach the DOM (observed on the Claude desktop Electron client) can defeat
|
||||
* `audio-locked` even when the host *intends* to lock audio. When we detect
|
||||
* such an environment, self-impose the same restriction the attribute would
|
||||
* apply. Web (browser) hosts preserve the attribute and don't need this.
|
||||
*/
|
||||
private _isLockedHostEnvironment(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent || "";
|
||||
// Claude desktop ships as an Electron app with a "Claude/<version>" UA token.
|
||||
return /\bClaude\/\d/.test(ua) && /\bElectron\b/.test(ua);
|
||||
}
|
||||
|
||||
/** True when audio playback must be locked: attribute OR host fallback. */
|
||||
private _isAudioLocked(): boolean {
|
||||
return this.hasAttribute("audio-locked") || this._isLockedHostEnvironment();
|
||||
}
|
||||
|
||||
/** 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")) {
|
||||
if (val === null && this._isAudioLocked()) {
|
||||
this.setAttribute("muted", "");
|
||||
return;
|
||||
}
|
||||
@@ -561,7 +587,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
onMuteToggle: () => void (this.muted = !this.muted),
|
||||
onVolumeChange: (v) => void (this.volume = v),
|
||||
},
|
||||
this.audioLocked,
|
||||
this._isAudioLocked(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user