// ===== 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"); if (msg.chatHistory) { chatMessages.innerHTML = ""; msg.chatHistory.forEach((m) => addChatMessage(m.username, m.message, m.timestamp)); } 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 = `
Filename
${msg.fileInfo.name}
Size
${formatSize(msg.fileInfo.size)}
Duration
${msg.fileInfo.duration ? formatTime(msg.fileInfo.duration) : "โ€”"}
`; fileCheckModal.classList.remove("hidden"); } // --- Users --- function updateUsers(users) { userCountEl.textContent = users.length; usersList.innerHTML = users .map( (u) => `
${u}
` ) .join(""); } // --- Chat --- function addChatMessage(author, text, timestamp) { const chatWelcome = $("chat-welcome"); if (chatWelcome) chatWelcome.remove(); const div = document.createElement("div"); div.className = "chat-msg"; const time = new Date(timestamp); const timeStr = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); div.innerHTML = `${escapeHtml(author)}${escapeHtml(text)}${timeStr}`; chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; } function addSystemMessage(text) { const div = document.createElement("div"); div.className = "chat-msg system-msg"; div.textContent = text; chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; } function addSyncNotification(msg) { if (!msg.username || msg.username === username) return; let text = ""; if (msg.action === "play") text = `${msg.username} pressed play`; else if (msg.action === "pause") text = `${msg.username} paused`; else if (msg.action === "seek") text = `${msg.username} seeked to ${formatTime(msg.position)}`; else if (msg.action === "speed") text = `${msg.username} changed speed to ${msg.speed}x`; if (text) addSystemMessage(text); } function escapeHtml(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } // --- Video Sync --- 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.position !== undefined) { const diff = Math.abs(videoPlayer.currentTime - data.position); if (diff > 0.5) { videoPlayer.currentTime = data.position; } } if (data.playing !== undefined) { if (data.playing && videoPlayer.paused) { 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(); } // Clear ignore flag after a short delay clearTimeout(syncTimeout); syncTimeout = setTimeout(() => { ignoreSync = false; }, 300); } function updatePlayPauseIcon() { if (videoPlayer.paused) { playIcon.classList.remove("hidden"); pauseIcon.classList.add("hidden"); } else { playIcon.classList.add("hidden"); pauseIcon.classList.remove("hidden"); } } // --- Get video duration from a file (used for file info) --- function getVideoDuration(file) { return new Promise((resolve) => { const video = document.createElement("video"); video.preload = "metadata"; video.onloadedmetadata = () => { resolve(video.duration); URL.revokeObjectURL(video.src); }; video.onerror = () => { resolve(0); URL.revokeObjectURL(video.src); }; video.src = URL.createObjectURL(file); }); } function renderFileInfo(container, file, duration) { container.innerHTML = `
${file.name}
${formatSize(file.size)}${duration ? " ยท " + formatTime(duration) : ""}
`; container.classList.remove("hidden"); } function isMkvFile(file) { return file.name.toLowerCase().endsWith(".mkv"); } function showFormatWarning(container) { const warning = document.createElement("div"); warning.className = "format-warning"; warning.innerHTML = `โš ๏ธ MKV files may not play correctly in browsers. Convert to MP4 for best results:
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! ๐Ÿ‘‹

'; createFileInfo.classList.add("hidden"); createFileInput.value = ""; createRoomBtn.disabled = true; showConnectionStatus("hidden"); showView(lobbyView); }); // --- Video Controls --- // Play/Pause function togglePlayPause() { if (videoPlayer.paused) { videoPlayer.play().catch(() => {}); videoWrapper.classList.add("playing"); send({ type: "sync", action: "play", position: videoPlayer.currentTime }); } else { videoPlayer.pause(); videoWrapper.classList.remove("playing"); send({ type: "sync", action: "pause", position: videoPlayer.currentTime }); } updatePlayPauseIcon(); } playPauseBtn.addEventListener("click", (e) => { e.stopPropagation(); togglePlayPause(); }); bigPlayBtn.addEventListener("click", togglePlayPause); videoWrapper.addEventListener("click", (e) => { if (e.target === videoPlayer || e.target === videoWrapper || e.target.closest(".video-overlay")) { togglePlayPause(); } }); // Keyboard: space to play/pause document.addEventListener("keydown", (e) => { if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; if (e.code === "Space" && roomView.classList.contains("active")) { e.preventDefault(); togglePlayPause(); } }); // Time update videoPlayer.addEventListener("timeupdate", () => { if (!videoPlayer.duration) return; currentTimeEl.textContent = formatTime(videoPlayer.currentTime); const pct = (videoPlayer.currentTime / videoPlayer.duration) * 1000; seekBar.value = pct; }); videoPlayer.addEventListener("loadedmetadata", () => { durationEl.textContent = formatTime(videoPlayer.duration); }); // Seek let isSeeking = false; seekBar.addEventListener("input", () => { isSeeking = true; const time = (seekBar.value / 1000) * videoPlayer.duration; currentTimeEl.textContent = formatTime(time); }); seekBar.addEventListener("change", () => { const time = (seekBar.value / 1000) * videoPlayer.duration; videoPlayer.currentTime = time; isSeeking = false; send({ type: "sync", action: "seek", position: time }); }); // Volume volumeBtn.addEventListener("click", () => { videoPlayer.muted = !videoPlayer.muted; volOnIcon.classList.toggle("hidden", videoPlayer.muted); volOffIcon.classList.toggle("hidden", !videoPlayer.muted); }); volumeSlider.addEventListener("input", () => { videoPlayer.volume = volumeSlider.value / 100; videoPlayer.muted = videoPlayer.volume === 0; volOnIcon.classList.toggle("hidden", videoPlayer.muted); volOffIcon.classList.toggle("hidden", !videoPlayer.muted); }); // Fullscreen fullscreenBtn.addEventListener("click", () => { if (document.fullscreenElement) { document.exitFullscreen(); } else { videoWrapper.requestFullscreen().catch(() => {}); } }); // --- Chat --- chatSendBtn.addEventListener("click", sendChat); chatInput.addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(); }); function sendChat() { const text = chatInput.value.trim(); if (!text) return; send({ type: "chat", message: text }); chatInput.value = ""; } // Double click to fullscreen videoPlayer.addEventListener("dblclick", (e) => { e.preventDefault(); if (document.fullscreenElement) { document.exitFullscreen(); } else { videoWrapper.requestFullscreen().catch(() => {}); } }); })();