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! 👋

'; createFileInfo.classList.add("hidden"); createFileInput.value = ""; createRoomBtn.disabled = true; + showConnectionStatus("hidden"); showView(lobbyView); }); @@ -507,15 +695,11 @@ if (videoPlayer.paused) { videoPlayer.play().catch(() => {}); videoWrapper.classList.add("playing"); - if (!ignoreSync) { - send({ type: "sync", action: "play", position: videoPlayer.currentTime }); - } + send({ type: "sync", action: "play", position: videoPlayer.currentTime }); } else { videoPlayer.pause(); videoWrapper.classList.remove("playing"); - if (!ignoreSync) { - send({ type: "sync", action: "pause", position: videoPlayer.currentTime }); - } + send({ type: "sync", action: "pause", position: videoPlayer.currentTime }); } updatePlayPauseIcon(); } @@ -565,9 +749,7 @@ const time = (seekBar.value / 1000) * videoPlayer.duration; videoPlayer.currentTime = time; isSeeking = false; - if (!ignoreSync) { - send({ type: "sync", action: "seek", position: time }); - } + send({ type: "sync", action: "seek", position: time }); }); // Volume @@ -588,9 +770,7 @@ speedSelect.addEventListener("change", () => { const speed = parseFloat(speedSelect.value); videoPlayer.playbackRate = speed; - if (!ignoreSync) { - send({ type: "sync", action: "speed", speed }); - } + send({ type: "sync", action: "speed", speed }); }); // Fullscreen diff --git a/public/index.html b/public/index.html index 87b0984..2882310 100644 --- a/public/index.html +++ b/public/index.html @@ -95,6 +95,8 @@
+ +
diff --git a/public/style.css b/public/style.css index 3d59318..e5463b7 100644 --- a/public/style.css +++ b/public/style.css @@ -1,5 +1,7 @@ /* ===== Reset & Base ===== */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -25,12 +27,13 @@ --radius: 8px; --radius-lg: 12px; --radius-xl: 16px; - --shadow: 0 4px 24px rgba(0,0,0,0.4); + --shadow: 0 4px 24px rgba(0, 0, 0, 0.4); --transition: 0.2s ease; --font: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif; } -html, body { +html, +body { height: 100%; font-family: var(--font); background: var(--bg-primary); @@ -39,33 +42,45 @@ html, body { -webkit-font-smoothing: antialiased; } -input, button, select { +input, +button, +select { font-family: var(--font); font-size: inherit; } -.hidden { display: none !important; } +.hidden { + display: none !important; +} .view { display: none; height: 100vh; } -.view.active { display: flex; } + +.view.active { + display: flex; +} /* ===== LOBBY ===== */ #lobby { align-items: center; justify-content: center; background: - radial-gradient(ellipse at 20% 50%, rgba(62,166,255,0.06) 0%, transparent 50%), - radial-gradient(ellipse at 80% 50%, rgba(62,166,255,0.04) 0%, transparent 50%), + radial-gradient(ellipse at 20% 50%, rgba(62, 166, 255, 0.06) 0%, transparent 50%), + radial-gradient(ellipse at 80% 50%, rgba(62, 166, 255, 0.04) 0%, transparent 50%), var(--bg-primary); animation: lobbyBg 20s ease-in-out infinite alternate; } @keyframes lobbyBg { - 0% { background-position: 0% 50%; } - 100% { background-position: 100% 50%; } + 0% { + background-position: 0% 50%; + } + + 100% { + background-position: 100% 50%; + } } .lobby-container { @@ -86,8 +101,15 @@ input, button, select { } @keyframes logoPulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.05); } + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.05); + } } .lobby-brand h1 { @@ -319,14 +341,21 @@ input, button, select { border-radius: var(--radius); font-size: 0.9rem; font-weight: 500; - box-shadow: 0 4px 20px rgba(255,78,69,0.3); + box-shadow: 0 4px 20px rgba(255, 78, 69, 0.3); z-index: 1000; animation: toastIn 0.3s ease; } @keyframes toastIn { - from { opacity: 0; transform: translateX(-50%) translateY(20px); } - to { opacity: 1; transform: translateX(-50%) translateY(0); } + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } } /* ===== FILE CHECK MODAL ===== */ @@ -342,7 +371,7 @@ input, button, select { .modal-backdrop { position: absolute; inset: 0; - background: rgba(0,0,0,0.7); + background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); } @@ -359,8 +388,15 @@ input, button, select { } @keyframes modalIn { - from { opacity: 0; transform: scale(0.95); } - to { opacity: 1; transform: scale(1); } + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } } .modal-content h2 { @@ -406,7 +442,7 @@ input, button, select { margin-top: 10px; } -.file-info-card .row > div { +.file-info-card .row>div { flex: 1; } @@ -419,14 +455,14 @@ input, button, select { } .file-match-status.match { - background: rgba(46,204,113,0.1); - border: 1px solid rgba(46,204,113,0.3); + background: rgba(46, 204, 113, 0.1); + border: 1px solid rgba(46, 204, 113, 0.3); color: var(--success); } .file-match-status.mismatch { - background: rgba(255,78,69,0.1); - border: 1px solid rgba(255,78,69,0.3); + background: rgba(255, 78, 69, 0.1); + border: 1px solid rgba(255, 78, 69, 0.3); color: var(--danger); } @@ -441,12 +477,70 @@ input, button, select { flex: 1; } -/* ===== ROOM VIEW ===== */ .room-layout { display: flex; width: 100%; height: 100vh; overflow: hidden; + position: relative; +} + +/* Connection Status Banner */ +.connection-status { + position: absolute; + top: 52px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + padding: 8px 20px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.3px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + animation: statusIn 0.3s ease; + transition: opacity 0.5s ease; +} + +.connection-status.reconnecting { + background: rgba(243, 156, 18, 0.15); + border: 1px solid rgba(243, 156, 18, 0.4); + color: var(--warning); + animation: statusIn 0.3s ease, statusPulse 2s ease-in-out infinite; +} + +.connection-status.reconnected { + background: rgba(46, 204, 113, 0.15); + border: 1px solid rgba(46, 204, 113, 0.4); + color: var(--success); +} + +.connection-status.hidden { + display: none !important; +} + +@keyframes statusIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-10px); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes statusPulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } } /* Video Area */ @@ -572,7 +666,7 @@ input, button, select { } .big-play-btn:hover svg circle { - fill: rgba(0,0,0,0.75); + fill: rgba(0, 0, 0, 0.75); } /* Controls bar */ @@ -581,7 +675,7 @@ input, button, select { bottom: 0; left: 0; right: 0; - background: linear-gradient(transparent, rgba(0,0,0,0.85)); + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); padding: 20px 12px 10px; opacity: 0; transition: opacity 0.3s ease; @@ -602,7 +696,7 @@ input, button, select { appearance: none; width: 100%; height: 4px; - background: rgba(255,255,255,0.2); + background: rgba(255, 255, 255, 0.2); border-radius: 2px; outline: none; cursor: pointer; @@ -620,7 +714,7 @@ input, button, select { background: var(--accent); border-radius: 50%; cursor: pointer; - box-shadow: 0 0 4px rgba(0,0,0,0.3); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); transition: transform 0.15s ease; } @@ -664,7 +758,7 @@ input, button, select { } .ctrl-btn:hover { - background: rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.1); } .volume-control { @@ -678,7 +772,7 @@ input, button, select { appearance: none; width: 70px; height: 3px; - background: rgba(255,255,255,0.3); + background: rgba(255, 255, 255, 0.3); border-radius: 2px; outline: none; cursor: pointer; @@ -712,14 +806,14 @@ input, button, select { .time-display { font-size: 0.8rem; - color: rgba(255,255,255,0.8); + color: rgba(255, 255, 255, 0.8); font-variant-numeric: tabular-nums; margin-left: 4px; } .speed-select { background: none; - border: 1px solid rgba(255,255,255,0.2); + border: 1px solid rgba(255, 255, 255, 0.2); color: white; padding: 4px 8px; border-radius: 4px; @@ -773,8 +867,15 @@ input, button, select { } @keyframes dotPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } } .users-list { @@ -845,8 +946,15 @@ input, button, select { } @keyframes msgIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .chat-msg .msg-author { @@ -955,4 +1063,4 @@ input, button, select { .file-badge { display: none; } -} +} \ No newline at end of file diff --git a/server.ts b/server.ts index db3b88f..62e4866 100644 --- a/server.ts +++ b/server.ts @@ -27,6 +27,7 @@ interface ChatMessage { // --- Room Management --- const rooms = new Map(); +const pendingDisconnects = new Map>(); // key: "roomCode:username" function generateRoomCode(): string { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars @@ -348,6 +349,64 @@ const server = Bun.serve({ ); break; } + + case "rejoin_room": { + const username = (msg.username || "").trim(); + const code = (msg.code || "").trim().toUpperCase(); + if (!username || !code) { + ws.send(JSON.stringify({ type: "error", message: "Username and room code required" })); + return; + } + + const room = rooms.get(code); + if (!room) { + ws.send(JSON.stringify({ type: "error", message: "Room no longer exists" })); + return; + } + + // Cancel any pending disconnect timer for this user + const disconnectKey = `${code}:${username}`; + const pendingTimer = pendingDisconnects.get(disconnectKey); + if (pendingTimer) { + clearTimeout(pendingTimer); + pendingDisconnects.delete(disconnectKey); + console.log(`[Room ${code}] ${username} reconnected (cancelled disconnect timer)`); + } + + // Swap in the new WebSocket + ws.data.username = username; + ws.data.roomCode = code; + room.users.set(username, ws as unknown as WebSocket); + + // Send full current state to the reconnected client + ws.send( + JSON.stringify({ + type: "room_rejoined", + code, + fileInfo: room.fileInfo, + users: getUserList(room), + state: { + playing: room.playing, + position: getCurrentPosition(room), + speed: room.speed, + }, + chatHistory: room.chatHistory.slice(-50), + }) + ); + + // Notify others that user is back + broadcastToRoom( + room, + { + type: "user_rejoined", + username, + users: getUserList(room), + }, + ws as unknown as WebSocket + ); + console.log(`[Room ${code}] ${username} rejoined`); + break; + } } }, @@ -358,19 +417,35 @@ const server = Bun.serve({ const room = rooms.get(roomCode); if (!room) return; - room.users.delete(username); - console.log(`[Room ${roomCode}] ${username} left`); + // Don't remove immediately — give a 30s grace period for reconnection + const disconnectKey = `${roomCode}:${username}`; + console.log(`[Room ${roomCode}] ${username} disconnected (waiting 30s for reconnect)`); - if (room.users.size === 0) { - rooms.delete(roomCode); - console.log(`[Room ${roomCode}] Deleted (empty)`); - } else { - broadcastToRoom(room, { - type: "user_left", - username, - users: getUserList(room), - }); - } + const timer = setTimeout(() => { + pendingDisconnects.delete(disconnectKey); + const currentRoom = rooms.get(roomCode); + if (!currentRoom) return; + + // Only remove if the stored WS is still the old one (not swapped by rejoin) + const currentWs = currentRoom.users.get(username); + if (currentWs === (ws as unknown as WebSocket)) { + currentRoom.users.delete(username); + console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`); + + if (currentRoom.users.size === 0) { + rooms.delete(roomCode); + console.log(`[Room ${roomCode}] Deleted (empty)`); + } else { + broadcastToRoom(currentRoom, { + type: "user_left", + username, + users: getUserList(currentRoom), + }); + } + } + }, 30_000); + + pendingDisconnects.set(disconnectKey, timer); }, }, });