Initial setup
This commit is contained in:
1
.buildpacks
Normal file
1
.buildpacks
Normal file
@@ -0,0 +1 @@
|
||||
https://github.com/jakeg/heroku-buildpack-bun
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
bun.lockb
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
69
README.md
Normal file
69
README.md
Normal file
@@ -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
|
||||
```
|
||||
9
package.json
Normal file
9
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
627
public/app.js
Normal file
627
public/app.js
Normal file
@@ -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 = `
|
||||
<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;
|
||||
|
||||
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 = `
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-meta">${formatSize(file.size)}${duration ? " · " + formatTime(duration) : ""}</div>
|
||||
`;
|
||||
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 = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
})();
|
||||
208
public/index.html
Normal file
208
public/index.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoSync — Watch Together</title>
|
||||
<meta name="description"
|
||||
content="Sync video playback with friends in real-time. Load a local file, create a room, and watch together.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- ===== LOBBY VIEW ===== -->
|
||||
<div id="lobby" class="view active">
|
||||
<div class="lobby-container">
|
||||
<div class="lobby-brand">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 48 48" width="56" height="56" fill="none">
|
||||
<rect x="4" y="8" width="40" height="32" rx="4" fill="#3ea6ff" opacity="0.15" />
|
||||
<rect x="4" y="8" width="40" height="32" rx="4" stroke="#3ea6ff" stroke-width="2.5" />
|
||||
<polygon points="20,16 34,24 20,32" fill="#3ea6ff" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>VideoSync</h1>
|
||||
<p class="tagline">Watch together, anywhere</p>
|
||||
</div>
|
||||
|
||||
<div class="lobby-card">
|
||||
<div class="input-group">
|
||||
<label for="username-input">Your Name</label>
|
||||
<input type="text" id="username-input" placeholder="Enter a display name" maxlength="20" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="lobby-divider"><span>then</span></div>
|
||||
|
||||
<div class="lobby-actions">
|
||||
<div class="action-panel create-panel">
|
||||
<h3>Create a Room</h3>
|
||||
<p class="action-desc">Select a video file to start watching</p>
|
||||
<label class="file-select-btn" id="create-file-label">
|
||||
<input type="file" id="create-file-input" accept="video/*" hidden>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
|
||||
</svg>
|
||||
<span>Choose Video File</span>
|
||||
</label>
|
||||
<div id="create-file-info" class="file-info hidden"></div>
|
||||
<button id="create-room-btn" class="btn-primary" disabled>Create Room</button>
|
||||
</div>
|
||||
|
||||
<div class="lobby-or"><span>or</span></div>
|
||||
|
||||
<div class="action-panel join-panel">
|
||||
<h3>Join a Room</h3>
|
||||
<p class="action-desc">Enter a room code from a friend</p>
|
||||
<input type="text" id="room-code-input" placeholder="e.g. ABC123" maxlength="6" autocomplete="off"
|
||||
style="text-transform: uppercase">
|
||||
<button id="join-room-btn" class="btn-secondary" disabled>Join Room</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lobby-error" class="error-toast hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== FILE CHECK MODAL ===== -->
|
||||
<div id="file-check-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<h2>File Required</h2>
|
||||
<p>To join this room, you need the following file:</p>
|
||||
<div id="required-file-info" class="file-info-card"></div>
|
||||
<p class="modal-instruction">Select your copy of this file:</p>
|
||||
<label class="file-select-btn" id="join-file-label">
|
||||
<input type="file" id="join-file-input" accept="video/*" hidden>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
|
||||
</svg>
|
||||
<span>Choose File</span>
|
||||
</label>
|
||||
<div id="join-file-status" class="file-match-status hidden"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="modal-cancel-btn" class="btn-secondary">Cancel</button>
|
||||
<button id="modal-confirm-btn" class="btn-primary" disabled>Join Room</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== ROOM VIEW ===== -->
|
||||
<div id="room" class="view">
|
||||
<div class="room-layout">
|
||||
<!-- Video Area -->
|
||||
<div class="video-area">
|
||||
<div class="video-topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="room-badge">Room: <strong id="room-code-display"></strong></span>
|
||||
<button id="copy-code-btn" class="icon-btn" title="Copy room code">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path
|
||||
d="M16 1H4a2 2 0 00-2 2v14h2V3h12V1zm3 4H8a2 2 0 00-2 2v14a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2zm0 16H8V7h11v14z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="file-badge" id="room-file-badge"></div>
|
||||
<button id="leave-room-btn" class="btn-leave">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-wrapper" id="video-wrapper">
|
||||
<video id="video-player" preload="auto"></video>
|
||||
<div class="video-overlay" id="video-overlay">
|
||||
<div class="big-play-btn" id="big-play-btn">
|
||||
<svg viewBox="0 0 68 68" width="68" height="68" fill="none">
|
||||
<circle cx="34" cy="34" r="33" fill="rgba(0,0,0,0.6)" stroke="white" stroke-width="2" />
|
||||
<polygon points="27,20 50,34 27,48" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-bar" id="controls-bar">
|
||||
<div class="seek-bar-container">
|
||||
<input type="range" id="seek-bar" class="seek-bar" value="0" min="0" max="1000" step="1">
|
||||
</div>
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<button id="play-pause-btn" class="ctrl-btn" title="Play/Pause">
|
||||
<svg id="play-icon" viewBox="0 0 24 24" width="28" height="28" fill="white">
|
||||
<polygon points="6,4 20,12 6,20" />
|
||||
</svg>
|
||||
<svg id="pause-icon" viewBox="0 0 24 24" width="28" height="28" fill="white" class="hidden">
|
||||
<rect x="5" y="4" width="4" height="16" />
|
||||
<rect x="15" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="volume-control">
|
||||
<button id="volume-btn" class="ctrl-btn" title="Mute/Unmute">
|
||||
<svg id="vol-on-icon" viewBox="0 0 24 24" width="22" height="22" fill="white">
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 8.5v7a4.49 4.49 0 002.5-3.5zM14 3.23v2.06a6.51 6.51 0 010 13.42v2.06A8.51 8.51 0 0014 3.23z" />
|
||||
</svg>
|
||||
<svg id="vol-off-icon" viewBox="0 0 24 24" width="22" height="22" fill="white" class="hidden">
|
||||
<path
|
||||
d="M16.5 12A4.5 4.5 0 0014 8.5v2.09l2.44 2.44c.03-.17.06-.34.06-.53zm2.5 0a6.5 6.5 0 01-.55 2.63l1.52 1.52A8.46 8.46 0 0021 12a8.51 8.51 0 00-7-8.77v2.06A6.51 6.51 0 0119 12zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.51-1.42.93-2.25 1.18v2.06a8.46 8.46 0 003.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" class="volume-slider" min="0" max="100" value="100">
|
||||
</div>
|
||||
<span class="time-display">
|
||||
<span id="current-time">0:00</span> / <span id="duration">0:00</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<select id="speed-select" class="speed-select" title="Playback speed">
|
||||
<option value="0.25">0.25x</option>
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="0.75">0.75x</option>
|
||||
<option value="1" selected>1x</option>
|
||||
<option value="1.25">1.25x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
<button id="fullscreen-btn" class="ctrl-btn" title="Fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="white">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Sidebar -->
|
||||
<div class="chat-area">
|
||||
<div class="chat-header">
|
||||
<h3>Live Chat</h3>
|
||||
<div class="users-indicator">
|
||||
<span class="online-dot"></span>
|
||||
<span id="user-count">0</span> watching
|
||||
</div>
|
||||
</div>
|
||||
<div class="users-list" id="users-list"></div>
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="chat-welcome" id="chat-welcome">
|
||||
<p>Welcome to the room! 👋</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<input type="text" id="chat-input" placeholder="Send a message..." autocomplete="off" maxlength="500">
|
||||
<button id="chat-send-btn" class="chat-send-btn" title="Send">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
958
public/style.css
Normal file
958
public/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
378
server.ts
Normal file
378
server.ts
Normal file
@@ -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<string, WebSocket>;
|
||||
chatHistory: ChatMessage[];
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
username: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// --- Room Management ---
|
||||
const rooms = new Map<string, RoomState>();
|
||||
|
||||
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<string, string> = {
|
||||
".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<WSData>({
|
||||
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}`);
|
||||
Reference in New Issue
Block a user