diff --git a/server/player/index.html b/server/player/index.html index 1d9e2cc..5cec7dd 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -1397,6 +1397,26 @@ // stage so this device's tile is the visible portion. Each tile is // 100vw × 100vh; the stage is the full grid, translated by this tile's // grid position (plus bezel offsets in px between tiles). + // #200: keep advanceTimer consistent with the current playback mode. A schedule-driven device (wall + // FOLLOWER or group member) runs NO local advance timer — its tick drives the index, and a stale solo + // timer would spuriously advance and desync (zombie timer, Bug B). A solo player or wall LEADER + // advances locally, so on the way OUT of a schedule-driven mode a surviving image/widget item (which + // never armed a timer while driven) would freeze (Bug C) — re-render it (buffered, no flash) to re-arm. + // Video/YouTube self-advance via their own end handlers and re-rendering would restart them, so leave + // those alone. Called on every mode enter/exit (applyWallMode/applyGroupSync), which is the single + // chokepoint every transition path routes through. + function reconcileAdvanceTimerForMode() { + if (isWallFollower() || groupSync) { + if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; } + return; + } + if (!isPlaying) return; + const item = playlist[currentIndex]; + if (!item || advanceTimer) return; // solo/leader that already has its timer armed -> nothing to do + const needsSoloTimer = !!item.widget_id || (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/')); + if (needsSoloTimer) playCurrentItem(); // re-render buffered + re-arm the solo advance timer + } + function applyWallMode(config) { const container = document.getElementById('playerContainer'); // Tear down previous wall mode (clear sync timer regardless of new state) @@ -1407,6 +1427,7 @@ wallConfig = null; container.classList.remove('wall-mode'); console.log('[wall] exited wall mode'); + reconcileAdvanceTimerForMode(); // #200: back to solo -> re-arm a surviving image/widget's timer return; } wallConfig = config; @@ -1443,6 +1464,7 @@ socket.emit('wall:sync-request', { wall_id: config.wall_id }); } } + reconcileAdvanceTimerForMode(); // #200: follower -> kill any stale solo timer; leader keeps its own } function emitWallSync() { @@ -1562,6 +1584,7 @@ if (!cfg) { if (groupSync) groupReport('info', 'group-sync exited'); groupSync = null; console.log('[group-sync] exited'); if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} groupPreloadEl = null; groupPreloadIdx = -1; } + reconcileAdvanceTimerForMode(); // #200: back to solo -> re-arm a surviving image/widget's timer return; } const first = !groupSync; @@ -1571,6 +1594,7 @@ groupReport('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(cfg.group_id).slice(0, 8) + ' off=' + clockOffsetMs + 'ms'); groupScheduleTick(); // align immediately groupSyncTimer = setInterval(groupScheduleTick, 250); // 4Hz local correction + reconcileAdvanceTimerForMode(); // #200: group member runs no solo timer -> kill any zombie } // Map the player rect into this device's viewport using vw/vh so the