Attempt to handle false "ended" events with mkv files in Chrome due to blob bug
This commit is contained in:
214
public/app.js
214
public/app.js
@@ -13,6 +13,13 @@
|
|||||||
let driftInterval = null;
|
let driftInterval = null;
|
||||||
let serverState = { playing: false, position: 0, speed: 1 };
|
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
|
// User colors for chat
|
||||||
const userColors = [
|
const userColors = [
|
||||||
"#3ea6ff", "#ff7043", "#66bb6a", "#ab47bc",
|
"#3ea6ff", "#ff7043", "#66bb6a", "#ab47bc",
|
||||||
@@ -106,11 +113,27 @@
|
|||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
function connect() {
|
function connect() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
console.log("WebSocket connected");
|
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) => {
|
ws.addEventListener("message", (e) => {
|
||||||
@@ -120,9 +143,10 @@
|
|||||||
|
|
||||||
ws.addEventListener("close", () => {
|
ws.addEventListener("close", () => {
|
||||||
console.log("WebSocket disconnected");
|
console.log("WebSocket disconnected");
|
||||||
// Attempt reconnect after 2s if in room
|
|
||||||
if (roomCode) {
|
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) {
|
function send(data) {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify(data));
|
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) {
|
switch (msg.type) {
|
||||||
case "room_created":
|
case "room_created":
|
||||||
roomCode = msg.code;
|
roomCode = msg.code;
|
||||||
|
roomFileInfo = msg.fileInfo;
|
||||||
enterRoom(msg);
|
enterRoom(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -151,6 +221,7 @@
|
|||||||
|
|
||||||
case "room_joined":
|
case "room_joined":
|
||||||
roomCode = msg.code;
|
roomCode = msg.code;
|
||||||
|
roomFileInfo = msg.fileInfo;
|
||||||
fileCheckModal.classList.add("hidden");
|
fileCheckModal.classList.add("hidden");
|
||||||
enterRoom(msg);
|
enterRoom(msg);
|
||||||
// Apply initial state
|
// Apply initial state
|
||||||
@@ -163,11 +234,29 @@
|
|||||||
}
|
}
|
||||||
break;
|
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":
|
case "user_joined":
|
||||||
updateUsers(msg.users);
|
updateUsers(msg.users);
|
||||||
addSystemMessage(`${msg.username} joined`);
|
addSystemMessage(`${msg.username} joined`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "user_rejoined":
|
||||||
|
updateUsers(msg.users);
|
||||||
|
addSystemMessage(`${msg.username} reconnected`);
|
||||||
|
break;
|
||||||
|
|
||||||
case "user_left":
|
case "user_left":
|
||||||
updateUsers(msg.users);
|
updateUsers(msg.users);
|
||||||
addSystemMessage(`${msg.username} left`);
|
addSystemMessage(`${msg.username} left`);
|
||||||
@@ -187,6 +276,13 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
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);
|
showError(msg.message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -202,14 +298,83 @@
|
|||||||
// Load local file into video player
|
// Load local file into video player
|
||||||
const file = localFile || joinFile;
|
const file = localFile || joinFile;
|
||||||
if (file) {
|
if (file) {
|
||||||
const url = URL.createObjectURL(file);
|
loadVideoSource(file);
|
||||||
videoPlayer.src = url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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
|
// Start drift correction
|
||||||
startDriftCorrection();
|
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 ---
|
// --- File Check Modal ---
|
||||||
let pendingRoomFileInfo = null;
|
let pendingRoomFileInfo = null;
|
||||||
|
|
||||||
@@ -291,6 +456,11 @@
|
|||||||
function applySync(data) {
|
function applySync(data) {
|
||||||
ignoreSync = true;
|
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) {
|
if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) {
|
||||||
videoPlayer.playbackRate = data.speed;
|
videoPlayer.playbackRate = data.speed;
|
||||||
speedSelect.value = String(data.speed);
|
speedSelect.value = String(data.speed);
|
||||||
@@ -305,15 +475,23 @@
|
|||||||
|
|
||||||
if (data.playing !== undefined) {
|
if (data.playing !== undefined) {
|
||||||
if (data.playing && videoPlayer.paused) {
|
if (data.playing && videoPlayer.paused) {
|
||||||
videoPlayer.play().catch(() => {});
|
videoPlayer.play().then(() => {
|
||||||
videoWrapper.classList.add("playing");
|
videoWrapper.classList.add("playing");
|
||||||
|
updatePlayPauseIcon();
|
||||||
|
}).catch(() => {
|
||||||
|
videoWrapper.classList.remove("playing");
|
||||||
|
updatePlayPauseIcon();
|
||||||
|
});
|
||||||
} else if (!data.playing && !videoPlayer.paused) {
|
} else if (!data.playing && !videoPlayer.paused) {
|
||||||
videoPlayer.pause();
|
videoPlayer.pause();
|
||||||
videoWrapper.classList.remove("playing");
|
videoWrapper.classList.remove("playing");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePlayPauseIcon();
|
updatePlayPauseIcon();
|
||||||
|
} else {
|
||||||
|
updatePlayPauseIcon();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatePlayPauseIcon();
|
||||||
|
}
|
||||||
|
|
||||||
// Clear ignore flag after a short delay
|
// Clear ignore flag after a short delay
|
||||||
clearTimeout(syncTimeout);
|
clearTimeout(syncTimeout);
|
||||||
@@ -488,16 +666,26 @@
|
|||||||
|
|
||||||
// --- Room: Leave ---
|
// --- Room: Leave ---
|
||||||
leaveRoomBtn.addEventListener("click", () => {
|
leaveRoomBtn.addEventListener("click", () => {
|
||||||
if (ws) ws.close();
|
// Intentional leave — clear roomCode before closing so we don't auto-reconnect
|
||||||
|
const code = roomCode;
|
||||||
roomCode = "";
|
roomCode = "";
|
||||||
|
isReconnecting = false;
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (ws) ws.close();
|
||||||
localFile = null;
|
localFile = null;
|
||||||
joinFile = null;
|
joinFile = null;
|
||||||
|
roomFileInfo = null;
|
||||||
|
messageQueue = [];
|
||||||
if (driftInterval) clearInterval(driftInterval);
|
if (driftInterval) clearInterval(driftInterval);
|
||||||
videoPlayer.src = "";
|
videoPlayer.src = "";
|
||||||
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
||||||
createFileInfo.classList.add("hidden");
|
createFileInfo.classList.add("hidden");
|
||||||
createFileInput.value = "";
|
createFileInput.value = "";
|
||||||
createRoomBtn.disabled = true;
|
createRoomBtn.disabled = true;
|
||||||
|
showConnectionStatus("hidden");
|
||||||
showView(lobbyView);
|
showView(lobbyView);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,16 +695,12 @@
|
|||||||
if (videoPlayer.paused) {
|
if (videoPlayer.paused) {
|
||||||
videoPlayer.play().catch(() => {});
|
videoPlayer.play().catch(() => {});
|
||||||
videoWrapper.classList.add("playing");
|
videoWrapper.classList.add("playing");
|
||||||
if (!ignoreSync) {
|
|
||||||
send({ type: "sync", action: "play", position: videoPlayer.currentTime });
|
send({ type: "sync", action: "play", position: videoPlayer.currentTime });
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
videoPlayer.pause();
|
videoPlayer.pause();
|
||||||
videoWrapper.classList.remove("playing");
|
videoWrapper.classList.remove("playing");
|
||||||
if (!ignoreSync) {
|
|
||||||
send({ type: "sync", action: "pause", position: videoPlayer.currentTime });
|
send({ type: "sync", action: "pause", position: videoPlayer.currentTime });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
updatePlayPauseIcon();
|
updatePlayPauseIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,9 +749,7 @@
|
|||||||
const time = (seekBar.value / 1000) * videoPlayer.duration;
|
const time = (seekBar.value / 1000) * videoPlayer.duration;
|
||||||
videoPlayer.currentTime = time;
|
videoPlayer.currentTime = time;
|
||||||
isSeeking = false;
|
isSeeking = false;
|
||||||
if (!ignoreSync) {
|
|
||||||
send({ type: "sync", action: "seek", position: time });
|
send({ type: "sync", action: "seek", position: time });
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Volume
|
// Volume
|
||||||
@@ -588,9 +770,7 @@
|
|||||||
speedSelect.addEventListener("change", () => {
|
speedSelect.addEventListener("change", () => {
|
||||||
const speed = parseFloat(speedSelect.value);
|
const speed = parseFloat(speedSelect.value);
|
||||||
videoPlayer.playbackRate = speed;
|
videoPlayer.playbackRate = speed;
|
||||||
if (!ignoreSync) {
|
|
||||||
send({ type: "sync", action: "speed", speed });
|
send({ type: "sync", action: "speed", speed });
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fullscreen
|
// Fullscreen
|
||||||
|
|||||||
@@ -95,6 +95,8 @@
|
|||||||
<!-- ===== ROOM VIEW ===== -->
|
<!-- ===== ROOM VIEW ===== -->
|
||||||
<div id="room" class="view">
|
<div id="room" class="view">
|
||||||
<div class="room-layout">
|
<div class="room-layout">
|
||||||
|
<!-- Connection Status Banner -->
|
||||||
|
<div id="connection-status" class="connection-status hidden"></div>
|
||||||
<!-- Video Area -->
|
<!-- Video Area -->
|
||||||
<div class="video-area">
|
<div class="video-area">
|
||||||
<div class="video-topbar">
|
<div class="video-topbar">
|
||||||
|
|||||||
180
public/style.css
180
public/style.css
@@ -1,5 +1,7 @@
|
|||||||
/* ===== Reset & Base ===== */
|
/* ===== Reset & Base ===== */
|
||||||
*, *::before, *::after {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -25,12 +27,13 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--radius-xl: 16px;
|
--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;
|
--transition: 0.2s ease;
|
||||||
--font: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -39,33 +42,45 @@ html, body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, button, select {
|
input,
|
||||||
|
button,
|
||||||
|
select {
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.view {
|
.view {
|
||||||
display: none;
|
display: none;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
.view.active { display: flex; }
|
|
||||||
|
.view.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== LOBBY ===== */
|
/* ===== LOBBY ===== */
|
||||||
#lobby {
|
#lobby {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 20% 50%, rgba(62,166,255,0.06) 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%),
|
radial-gradient(ellipse at 80% 50%, rgba(62, 166, 255, 0.04) 0%, transparent 50%),
|
||||||
var(--bg-primary);
|
var(--bg-primary);
|
||||||
animation: lobbyBg 20s ease-in-out infinite alternate;
|
animation: lobbyBg 20s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes lobbyBg {
|
@keyframes lobbyBg {
|
||||||
0% { background-position: 0% 50%; }
|
0% {
|
||||||
100% { background-position: 100% 50%; }
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-container {
|
.lobby-container {
|
||||||
@@ -86,8 +101,15 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logoPulse {
|
@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 {
|
.lobby-brand h1 {
|
||||||
@@ -319,14 +341,21 @@ input, button, select {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
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;
|
z-index: 1000;
|
||||||
animation: toastIn 0.3s ease;
|
animation: toastIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
from {
|
||||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== FILE CHECK MODAL ===== */
|
/* ===== FILE CHECK MODAL ===== */
|
||||||
@@ -342,7 +371,7 @@ input, button, select {
|
|||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +388,15 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modalIn {
|
@keyframes modalIn {
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
from {
|
||||||
to { opacity: 1; transform: scale(1); }
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h2 {
|
.modal-content h2 {
|
||||||
@@ -406,7 +442,7 @@ input, button, select {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-info-card .row > div {
|
.file-info-card .row>div {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,14 +455,14 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-match-status.match {
|
.file-match-status.match {
|
||||||
background: rgba(46,204,113,0.1);
|
background: rgba(46, 204, 113, 0.1);
|
||||||
border: 1px solid rgba(46,204,113,0.3);
|
border: 1px solid rgba(46, 204, 113, 0.3);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-match-status.mismatch {
|
.file-match-status.mismatch {
|
||||||
background: rgba(255,78,69,0.1);
|
background: rgba(255, 78, 69, 0.1);
|
||||||
border: 1px solid rgba(255,78,69,0.3);
|
border: 1px solid rgba(255, 78, 69, 0.3);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,12 +477,70 @@ input, button, select {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== ROOM VIEW ===== */
|
|
||||||
.room-layout {
|
.room-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
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 */
|
/* Video Area */
|
||||||
@@ -572,7 +666,7 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.big-play-btn:hover svg circle {
|
.big-play-btn:hover svg circle {
|
||||||
fill: rgba(0,0,0,0.75);
|
fill: rgba(0, 0, 0, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls bar */
|
/* Controls bar */
|
||||||
@@ -581,7 +675,7 @@ input, button, select {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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;
|
padding: 20px 12px 10px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
@@ -602,7 +696,7 @@ input, button, select {
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -620,7 +714,7 @@ input, button, select {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
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;
|
transition: transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,7 +758,7 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ctrl-btn:hover {
|
.ctrl-btn:hover {
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
@@ -678,7 +772,7 @@ input, button, select {
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
width: 70px;
|
width: 70px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: rgba(255,255,255,0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -712,14 +806,14 @@ input, button, select {
|
|||||||
|
|
||||||
.time-display {
|
.time-display {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: rgba(255,255,255,0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speed-select {
|
.speed-select {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(255,255,255,0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -773,8 +867,15 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dotPulse {
|
@keyframes dotPulse {
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-list {
|
.users-list {
|
||||||
@@ -845,8 +946,15 @@ input, button, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes msgIn {
|
@keyframes msgIn {
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg .msg-author {
|
.chat-msg .msg-author {
|
||||||
|
|||||||
85
server.ts
85
server.ts
@@ -27,6 +27,7 @@ interface ChatMessage {
|
|||||||
|
|
||||||
// --- Room Management ---
|
// --- Room Management ---
|
||||||
const rooms = new Map<string, RoomState>();
|
const rooms = new Map<string, RoomState>();
|
||||||
|
const pendingDisconnects = new Map<string, ReturnType<typeof setTimeout>>(); // key: "roomCode:username"
|
||||||
|
|
||||||
function generateRoomCode(): string {
|
function generateRoomCode(): string {
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars
|
||||||
@@ -348,6 +349,64 @@ const server = Bun.serve<WSData>({
|
|||||||
);
|
);
|
||||||
break;
|
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<WSData>({
|
|||||||
const room = rooms.get(roomCode);
|
const room = rooms.get(roomCode);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
room.users.delete(username);
|
// Don't remove immediately — give a 30s grace period for reconnection
|
||||||
console.log(`[Room ${roomCode}] ${username} left`);
|
const disconnectKey = `${roomCode}:${username}`;
|
||||||
|
console.log(`[Room ${roomCode}] ${username} disconnected (waiting 30s for reconnect)`);
|
||||||
|
|
||||||
if (room.users.size === 0) {
|
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);
|
rooms.delete(roomCode);
|
||||||
console.log(`[Room ${roomCode}] Deleted (empty)`);
|
console.log(`[Room ${roomCode}] Deleted (empty)`);
|
||||||
} else {
|
} else {
|
||||||
broadcastToRoom(room, {
|
broadcastToRoom(currentRoom, {
|
||||||
type: "user_left",
|
type: "user_left",
|
||||||
username,
|
username,
|
||||||
users: getUserList(room),
|
users: getUserList(currentRoom),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
pendingDisconnects.set(disconnectKey, timer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user