Attempt to handle false "ended" events with mkv files in Chrome due to blob bug

This commit is contained in:
Peter Stockings
2026-03-01 21:57:53 +11:00
parent 69feac1d55
commit bec873a9c7
4 changed files with 435 additions and 70 deletions

View File

@@ -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

View File

@@ -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">

View File

@@ -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;
@@ -30,7 +32,8 @@
--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,18 +42,25 @@ 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 {
@@ -64,8 +74,13 @@ input, button, select {
} }
@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 {
@@ -325,8 +347,15 @@ input, button, select {
} }
@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 ===== */
@@ -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 {
@@ -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 */
@@ -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 {

View File

@@ -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);
}, },
}, },
}); });