744 lines
24 KiB
JavaScript
744 lines
24 KiB
JavaScript
// ===== 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 = `
|
|
<div class="label">Filename</div>
|
|
<div class="value">${msg.fileInfo.name}</div>
|
|
<div class="row">
|
|
<div>
|
|
<div class="label">Size</div>
|
|
<div class="value">${formatSize(msg.fileInfo.size)}</div>
|
|
</div>
|
|
<div>
|
|
<div class="label">Duration</div>
|
|
<div class="value">${msg.fileInfo.duration ? formatTime(msg.fileInfo.duration) : "—"}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
fileCheckModal.classList.remove("hidden");
|
|
}
|
|
|
|
// --- Users ---
|
|
function updateUsers(users) {
|
|
userCountEl.textContent = users.length;
|
|
usersList.innerHTML = users
|
|
.map(
|
|
(u) =>
|
|
`<div class="user-chip"><span class="user-dot" style="background:${getUserColor(u)}"></span>${u}</div>`
|
|
)
|
|
.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 = `<span class="msg-author" style="color:${getUserColor(author)}">${escapeHtml(author)}</span><span class="msg-text">${escapeHtml(text)}</span><span class="msg-time">${timeStr}</span>`;
|
|
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 = `
|
|
<div class="file-name">${file.name}</div>
|
|
<div class="file-meta">${formatSize(file.size)}${duration ? " · " + formatTime(duration) : ""}</div>
|
|
`;
|
|
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 = `⚠️ <strong>MKV files may not play correctly</strong> in browsers. Convert to MP4 for best results:<br><code>ffmpeg -i file.mkv -c copy file.mp4</code>`;
|
|
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 = '<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);
|
|
});
|
|
|
|
// --- 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(() => {});
|
|
}
|
|
});
|
|
})();
|