Skip to content

Give a view-only share guest a real player, native WebRTC first, and name the camera in the bar - #379

Merged
widgetii merged 2 commits into
masterfrom
share-player
Oct 2, 2026
Merged

widgetii merged 2 commits into
masterfrom
share-player

Conversation

@widgetii

@widgetii widgetii commented Oct 2, 2026

Copy link
Copy Markdown
Member

The player. A view-only share link used to show the camera's MJPEG in a bare <img>: no sound, no snapshot, no full screen, and the heaviest way to move a 4K picture. The share page now has its own player, static/player.js. It tries the same three ways the camera's own WebUI player does, in the same order:

  1. Native WebRTC. Signalling goes over /ws/webrtc, carried by the tunnel. The media gets its own peer connection straight to the camera, using the share's ICE servers. That gives sub-second latency and the browser's hardware decoder, and keeps heavy traffic off the tunnel's data channel.
  2. MSE over /ws/video, through the tunnel, when WebRTC will not play: a codec this browser's WebRTC does not offer, or a camera whose media slots are taken (it answers busy).
  3. The camera's MJPEG, with the reason shown to the guest.

Sound is asked for on demand, and renegotiated on whichever of the three is playing. Snapshot downloads /image.jpg, and Full screen works.

The camera's name. The camera now names itself in WELCOME (the camera field). The bar and the tab title show it, so a guest with links to two cameras can tell them apart. Without the field the bar still reads "Shared camera".

Checks

  • service/run.sh test ./internal/sharerelay/... passes. The relay itself is unchanged; it serves the new file like the others, under the versioned path.
  • End to end on a Hi3516AV300: a local openipc serve --role share, a camera build pointed at it, guests in headless Chromium, and the owner watching Live throughout.
    • H.264: native WebRTC plays 4K in real time. Sound renegotiates, Snapshot downloads, and the bar and title name the camera.
    • H.264, media slots full: a second guest's player gets busy and falls back to MSE at 4K.
    • H.265 (headless Chromium has no HEVC in WebRTC or MSE): WebRTC is refused, MSE cannot decode, and the player ends on MJPEG with the explanation shown.
    • End: ending each link brings up the guest's "The owner ended this link."
  • At phone width (390 px) nothing scrolls sideways, and the controls are 36 px tall.

Merging deploys nothing. Per deploy/DEV-VALIDATION.md, it should be validated on dev before production.

…name the camera in the bar

A view-only link got the camera's MJPEG in a bare <img>: no sound, no
snapshot, no full screen, and the heaviest way to move a 4K picture.

It now walks the same chain as the camera's own WebUI player:

1. WebRTC: signalling over /ws/webrtc through the tunnel, media on its own
   peer connection straight to the camera with the share's ICE servers --
   sub-second latency, the hardware decoder, and nothing heavy on the
   tunnel's data channel.
2. MSE over /ws/video through the tunnel, when WebRTC will not: a codec this
   browser's WebRTC does not offer, a camera whose media slots are taken
   ("busy"), a path the media cannot cross.
3. The camera's MJPEG, with the reason shown.

With sound on request (renegotiated on whichever rung is playing), a snapshot
that downloads, and full screen.

The camera names itself in WELCOME now; the bar and the tab title show it,
so a guest with links to two cameras can tell them apart.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add a WebRTC-first player for view-only camera shares

✨ Enhancement 🕐 40+ Minutes

Grey Divider

AI Description

• Replace view-only MJPEG with WebRTC-first playback, falling back to MSE and then MJPEG.
• Add on-demand sound, snapshot downloads, full screen, and explanations when playback falls back.
• Show the camera name from WELCOME in the share bar and tab title when available.
Diagram

graph TD
  Shell["Share shell"] --> Player["Guest player"] --> RTC["Native WebRTC"] -->|fallback| MSE["MSE playback"] -->|fallback| MJPEG["MJPEG playback"]
  RTC -->|signalling| Tunnel["Share tunnel"] --> Camera["Camera"]
  MSE -->|video stream| Tunnel
  MJPEG -->|image requests| Tunnel
  Camera -->|direct media| RTC
  Tunnel -->|WELCOME| Shell
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Extract a shared WebUI playback core
  • ➕ Keeps playback behavior consistent between the camera WebUI and share guests.
  • ➕ Reduces future duplication of codec and fallback handling.
  • ➖ Requires a broader WebUI refactor and an interface for the share tunnel.
  • ➖ Expands the regression surface beyond view-only sharing.

Recommendation: Keep the dedicated share player for this PR: the view-only page cannot use the camera WebUI player directly, and the scoped change avoids refactoring owner playback. Consider a shared core later if the two players begin to drift.

Files changed (3) +445 / -5

Enhancement (3) +445 / -5
index.htmlStyle the share player and add a camera-name target +9/-2

Style the share player and add a camera-name target

• Adds video, fallback-note, and control styling for the view-only player, including wrapped controls and hidden-element handling. Gives the header camera label an ID so it can display the name supplied at connection time.

service/internal/sharerelay/static/index.html

player.jsImplement the view-only guest player +423/-0

Implement the view-only guest player

• Adds WebRTC-first playback with MSE and MJPEG fallbacks, connection and decoder recovery, and playback cleanup. Provides on-demand sound, snapshot downloads, full screen, and guest-facing fallback messages.

service/internal/sharerelay/static/player.js

shell.jsMount and close the player and display the camera name +13/-3

Mount and close the player and display the camera name

• Loads the new player for view-only shares, passes it the tunneled WebSocket adapter and ICE servers, and closes it when the share ends. Uses the WELCOME camera field for the header and tab title when present, retaining the existing generic text otherwise.

service/internal/sharerelay/static/shell.js

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. An ended link can reopen a blank player ✓ Resolved
Description
main() awaits the player module after registering tunnel.onclose, but does not check whether the
tunnel closed before calling mount(). If the link ends during that import, the close handler shows
the end notice and then mount() replaces it and tries to open media sockets on a closed tunnel.
Code

service/internal/sharerelay/static/shell.js[R278-279]

+      const { mount } = await import('./player.js');
+      player = mount($('main'), {
Evidence
The close handler only closes an already-assigned player, while the new await delays its assignment.
mount() replaces the main content, and opening a stream on a closed tunnel throws.

service/internal/sharerelay/static/shell.js[257-265]
service/internal/sharerelay/static/shell.js[277-282]
service/internal/sharerelay/static/player.js[79-81]
service/internal/sharerelay/static/tunnel.js[412-415]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A link can close while the player module loads, allowing the player to replace the ended-link notice.
## Fix Focus Areas
- service/internal/sharerelay/static/shell.js[257-282]
## Recommended Fix
After the import resolves, check whether the tunnel has closed before mounting. Leave the close handler's notice intact and do not start a player on a closed tunnel.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Some camera candidates never reach playback ✓ Resolved
Description
webrtc() forwards each candidate directly to addIceCandidate() and silently discards a
rejection. A candidate delivered before the asynchronous answer has set the remote description is
lost rather than retried, which can leave the media peer without a usable path.
Code

service/internal/sharerelay/static/player.js[R205-206]

+        } else if (m.reply === 'candidate') {
+          peer.addIceCandidate({ candidate: m.data, sdpMid: m.mid }).catch(() => {});
Evidence
The answer is applied asynchronously, but the independently delivered candidate is submitted
immediately and its error is swallowed. The relay forwards answer and candidate replies as separate
messages.

service/internal/sharerelay/static/player.js[195-206]
service/internal/sharerelay/relay.go[23-35]
service/internal/sharerelay/relay.go[387-400]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A remote ICE candidate can arrive before the answer has been applied and is then silently discarded.
## Fix Focus Areas
- service/internal/sharerelay/static/player.js[195-206]
## Recommended Fix
Queue candidates until setRemoteDescription succeeds, then add them in order. Handle candidate failures without silently losing the only usable path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Candidates without a media ID are lost ✓ Resolved
Description
webrtc() passes m.mid to addIceCandidate() without supplying a media-line identifier when that
field is absent. The relay omits empty mid values, so such a candidate is rejected and its error
is swallowed instead of being associated with the offered video line.
Code

service/internal/sharerelay/static/player.js[R205-206]

+        } else if (m.reply === 'candidate') {
+          peer.addIceCandidate({ candidate: m.data, sdpMid: m.mid }).catch(() => {});
Evidence
The relay conditionally includes mid, and the existing tunnel connection explicitly defaults it to
0; the new player does neither.

service/internal/sharerelay/relay.go[387-400]
service/internal/sharerelay/static/tunnel.js[255-263]
service/internal/sharerelay/static/player.js[205-206]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The relay can deliver a candidate without `mid`, but the new player submits it without a media-line identifier.
## Fix Focus Areas
- service/internal/sharerelay/static/player.js[205-206]
## Recommended Fix
Use the appropriate offered media-line identifier when `m.mid` is absent, as the existing tunnel connection does, and retain explicit handling for candidate failures.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Turning on sound can remove the picture ✓ Resolved
Description
webrtc() assigns video.srcObject anew for every ontrack event, creating a single-track stream
when the event has no associated stream. If video and audio arrive in separate streams or without a
shared stream, the later audio event replaces the video source with audio alone.
Code

service/internal/sharerelay/static/player.js[R162-165]

+    peer.ontrack = (ev) => {
+      if (my !== attempt) return;
+      video.srcObject = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]);
+      video.muted = !wantAudio;
Evidence
The player requests separate video and optional audio transceivers, but each track event overwrites
the element's source; the fallback stream contains only the event's track.

service/internal/sharerelay/static/player.js[153-166]
service/internal/sharerelay/static/player.js[383-387]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A later audio track event can replace the video element's video-only source with an audio-only source.
## Fix Focus Areas
- service/internal/sharerelay/static/player.js[153-166]
## Recommended Fix
Maintain a shared MediaStream for each peer attempt and add received video and audio tracks to it, rather than replacing `srcObject` on every track event.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (2)
5. Repeated video drops never reach fallback ✓ Resolved
Description
mseOpen() resets reconnects on every init message, even before that connection has played a
frame. If the camera repeatedly sends an init and then closes the video socket, each close counts as
the first failure, so the retry limit never reaches MJPEG.
Code

service/internal/sharerelay/static/player.js[338]

+          if (info && info.type === 'init') { started = true; reconnects = 0; onInit(info); }
Evidence
An init resets the counter to zero; the subsequent close increments it to one and schedules another
attempt, making the RECONNECT_MAX branch unreachable for repeated init-then-close cycles.

service/internal/sharerelay/static/player.js[338-338]
service/internal/sharerelay/static/player.js[348-357]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Receiving an init message clears the reconnect count even when the new connection immediately fails.
## Fix Focus Areas
- service/internal/sharerelay/static/player.js[330-357]
## Recommended Fix
Count consecutive connections that close before playback, resetting the failure count only after playback has succeeded or the connection has remained stable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. A stale video callback can break fallback ✓ Resolved
Description
onInit() installs a sourceopen callback that uses the mutable ms field rather than the
MediaSource that emitted the event. If another init replaces the source before the first opens, the
old callback can add a buffer to the replacement source or call mjpeg() after the old attempt has
ended.
Code

service/internal/sharerelay/static/player.js[R307-309]

+    ms.addEventListener('sourceopen', () => {
+      try {
+        sb = ms.addSourceBuffer(mime);
Evidence
dropSource() clears the current source without invalidating its listener; a subsequent init
assigns a different source to ms, which the earlier listener dereferences when it runs.

service/internal/sharerelay/static/player.js[107-115]
service/internal/sharerelay/static/player.js[300-318]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A delayed `sourceopen` callback can act on a newer MediaSource or a stopped playback attempt.
## Fix Focus Areas
- service/internal/sharerelay/static/player.js[107-115]
- service/internal/sharerelay/static/player.js[300-318]
## Recommended Fix
Capture the MediaSource and attempt in the callback, and return unless both are still current. Use the captured source when adding its SourceBuffer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread service/internal/sharerelay/static/shell.js
Comment thread service/internal/sharerelay/static/player.js Outdated
Comment thread service/internal/sharerelay/static/player.js Outdated
Comment thread service/internal/sharerelay/static/player.js
Comment thread service/internal/sharerelay/static/player.js Outdated
Comment thread service/internal/sharerelay/static/player.js Outdated
…d harden the player's fallbacks

- Candidates that arrive before the camera's answer are held until it is in,
  rather than refused and lost; one without a media ID goes to the video line.
- Every track joins one stream, so sound arriving apart from the picture no
  longer replaces it.
- The MSE retry count resets when video plays, not on an init the camera may
  send before dropping again, so repeated drops do reach the MJPEG floor.
- A MediaSource's sourceopen acts only for that source and attempt.
- A link that ends while the player loads is not covered by a blank player.
@widgetii
widgetii merged commit cc1f36f into master Oct 2, 2026
2 checks passed
@widgetii
widgetii deleted the share-player branch October 2, 2026 14:48
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.

1 participant