summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-26 15:09:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-26 15:09:20 +0200
commitdb7f81fd08742847f3ebea061c75530b7b31b934 (patch)
tree14b308686860eea0a9c12c47c39f8a68a1bba376 /packages
parent7126fd3c265ba75d77b449bfd0f83f5f3e584b74 (diff)
parent59d9f50bf41b2b38b7c95f8da52b698dff923c2d (diff)
downloadmeshbay-db7f81fd08742847f3ebea061c75530b7b31b934.tar.gz
Merge branch 'debug/webrtc-lock-resume'
WebRTC transport dies silently after an extended mobile screen lock (confirmed live via client-side trace + node logs): ICE goes disconnected -> failed within ~10s of each other on both ends, but the DataChannel's readyState stays "open" throughout, so nothing failed fast — every request just sat out its own timeout, matching the reported symptom (poster spinners, blocked chat, dead new streams, stuck music). - Automatic reconnect on WebRTC "failed": capped exponential backoff, redoes the full signaling handshake, wakes immediately on visibilitychange instead of waiting out a throttled backoff timer. - Fixed two real bugs the reconnect work exposed: the signaling POST to the hub kept using the token captured at construction, never the fresh one fetched per reconnect attempt (401 loop, no possible recovery); and connect() re-armed a diagnostic listener/interval on every attempt without disposing of the previous one. - pipelinedDownload retries a lost chunk instead of aborting the whole transfer — covers Files downloads, video poster/thumbnail fetches, and music-player.js's blob-based track download. - music-player.js: don't throw "Transport not connected" while a reconnect is already landing (waitForReconnect); prefetch depth now adapts to network type (5 tracks ahead on Wi-Fi, 3 on cellular or unrecognized — Firefox/Safari included, where the detection API is simply absent). - video-player.js: onReconnected reissues the existing seek-to-current-time path, so a mid-stream reconnect looks like an ordinary seek rather than a dead player; holds a Screen Wake Lock unconditionally while open. - New opt-in (off by default) user preference: keep the screen on during audio playback, for whoever wants to trade battery for sidestepping the screen-lock gap entirely — off by default because the ordinary expectation (matching Spotify/Deezer) is that the phone locks on its own while listening. - hub: /app and / now serve Cache-Control: no-store — the SPA shell had no cache header at all, so a browser that cached it heuristically could keep re-serving an old build (old ASSET_V, old JS) through any number of reloads or pull-to-refreshes. Verified against real production use across many rounds (demo groups, actual mobile screen-lock testing) rather than synthetic reproduction alone. 430 hub tests + 650 node tests passing throughout.
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js100
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js387
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js62
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py39
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