commit cc0136d712bd081eef4196ba32109220faf54710 Author: Peter Stockings Date: Sat Feb 28 20:16:53 2026 +1100 Initial setup diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000..41b12bf --- /dev/null +++ b/.buildpacks @@ -0,0 +1 @@ +https://github.com/jakeg/heroku-buildpack-bun diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86ab79e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +bun.lockb +.env +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b0cbe9 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# ๐ŸŽฌ VideoSync + +Watch videos together in real-time. Each user loads a local video file โ€” only sync commands and chat messages travel over the network. + +## Features + +- **Room system** โ€” create a room, share the 6-character code with friends +- **File verification** โ€” joiners must have the exact same file (matched by name + size) +- **Playback sync** โ€” play, pause, seek, and speed changes broadcast to all clients instantly +- **Drift correction** โ€” automatic re-sync every 5 seconds to keep everyone aligned +- **Live chat** โ€” YouTube-style chat sidebar with colored usernames +- **Local playback** โ€” multi-gigabyte files work fine since nothing is uploaded + +## Quick Start + +```bash +# Install Bun (if not already installed) +curl -fsSL https://bun.sh/install | bash + +# Start the server +bun run server.ts +``` + +Open **http://localhost:3000** in your browser. + +## How It Works + +1. Enter your name and select a video file โ†’ **Create Room** +2. Share the room code with a friend +3. Friend enters the code โ†’ **Join Room** โ†’ selects their copy of the same file +4. Play/pause/seek in either browser โ€” the other stays in sync + +## Deployment + +```bash +# Run on a custom port +PORT=8080 bun run server.ts +``` + +For production, put behind **nginx** with WebSocket proxy support: + +```nginx +location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; +} +``` + +## Tech Stack + +| Component | Technology | +|-----------|-----------| +| Server | Bun (native HTTP + WebSocket) | +| Frontend | Vanilla HTML / CSS / JS | +| Dependencies | None | + +## Project Structure + +``` +โ”œโ”€โ”€ server.ts # Bun WebSocket server +โ”œโ”€โ”€ package.json +โ””โ”€โ”€ public/ + โ”œโ”€โ”€ index.html # Single-page app + โ”œโ”€โ”€ style.css # Dark theme + โ””โ”€โ”€ app.js # Client sync + chat logic +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..5899eed --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "videosync", + "version": "1.0.0", + "description": "Synchronized video playback with live chat", + "scripts": { + "start": "bun run server.ts", + "dev": "bun --watch run server.ts" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..90aa871 --- /dev/null +++ b/public/app.js @@ -0,0 +1,627 @@ +// ===== 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(() => {}); + } + }); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..466675f --- /dev/null +++ b/public/index.html @@ -0,0 +1,208 @@ + + + + + + + VideoSync โ€” Watch Together + + + + + + + + + +
+
+
+
+ + + + + +
+

VideoSync

+

Watch together, anywhere

+
+ +
+
+ + +
+ +
then
+ +
+
+

Create a Room

+

Select a video file to start watching

+ + + +
+ +
or
+ +
+

Join a Room

+

Enter a room code from a friend

+ + +
+
+
+ + +
+
+ + + + + +
+
+ +
+
+
+ Room: + +
+
+
+ +
+
+ +
+ +
+
+ + + + +
+
+
+
+ +
+
+
+ +
+ + +
+ + 0:00 / 0:00 + +
+
+ + +
+
+
+
+
+ + +
+
+

Live Chat

+
+ + 0 watching +
+
+
+
+
+

Welcome to the room! ๐Ÿ‘‹

+
+
+
+ + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..3d59318 --- /dev/null +++ b/public/style.css @@ -0,0 +1,958 @@ +/* ===== Reset & Base ===== */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-primary: #0f0f0f; + --bg-secondary: #1a1a1a; + --bg-tertiary: #272727; + --bg-hover: #333333; + --bg-card: #1e1e1e; + --text-primary: #f1f1f1; + --text-secondary: #aaaaaa; + --text-muted: #717171; + --accent: #3ea6ff; + --accent-hover: #65b8ff; + --accent-glow: rgba(62, 166, 255, 0.25); + --danger: #ff4e45; + --success: #2ecc71; + --warning: #f39c12; + --border: #333333; + --border-light: #3a3a3a; + --radius: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --shadow: 0 4px 24px rgba(0,0,0,0.4); + --transition: 0.2s ease; + --font: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif; +} + +html, body { + height: 100%; + font-family: var(--font); + background: var(--bg-primary); + color: var(--text-primary); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +input, button, select { + font-family: var(--font); + font-size: inherit; +} + +.hidden { display: none !important; } + +.view { + display: none; + height: 100vh; +} +.view.active { display: flex; } + +/* ===== LOBBY ===== */ +#lobby { + align-items: center; + justify-content: center; + background: + radial-gradient(ellipse at 20% 50%, rgba(62,166,255,0.06) 0%, transparent 50%), + radial-gradient(ellipse at 80% 50%, rgba(62,166,255,0.04) 0%, transparent 50%), + var(--bg-primary); + animation: lobbyBg 20s ease-in-out infinite alternate; +} + +@keyframes lobbyBg { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } +} + +.lobby-container { + width: 100%; + max-width: 640px; + padding: 24px; + position: relative; +} + +.lobby-brand { + text-align: center; + margin-bottom: 32px; +} + +.logo-icon { + margin-bottom: 12px; + animation: logoPulse 3s ease-in-out infinite; +} + +@keyframes logoPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.lobby-brand h1 { + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -1px; + background: linear-gradient(135deg, #ffffff 30%, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.tagline { + color: var(--text-secondary); + font-size: 1rem; + margin-top: 4px; + font-weight: 300; +} + +.lobby-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 28px; + box-shadow: var(--shadow); +} + +.input-group { + margin-bottom: 4px; +} + +.input-group label { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.input-group input, +#room-code-input { + width: 100%; + padding: 12px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 1rem; + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.input-group input:focus, +#room-code-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.lobby-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.lobby-divider::before, +.lobby-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.lobby-actions { + display: flex; + gap: 20px; +} + +.action-panel { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; +} + +.action-panel h3 { + font-size: 1rem; + font-weight: 600; +} + +.action-desc { + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.4; +} + +.lobby-or { + display: flex; + align-items: center; + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.lobby-or::before, +.lobby-or::after { + content: ''; + width: 1px; + height: 40px; + background: var(--border); + margin: 0 0; +} + +.lobby-or span { + writing-mode: vertical-rl; + padding: 8px 0; +} + +/* File select button */ +.file-select-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--bg-tertiary); + border: 1px dashed var(--border-light); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 0.85rem; + cursor: pointer; + transition: all var(--transition); +} + +.file-select-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(62, 166, 255, 0.08); +} + +/* File info display */ +.file-info { + width: 100%; + padding: 10px 14px; + background: rgba(62, 166, 255, 0.08); + border: 1px solid rgba(62, 166, 255, 0.2); + border-radius: var(--radius); + font-size: 0.8rem; + color: var(--text-secondary); + text-align: left; +} + +.file-info .file-name { + color: var(--text-primary); + font-weight: 500; + word-break: break-all; +} + +.file-info .file-meta { + margin-top: 4px; + color: var(--text-muted); + font-size: 0.75rem; +} + +/* Buttons */ +.btn-primary { + width: 100%; + padding: 12px 24px; + background: var(--accent); + color: #000; + border: none; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: 0 0 16px var(--accent-glow); +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-secondary { + width: 100%; + padding: 12px 24px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--text-muted); +} + +.btn-secondary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Error toast */ +.error-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + background: var(--danger); + color: white; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 500; + box-shadow: 0 4px 20px rgba(255,78,69,0.3); + z-index: 1000; + animation: toastIn 0.3s ease; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(20px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +/* ===== FILE CHECK MODAL ===== */ +.modal { + position: fixed; + inset: 0; + z-index: 500; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 28px; + max-width: 480px; + width: 90%; + box-shadow: var(--shadow); + animation: modalIn 0.25s ease; +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.modal-content h2 { + font-size: 1.2rem; + margin-bottom: 8px; +} + +.modal-content p { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 16px; +} + +.modal-instruction { + margin-top: 16px !important; +} + +.file-info-card { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} + +.file-info-card .label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.file-info-card .value { + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 500; + word-break: break-all; +} + +.file-info-card .row { + display: flex; + gap: 16px; + margin-top: 10px; +} + +.file-info-card .row > div { + flex: 1; +} + +.file-match-status { + margin-top: 12px; + padding: 10px 14px; + border-radius: var(--radius); + font-size: 0.85rem; + font-weight: 500; +} + +.file-match-status.match { + background: rgba(46,204,113,0.1); + border: 1px solid rgba(46,204,113,0.3); + color: var(--success); +} + +.file-match-status.mismatch { + background: rgba(255,78,69,0.1); + border: 1px solid rgba(255,78,69,0.3); + color: var(--danger); +} + +.modal-actions { + display: flex; + gap: 12px; + margin-top: 20px; +} + +.modal-actions .btn-secondary, +.modal-actions .btn-primary { + flex: 1; +} + +/* ===== ROOM VIEW ===== */ +.room-layout { + display: flex; + width: 100%; + height: 100vh; + overflow: hidden; +} + +/* Video Area */ +.video-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: #000; +} + +.video-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + min-height: 48px; +} + +.topbar-left, +.topbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.room-badge { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.room-badge strong { + color: var(--accent); + letter-spacing: 1px; +} + +.icon-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all var(--transition); + display: flex; + align-items: center; +} + +.icon-btn:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.file-badge { + font-size: 0.75rem; + color: var(--text-muted); + background: var(--bg-tertiary); + padding: 4px 10px; + border-radius: 20px; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-leave { + padding: 6px 16px; + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); + border-radius: var(--radius); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); +} + +.btn-leave:hover { + background: var(--danger); + color: white; +} + +/* Video wrapper */ +.video-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #000; + cursor: pointer; +} + +#video-player { + width: 100%; + height: 100%; + object-fit: contain; +} + +.video-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transition: opacity var(--transition); + pointer-events: none; +} + +.big-play-btn { + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: auto; + cursor: pointer; +} + +.video-wrapper:not(.playing) .big-play-btn, +.video-wrapper.show-play .big-play-btn { + opacity: 1; +} + +.big-play-btn:hover svg circle { + fill: rgba(0,0,0,0.75); +} + +/* Controls bar */ +.controls-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0,0,0,0.85)); + padding: 20px 12px 10px; + opacity: 0; + transition: opacity 0.3s ease; +} + +.video-wrapper:hover .controls-bar, +.video-wrapper:not(.playing) .controls-bar { + opacity: 1; +} + +.seek-bar-container { + padding: 0 4px; + margin-bottom: 4px; +} + +.seek-bar { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: rgba(255,255,255,0.2); + border-radius: 2px; + outline: none; + cursor: pointer; + transition: height 0.15s ease; +} + +.seek-bar:hover { + height: 6px; +} + +.seek-bar::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 4px rgba(0,0,0,0.3); + transition: transform 0.15s ease; +} + +.seek-bar:hover::-webkit-slider-thumb { + transform: scale(1.3); +} + +.seek-bar::-moz-range-thumb { + width: 14px; + height: 14px; + background: var(--accent); + border-radius: 50%; + border: none; + cursor: pointer; +} + +.controls-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4px; +} + +.controls-left, +.controls-right { + display: flex; + align-items: center; + gap: 8px; +} + +.ctrl-btn { + background: none; + border: none; + color: white; + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + transition: all var(--transition); +} + +.ctrl-btn:hover { + background: rgba(255,255,255,0.1); +} + +.volume-control { + display: flex; + align-items: center; + gap: 4px; +} + +.volume-slider { + -webkit-appearance: none; + appearance: none; + width: 70px; + height: 3px; + background: rgba(255,255,255,0.3); + border-radius: 2px; + outline: none; + cursor: pointer; + opacity: 0; + width: 0; + transition: opacity 0.2s, width 0.2s; +} + +.volume-control:hover .volume-slider { + opacity: 1; + width: 70px; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: white; + border-radius: 50%; + cursor: pointer; +} + +.volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + background: white; + border-radius: 50%; + border: none; + cursor: pointer; +} + +.time-display { + font-size: 0.8rem; + color: rgba(255,255,255,0.8); + font-variant-numeric: tabular-nums; + margin-left: 4px; +} + +.speed-select { + background: none; + border: 1px solid rgba(255,255,255,0.2); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + outline: none; +} + +.speed-select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* ===== CHAT SIDEBAR ===== */ +.chat-area { + width: 340px; + min-width: 340px; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-left: 1px solid var(--border); +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.chat-header h3 { + font-size: 0.95rem; + font-weight: 600; +} + +.users-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.online-dot { + width: 8px; + height: 8px; + background: var(--success); + border-radius: 50%; + animation: dotPulse 2s ease-in-out infinite; +} + +@keyframes dotPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.users-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); +} + +.user-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--bg-tertiary); + border-radius: 20px; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.user-chip .user-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +/* Chat Messages */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 4px; + scroll-behavior: smooth; +} + +.chat-messages::-webkit-scrollbar { + width: 4px; +} + +.chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 2px; +} + +.chat-messages::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +.chat-welcome { + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; + padding: 20px 0; +} + +.chat-msg { + padding: 6px 0; + font-size: 0.85rem; + line-height: 1.4; + animation: msgIn 0.2s ease; +} + +@keyframes msgIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-msg .msg-author { + font-weight: 600; + margin-right: 6px; +} + +.chat-msg .msg-text { + color: var(--text-secondary); + word-break: break-word; +} + +.chat-msg.system-msg { + color: var(--text-muted); + font-size: 0.8rem; + font-style: italic; + padding: 4px 0; +} + +.chat-msg .msg-time { + font-size: 0.7rem; + color: var(--text-muted); + margin-left: 6px; +} + +/* Chat Input */ +.chat-input-area { + display: flex; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg-secondary); +} + +#chat-input { + flex: 1; + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-primary); + font-size: 0.85rem; + outline: none; + transition: border-color var(--transition); +} + +#chat-input:focus { + border-color: var(--accent); +} + +.chat-send-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: var(--accent); + color: #000; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + flex-shrink: 0; +} + +.chat-send-btn:hover { + background: var(--accent-hover); + box-shadow: 0 0 12px var(--accent-glow); +} + +/* ===== Responsive ===== */ +@media (max-width: 800px) { + .lobby-actions { + flex-direction: column; + } + + .lobby-or { + flex-direction: row; + } + + .lobby-or::before, + .lobby-or::after { + width: 40px; + height: 1px; + writing-mode: horizontal-tb; + } + + .lobby-or span { + writing-mode: horizontal-tb; + } + + .room-layout { + flex-direction: column; + } + + .chat-area { + width: 100%; + min-width: 100%; + height: 40vh; + } + + .video-area { + height: 60vh; + } + + .file-badge { + display: none; + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..db3b88f --- /dev/null +++ b/server.ts @@ -0,0 +1,378 @@ +import { readFileSync, existsSync, statSync } from "fs"; +import { join, extname } from "path"; + +// --- Types --- +interface FileInfo { + name: string; + size: number; + duration: number; +} + +interface RoomState { + code: string; + fileInfo: FileInfo; + playing: boolean; + position: number; + speed: number; + lastUpdate: number; // timestamp when position was last set + users: Map; + chatHistory: ChatMessage[]; +} + +interface ChatMessage { + username: string; + message: string; + timestamp: number; +} + +// --- Room Management --- +const rooms = new Map(); + +function generateRoomCode(): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars + let code = ""; + for (let i = 0; i < 6; i++) { + code += chars[Math.floor(Math.random() * chars.length)]; + } + return code; +} + +function getCurrentPosition(room: RoomState): number { + if (!room.playing) return room.position; + const elapsed = (Date.now() - room.lastUpdate) / 1000; + return room.position + elapsed * room.speed; +} + +function broadcastToRoom(room: RoomState, message: object, excludeWs?: WebSocket) { + const data = JSON.stringify(message); + for (const [, ws] of room.users) { + if (ws !== excludeWs && ws.readyState === 1) { + ws.send(data); + } + } +} + +function getUserList(room: RoomState): string[] { + return Array.from(room.users.keys()); +} + +// --- MIME Types --- +const mimeTypes: Record = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".woff": "font/woff", +}; + +// --- Static File Server --- +const publicDir = join(import.meta.dir, "public"); + +function serveStatic(path: string): Response { + // Default to index.html + if (path === "/" || path === "") path = "/index.html"; + + const filePath = join(publicDir, path); + + // Security: prevent directory traversal + if (!filePath.startsWith(publicDir)) { + return new Response("Forbidden", { status: 403 }); + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + return new Response("Not Found", { status: 404 }); + } + + const ext = extname(filePath); + const contentType = mimeTypes[ext] || "application/octet-stream"; + const file = readFileSync(filePath); + + return new Response(file, { + headers: { "Content-Type": contentType }, + }); +} + +// --- WebSocket Handler --- +interface WSData { + username: string; + roomCode: string; +} + +const server = Bun.serve({ + port: Number(process.env.PORT) || 3000, + + fetch(req, server) { + const url = new URL(req.url); + + // WebSocket upgrade + if (url.pathname === "/ws") { + const upgraded = server.upgrade(req, { + data: { username: "", roomCode: "" }, + }); + if (upgraded) return undefined; + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // Static files + return serveStatic(url.pathname); + }, + + websocket: { + open(ws) { + // Connection opened, wait for messages + }, + + message(ws, raw) { + let msg: any; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); + return; + } + + switch (msg.type) { + case "create_room": { + const username = (msg.username || "").trim(); + if (!username) { + ws.send(JSON.stringify({ type: "error", message: "Username required" })); + return; + } + if (!msg.fileInfo || !msg.fileInfo.name || !msg.fileInfo.size) { + ws.send(JSON.stringify({ type: "error", message: "File info required" })); + return; + } + + const code = generateRoomCode(); + const room: RoomState = { + code, + fileInfo: { + name: msg.fileInfo.name, + size: msg.fileInfo.size, + duration: msg.fileInfo.duration || 0, + }, + playing: false, + position: 0, + speed: 1, + lastUpdate: Date.now(), + users: new Map(), + chatHistory: [], + }; + room.users.set(username, ws as unknown as WebSocket); + rooms.set(code, room); + + ws.data.username = username; + ws.data.roomCode = code; + + ws.send( + JSON.stringify({ + type: "room_created", + code, + fileInfo: room.fileInfo, + users: getUserList(room), + }) + ); + console.log(`[Room ${code}] Created by ${username} โ€” file: ${room.fileInfo.name}`); + break; + } + + case "join_room": { + const username = (msg.username || "").trim(); + const code = (msg.code || "").trim().toUpperCase(); + if (!username) { + ws.send(JSON.stringify({ type: "error", message: "Username required" })); + return; + } + const room = rooms.get(code); + if (!room) { + ws.send(JSON.stringify({ type: "error", message: "Room not found" })); + return; + } + if (room.users.has(username)) { + ws.send(JSON.stringify({ type: "error", message: "Username already taken in this room" })); + return; + } + + // Send room info so client can verify file before fully joining + ws.data.username = username; + ws.data.roomCode = code; + + ws.send( + JSON.stringify({ + type: "room_file_check", + code, + fileInfo: room.fileInfo, + }) + ); + break; + } + + case "confirm_join": { + const code = ws.data.roomCode; + const username = ws.data.username; + const room = rooms.get(code); + if (!room) { + ws.send(JSON.stringify({ type: "error", message: "Room not found" })); + return; + } + + // Verify file matches + if ( + !msg.fileInfo || + msg.fileInfo.name !== room.fileInfo.name || + msg.fileInfo.size !== room.fileInfo.size + ) { + ws.send( + JSON.stringify({ + type: "error", + message: "File mismatch โ€” you need the exact same file to join", + }) + ); + return; + } + + room.users.set(username, ws as unknown as WebSocket); + + // Send current state to the new user + ws.send( + JSON.stringify({ + type: "room_joined", + code, + fileInfo: room.fileInfo, + users: getUserList(room), + state: { + playing: room.playing, + position: getCurrentPosition(room), + speed: room.speed, + }, + chatHistory: room.chatHistory.slice(-50), + }) + ); + + // Notify others + broadcastToRoom( + room, + { + type: "user_joined", + username, + users: getUserList(room), + }, + ws as unknown as WebSocket + ); + console.log(`[Room ${code}] ${username} joined`); + break; + } + + case "sync": { + const room = rooms.get(ws.data.roomCode); + if (!room) return; + + // Update authoritative state + if (msg.action === "play") { + room.playing = true; + room.position = msg.position ?? getCurrentPosition(room); + room.lastUpdate = Date.now(); + } else if (msg.action === "pause") { + room.position = msg.position ?? getCurrentPosition(room); + room.playing = false; + room.lastUpdate = Date.now(); + } else if (msg.action === "seek") { + room.position = msg.position; + room.lastUpdate = Date.now(); + } else if (msg.action === "speed") { + room.position = getCurrentPosition(room); + room.speed = msg.speed; + room.lastUpdate = Date.now(); + } + + // Broadcast to others + broadcastToRoom( + room, + { + type: "sync", + action: msg.action, + position: room.position, + playing: room.playing, + speed: room.speed, + username: ws.data.username, + timestamp: Date.now(), + }, + ws as unknown as WebSocket + ); + break; + } + + case "chat": { + const room = rooms.get(ws.data.roomCode); + if (!room) return; + const text = (msg.message || "").trim(); + if (!text) return; + + const chatMsg: ChatMessage = { + username: ws.data.username, + message: text, + timestamp: Date.now(), + }; + room.chatHistory.push(chatMsg); + // Keep chat history bounded + if (room.chatHistory.length > 200) { + room.chatHistory = room.chatHistory.slice(-100); + } + + broadcastToRoom(room, { + type: "chat", + username: chatMsg.username, + message: chatMsg.message, + timestamp: chatMsg.timestamp, + }); + break; + } + + case "request_state": { + const room = rooms.get(ws.data.roomCode); + if (!room) return; + ws.send( + JSON.stringify({ + type: "state", + state: { + playing: room.playing, + position: getCurrentPosition(room), + speed: room.speed, + }, + }) + ); + break; + } + } + }, + + close(ws) { + const { username, roomCode } = ws.data; + if (!roomCode) return; + + const room = rooms.get(roomCode); + if (!room) return; + + room.users.delete(username); + console.log(`[Room ${roomCode}] ${username} left`); + + if (room.users.size === 0) { + rooms.delete(roomCode); + console.log(`[Room ${roomCode}] Deleted (empty)`); + } else { + broadcastToRoom(room, { + type: "user_left", + username, + users: getUserList(room), + }); + } + }, + }, +}); + +console.log(`๐ŸŽฌ VideoSync server running on http://localhost:${server.port}`);