// ===== VlcSync Client ===== (() => { "use strict"; // --- State --- let ws = null; let username = ""; let roomCode = ""; let localFile = null; // File object for the creator let joinFile = null; // File object for the joiner let ignoreSync = false; // flag to avoid feedback loops let syncTimeout = null; // 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", "#ffa726", "#ef5350", "#26c6da", "#ec407a", ]; const userColorMap = {}; function getUserColor(name) { if (!userColorMap[name]) { const idx = Object.keys(userColorMap).length % userColors.length; userColorMap[name] = userColors[idx]; } return userColorMap[name]; } // --- DOM Refs --- const $ = (id) => document.getElementById(id); const lobbyView = $("lobby"); const roomView = $("room"); const usernameInput = $("username-input"); const createFileInput = $("create-file-input"); const createFileInfo = $("create-file-info"); const createRoomBtn = $("create-room-btn"); const roomCodeInput = $("room-code-input"); const joinRoomBtn = $("join-room-btn"); const lobbyError = $("lobby-error"); // Modal const fileCheckModal = $("file-check-modal"); const requiredFileInfo = $("required-file-info"); const joinFileInput = $("join-file-input"); const joinFileStatus = $("join-file-status"); const modalCancelBtn = $("modal-cancel-btn"); const modalConfirmBtn = $("modal-confirm-btn"); // Room const roomCodeDisplay = $("room-code-display"); const copyCodeBtn = $("copy-code-btn"); const roomFileBadge = $("room-file-badge"); const leaveRoomBtn = $("leave-room-btn"); const videoPlayer = $("video-player"); const videoWrapper = $("video-wrapper"); const bigPlayBtn = $("big-play-btn"); const seekBar = $("seek-bar"); const playPauseBtn = $("play-pause-btn"); const playIcon = $("play-icon"); const pauseIcon = $("pause-icon"); const volumeBtn = $("volume-btn"); const volOnIcon = $("vol-on-icon"); const volOffIcon = $("vol-off-icon"); const volumeSlider = $("volume-slider"); const currentTimeEl = $("current-time"); const durationEl = $("duration"); const fullscreenBtn = $("fullscreen-btn"); const userCountEl = $("user-count"); const usersList = $("users-list"); const chatMessages = $("chat-messages"); const chatInput = $("chat-input"); const chatSendBtn = $("chat-send-btn"); // --- Helpers --- function formatTime(seconds) { if (!isFinite(seconds) || seconds < 0) return "0:00"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; } return `${m}:${s.toString().padStart(2, "0")}`; } function formatSize(bytes) { if (bytes < 1024) return bytes + " B"; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + " MB"; return (bytes / 1073741824).toFixed(2) + " GB"; } function showError(msg) { lobbyError.textContent = msg; lobbyError.classList.remove("hidden"); setTimeout(() => lobbyError.classList.add("hidden"), 4000); } function showView(viewEl) { document.querySelectorAll(".view").forEach((v) => v.classList.remove("active")); viewEl.classList.add("active"); } // --- 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) => { const msg = JSON.parse(e.data); handleMessage(msg); }); ws.addEventListener("close", () => { console.log("WebSocket disconnected"); if (roomCode) { isReconnecting = true; showConnectionStatus("reconnecting"); scheduleReconnect(); } }); ws.addEventListener("error", (err) => { console.error("WebSocket error:", err); }); } 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"; } } // --- Message Handler --- function handleMessage(msg) { switch (msg.type) { case "room_created": roomCode = msg.code; roomFileInfo = msg.fileInfo; enterRoom(msg); break; case "room_file_check": showFileCheckModal(msg); break; case "room_joined": roomCode = msg.code; roomFileInfo = msg.fileInfo; fileCheckModal.classList.add("hidden"); enterRoom(msg); // Apply initial state if (msg.state) { applySync(msg.state); } // Load chat history if (msg.chatHistory) { msg.chatHistory.forEach((m) => addChatMessage(m.username, m.message, m.timestamp)); } 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"); 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`); break; case "sync": applySync(msg); addSyncNotification(msg); break; case "chat": addChatMessage(msg.username, msg.message, msg.timestamp); break; case "state": if (msg.state) applySync(msg.state); 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; } } // --- Room Entry --- function enterRoom(msg) { roomCodeDisplay.textContent = msg.code; roomFileBadge.textContent = `๐ ${msg.fileInfo.name} (${formatSize(msg.fileInfo.size)})`; updateUsers(msg.users); showView(roomView); // Load local file into video player const file = localFile || joinFile; if (file) { if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); currentBlobUrl = URL.createObjectURL(file); videoPlayer.src = currentBlobUrl; } // --- Resync on tab focus (handles background tab throttling) --- document.addEventListener("visibilitychange", () => { if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) { send({ type: "request_state" }); } }); } // --- State tracking --- let currentBlobUrl = null; let lastServerState = { playing: false, position: 0 }; // --- File Check Modal --- let pendingRoomFileInfo = null; function showFileCheckModal(msg) { pendingRoomFileInfo = msg.fileInfo; joinFile = null; joinFileStatus.classList.add("hidden"); modalConfirmBtn.disabled = true; // Display required file info requiredFileInfo.innerHTML = `
ffmpeg -i file.mkv -c copy file.mp4`;
container.parentNode.insertBefore(warning, container.nextSibling);
}
// ===== EVENT LISTENERS =====
// --- Lobby: Username ---
usernameInput.addEventListener("input", () => {
const hasName = usernameInput.value.trim().length > 0;
createRoomBtn.disabled = !hasName || !localFile;
joinRoomBtn.disabled = !hasName || roomCodeInput.value.trim().length < 4;
});
// --- Lobby: Create Room File ---
createFileInput.addEventListener("change", async () => {
const file = createFileInput.files[0];
if (!file) return;
localFile = file;
const duration = await getVideoDuration(file);
localFile._duration = duration;
// Remove any previous format warning
const oldWarning = createFileInfo.parentNode.querySelector(".format-warning");
if (oldWarning) oldWarning.remove();
renderFileInfo(createFileInfo, file, duration);
if (isMkvFile(file)) showFormatWarning(createFileInfo);
createRoomBtn.disabled = !usernameInput.value.trim();
});
// --- Lobby: Create Room ---
createRoomBtn.addEventListener("click", () => {
username = usernameInput.value.trim();
if (!username || !localFile) return;
connect();
// Wait for connection to be open
const waitForOpen = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
clearInterval(waitForOpen);
send({
type: "create_room",
username,
fileInfo: {
name: localFile.name,
size: localFile.size,
duration: localFile._duration || 0,
},
});
}
}, 50);
});
// --- Lobby: Room Code ---
roomCodeInput.addEventListener("input", () => {
const hasName = usernameInput.value.trim().length > 0;
const hasCode = roomCodeInput.value.trim().length >= 4;
joinRoomBtn.disabled = !hasName || !hasCode;
});
// --- Lobby: Join Room ---
joinRoomBtn.addEventListener("click", () => {
username = usernameInput.value.trim();
const code = roomCodeInput.value.trim().toUpperCase();
if (!username || !code) return;
connect();
const waitForOpen = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
clearInterval(waitForOpen);
send({ type: "join_room", username, code });
}
}, 50);
});
// --- Modal: File Select ---
joinFileInput.addEventListener("change", async () => {
const file = joinFileInput.files[0];
if (!file || !pendingRoomFileInfo) return;
joinFile = file;
const duration = await getVideoDuration(file);
joinFile._duration = duration;
const nameMatch = file.name === pendingRoomFileInfo.name;
const sizeMatch = file.size === pendingRoomFileInfo.size;
if (nameMatch && sizeMatch) {
joinFileStatus.className = "file-match-status match";
joinFileStatus.textContent = "โ File matches!";
joinFileStatus.classList.remove("hidden");
modalConfirmBtn.disabled = false;
} else {
let reason = "";
if (!nameMatch) reason += `Name: "${file.name}" โ "${pendingRoomFileInfo.name}". `;
if (!sizeMatch) reason += `Size: ${formatSize(file.size)} โ ${formatSize(pendingRoomFileInfo.size)}.`;
joinFileStatus.className = "file-match-status mismatch";
joinFileStatus.textContent = `โ File does not match. ${reason}`;
joinFileStatus.classList.remove("hidden");
modalConfirmBtn.disabled = true;
}
});
// --- Modal: Cancel ---
modalCancelBtn.addEventListener("click", () => {
fileCheckModal.classList.add("hidden");
joinFile = null;
pendingRoomFileInfo = null;
if (ws) ws.close();
});
// --- Modal: Confirm Join ---
modalConfirmBtn.addEventListener("click", () => {
if (!joinFile || !pendingRoomFileInfo) return;
localFile = joinFile; // use join file as the video source
send({
type: "confirm_join",
fileInfo: {
name: joinFile.name,
size: joinFile.size,
duration: joinFile._duration || 0,
},
});
});
// --- Room: Copy Code ---
copyCodeBtn.addEventListener("click", () => {
navigator.clipboard.writeText(roomCode).then(() => {
copyCodeBtn.title = "Copied!";
setTimeout(() => (copyCodeBtn.title = "Copy room code"), 2000);
});
});
// --- Room: Leave ---
leaveRoomBtn.addEventListener("click", () => {
// 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 = [];
videoPlayer.src = "";
chatMessages.innerHTML = 'Welcome to the room! ๐