diff options
Diffstat (limited to 'packages')
19 files changed, 686 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index a50a2d7..b1489ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -536,6 +536,7 @@ async def update_profile( ALLOWED_PREF_KEYS = frozenset([ "notifications_disabled", "default_tab", + "music_keep_screen_on", ]) def _valid_pref_key(key: str) -> bool: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 8aff952..6a96602 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -60,19 +60,31 @@ def _asset_version() -> str: ASSET_V = _asset_version() +# No Cache-Control here meant no explicit signal either way, and a browser +# left to its own heuristics can decide this is fresh enough without asking +# — which nothing about a subsequent reload, pull-to-refresh included, +# is guaranteed to override. `{v}` only reaches the browser at all if this +# shell itself is refetched; a heuristically-cached copy of it re-serves the +# OLD hash and therefore the old JS forever, indistinguishable from a fix not +# working. `no-store` forces every navigation here to hit the network, which +# is the only way `{v}` can ever change what a browser holding an old page +# actually asks for next. +_NO_STORE = {"Cache-Control": "no-store"} + + @router.get("/app", response_class=HTMLResponse) async def app_root(): - return HTMLResponse(_HTML) + return HTMLResponse(_HTML, headers=_NO_STORE) @router.get("/app/{path:path}", response_class=HTMLResponse) async def app_catchall(path: str): - return HTMLResponse(_HTML) + return HTMLResponse(_HTML, headers=_NO_STORE) @router.get("/", response_class=HTMLResponse) async def index(): - return HTMLResponse(_HTML) + return HTMLResponse(_HTML, headers=_NO_STORE) _HTML = """\ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 5ec1ac8..446360c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1593,6 +1593,12 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); const [globalMute, setGlobalMute] = useState(false); const [defaultTab, setDefaultTab] = useState('chat'); + // Off by default (musicbay.md §2.2): the ordinary expectation, matching + // Spotify/Deezer, is that the phone locks on its own idle timer while + // listening. This is for whoever would rather trade battery for it — + // e.g. to ride out the WebRTC screen-lock reconnect gap without waiting + // on the automatic recovery at all. + const [keepScreenOnAudio, setKeepScreenOnAudio] = useState(false); const onLocaleChange = useCallback((e) => { const code = e.target.value; @@ -1610,10 +1616,29 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { .then(prefs => { if (prefs.notifications_disabled === 'true') setGlobalMute(true); if (prefs.default_tab) setDefaultTab(prefs.default_tab); + if (prefs.music_keep_screen_on === 'true') setKeepScreenOnAudio(true); }) .catch(() => {}); }, [user.token]); + const toggleKeepScreenOnAudio = useCallback(async () => { + const next = !keepScreenOnAudio; + setKeepScreenOnAudio(next); + try { + await hubFetch('/v1/users/me/preferences/music_keep_screen_on', { + method: 'PUT', token: user.token, + body: { value: next ? 'true' : 'false' }, + }); + // A string, matching what a fresh page load reads from the hub + // (prefs.music_keep_screen_on === 'true' above) — music-player.js + // compares against that same string, and userPrefs is one shared bag + // fed from both this immediate update and that load. + if (onPrefsChange) onPrefsChange({ music_keep_screen_on: next ? 'true' : 'false' }); + } catch (err) { + setKeepScreenOnAudio(!next); + } + }, [keepScreenOnAudio, user.token, onPrefsChange]); + const toggleGlobalMute = useCallback(async () => { const next = !globalMute; setGlobalMute(next); @@ -1802,6 +1827,14 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { </select> </div> <p class="settings-hint">${t('settings.default_tab_hint')}</p> + <div class="settings-row"> + <span class="settings-label">${t('settings.music_keep_screen_on')}</span> + <label class="settings-value" style="cursor:pointer"> + <input type="checkbox" checked=${keepScreenOnAudio} + onChange=${toggleKeepScreenOnAudio} /> + </label> + </div> + <p class="settings-hint">${t('settings.music_keep_screen_on_hint')}</p> </div> ${platform.isNative && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index b44d105..ba76ac9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -117,6 +117,41 @@ function _b64ToU8(b64) { return arr; } +// A dead transport (screen-lock WebRTC failure, see transport.js's +// _reconnectLoop) surfaces here as a rejected fetchChunk — TransportLostError +// when the pending request was killed outright, a plain timeout if it was +// still waiting when this ran. Either way the chunk itself was never the +// problem, and the file already on disk (writable has real bytes in it by +// now) is worth more than an all-or-nothing download: retry the same chunk +// instead of letting one bad moment abort the whole transfer. Each retry +// re-enters transport.fetchChunk, whose own _sendAndWait waits out an +// in-flight reconnect before trying again, so this loop is mostly just +// giving that reconnect the time and the attempts to land. +const CHUNK_RETRY_ATTEMPTS = 6; +const CHUNK_RETRY_DELAY_MS = 1500; + +function _isRetryableTransportError(err) { + return err.name === 'TransportLostError' + || err.message === 'Response timeout' + || (err.message || '').startsWith('DataChannel not open'); +} + +async function _fetchChunkResilient(transport, fileId, index) { + let lastErr; + for (let attempt = 0; attempt < CHUNK_RETRY_ATTEMPTS; attempt++) { + try { + return await transport.fetchChunk(fileId, index); + } catch (err) { + if (!_isRetryableTransportError(err)) throw err; + lastErr = err; + if (attempt < CHUNK_RETRY_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, CHUNK_RETRY_DELAY_MS)); + } + } + } + throw lastErr; +} + async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable, signal) { const results = writable ? null : new Array(totalChunks); @@ -125,7 +160,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk const fire = () => { while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { - inflight[nextSend] = transport.fetchChunk(fileId, nextSend); + inflight[nextSend] = _fetchChunkResilient(transport, fileId, nextSend); nextSend++; } }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index cdbe612..470f169 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -226,6 +226,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // any other, and two sources for one address is how they drift. const transport = new window.MeshBayTransport(HUB, live); transportRef.current = transport; + // Consulted only by the automatic reconnect after a WebRTC failure + // (transport.js's _reconnectLoop) — the token captured by this + // connect() call can be stale by then, since the whole point is that + // some real time (screen lock, a dead NAT mapping) passed unnoticed. + transport.onNeedToken = async () => (await ensureFreshToken()) || token; const ack = await transport.connect( nodeId, live, groupId, null, sessionKeys, session.bundleKey, username, @@ -647,7 +652,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, bar's own unmount cleanup is what actually stops playback. */ musicQueue && html` <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue} - onClose=${() => setMusicQueue(null)} /> + userPrefs=${userPrefs} onClose=${() => setMusicQueue(null)} /> `} </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 1e367fb..b12f3f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -259,6 +259,8 @@ export default { 'settings.defaults': 'Standardwerte', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Node-Identitäten', 'settings.node_pins_hint': 'Der Identitätsschlüssel jedes Nodes wird bei der ersten ' + 'Verbindung gemerkt. Ändert er sich, wird die Verbindung abgelehnt — das ist nur ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 5aedd7a..54f47e9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -256,6 +256,8 @@ export default { 'settings.defaults': 'Defaults', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Node identities', 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.", 'settings.node_pins_count': { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 46a4638..03d1d62 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -257,6 +257,8 @@ export default { 'settings.defaults': 'Valores predeterminados', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Identidades de los nodes', 'settings.node_pins_hint': 'La clave de identidad de cada node se memoriza la ' + 'primera vez que se conecta. Si cambia, la conexión se rechaza — algo esperable ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 5cd8894..48289d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -258,6 +258,8 @@ export default { 'settings.defaults': 'Valeurs par défaut', 'settings.default_tab': 'Onglet par défaut', 'settings.default_tab_hint': 'L\'onglet qui s\'ouvre en premier quand vous entrez dans un groupe.', + 'settings.music_keep_screen_on': 'Garder l\'écran allumé pendant l\'écoute', + 'settings.music_keep_screen_on_hint': 'Empêche le téléphone de se verrouiller automatiquement pendant qu\'un morceau joue. Désactivé par défaut — la plupart des gens préfèrent que leur téléphone se verrouille normalement pendant l\'écoute, comme sur Spotify ou Deezer.', 'settings.node_pins': 'Identités des nodes', 'settings.node_pins_hint': "La clé d'identité de chaque node est mémorisée lors de " + 'la première connexion. Si elle change, la connexion est refusée — ce qui n’est ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 26e4e8f..f73da13 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -258,6 +258,8 @@ export default { 'settings.defaults': 'Valori predefiniti', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Identità dei node', 'settings.node_pins_hint': "La chiave d'identità di ogni node viene memorizzata alla " + 'prima connessione. Se cambia, la connessione viene rifiutata — cosa che ci si ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 903a902..0b04cca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -255,6 +255,8 @@ export default { 'settings.defaults': 'デフォルト', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'node の識別情報', 'settings.node_pins_hint': '各 node の識別鍵は、最初に接続したときに記憶されます。' + 'それが変わった場合、接続は拒否されます。これが起こるのは、運営者が node を' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 217aead..ef8a60b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -259,6 +259,8 @@ export default { 'settings.defaults': 'Standaardwaarden', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Node-identiteiten', 'settings.node_pins_hint': 'De identiteitssleutel van elke node wordt bij de eerste ' + 'verbinding onthouden. Verandert die, dan wordt de verbinding geweigerd — wat ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 25d67f9..63ef1f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -270,6 +270,8 @@ export default { 'settings.defaults': 'Wartości domyślne', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Tożsamości nodes', 'settings.node_pins_hint': 'Klucz tożsamości każdego node jest zapamiętywany przy ' + 'pierwszym połączeniu. Jeśli się zmieni, połączenie zostanie odrzucone — czego ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index c32ff63..71f2829 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -259,6 +259,8 @@ export default { 'settings.defaults': 'Padrões', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'Identidades dos nodes', 'settings.node_pins_hint': 'A chave de identidade de cada node é memorizada na ' + 'primeira conexão. Se ela mudar, a conexão é recusada — o que só é esperado ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index ee0efdf..d88817f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -251,6 +251,8 @@ export default { 'settings.defaults': '默认值', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', + 'settings.music_keep_screen_on': 'Keep screen on during music playback', + 'settings.music_keep_screen_on_hint': 'Prevents the phone from locking on its own while a track is playing. Off by default — most people want their phone to lock normally while listening, like Spotify or Deezer.', 'settings.node_pins': 'node 身份', 'settings.node_pins_hint': '每个 node 的身份密钥都会在您首次连接时被记住。' + '如果它发生变化,连接会被拒绝——只有当运营者重装 node 时才应如此。' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index 7a3978c..ffe092d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -65,10 +65,36 @@ function shuffledOrder(n, keepFirst) { return order; } -// Bounded: only the currently playing track plus a one-track read-ahead are +// Bounded: only the currently playing track plus the read-ahead window are // ever worth holding in memory. Older blob URLs are revoked, not merely // dropped — otherwise every track played in a session leaks its object URL. -const MAX_CACHED_BLOBS = 3; +// Sized for the largest read-ahead prefetchDepth() can return (currently +// playing + 5 on Wi-Fi) — a smaller run on cellular just evicts sooner. +const MAX_CACHED_BLOBS = 6; + +/** + * How many tracks to warm the cache for, ahead of the one playing. + * + * A prefetched track needs no live connection to play — it is exactly what + * buys time through a screen-lock network gap (see transport.js's + * auto-reconnect) — so the more of a mobile-data budget it is safe to spend + * on tracks that might not even get listened to, the better the odds a lock + * of ordinary length is fully covered by tracks already sitting in + * blobCacheRef. Wi-Fi is effectively free and usually fast, so 5; a metered + * connection (or one this API cannot see at all) gets 3 — enough to matter, + * not so much it burns a noticeable chunk of a data plan on an album that + * might get abandoned after track one. + * + * `navigator.connection` is Chromium-only (Chrome, Edge, Electron) — plain + * `undefined` on Firefox and Safari, where this must fall through to the + * conservative tier exactly as it would for a cellular connection it could + * name. Never assume "fast" from the absence of a signal that says so. + */ +function prefetchDepth() { + const conn = navigator.connection; + if (conn && conn.type === 'wifi') return 5; + return 3; +} function loadVolume() { try { @@ -151,7 +177,7 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) { `; } -function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { +function MusicPlayerBar({ transportRef, gekRef, queue, onClose, userPrefs }) { const audioRef = useRef(null); const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index } const blobInsertRef = useRef(0); @@ -201,6 +227,47 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { for (const { url } of blobCacheRef.current.values()) URL.revokeObjectURL(url); }, []); + // Screen Wake Lock, opt-in only (Settings → music_keep_screen_on) and only + // while a track is actually playing — off by default because the ordinary + // expectation, matching Spotify/Deezer, is that the phone locks on its own + // idle timer while listening (docs/musicbay.md §2.2). Unlike the video + // player's unconditional lock, this must not fight that default for + // everyone who never asked for it; it exists for whoever explicitly wants + // to trade battery for riding out the WebRTC screen-lock reconnect gap + // without waiting on it at all. + useEffect(() => { + if (!playing) return; + if (!(userPrefs && userPrefs.music_keep_screen_on === 'true')) return; + if (!('wakeLock' in navigator)) return; + let sentinel = null; + let cancelled = false; + const acquire = async () => { + try { + const wl = await navigator.wakeLock.request('screen'); + if (cancelled) { try { wl.release(); } catch { /* ignore */ } return; } + sentinel = wl; + wl.addEventListener('release', () => { sentinel = 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); + } + }; + acquire(); + // Released automatically the moment the page goes hidden (spec + // behaviour) — re-requested here so it holds again once foregrounded, + // same as the video player's handling of the same event. + const onVisibility = () => { + if (document.visibilityState === 'visible' && !sentinel) acquire(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + cancelled = true; + document.removeEventListener('visibilitychange', onVisibility); + if (sentinel) { try { sentinel.release(); } catch { /* already released */ } } + }; + }, [playing, userPrefs && userPrefs.music_keep_screen_on]); + const evictOldBlobs = useCallback(() => { const cache = blobCacheRef.current; while (cache.size > MAX_CACHED_BLOBS) { @@ -218,7 +285,14 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { const cached = blobCacheRef.current.get(entry.id); if (cached) return cached.url; const transport = transportRef.current; - if (!transport || !transport.connected) throw new Error(t('music.err_transport')); + if (!transport) throw new Error(t('music.err_transport')); + // A track ending (or "next") right after a screen-lock reconnect started + // is exactly when this used to throw: `connected` was still false because + // the reconnect it only had to wait a few seconds for hadn't landed yet. + // waitForReconnect is a no-op when nothing is in flight, so this costs + // nothing on the ordinary path. + if (!transport.connected) await transport.waitForReconnect(); + if (!transport.connected) throw new Error(t('music.err_transport')); let downloadId = entry.id; let downloadSize = entry.size; @@ -248,12 +322,22 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { return url; }, [transportRef, gekRef, evictOldBlobs]); - // Silently warms the cache for the next track so pressing "next" doesn't + // Silently warms the cache for the next tracks so pressing "next" doesn't // visibly wait (musicbay.md §2.2) — best-effort, never surfaces an error. + // + // More than one: a screen lock can cost the transport several minutes (see + // the WebRTC auto-reconnect in transport.js — this is the other half of + // the same fix). A track already sitting in blobCacheRef needs no + // connection at all to play, so whatever got fetched *before* the lock + // started plays through it regardless of what the connection is doing + // afterward — see prefetchDepth() for how far ahead that runway goes. const prefetchNext = useCallback((fromPos) => { - const nextEntry = tracks[order[fromPos + 1]]; - if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) return; - fetchTrackBlob(nextEntry).catch(() => {}); + const ahead = prefetchDepth(); + for (let i = 1; i <= ahead; i++) { + const nextEntry = tracks[order[fromPos + i]]; + if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) continue; + fetchTrackBlob(nextEntry).catch(() => {}); + } }, [tracks, order, fetchTrackBlob]); // (Re)initialize the queue whenever the shell hands over a new one. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 7535594..9f85ee1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -81,6 +81,103 @@ const ADMIN_OP_TYPES = new Set([ 'group_detach', 'invite_create', ]); +// ── Diagnostic trace (opt-in, off by default) ─────────────────────────────── +// Ring buffer of transport health events (connection/ICE/DataChannel state +// transitions, request timeouts, visibility changes, periodic health pings), +// persisted to localStorage so a connection that gets stuck can be inspected +// after the fact — the field case this exists for is a phone with no +// devtools attached. Added while chasing a report of the transport going +// unresponsive after a mobile screen lock of several minutes; kept in the +// tree afterward rather than ripped out, since the next hard-to-reproduce +// connection bug will want the same thing and it costs nothing while off. +// +// Enable once by opening the app with ?trace=1 in the URL — this persists in +// localStorage, so every later visit stays in trace mode until ?trace=0 +// clears it. Read the log back at any time by navigating to #mb-debug (e.g. +// https://meshbay.org/app/#mb-debug), which replaces the page with a plain +// text dump — no devtools required. +const TRACE_KEY = 'mb_trace'; +const TRACE_LOG_KEY = 'mb_trace_log'; +const TRACE_MAX = 500; +// How often to probe the channel with a ping while trace mode is on — purely +// diagnostic (to see when a health check starts failing), not a keepalive: +// must stay opt-in, never run by default. +const TRACE_PING_INTERVAL_MS = 25000; + +(function _initTraceFlag() { + try { + const params = new URLSearchParams(location.search); + if (params.has('trace')) { + if (params.get('trace') === '0') localStorage.removeItem(TRACE_KEY); + else localStorage.setItem(TRACE_KEY, '1'); + } + } catch { /* localStorage unavailable (private mode, etc.) — trace stays off */ } +})(); + +function traceEnabled() { + try { return localStorage.getItem(TRACE_KEY) === '1'; } catch { return false; } +} + +function trace(event, data) { + if (!traceEnabled()) return; + try { + const buf = JSON.parse(localStorage.getItem(TRACE_LOG_KEY) || '[]'); + buf.push({ t: new Date().toISOString(), event, ...data }); + while (buf.length > TRACE_MAX) buf.shift(); + localStorage.setItem(TRACE_LOG_KEY, JSON.stringify(buf)); + } catch { /* storage full or unavailable — tracing is best-effort */ } +} + +window.MeshBayTrace = { + enabled: traceEnabled, + dump() { + try { return JSON.parse(localStorage.getItem(TRACE_LOG_KEY) || '[]'); } catch { return []; } + }, + clear() { try { localStorage.removeItem(TRACE_LOG_KEY); } catch { /* ignore */ } }, +}; + +function _showTraceView() { + { + const renderTraceView = () => { + const log = window.MeshBayTrace.dump(); + const text = JSON.stringify(log, null, 2); + document.body.innerHTML = ''; + document.title = 'MeshBay — Diagnostic'; + const bar = document.createElement('div'); + bar.style.cssText = 'font-family:monospace;padding:8px;'; + const copyBtn = document.createElement('button'); + copyBtn.textContent = 'Copier'; + copyBtn.onclick = () => { navigator.clipboard.writeText(text).catch(() => {}); }; + const clearBtn = document.createElement('button'); + clearBtn.textContent = 'Vider'; + clearBtn.onclick = () => { window.MeshBayTrace.clear(); renderTraceView(); }; + const refreshBtn = document.createElement('button'); + refreshBtn.textContent = 'Rafraîchir'; + refreshBtn.onclick = renderTraceView; + const info = document.createElement('span'); + info.textContent = ` — ${log.length} évènement(s) — trace ${traceEnabled() ? 'active' : 'inactive'}`; + info.style.marginLeft = '8px'; + bar.append(copyBtn, clearBtn, refreshBtn, info); + const pre = document.createElement('pre'); + pre.style.cssText = 'font-family:monospace;font-size:11px;white-space:pre-wrap;' + + 'word-break:break-all;padding:8px;'; + pre.textContent = text; + document.body.append(bar, pre); + }; + renderTraceView(); + } +} + +// Fragment-only URL changes (typing #mb-debug into an already-loaded page, +// or a link to it) do not reload the document, so DOMContentLoaded alone +// would miss them — hashchange is what a same-document navigation fires. +if (location.hash === '#mb-debug') { + document.addEventListener('DOMContentLoaded', _showTraceView); +} +window.addEventListener('hashchange', () => { + if (location.hash === '#mb-debug') _showTraceView(); +}); + const JOIN_REFUSALS = { code_required: 'This node does not know this browser yet. Ask the node operator ' + 'for a pairing code (meshbay-node operator pair).', @@ -117,6 +214,50 @@ class MeshBayTransport { // several uploads may be in flight at once and their acks interleave; the // node names the file in every one. this._uploaders = new Map(); + // Set once close() runs — stops the automatic reconnect from firing on a + // connection the caller tore down on purpose (leaving the group, page + // unload), which would otherwise race back in right as everything else + // is being torn down. + this._closed = false; + // The arguments connect() was last given, minus the token (refreshed at + // reconnect time — see onNeedToken) and sessionKeys (kept live on `this`, + // since a reconnect must reuse the identity connect() settled on, not + // whatever the very first caller passed in — see _reconnectLoop). + this._connectArgs = null; + this._lastToken = null; + this._reconnectPromise = null; + this._reconnectAttempts = 0; + // True only for the duration of the connect() call _reconnectLoop makes + // to actually retry — as opposed to the backoff delay around it, which + // is most of _reconnectPromise's lifetime. Needed because that connect() + // call sends its own handshake through _sendAndWait, which would + // otherwise see the very _reconnectPromise it is running inside of as + // "a reconnect to wait for" and stall every handshake step for the full + // 6s gate below before ever sending it. + this._inReconnectAttempt = false; + this._onReconnected = null; + this._onNeedToken = null; + // Cuts the backoff wait short the moment the page is foregrounded again — + // found live to matter: a screen lock throttles the tab's own timers + // along with everything else, so a backoff already counting down when the + // phone locked can run for minutes of *wall clock* past its nominal delay + // before it next gets to run at all. Set once, here, rather than inside + // connect() like the diagnostic listener above it — this one has to + // survive every reconnect attempt, not restart with each one. + this._reconnectWakeResolve = null; + this._onVisibilityWake = () => { + if (document.visibilityState === 'visible') this._wakeReconnect(); + }; + document.addEventListener('visibilitychange', this._onVisibilityWake); + } + + /** Cuts short a reconnect currently backing off (see _reconnectLoop). A + * no-op when nothing is waiting, so this is safe to call unconditionally. */ + _wakeReconnect() { + if (this._reconnectWakeResolve) { + this._reconnectWakeResolve(); + this._reconnectWakeResolve = null; + } } get connected() { return this._connected; } @@ -138,6 +279,17 @@ class MeshBayTransport { set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } + // Fired once an automatic reconnect (see _reconnectLoop) lands a fresh + // handshake, so a consumer with something mid-flight on the old channel — + // today only the video player — can pick back up rather than sit dead. + set onReconnected(fn) { this._onReconnected = fn; } + // Reconnecting redoes the handshake, which needs a JWT that may have gone + // stale while the connection was down for minutes. Without this the + // reconnect resends whatever token the original connect() call captured, + // which the node's clock-skew check (stale_request) or plain expiry can + // by then have already invalidated. Set to whatever the caller uses to + // refresh the hub session token (see group-page.js's ensureFreshToken). + set onNeedToken(fn) { this._onNeedToken = fn; } get sessionKeys() { return this._sessionKeys; } @@ -147,6 +299,21 @@ class MeshBayTransport { async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode) { + // Remembered for _reconnectLoop, which calls connect() again with these + // same values (plus a freshly-fetched token and the identity connect() + // itself settles on below) after the WebRTC connection is declared + // "failed" — see the pc.onconnectionstatechange handler further down. + this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode }; + this._lastToken = jwtToken; + // The constructor sets this once from whatever token the caller had at + // the time — and the signaling POST below reads *this*, not `jwtToken`. + // A reconnect passes a freshly-fetched `jwtToken` (see onNeedToken) but + // that never reached here before, so the signaling call kept using the + // original token no matter how many minutes had passed or how many + // reconnect attempts fetched a new one — confirmed live: every attempt + // failed "Signaling failed: 401 Invalid or expired token" in a loop, + // never actually trying the fresh token connect() had just been handed. + this._accessToken = jwtToken; this._gekRaw = gekRaw || null; this._sessionKeys = sessionKeys || null; this._bundleKey = bundleKey || null; @@ -168,6 +335,7 @@ class MeshBayTransport { this._channel.onopen = () => { clearTimeout(timeout); this._connected = true; + trace('channel_open', {}); resolve(); }; }); @@ -175,6 +343,11 @@ class MeshBayTransport { this._channel.onmessage = (event) => this._onMessage(event.data); this._channel.onclose = (ev) => { console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); + trace('channel_close', { + readyState: this._channel?.readyState, + pc: this._pc?.connectionState, + ice: this._pc?.iceConnectionState, + }); this._connected = false; if (channelReject) channelReject(new Error('DataChannel closed')); for (const [, p] of this._pending) p.reject(new Error('DataChannel closed')); @@ -182,16 +355,93 @@ class MeshBayTransport { }; this._channel.onerror = (ev) => { console.error('[MeshBay] DataChannel error', ev); + trace('channel_error', { + pc: this._pc?.connectionState, + ice: this._pc?.iceConnectionState, + }); if (channelReject) channelReject(new Error('DataChannel error')); }; - this._pc.onconnectionstatechange = () => { - console.log('[MeshBay] PC state:', this._pc.connectionState); + // Captured locally rather than read back through `this._pc`: once a + // reconnect replaces it, a late event from this (by then orphaned) pc + // must still be judged against the pc it actually came from, not + // whatever is current — the `pc === this._pc` check below is what that + // buys. + const pc = this._pc; + pc.onconnectionstatechange = () => { + console.log('[MeshBay] PC state:', pc.connectionState); + trace('pc_state', { state: pc.connectionState }); + // "failed" is ICE's own verdict that nothing here will recover on its + // own (unlike a transient "disconnected", which often clears itself) — + // confirmed live: mobile screen lock for several minutes reliably + // produces disconnected → failed about 10s apart, on both ends, and + // nothing today ever moves past that without a full page reload. + // `channel.readyState` is no help distinguishing this: it was observed + // staying "open" throughout, so every send from here on would simply + // sit out its own timeout instead of failing fast. + if (pc.connectionState === 'failed' && pc === this._pc && !this._closed) { + this._connected = false; + this._reconnect(); + const err = new Error('WebRTC connection lost'); + err.name = 'TransportLostError'; + for (const [, p] of this._pending) p.reject(err); + this._pending.clear(); + } }; - this._pc.oniceconnectionstatechange = () => { - console.log('[MeshBay] ICE state:', this._pc.iceConnectionState); + pc.oniceconnectionstatechange = () => { + console.log('[MeshBay] ICE state:', pc.iceConnectionState); + trace('ice_state', { state: pc.iceConnectionState }); }; + // Diagnostic-only: a periodic health ping and a resume-triggered one, so + // a trace captures exactly what state the connection was in right as the + // page comes back from being backgrounded/locked — never active unless + // trace mode is on (see TRACE_KEY above). + // + // connect() runs again on every reconnect attempt (see _reconnectLoop), + // and each run used to add its own listener/interval on top of the + // previous one without ever removing it — confirmed live: 8 failed + // attempts during one screen lock left 8 duplicate `visibility` trace + // lines firing off the same real event. Disposing of the prior instance + // first is what keeps this to one. + if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } + if (traceEnabled()) { + const healthPing = async (reason) => { + const before = { + pc: this._pc?.connectionState, + ice: this._pc?.iceConnectionState, + channel: this._channel?.readyState, + }; + const start = Date.now(); + try { + await this.ping(8000); + trace('health_ping', { reason, ok: true, rtt_ms: Date.now() - start, ...before }); + } catch (e) { + trace('health_ping', { reason, ok: false, error: String(e && e.message || e), + elapsed_ms: Date.now() - start, ...before }); + } + }; + const onVisibility = () => { + trace('visibility', { + state: document.visibilityState, + pc: this._pc?.connectionState, + ice: this._pc?.iceConnectionState, + channel: this._channel?.readyState, + }); + if (document.visibilityState === 'visible' && this._channel?.readyState === 'open') { + healthPing('resume'); + } + }; + document.addEventListener('visibilitychange', onVisibility); + const healthInterval = setInterval(() => { + if (this._channel?.readyState === 'open') healthPing('interval'); + }, TRACE_PING_INTERVAL_MS); + this._diagCleanup = () => { + document.removeEventListener('visibilitychange', onVisibility); + clearInterval(healthInterval); + }; + } + const offer = await this._pc.createOffer(); await this._pc.setLocalDescription(offer); @@ -426,6 +676,89 @@ class MeshBayTransport { } /** + * Kick off (or join, if one is already running) the automatic reconnect + * after the WebRTC connection is declared unrecoverable. Idempotent: every + * caller racing to reconnect at once — the connectionstatechange handler, + * and any request that lands in the gap _sendAndWait waits out below — + * shares the one attempt instead of piling up parallel handshakes against + * the node. + */ + _reconnect() { + if (this._closed) return Promise.resolve(); + if (!this._reconnectPromise) { + this._reconnectPromise = this._reconnectLoop().finally(() => { + this._reconnectPromise = null; + }); + } + return this._reconnectPromise; + } + + /** + * Redo the signaling handshake from scratch — the only thing that works + * once aiortc has declared a connection "failed": the node discards that + * session the moment it sees the same state (webrtc_server.py's + * on_state_change), so there is no lower-level session left to resume, only + * a fresh one to negotiate. Retries with capped exponential backoff + * (1s, 2s, 4s ... 30s) rather than a fixed number of attempts, because the + * two real causes seen so far — a mobile carrier dropping the NAT mapping + * during screen lock, and the node's own machine being briefly unreachable + * — both resolve on their own eventually, and there is no good moment to + * decide the user would rather see a dead app than keep waiting. + */ + async _reconnectLoop() { + this._reconnectAttempts = 0; + while (!this._closed) { + this._reconnectAttempts += 1; + const delayMs = Math.min(30000, 1000 * 2 ** (this._reconnectAttempts - 1)); + trace('reconnect_wait', { attempt: this._reconnectAttempts, delay_ms: delayMs }); + // Interruptible: _wakeReconnect (fired on visibilitychange → visible) + // resolves this immediately instead of waiting out the rest of a + // backoff that was mostly spent while nothing could succeed anyway. + await new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + this._reconnectWakeResolve = () => { clearTimeout(timer); resolve(); }; + }); + this._reconnectWakeResolve = null; + if (this._closed) return; + try { + // Best-effort: these are already unusable, but leaving them wired up + // risks a stray late event from the old pc doing something once a + // new one is in `this._pc` — the `pc === this._pc` guard above closes + // most of that gap, this closes the rest. + try { this._channel && this._channel.close(); } catch { /* already gone */ } + try { this._pc && this._pc.close(); } catch { /* already gone */ } + const args = this._connectArgs; + const token = this._onNeedToken ? await this._onNeedToken() : this._lastToken; + trace('reconnect_attempt', { attempt: this._reconnectAttempts }); + this._inReconnectAttempt = true; + try { + await this.connect(args.nodeId, token, args.groupId, args.gekRaw, + this._sessionKeys, args.bundleKey, args.username, + args.userId, args.joinCode); + } finally { + this._inReconnectAttempt = false; + } + trace('reconnect_ok', { attempt: this._reconnectAttempts }); + console.log('[MeshBay] Reconnected after', this._reconnectAttempts, 'attempt(s)'); + if (this._onReconnected) { + try { this._onReconnected(); } catch (e) { + console.error('[MeshBay] onReconnected handler threw:', e); + } + } + return; + } catch (e) { + trace('reconnect_attempt_failed', { + attempt: this._reconnectAttempts, error: String(e && e.message || e), + }); + console.warn('[MeshBay] Reconnect attempt', this._reconnectAttempts, + 'failed:', e.message); + // Loop again with a longer backoff — closing over `args`/`token` + // freshly next time, in case the token was the actual problem. + } + } + } + + /** * Pair this browser with the node using a one-time code (M3, and the same * substitution as H3). * @@ -1439,7 +1772,39 @@ class MeshBayTransport { get gekRaw() { return this._gekRaw; } + /** + * Give an automatic reconnect already in progress (see _reconnectLoop) a + * bounded chance to land before giving up. + * + * _sendAndWait does this internally for every request that goes through + * it, so most callers never need this directly. It exists for the ones + * that check `transport.connected` themselves before doing anything else — + * music-player.js's fetchTrackBlob is the one this was written for: found + * live throwing "Transport not connected" on the track *after* a + * screen-lock reconnect had already been under way for a while, because + * that check ran, saw `connected` still false, and threw before the + * reconnect it only had to wait a few seconds for got the chance to finish. + * A no-op — returns immediately — when nothing is being reconnected, + * including once one has already succeeded, so it is safe to call + * unconditionally ahead of such a check. + */ + async waitForReconnect(timeoutMs = 6000) { + if (!this._reconnectPromise) return; + await Promise.race([ + this._reconnectPromise.catch(() => {}), + new Promise((r) => setTimeout(r, timeoutMs)), + ]); + } + close() { + // Must be set before pc.close() below: that close() itself can drive the + // pc to "closed" synchronously, and the connectionstatechange handler + // only skips reconnecting because of this flag, not because "closed" is + // absent from its own trigger condition. + this._closed = true; + document.removeEventListener('visibilitychange', this._onVisibilityWake); + this._wakeReconnect(); + if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); this._connected = false; @@ -1449,13 +1814,25 @@ class MeshBayTransport { // ── Internal ────────────────────────────────────────────────────────────── - _sendAndWait(obj, timeoutMs = 30000) { + async _sendAndWait(obj, timeoutMs = 30000) { + // A reconnect already in flight (see _reconnectLoop) means the channel + // this would send on is the one just declared dead. `_inReconnectAttempt` + // excludes the handshake connect() itself makes while reconnecting — that + // call runs *inside* this same _reconnectPromise, which cannot resolve + // until it returns, so waiting on it here would just be waiting on + // itself for the full 6s, on every step of the handshake, every time. + if (!this._inReconnectAttempt) await this.waitForReconnect(6000); return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { this._pending.delete(id); console.error('[MeshBay] Response timeout for', obj.type, 'after', timeoutMs, 'ms, channel=', this._channel?.readyState); + trace('send_timeout', { + reqType: obj.type, timeoutMs, + pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, + channel: this._channel?.readyState, + }); reject(new Error('Response timeout')); }, timeoutMs); this._pending.set(id, { 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 = []; diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 6709fbc..724527b 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -199,6 +199,20 @@ def _pack(obj: dict) -> bytes: return struct.pack(">I", len(data)) + data +# Opt-in, off by default: a per-session heartbeat log (message count, time +# since the last message, ICE state) and ICE-state-change logging, on top of +# the connectionstatechange logging that already runs unconditionally. Added +# while chasing a report of the browser side going unresponsive after a +# mobile screen lock; --log-level DEBUG was not the right knob for this, +# since it is already used for the per-message request/response tracing +# every group index lookup produces, and turning that on for days of normal +# operation just to catch one intermittent session is not viable. Set +# MESHBAY_WEBRTC_TRACE=1 in the node's environment for the duration of a +# debugging session. +_WEBRTC_TRACE = os.environ.get("MESHBAY_WEBRTC_TRACE") == "1" +_WEBRTC_TRACE_INTERVAL_S = 30.0 + + class _DataChannelBuffer: """ Accumulate DataChannel messages and extract length-prefixed msgpack. @@ -295,6 +309,9 @@ class WebRTCPeerSession: self._nonce_client: bytes = b"" self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation self._uploads: dict[str, dict] = {} # filename → {next_index, bytes} + # Diagnostics only (_WEBRTC_TRACE): when the last DataChannel message + # arrived, so the heartbeat can report silence duration. + self._last_msg_at: float = 0.0 def _setup_channel(self, channel: RTCDataChannel) -> None: self._channel = channel @@ -305,6 +322,7 @@ class WebRTCPeerSession: if isinstance(message, str): message = message.encode() self._msg_count += 1 + self._last_msg_at = time.monotonic() if self._msg_count <= 3: log.info("WebRTC data received: %d bytes, msg #%d (peer=%s)", len(message), self._msg_count, self._peer_id) @@ -312,6 +330,22 @@ class WebRTCPeerSession: for msg in self._buffer.messages(): self._handle_message(msg) + if _WEBRTC_TRACE: + self._spawn(self._trace_heartbeat()) + + async def _trace_heartbeat(self) -> None: + """Diagnostics only (_WEBRTC_TRACE): periodic proof-of-life for this + session, so a gap in these lines pinpoints when the node stopped + hearing from a peer that (from its own side) may still look connected.""" + while True: + await asyncio.sleep(_WEBRTC_TRACE_INTERVAL_S) + silence = time.monotonic() - self._last_msg_at if self._last_msg_at else -1 + log.info( + "WebRTC heartbeat peer=%s msgs=%d silence=%.0fs pc=%s ice=%s", + self._peer_id, self._msg_count, silence, + self._pc.connectionState, self._pc.iceConnectionState, + ) + def _handle_message(self, msg: dict) -> None: mtype = msg.get("type") log.debug("WebRTC recv: %s", mtype) @@ -4353,6 +4387,11 @@ class WebRTCTransport: log.info("WebRTC DataChannel opened: %s (peer=%s)", channel.label, peer_id) session._setup_channel(channel) + if _WEBRTC_TRACE: + @pc.on("iceconnectionstatechange") + def on_ice_state_change(): + log.info("WebRTC ICE state: %s (peer=%s)", pc.iceConnectionState, peer_id) + @pc.on("connectionstatechange") async def on_state_change(): state = pc.connectionState |