diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-26 15:09:20 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-26 15:09:20 +0200 |
| commit | db7f81fd08742847f3ebea061c75530b7b31b934 (patch) | |
| tree | 14b308686860eea0a9c12c47c39f8a68a1bba376 /packages/meshbay-hub/src/meshbay_hub/static/video-player.js | |
| parent | 7126fd3c265ba75d77b449bfd0f83f5f3e584b74 (diff) | |
| parent | 59d9f50bf41b2b38b7c95f8da52b698dff923c2d (diff) | |
| download | meshbay-db7f81fd08742847f3ebea061c75530b7b31b934.tar.gz | |
Merge branch 'debug/webrtc-lock-resume'
WebRTC transport dies silently after an extended mobile screen lock
(confirmed live via client-side trace + node logs): ICE goes
disconnected -> failed within ~10s of each other on both ends, but the
DataChannel's readyState stays "open" throughout, so nothing failed fast —
every request just sat out its own timeout, matching the reported symptom
(poster spinners, blocked chat, dead new streams, stuck music).
- Automatic reconnect on WebRTC "failed": capped exponential backoff,
redoes the full signaling handshake, wakes immediately on
visibilitychange instead of waiting out a throttled backoff timer.
- Fixed two real bugs the reconnect work exposed: the signaling POST to
the hub kept using the token captured at construction, never the fresh
one fetched per reconnect attempt (401 loop, no possible recovery); and
connect() re-armed a diagnostic listener/interval on every attempt
without disposing of the previous one.
- pipelinedDownload retries a lost chunk instead of aborting the whole
transfer — covers Files downloads, video poster/thumbnail fetches, and
music-player.js's blob-based track download.
- music-player.js: don't throw "Transport not connected" while a
reconnect is already landing (waitForReconnect); prefetch depth now
adapts to network type (5 tracks ahead on Wi-Fi, 3 on cellular or
unrecognized — Firefox/Safari included, where the detection API is
simply absent).
- video-player.js: onReconnected reissues the existing seek-to-current-time
path, so a mid-stream reconnect looks like an ordinary seek rather than
a dead player; holds a Screen Wake Lock unconditionally while open.
- New opt-in (off by default) user preference: keep the screen on during
audio playback, for whoever wants to trade battery for sidestepping the
screen-lock gap entirely — off by default because the ordinary
expectation (matching Spotify/Deezer) is that the phone locks on its
own while listening.
- hub: /app and / now serve Cache-Control: no-store — the SPA shell had no
cache header at all, so a browser that cached it heuristically could
keep re-serving an old build (old ASSET_V, old JS) through any number of
reloads or pull-to-refreshes.
Verified against real production use across many rounds (demo groups,
actual mobile screen-lock testing) rather than synthetic reproduction
alone. 430 hub tests + 650 node tests passing throughout.
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-player.js | 62 |
1 files changed, 62 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 82e1116..0c0c6c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -164,6 +164,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const castDeviceRef = useRef(null); const castRestartGenRef = useRef(0); const landingPlayheadRef = useRef(false); + // The current Screen Wake Lock sentinel, if the browser granted one — see + // the effect below. Null on any platform/context that does not support it, + // which playback has never depended on. + const wakeLockRef = useRef(null); /** * The buffered range the playhead is actually in, or null. @@ -529,6 +533,24 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { setPhase('error'); }; + // The old stream died with the connection (the node retires it the + // moment its session goes away — see webrtc_server.py's + // on_state_change), so there is nothing to resume on the wire, only a + // reason to ask again. requestSeek already knows how to land a new + // stream_init on the live SourceBuffer without resetting playback — + // exactly what dragging the scrubber does — so reusing it here means a + // screen-lock reconnect looks like a seek to where the film already + // was, not a reload. + transport.onReconnected = () => { + if (cancelled) return; + const v = videoRef.current; + const seek = requestSeekRef.current; + if (!v || !seek) return; + console.log('[MeshBay] transport reconnected — resuming stream at', + v.currentTime.toFixed(1)); + seek(v.currentTime); + }; + transport.onStreamInit = (msg) => { if (cancelled) return; if (msg.file_id && msg.file_id !== entry.id) return; @@ -762,11 +784,49 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { if (t && t.connected) t.stopStream(); }; const onPageHide = () => leave('pagehide'); + + // Screen Wake Lock: keeps the display on while this page is open and + // visible, purely so the phone stops auto-locking mid-film on its own + // idle timer — the commonest real-world trigger for the WebRTC-drop + // recovery above, and the one case it can sidestep entirely rather than + // recover from. Unrelated to streaming/transport in every direction: + // requesting, holding, or losing this lock touches no DataChannel, no + // SourceBuffer, no playback state, so it cannot itself cause a stall or + // a regression in the existing pipeline. It also does nothing at all on + // a phone the user locks with the power button, or once the tab is + // backgrounded (the spec releases it automatically) — the reconnect path + // above is still the one that has to handle those. + const releaseWakeLock = () => { + const wl = wakeLockRef.current; + wakeLockRef.current = null; + if (wl) { try { wl.release(); } catch { /* already released */ } } + }; + const acquireWakeLock = async () => { + if (!('wakeLock' in navigator)) return; + try { + const wl = await navigator.wakeLock.request('screen'); + // The effect may have torn down while this was in flight. + if (cancelled) { try { wl.release(); } catch { /* ignore */ } return; } + wakeLockRef.current = wl; + wl.addEventListener('release', () => { wakeLockRef.current = null; }); + } catch (e) { + // Battery saver, no permission, an insecure context — playback has + // never depended on this, so there is nothing to fall back to. + console.warn('[MeshBay] Wake lock request failed:', e.message); + } + }; + acquireWakeLock(); + // NOT wired to stopStream. Android fires visibilitychange when a video goes // fullscreen, so cutting the stream here killed the film the moment it was // watched properly. Logged only, until that is confirmed or ruled out. const onVisibility = () => { console.log('[MeshBay] visibilitychange:', document.visibilityState); + // The lock is released automatically the moment the page goes hidden + // (spec behaviour, not something to undo) — re-requesting it here is + // what makes it hold again once the film is actually back on screen, + // including the fullscreen transition this handler already exists for. + if (document.visibilityState === 'visible') acquireWakeLock(); }; window.addEventListener('pagehide', onPageHide); document.addEventListener('visibilitychange', onVisibility); @@ -820,6 +880,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { clearInterval(pumpTimer); clearInterval(diagTimer); clearTimeout(seekTimerRef.current); + releaseWakeLock(); // Closing the player is the commonest way to stop watching, so this is // the write that matters most. if (videoRef.current) { @@ -849,6 +910,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { transport.onStreamData = null; transport.onStreamEnd = null; transport.onStreamError = null; + transport.onReconnected = null; } // The queue can hold several megabytes of decrypted video. queueRef.current = []; |