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