diff --git a/public/app.js b/public/app.js index 90aa871..2100891 100644 --- a/public/app.js +++ b/public/app.js @@ -13,6 +13,13 @@ let driftInterval = null; let serverState = { playing: false, position: 0, speed: 1 }; + // Reconnection state + let reconnectAttempts = 0; + let reconnectTimer = null; + let isReconnecting = false; + let messageQueue = []; // queued messages while disconnected + let roomFileInfo = null; // cached file info for the room + // User colors for chat const userColors = [ "#3ea6ff", "#ff7043", "#66bb6a", "#ab47bc", @@ -106,11 +113,27 @@ // --- WebSocket --- function connect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; ws = new WebSocket(`${protocol}//${location.host}/ws`); ws.addEventListener("open", () => { console.log("WebSocket connected"); + reconnectAttempts = 0; + + // If we were in a room, auto-rejoin + if (isReconnecting && roomCode && username) { + console.log("Auto-rejoining room", roomCode); + ws.send(JSON.stringify({ + type: "rejoin_room", + username, + code: roomCode, + })); + } }); ws.addEventListener("message", (e) => { @@ -120,9 +143,10 @@ ws.addEventListener("close", () => { console.log("WebSocket disconnected"); - // Attempt reconnect after 2s if in room if (roomCode) { - setTimeout(() => connect(), 2000); + isReconnecting = true; + showConnectionStatus("reconnecting"); + scheduleReconnect(); } }); @@ -131,9 +155,54 @@ }); } + function scheduleReconnect() { + // Exponential backoff: 1s, 2s, 4s, 8s, capped at 10s + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); + reconnectAttempts++; + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); + reconnectTimer = setTimeout(() => connect(), delay); + } + function send(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); + } else if (roomCode) { + // Queue messages while disconnected + if (data.type === "sync") { + // Only keep the latest sync message (stale syncs are useless) + const idx = messageQueue.findIndex((m) => m.type === "sync"); + if (idx >= 0) messageQueue[idx] = data; + else messageQueue.push(data); + } else { + messageQueue.push(data); + } + } + } + + function flushMessageQueue() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const queue = messageQueue.splice(0); + for (const msg of queue) { + ws.send(JSON.stringify(msg)); + } + } + + // --- Connection Status Indicator --- + function showConnectionStatus(status) { + const banner = $("connection-status"); + if (!banner) return; + + if (status === "reconnecting") { + banner.textContent = "⚡ Reconnecting..."; + banner.className = "connection-status reconnecting"; + } else if (status === "reconnected") { + banner.textContent = "✓ Reconnected"; + banner.className = "connection-status reconnected"; + setTimeout(() => { + banner.className = "connection-status hidden"; + }, 3000); + } else { + banner.className = "connection-status hidden"; } } @@ -142,6 +211,7 @@ switch (msg.type) { case "room_created": roomCode = msg.code; + roomFileInfo = msg.fileInfo; enterRoom(msg); break; @@ -151,6 +221,7 @@ case "room_joined": roomCode = msg.code; + roomFileInfo = msg.fileInfo; fileCheckModal.classList.add("hidden"); enterRoom(msg); // Apply initial state @@ -163,11 +234,29 @@ } break; + case "room_rejoined": + // Seamless reconnection — sync to server state + isReconnecting = false; + showConnectionStatus("reconnected"); + updateUsers(msg.users); + if (msg.state) { + applySync(msg.state); + } + addSystemMessage("Reconnected"); + startDriftCorrection(); + flushMessageQueue(); + break; + case "user_joined": updateUsers(msg.users); addSystemMessage(`${msg.username} joined`); break; + case "user_rejoined": + updateUsers(msg.users); + addSystemMessage(`${msg.username} reconnected`); + break; + case "user_left": updateUsers(msg.users); addSystemMessage(`${msg.username} left`); @@ -187,6 +276,13 @@ break; case "error": + // If room no longer exists on rejoin, go back to lobby + if (isReconnecting) { + isReconnecting = false; + showConnectionStatus("hidden"); + roomCode = ""; + showView(lobbyView); + } showError(msg.message); break; } @@ -202,14 +298,83 @@ // Load local file into video player const file = localFile || joinFile; if (file) { - const url = URL.createObjectURL(file); - videoPlayer.src = url; + loadVideoSource(file); } + // --- Handle false "ended" events (Chromium MKV blob bug) --- + // Chrome can't properly index MKV containers loaded via blob URLs. + // After ~30s it may jump to the end and fire "ended" even though + // the video isn't actually over. Recovery: reload the blob and seek. + let recoveryAttempts = 0; + const MAX_RECOVERY_ATTEMPTS = 5; + + videoPlayer.addEventListener("ended", () => { + const duration = videoPlayer.duration || 0; + // Only attempt recovery if we have server state showing we're not near the end + if (duration > 0 && lastServerState.position < duration - 5 && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) { + recoveryAttempts++; + console.log(`[MKV-RECOVERY] False ended detected. Server pos=${lastServerState.position.toFixed(1)}, duration=${duration.toFixed(1)}. Reloading source (attempt ${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})`); + + const targetPos = lastServerState.position; + const wasPlaying = lastServerState.playing; + const currentFile = localFile || joinFile; + + if (currentFile) { + // Reload the video with a fresh blob URL + loadVideoSource(currentFile, targetPos, wasPlaying); + } + } + }); + + // Reset recovery counter when video plays successfully for a while + let recoveryResetTimer = null; + videoPlayer.addEventListener("timeupdate", () => { + if (recoveryAttempts > 0) { + clearTimeout(recoveryResetTimer); + recoveryResetTimer = setTimeout(() => { + recoveryAttempts = 0; + }, 10000); // Reset after 10s of successful playback + } + }); + + // --- Resync on tab focus (handles background tab throttling) --- + document.addEventListener("visibilitychange", () => { + if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) { + console.log("[SYNC] Tab became visible, requesting state resync"); + send({ type: "request_state" }); + } + }); + // Start drift correction startDriftCorrection(); } + // --- Video Source Loading --- + let currentBlobUrl = null; + let lastServerState = { playing: false, position: 0, speed: 1 }; + + function loadVideoSource(file, seekTo, shouldPlay) { + // Revoke old blob URL to free memory + if (currentBlobUrl) { + URL.revokeObjectURL(currentBlobUrl); + } + currentBlobUrl = URL.createObjectURL(file); + videoPlayer.src = currentBlobUrl; + + if (seekTo !== undefined) { + videoPlayer.addEventListener("loadedmetadata", function onMeta() { + videoPlayer.removeEventListener("loadedmetadata", onMeta); + videoPlayer.currentTime = seekTo; + if (shouldPlay) { + videoPlayer.play().then(() => { + videoWrapper.classList.add("playing"); + updatePlayPauseIcon(); + }).catch(() => {}); + } + }); + } + } + // --- File Check Modal --- let pendingRoomFileInfo = null; @@ -291,6 +456,11 @@ function applySync(data) { ignoreSync = true; + // Track latest server state for recovery + if (data.position !== undefined) lastServerState.position = data.position; + if (data.playing !== undefined) lastServerState.playing = data.playing; + if (data.speed !== undefined) lastServerState.speed = data.speed; + if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) { videoPlayer.playbackRate = data.speed; speedSelect.value = String(data.speed); @@ -305,16 +475,24 @@ if (data.playing !== undefined) { if (data.playing && videoPlayer.paused) { - videoPlayer.play().catch(() => {}); - videoWrapper.classList.add("playing"); + videoPlayer.play().then(() => { + videoWrapper.classList.add("playing"); + updatePlayPauseIcon(); + }).catch(() => { + videoWrapper.classList.remove("playing"); + updatePlayPauseIcon(); + }); } else if (!data.playing && !videoPlayer.paused) { videoPlayer.pause(); videoWrapper.classList.remove("playing"); + updatePlayPauseIcon(); + } else { + updatePlayPauseIcon(); } + } else { + updatePlayPauseIcon(); } - updatePlayPauseIcon(); - // Clear ignore flag after a short delay clearTimeout(syncTimeout); syncTimeout = setTimeout(() => { @@ -488,16 +666,26 @@ // --- Room: Leave --- leaveRoomBtn.addEventListener("click", () => { - if (ws) ws.close(); + // Intentional leave — clear roomCode before closing so we don't auto-reconnect + const code = roomCode; roomCode = ""; + isReconnecting = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws) ws.close(); localFile = null; joinFile = null; + roomFileInfo = null; + messageQueue = []; if (driftInterval) clearInterval(driftInterval); videoPlayer.src = ""; chatMessages.innerHTML = '
Welcome to the room! 👋