aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-04 22:34:21 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-04 22:34:21 +0200
commit859d7ce71795dcfd5701cb08a684dcef23e7c4c0 (patch)
treedc528e2bc5dea8b6c4eb8e0cf156617a4b806c58 /packages
parent983d576e1cc2359505fa4fc0c0c689ec65c99e86 (diff)
downloadmeshbay-859d7ce71795dcfd5701cb08a684dcef23e7c4c0.tar.gz
fix(hub): music player retries a track lost to a screen-off disconnect
A transport failure while the page is hidden, or within 30 s of waking, keeps the track and its spinner and retries once the page or the connection is back, instead of skipping it with an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js63
1 files changed, 60 insertions, 3 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 154d761..f0f7c47 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -217,6 +217,13 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue
// one of them being off is a property of that group rather than of each of
// its tracks in turn โ€” see advancePastFailure below (docs/playlists.md ยง11.3).
const downGroupsRef = useRef(new Set());
+ // When the page last came back from hidden. A phone with its screen off
+ // drops every connection at once, so a track that fails for lack of one
+ // while the page sleeps, or just after it wakes, says nothing about its
+ // group: it is retried rather than skipped (see the load effect below).
+ const wokeAtRef = useRef(0);
+ const [retryNonce, setRetryNonce] = useState(0);
+ const WAKE_GRACE_MS = 30000;
const currentTrack = tracks[order[pos]] || null;
@@ -345,7 +352,11 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue
}
if (!transport) throw groupDown();
if (!transport.connected) await transport.waitForReconnect();
- if (!transport.connected) throw groupDown();
+ if (!transport.connected) {
+ const e = groupDown();
+ e.transport = transport;
+ throw e;
+ }
let downloadId = entry.id;
let downloadSize = entry.size;
@@ -441,8 +452,46 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue
// effect above (pos/order/tracks all just changed together) and also
// after skipNext/skipPrev/onEnded update `pos` alone.
useEffect(() => {
+ const onVisibility = () => {
+ if (document.visibilityState === 'visible') wokeAtRef.current = Date.now();
+ };
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => document.removeEventListener('visibilitychange', onVisibility);
+ }, []);
+
+ const asleepOrJustWoke = () => document.visibilityState === 'hidden'
+ || Date.now() - wokeAtRef.current < WAKE_GRACE_MS;
+
+ // Retries the current track once the page is back and a connection may be:
+ // the screen coming on, the network coming back, or the track's own
+ // transport finishing its reconnect, whichever comes first. A visible page
+ // also retries on a timer, so a failure that outlives the grace window
+ // ends on the ordinary error path instead of a spinner nobody can clear.
+ const retryWhenAwake = (myToken, transport) => {
+ let stop = () => {};
+ let timer = null;
+ const fire = () => {
+ stop();
+ if (loadTokenRef.current === myToken) setRetryNonce((n) => n + 1);
+ };
+ const onVisibility = () => { if (document.visibilityState === 'visible') fire(); };
+ document.addEventListener('visibilitychange', onVisibility);
+ window.addEventListener('online', fire);
+ const unsubscribe = transport ? transport.addReconnectListener(fire) : () => {};
+ if (document.visibilityState === 'visible') timer = setTimeout(fire, 10000);
+ stop = () => {
+ clearTimeout(timer);
+ document.removeEventListener('visibilitychange', onVisibility);
+ window.removeEventListener('online', fire);
+ unsubscribe();
+ };
+ if (transport && transport.connected) fire();
+ };
+
+ useEffect(() => {
if (!currentTrack) return;
const myToken = ++loadTokenRef.current;
+ let waiting = false;
setLoading(true);
setError('');
(async () => {
@@ -462,17 +511,25 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue
// Autoplay refused (no recent tap): the track is loaded and fine, it
// waits for the play button. Not an error, and no reason to skip it.
if (err && err.name === 'NotAllowedError') { setPlaying(false); prefetchNext(pos); return; }
+ if (err && err.isGroupDown && asleepOrJustWoke()) {
+ // Our own connection is what went, not the group: keep the spinner
+ // and this track, and try it again once we are back.
+ waiting = true;
+ setPlaying(false);
+ retryWhenAwake(myToken, err.transport);
+ return;
+ }
setError(err.message || String(err));
setPlaying(false);
// `fetchTrackBlob` throws `err_transport` when no node answered for
// this track's group; anything else is about the file itself.
advancePastFailure(err.isGroupDown ? currentTrack.groupId : null);
} finally {
- if (loadTokenRef.current === myToken) setLoading(false);
+ if (loadTokenRef.current === myToken && !waiting) setLoading(false);
}
})();
// eslint-disable-next-line
- }, [currentTrack && currentTrack.id]);
+ }, [currentTrack && currentTrack.id, retryNonce]);
// Belt and suspenders alongside the catch block above: `.play()` rejecting
// is the common path for a source the browser can't decode at all, but a