// ===== 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;
let driftInterval = null;
let serverState = { playing: false, position: 0, speed: 1 };
// 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 speedSelect = $("speed-select");
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() {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.addEventListener("open", () => {
console.log("WebSocket connected");
});
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
handleMessage(msg);
});
ws.addEventListener("close", () => {
console.log("WebSocket disconnected");
// Attempt reconnect after 2s if in room
if (roomCode) {
setTimeout(() => connect(), 2000);
}
});
ws.addEventListener("error", (err) => {
console.error("WebSocket error:", err);
});
}
function send(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
// --- Message Handler ---
function handleMessage(msg) {
switch (msg.type) {
case "room_created":
roomCode = msg.code;
enterRoom(msg);
break;
case "room_file_check":
showFileCheckModal(msg);
break;
case "room_joined":
roomCode = msg.code;
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 "user_joined":
updateUsers(msg.users);
addSystemMessage(`${msg.username} joined`);
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":
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) {
const url = URL.createObjectURL(file);
videoPlayer.src = url;
}
// Start drift correction
startDriftCorrection();
}
// --- 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;
if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) {
videoPlayer.playbackRate = data.speed;
speedSelect.value = String(data.speed);
}
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().catch(() => {});
videoWrapper.classList.add("playing");
} else if (!data.playing && !videoPlayer.paused) {
videoPlayer.pause();
videoWrapper.classList.remove("playing");
}
}
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");
}
}
function startDriftCorrection() {
if (driftInterval) clearInterval(driftInterval);
driftInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
send({ type: "request_state" });
}
}, 5000);
}
// --- 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");
}
// ===== 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;
renderFileInfo(createFileInfo, file, duration);
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", () => {
if (ws) ws.close();
roomCode = "";
localFile = null;
joinFile = null;
if (driftInterval) clearInterval(driftInterval);
videoPlayer.src = "";
chatMessages.innerHTML = 'Welcome to the room! ๐
';
createFileInfo.classList.add("hidden");
createFileInput.value = "";
createRoomBtn.disabled = true;
showView(lobbyView);
});
// --- Video Controls ---
// Play/Pause
function togglePlayPause() {
if (videoPlayer.paused) {
videoPlayer.play().catch(() => {});
videoWrapper.classList.add("playing");
if (!ignoreSync) {
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 });
}
}
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;
if (!ignoreSync) {
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);
});
// Speed
speedSelect.addEventListener("change", () => {
const speed = parseFloat(speedSelect.value);
videoPlayer.playbackRate = speed;
if (!ignoreSync) {
send({ type: "sync", action: "speed", speed });
}
});
// 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(() => {});
}
});
})();