From fd5812c5c29eaf4fe26176eb36e6164907bfbce4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:27:53 -0400 Subject: [PATCH] Player: keep retrying after a lost connection, resume songs cut short, recover on tab return, never let a screen error block playback Co-Authored-By: Claude Sonnet 5.5 --- public/player.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/public/player.js b/public/player.js index 6364fe6..d393c92 100644 --- a/public/player.js +++ b/public/player.js @@ -51,6 +51,7 @@ async function tcPump() { } function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); } addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); }); +addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && cur() && !pl.a.paused && (pl.a.readyState < 3 || pl.a.error)) { pl.retries = 0; recover(true); } }); // (back from sleep or another app) let loadTok = 0, loadTimer = null, stallTimer = null; pl.retries = 0; pl.buf = false; @@ -60,10 +61,12 @@ function loadCur(delay = 220) { clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false; const tok = ++loadTok; pl.buf = true; if (delay) pl.a.pause(); - setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState(); - if (lyr.open) { lyrShell(); lyrReload(); } - if (typeof vizTrackChanged === 'function') vizTrackChanged(); - if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint(); + try { // (a bug in any screen's refresh must never stop the music from starting) + setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState(); + if (lyr.open) { lyrShell(); lyrReload(); } + if (typeof vizTrackChanged === 'function') vizTrackChanged(); + if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint(); + } catch (e) { console.error('player refresh', e); } const go = () => { if (tok !== loadTok) return; const hit = tc.m.get(t.src); @@ -84,7 +87,8 @@ function recover(now) { clearTimeout(stallTimer); const t = cur(), a = pl.a; if (!t || a.paused) return; stallTimer = setTimeout(() => { - if (a.paused || (a.readyState >= 3 && !a.error)) return; + const forced = pl.forceRecover; pl.forceRecover = false; + if (a.paused || (!forced && a.readyState >= 3 && !a.error)) return; const hit = tc.m.get(t.src), pos = a.currentTime; const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem) if (bad && !hit) { @@ -93,7 +97,9 @@ function recover(now) { } const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t); if (!hit && !navigator.onLine) return; // 'online' will call us again - if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; } + if (!hit && !bad && pl.retries >= 5) { // keep trying every 15 s (the 'online' signal is not always sent, e.g. when only the server was unreachable) + if (!pl.gaveUp) { pl.gaveUp = true; pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); } + stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; } if (!hit || a.src !== hit.url) pl.retries++; a.src = want; a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true }); @@ -162,7 +168,10 @@ function tlReport(state) { tlSend(state, tlKey, tlPos, tlDur); } function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; } -pl.a.addEventListener('ended', () => advance(true)); +pl.a.addEventListener('ended', () => { // a dropped connection can look like the end of the song: if it stopped well short of the length, carry on from there instead of skipping + const a = pl.a, t = cur(), d = t && t.dur ? t.dur / 1000 : 0; + if (t && d > 20 && !/^radio:/.test(String(t.key)) && a.currentTime < d - 8 && pl.endRetry !== t.key + '|' + Math.floor(a.currentTime / 5)) { pl.endRetry = t.key + '|' + Math.floor(a.currentTime / 5); pl.forceRecover = true; pl.buf = true; syncUI(); recover(true); return; } + advance(true); }); let lastSave = 0, lastPos = 0; pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } @@ -174,7 +183,7 @@ pl.a.addEventListener('timeupdate', () => { }); addEventListener('pagehide', saveState); ['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI())); -pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; }); +pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; }); pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; }); pl.a.addEventListener('canplaythrough', () => tcKick()); ['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));