Attempt to handle false "ended" events with mkv files in Chrome due to blob bug
This commit is contained in:
222
public/app.js
222
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 = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user