Skip to content

fix(whep): unhide player audio controls; expose Location cross-origin - #811

Merged
srperens merged 5 commits into
Eyevinn:mainfrom
wagenet:wagenet/whep-player-audio-controls
Oct 2, 2026
Merged

srperens merged 5 commits into
Eyevinn:mainfrom
wagenet:wagenet/whep-player-audio-controls

Conversation

@wagenet

@wagenet wagenet commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Written by Claude (AI), on behalf of @wagenet.

Description

Fixes for the WHEP player and for WHIP/WHEP clients on another origin.

Player starts silent with no way to unmute. player.html starts both media elements muted, but it only showed the mute and volume controls for audio-only or multi-audio streams. A stream with video and one audio track, such as a normal program feed, played silently and offered no control. The controls now show whenever the stream has audio.

Showing them was not enough on its own. For that stream shape the page plays audio from the <video> element and clears <audio>.srcObject, so the existing mute button toggled an element with no track on it. Mute and volume now apply to both elements and are reapplied whenever a source is attached. The page also follows mute and volume changes made through the <video> element's native controls, so the two never disagree.

With two or more video tracks and one audio track, the page gives <video> a stream holding only the selected video track, yet still cleared <audio>.srcObject, so the audio track was on neither element. With the controls shown, that stream would offer a mute button that does nothing. <audio> now keeps its source unless the stream has exactly one video track.

The CORS layer hides Location from other origins. The WHIP proxy sets Access-Control-Expose-Headers: Location, Link, Accept-Patch, ETag and the WHEP proxy sets Location, Link. The app-wide CorsLayer then replaces that header with its own list, which only named mcp-session-id, on any request carrying an Origin. (tower-http extends the response headers with HeaderMap::extend, which overwrites.) A client on another origin could create a session but could not read the resource URL it needs to end it. The layer's list now includes all four, and its allowed request headers now include If-Match, which a WHIP PATCH uses to send the ETag back. The layer answers preflights before the handlers see them.

Strom's pages resolved Location against the wrong server. The proxies return the resource as a path. whep.js passed it to fetch() as is and whip.js prefixed the page's origin, so a player or ingest page pointed at an endpoint on another server sent its DELETE to its own server. Both now resolve it against the endpoint URL, through a shared resolveResourceUrl in webrtc.js.

Evidence

Real Chrome (headless, --autoplay-policy=user-gesture-required) against a five-seat rig with synthetic speaking publishers; the controls were clicked with Playwright, a trusted input.

before after
/whep/program (video + 1 audio): #audioControls display: none visible
after clicking mute button (no button) <video> unmuted, playing; speech peak −0.01 dBFS on its track
/whep/p1-return (audio only) after click — <audio> unmuted, playing; peak −1.5 dBFS
WHEP POST from another origin 201, Location = null 201, Location = /whep/program/resource/…

The rig checks above ran on a build of the fork's integration branch, because the rig depends on other open PRs; that build does not include the multi-video fix. The page at this head was checked with a headless Chromium harness that loads the real player.html and whep.js and answers the offer with real WebRTC senders. In nine stream shapes (video + 1 audio, audio only, 2 audio + video, 2 video + 1 audio, reconnect, native mute, slider values), exactly one element is audible after a trusted click on mute; the 2 video + 1 audio shapes have none without the multi-video fix.

Tests

  • New: backend/tests/cors_expose_headers_test.rs sends a cross-origin POST to /whip/… and /whep/… through the real router and asserts all four headers are exposed. It passes with the fix, and fails with the lib.rs change reverted (exposed: "mcp-session-id").
  • New: whip_patch_preflight_allows_if_match in the same file sends a cross-origin PATCH preflight with If-Match. It fails with header::IF_MATCH removed (allowed: "content-type,authorization,accept,cookie,mcp-session-id").
  • New: backend/static/tests/resource_url.test.js (runs in CI's Static JS job) covers resolveResourceUrl. Resolving against the page instead fails it with the page's host in place of the endpoint's. In the browser harness, Disconnect on a player pointed at another host now sends the DELETE to that host.
  • Ran on this branch: node --test backend/static/tests/*.test.js, cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --test cors_expose_headers_test --test openapi_test --test api_tests --test api_not_found_test --test mcp_endpoint_test.
  • Not run: the full cargo test --workspace.
  • No automated guard for the player fixes. The page is DOM-driven inline script, CI's static JS job only runs Node unit tests, and the harness above needs Playwright and Chromium, which CI does not install. The browser checks above are the evidence.
  • Not tested in Safari or Firefox: WebDriver is disabled in Safari's settings on the test machine, and only Chromium is installed for Playwright. <video> used to be forced muted; it can now be unmuted when a reconnect calls play() outside a click, which a stricter autoplay policy could reject, leaving the video paused. Chrome allowed it.

🤖 Generated with Claude Code

@srperens srperens mentioned this pull request Sep 14, 2026
@srperens

Copy link
Copy Markdown
Collaborator

Noted as a draft, so I am holding off on a full review until it is marked ready for review. If you want one before then, say so here or request me as a reviewer.

wagenet and others added 2 commits September 25, 2026 21:49
Both media elements start muted, and the mute and volume controls were only
shown for audio-only or multi-audio streams. A stream with video and one
audio track therefore played silently with no control to unmute it.

Revealing the controls is not enough on its own. For that stream shape the
audio plays from the video element, and the audio element's source is
cleared, so the mute button toggled an element with nothing on it. Mute and
volume now apply to both elements and are reapplied whenever a source is
attached. Changes made through the video element's native controls are
followed so the two do not disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The WHIP and WHEP proxies set Access-Control-Expose-Headers to
"Location, Link, Accept-Patch, ETag", but the app-wide CorsLayer replaces
that header with its own list, which named only mcp-session-id, on every
request carrying an Origin. A client on another origin could create a WHIP
or WHEP session but not read the Location it needs to end it.

The layer's list now carries the proxies' headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wagenet
wagenet force-pushed the wagenet/whep-player-audio-controls branch from bc6f2e6 to ea1e65c Compare September 26, 2026 04:52
wagenet and others added 3 commits September 26, 2026 07:09
With several video tracks the video element gets a stream holding only the
selected video track, but the media-status handler still cleared the audio
element's source whenever video was present. The one audio track then sat on
neither element, so the player stayed silent and the mute button did nothing.
The audio element now keeps its source unless the stream has exactly one video
track, which is the case where audio is carried on the video element.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The WHIP and WHEP proxies return the session resource as a path. whep.js
passed it to fetch() as is and whip.js prefixed the page's origin, so a page
on one server talking to an endpoint on another sent its DELETE to its own
server and left the real session running. Both now resolve the Location
against the URL the offer was POSTed to.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A WHIP PATCH sends the session's ETag back in If-Match. The app-wide CORS
layer answers preflights before the WHIP handlers see them, and its allowed
headers did not include If-Match, so a client on another origin could read
the ETag but never send it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve. The three fixes are independent and correct, the CORS change and the URL resolution are guarded by tests that ran in this repo's CI, and the player fixes have no guard, as the body says outright. #883 tracks that gap.

Claims

Claim Verdict Evidence
The app-wide CORS layer now exposes what the proxies set CONFIRMED backend/src/lib.rs:516 — header::LOCATION, plus LINK, accept-patch, ETAG. This is a superset of backend/src/api/whep_player.rs:579 — .header(header::ACCESS_CONTROL_EXPOSE_HEADERS, "Location, Link");. whip_post_exposes_proxy_headers_cross_origin ... ok and whep_post_exposes_proxy_headers_cross_origin ... ok in Check (Linux) job 108422755828
tower-http overwrites a handler's Access-Control-Expose-Headers EXTERNAL Assumption: CorsLayer 0.6/0.7 writes its own value over the handler's. The fix is correct either way, because the layer's list now contains the proxies' list
Every page that loads whep.js/whip.js defines resolveResourceUrl first CONFIRMED player.html, streams.html, vision-mixer.html and ingest.html each put <script src="/static/webrtc.js"> directly before it, e.g. backend/static/whep/player.html:102 — <script src="/static/webrtc.js"></script>.
Location resolves against the endpoint, not the page CONFIRMED backend/static/webrtc/webrtc.js:158 — return new URL(location, new URL(endpoint, pageUrl)).href;, used at backend/static/whep/whep.js:413 and backend/static/whip/whip.js:365. Four resource_url subtests pass in Static JS Tests
Audio controls show for every stream with audio CONFIRMED backend/static/whep/player.html:459 — setElementClass('audioControls', 'active', hasAudio);. There is no automated guard; the Playwright harness run by hand is the evidence, and the body says so, as CLAUDE.md's Tests rules require

Diagnosis. Coverage is ABSOLUTE for the three causes named.

  • The mute fix works because applyEarState() writes both elements. The previous toggle wrote only <audio>, which has no track for the video + one-audio shape.
  • backend/static/whep/player.html:461 — if (hasVideo && !multiAudio && !multiVideo) { is what keeps the multi-video audio playing.
  • SPECULATIVE (not verified): on a reconnect after the listener has unmuted, onVideoTrack adds the audio track to <video> before the stream-info callback clears <audio>. For that interval both elements are unmuted on the same track. This would be at most a brief overlap.

Radius. SHARED.

  • The CORS change covers every route, but it only widens lists. With the default allow_origin(Any), any origin can now read Location/ETag. That hands out a session's resource URL only to the client that created the session.
  • The JS change reaches all four WebRTC pages, including the vision mixer's preview.
  • The Safari/Firefox autoplay concern the body names is EXTERNAL. Assumption: the persistent <video> element keeps its user-activation grant across a reconnect.

Overlaps

  • #885 (DELETE lost when Disconnect is clicked during the POST) will edit the same this.resourceUrl = … line in both clients. It is independent in behaviour, and its fix should keep using resolveResourceUrl.
  • #883 is the follow-up that would give the player fixes a guard.
  • #879's lib.rs changes are elsewhere in create_app_with_config. They do not conflict.

Tests & CI. At 31dead1, Check (Linux), both Build (Linux) jobs, WASM, API Contract Check and Static JS Tests (12/12) are green, and all three cors_expose_headers_test cases executed. macOS and Windows show skipping, which is fine here because nothing in the diff is platform-specific.

Confidence: HIGH

@srperens
srperens merged commit b5eac5a into Eyevinn:main Oct 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants