From 3be8bd2a7885fbd141f6cc12c2d2073f9a0ac56c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 10:27:45 +0200 Subject: debug(transport): opt-in WebRTC health tracing for mobile-lock investigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client (transport.js): a localStorage ring buffer of connection/ICE/ DataChannel state transitions, visibility changes, request timeouts, and periodic health pings — enabled once via ?trace=1 (persists), read back at any time via #mb-debug without devtools. Off by default, zero behavior change unless enabled. Node (webrtc_server.py): MESHBAY_WEBRTC_TRACE=1 gates ICE-state-change logging and a per-session heartbeat (message count, seconds since last message, ICE/connection state) every 30s. Debugging aid for the "stuck after several minutes of mobile screen lock" report — not a fix. Stays on this branch until confirmed useful/resolved. --- .../src/meshbay_hub/static/transport.js | 156 +++++++++++++++++++++ .../src/meshbay_node/transport/webrtc_server.py | 39 ++++++ 2 files changed, 195 insertions(+) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 7535594..6c038f6 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).', @@ -168,6 +265,7 @@ class MeshBayTransport { this._channel.onopen = () => { clearTimeout(timeout); this._connected = true; + trace('channel_open', {}); resolve(); }; }); @@ -175,6 +273,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 +285,63 @@ 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); + trace('pc_state', { state: this._pc.connectionState }); }; this._pc.oniceconnectionstatechange = () => { console.log('[MeshBay] ICE state:', this._pc.iceConnectionState); + trace('ice_state', { state: this._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). + 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); @@ -1440,6 +1590,7 @@ class MeshBayTransport { get gekRaw() { return this._gekRaw; } close() { + if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); this._connected = false; @@ -1456,6 +1607,11 @@ class MeshBayTransport { 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-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 -- cgit v1.2.3 From 27d59cbb2d11d863273e2282257d81ac9911ab97 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 11:17:37 +0200 Subject: fix(transport): auto-reconnect after WebRTC failure, without dropping in-flight streams/downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live (client trace + node logs, mobile screen-lock ~5min): ICE goes disconnected -> failed within ~10s on both ends, but the DataChannel's readyState stays "open" throughout, so nothing failed fast — every request just sat out its own 8s/30s timeout, matching the reported symptom (poster spinners, blocked chat, dead new streams). transport.js: on connectionState "failed", reject pending requests immediately (TransportLostError) and start a self-contained reconnect loop (capped exponential backoff, redoes the full signaling handshake — the node already discards the old session on its own "failed"/"closed", so there is nothing lower-level to resume). New hooks: onNeedToken (fetch a fresh JWT, since the captured one may have expired during the outage) and onReconnected (let a consumer resume something that was mid-flight). file-utils.js: pipelinedDownload retries a lost chunk instead of aborting the whole transfer — covers Files downloads, poster/thumbnail fetches, and music-player.js's blob-based track download, all of which go through it. video-player.js: onReconnected reissues the existing seek-to-current-time path, which already knows how to land a new stream_init on the live SourceBuffer without resetting playback. Playing audio is unaffected either way — musicbay.md's design downloads a track to a blob before playing it, so a dead transport was never a network dependency for what is already playing. Stays on this branch until confirmed by real-device testing. --- .../src/meshbay_hub/static/file-utils.js | 37 ++++- .../src/meshbay_hub/static/group-page.js | 5 + .../src/meshbay_hub/static/transport.js | 171 ++++++++++++++++++++- .../src/meshbay_hub/static/video-player.js | 19 +++ 4 files changed, 224 insertions(+), 8 deletions(-) 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..8683a70 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, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 6c038f6..56ec2c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -214,6 +214,29 @@ 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; } get connected() { return this._connected; } @@ -235,6 +258,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; } @@ -244,6 +278,12 @@ 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; this._gekRaw = gekRaw || null; this._sessionKeys = sessionKeys || null; this._bundleKey = bundleKey || null; @@ -292,13 +332,35 @@ class MeshBayTransport { if (channelReject) channelReject(new Error('DataChannel error')); }; - this._pc.onconnectionstatechange = () => { - console.log('[MeshBay] PC state:', this._pc.connectionState); - trace('pc_state', { 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); - trace('ice_state', { 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 @@ -575,6 +637,82 @@ class MeshBayTransport { throw rejected; } + /** + * 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 }); + await new Promise((r) => setTimeout(r, delayMs)); + 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). @@ -1590,6 +1728,11 @@ class MeshBayTransport { get gekRaw() { return this._gekRaw; } 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; if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); @@ -1600,7 +1743,21 @@ 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. Waiting here, bounded + // rather than open-ended — the loop can be backing off for up to 30s + // between attempts, and a caller (in particular file-utils.js's + // pipelinedDownload, which retries a lost chunk itself) should get its + // own timeout rather than sit through someone else's backoff — gives a + // fresh handshake a real chance to land before this request is even + // attempted, instead of guaranteeing it dies with the old one. + if (this._reconnectPromise && !this._inReconnectAttempt) { + await Promise.race([ + this._reconnectPromise.catch(() => {}), + new Promise((r) => setTimeout(r, 6000)), + ]); + } return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { 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..78760d8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -529,6 +529,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; @@ -849,6 +867,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 = []; -- cgit v1.2.3 From ab96d5821fe9cda4f31b3389e1c6fb3e6010682d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 11:35:31 +0200 Subject: feat(video): hold a Screen Wake Lock while a video is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purely additive and isolated from the streaming/transport code: touches no DataChannel, SourceBuffer, or playback state, so it cannot itself cause a stall or regress existing playback (PC/Electron included) — feature-detected and try/caught, a no-op wherever unsupported or refused. Sidesteps the commonest real-world trigger for the WebRTC-drop recovery (the phone auto-locking on its own idle timer while someone just watches). Does nothing for a deliberate power-button lock or backgrounding the tab — released automatically in both cases per spec — so the reconnect path is still what handles those. --- .../src/meshbay_hub/static/video-player.js | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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 78760d8..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. @@ -780,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); @@ -838,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) { -- cgit v1.2.3 From 6e0d2b9a477f1e9f1dec646c0de15440106ed7ed Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 11:44:23 +0200 Subject: fix(music): don't throw "Transport not connected" while a reconnect is landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live: >5min screen lock while music played, then "Transport not connected" at the 2nd track's end (~8min in) despite the auto-reconnect from the previous commit. Root cause: fetchTrackBlob's own `!transport.connected` pre-flight check ran and threw before the already-in-progress reconnect got the few seconds it needed — it never reached _sendAndWait, which is the only place the previous fix taught the transport to wait. Adds transport.waitForReconnect(), factored out of _sendAndWait's existing gate, and calls it from fetchTrackBlob before giving up. No-op when nothing is being reconnected, so the ordinary path is unchanged. --- .../src/meshbay_hub/static/music-player.js | 9 ++++- .../src/meshbay_hub/static/transport.js | 43 +++++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) 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..73cae27 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -218,7 +218,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; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 56ec2c7..4eb7ac2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -1727,6 +1727,30 @@ 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 @@ -1745,19 +1769,12 @@ class MeshBayTransport { 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. Waiting here, bounded - // rather than open-ended — the loop can be backing off for up to 30s - // between attempts, and a caller (in particular file-utils.js's - // pipelinedDownload, which retries a lost chunk itself) should get its - // own timeout rather than sit through someone else's backoff — gives a - // fresh handshake a real chance to land before this request is even - // attempted, instead of guaranteeing it dies with the old one. - if (this._reconnectPromise && !this._inReconnectAttempt) { - await Promise.race([ - this._reconnectPromise.catch(() => {}), - new Promise((r) => setTimeout(r, 6000)), - ]); - } + // 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(() => { -- cgit v1.2.3 From 03c2c0deaebee645bd61dfb8d4e7bd9d942e6408 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 12:16:10 +0200 Subject: fix(hub): no-store on the SPA HTML shell — pull-to-refresh wasn't enough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Likely root cause of a very confusing test result: the /app HTML response had no Cache-Control at all, so a browser that decided to cache it heuristically could keep re-serving the SAME old page (old ASSET_V, old JS) indefinitely — a normal reload, pull-to-refresh included, has no reason to override a cache entry it still considers fresh. Every static asset already gets a fresh URL from ASSET_V precisely so a change is visible, but that only matters if the HTML naming that URL is itself refetched. no-store forces every navigation here to hit the network. --- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 = """\ -- cgit v1.2.3 From 5dcf3066a2be83d5422ea176e39b413c4769bcf8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 12:44:50 +0200 Subject: fix(transport): wake a backing-off reconnect on visibilitychange, fix listener leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live by trace: during a screen lock, every reconnect attempt failed with "Failed to fetch" (the browser grants no network access to a locked/backgrounded tab, no code can change that) — expected. But the backoff timer itself was also throttled while locked: an attempt scheduled 30s out took ~3 minutes of wall clock to fire, because a backgrounded tab's timers run only when the OS lets them. Recovery after unlocking was correspondingly delayed rather than prompt. Fix: an always-on visibilitychange listener (separate from the diagnostic one, and unlike it not gated on trace mode) resolves the current backoff wait immediately once the page is visible again, instead of waiting out whatever of it is left. The actual reconnect this enables is fast (~1.2s in the trace that showed the "Failed to fetch" run) — the wait was the throttled part. Also fixes a real bug the same trace exposed: connect() re-arms the diagnostic visibility listener and health-ping interval on every attempt without ever removing the previous instance's — 8 failed attempts during one lock left 8 duplicate `visibility` trace lines per real event, and (more than a cosmetic issue) 8 concurrent health-ping intervals once reconnected. --- .../src/meshbay_hub/static/transport.js | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 4eb7ac2..35f729e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -237,6 +237,27 @@ class MeshBayTransport { 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; } @@ -367,6 +388,14 @@ class MeshBayTransport { // 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 = { @@ -673,7 +702,14 @@ class MeshBayTransport { this._reconnectAttempts += 1; const delayMs = Math.min(30000, 1000 * 2 ** (this._reconnectAttempts - 1)); trace('reconnect_wait', { attempt: this._reconnectAttempts, delay_ms: delayMs }); - await new Promise((r) => setTimeout(r, 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 @@ -1757,6 +1793,8 @@ class MeshBayTransport { // 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(); -- cgit v1.2.3 From 5ebebb8748453323709136d86f3b027cee309d4d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 13:38:04 +0200 Subject: feat(music): prefetch 2 tracks ahead instead of 1 Complements the WebRTC auto-reconnect: a track already sitting in blobCacheRef needs no live connection to play, so whatever was fetched before a screen lock started plays through it regardless of what the transport is doing during the lock. One track of runway was often shorter than the lock itself; two buys more of it. MAX_CACHED_BLOBS (3) already covers the currently-playing track plus these two, so no cache-size change needed. --- .../src/meshbay_hub/static/music-player.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 73cae27..7fdc74e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -255,12 +255,24 @@ 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. + // + // Two ahead, not 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 — + // it just buys more of that runway than fetching only one track ahead did. + // MAX_CACHED_BLOBS is sized for exactly this: the one playing plus these two. + const PREFETCH_AHEAD = 2; + const prefetchNext = useCallback((fromPos) => { - const nextEntry = tracks[order[fromPos + 1]]; - if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) return; - fetchTrackBlob(nextEntry).catch(() => {}); + for (let ahead = 1; ahead <= PREFETCH_AHEAD; ahead++) { + const nextEntry = tracks[order[fromPos + ahead]]; + 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. -- cgit v1.2.3 From a31b26df45860af16061fb43ccc3443381ed3df2 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 13:54:56 +0200 Subject: fix(transport): reconnect used a fresh handshake token but a stale signaling one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live: every reconnect attempt failed "Signaling failed: 401 Invalid or expired token", looping for 4+ minutes with no chance of ever succeeding. connect() takes one token but uses it in two places — the handshake sent to the node, and the Authorization header on the signaling POST to the hub — and only the constructor's original `this._accessToken` was ever used for the latter. onNeedToken correctly fetches a fresh token for each reconnect attempt, but it only ever reached the handshake; the signaling call kept sending whatever token the transport was constructed with, no matter how many minutes had passed or how many attempts fetched a new one. connect() now updates this._accessToken on every call, reconnects included, so both places use the same current token. --- packages/meshbay-hub/src/meshbay_hub/static/transport.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 35f729e..9f85ee1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -305,6 +305,15 @@ class MeshBayTransport { // "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; -- cgit v1.2.3 From 59d9f50bf41b2b38b7c95f8da52b698dff923c2d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 26 Aug 2026 14:43:16 +0200 Subject: feat(music): network-adaptive prefetch depth, opt-in keep-screen-on toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefetch depth (music-player.js): 5 tracks ahead on Wi-Fi, 3 on cellular — more runway through a screen-lock network gap when the connection is cheap and fast, less when it's metered. navigator.connection is Chromium-only; Firefox/Safari (where it's undefined) get the same conservative tier as an unrecognized connection type, never assumed fast. MAX_CACHED_BLOBS raised to 6 to hold the largest case (current + 5). Keep-screen-on-during-audio (new user preference, off by default): a Settings toggle, backed by a new whitelisted key on /v1/users/me/preferences (music_keep_screen_on). music-player.js holds a Screen Wake Lock only while a track is playing and only when the user has opted in — unlike the video player's unconditional lock, this must not fight the ordinary expectation (matching Spotify/Deezer) that the phone locks on its own while listening. --- packages/meshbay-hub/src/meshbay_hub/api/users.py | 1 + packages/meshbay-hub/src/meshbay_hub/static/app.js | 33 ++++++++ .../src/meshbay_hub/static/group-page.js | 2 +- .../src/meshbay_hub/static/locales/de.js | 2 + .../src/meshbay_hub/static/locales/en.js | 2 + .../src/meshbay_hub/static/locales/es.js | 2 + .../src/meshbay_hub/static/locales/fr.js | 2 + .../src/meshbay_hub/static/locales/it.js | 2 + .../src/meshbay_hub/static/locales/ja.js | 2 + .../src/meshbay_hub/static/locales/nl.js | 2 + .../src/meshbay_hub/static/locales/pl.js | 2 + .../src/meshbay_hub/static/locales/pt-BR.js | 2 + .../src/meshbay_hub/static/locales/zh-CN.js | 2 + .../src/meshbay_hub/static/music-player.js | 91 ++++++++++++++++++---- 14 files changed, 133 insertions(+), 14 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/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 }) {

${t('settings.default_tab_hint')}

+
+ ${t('settings.music_keep_screen_on')} + +
+

${t('settings.music_keep_screen_on_hint')}

${platform.isNative && html` 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 8683a70..470f169 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -652,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)} /> `} `; 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 7fdc74e..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) { @@ -258,18 +325,16 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { // 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. // - // Two ahead, not 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 + // 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 — - // it just buys more of that runway than fetching only one track ahead did. - // MAX_CACHED_BLOBS is sized for exactly this: the one playing plus these two. - const PREFETCH_AHEAD = 2; - + // 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) => { - for (let ahead = 1; ahead <= PREFETCH_AHEAD; ahead++) { - const nextEntry = tracks[order[fromPos + ahead]]; + 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(() => {}); } -- cgit v1.2.3