Repository navigation
fix(whep): unhide player audio controls; expose Location cross-origin - #811
Conversation
|
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. |
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>
bc6f2e6 to
ea1e65c
Compare
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
left a comment
There was a problem hiding this comment.
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,onVideoTrackadds 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 readLocation/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 usingresolveResourceUrl. - #883 is the follow-up that would give the player fixes a guard.
- #879's
lib.rschanges are elsewhere increate_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
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.htmlstarts 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
Locationfrom other origins. The WHIP proxy setsAccess-Control-Expose-Headers: Location, Link, Accept-Patch, ETagand the WHEP proxy setsLocation, Link. The app-wideCorsLayerthen replaces that header with its own list, which only namedmcp-session-id, on any request carrying anOrigin. (tower-http extends the response headers withHeaderMap::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 includeIf-Match, which a WHIP PATCH uses to send the ETag back. The layer answers preflights before the handlers see them.Strom's pages resolved
Locationagainst the wrong server. The proxies return the resource as a path.whep.jspassed it tofetch()as is andwhip.jsprefixed 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 sharedresolveResourceUrlinwebrtc.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./whep/program(video + 1 audio):#audioControlsdisplay: none<video>unmuted, playing; speech peak −0.01 dBFS on its track/whep/p1-return(audio only) after click<audio>unmuted, playing; peak −1.5 dBFSLocation=nullLocation=/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.htmlandwhep.jsand 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
backend/tests/cors_expose_headers_test.rssends 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 thelib.rschange reverted (exposed: "mcp-session-id").whip_patch_preflight_allows_if_matchin the same file sends a cross-origin PATCH preflight withIf-Match. It fails withheader::IF_MATCHremoved (allowed: "content-type,authorization,accept,cookie,mcp-session-id").backend/static/tests/resource_url.test.js(runs in CI's Static JS job) coversresolveResourceUrl. 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.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.cargo test --workspace.<video>used to be forced muted; it can now be unmuted when a reconnect callsplay()outside a click, which a stricter autoplay policy could reject, leaving the video paused. Chrome allowed it.🤖 Generated with Claude Code