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:
xiaye
2026-06-09 10:04:49 -07:00
co-authored by Claude Opus 4.7
parent 3d7d7c0291
commit cc45b1fa33
3 changed files with 137 additions and 2 deletions
+8
View File
@@ -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", () => {
+28 -2
View File
@@ -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(),
);
}