A real-time synchronized video watching application for two remote users. Both parties watch the same local video file through the browser — no video streaming involved. Only play/pause/seek commands and chat messages are synced via WebSocket.
- Synchronized local video — both users select the same file locally, only playback state is synced
- YouTube synchronization — watch YouTube videos together in sync
- Two-user rooms — 5-digit room codes, max 2 participants
- Host/admin controls — first user controls playback, second user mirrors
- Room passwords — optional password protection
- Chat — real-time messaging with typing indicators
- Reactions — emoji reactions floating over the video
- Microphone — WebRTC voice chat via simple-peer
- Camera — WebRTC video chat with local/remote preview
- STUN/TURN — Cloudflare TURN integration for NAT traversal
- External subtitles — SRT/VTT file support
- Embedded subtitles — ffmpeg.wasm extraction from video files
- Dark/light mode — theme toggle with persistence
- Keyboard shortcuts — Space, F, M, C, Esc, ?
- Share — WhatsApp, Twitter, Telegram, clipboard
- Session persistence — room/name saved in localStorage
- Responsive — mobile-friendly with overlay chat
- Frontend: React + Vite, Socket.io-client, simple-peer, mp4box, ffmpeg.wasm
- Backend: Node.js + Express + Socket.io
- Sync: WebSocket (real-time bidirectional)
- Deploy: Docker Compose, Nginx reverse proxy
cd backend
npm install
SITE_PASSWORD=choose-a-private-password npm startServer runs on port 3001. For a public deployment, also set ALLOWED_ORIGINS to the frontend URL. The plain Node.js start script reads environment variables from the shell; it does not load a backend .env file automatically.
cd frontend
npm install
npm run devOpen http://localhost:5173 in your browser.
Create a project-root .env file (you can copy backend/.env.example) and set SITE_PASSWORD to a private value. Set ALLOWED_ORIGINS to the public frontend URL when deploying remotely. Docker Compose requires SITE_PASSWORD so a deployment cannot accidentally expose the private beta without authentication.
docker compose up -d --buildFrontend: http://localhost:8050, Backend: internal port 3001.
| Variable | Description | Default |
|---|---|---|
ALLOWED_ORIGINS |
Comma-separated CORS origins | http://localhost:5173 |
SITE_PASSWORD |
Optional server-verified site entry password; authenticated sessions last 24 hours | (empty = no auth) |
CLOUDFLARE_TURN_TOKEN |
Cloudflare TURN API token | (empty = STUN-only) |
CLOUDFLARE_TURN_KEY_ID |
Cloudflare TURN key ID | (empty = STUN-only) |
| Variable | Description | Default |
|---|---|---|
VITE_SOCKET_URL |
Socket.io server URL | / |
When SITE_PASSWORD is set, the frontend obtains a short-lived login token from /api/auth; Socket.IO and TURN requests require that token. Room passwords are kept for the current browser tab so a page refresh can rejoin a protected room. Embedded subtitle extraction is skipped for files above 256 MiB to avoid copying a large movie into browser/WASM memory; external SRT/VTT subtitles remain available.
- Open
http://localhost:5173in two different browser tabs (or two different machines). - Enter the same Room Name (e.g.
movie-night), use different display names, and click "Join Room". - Select the same video file on both sides (the file is not uploaded to the server — it plays locally via
URL.createObjectURL). - When one side plays/pauses/seeks, the other side mirrors the action with ~100-200ms delay.
- Use the chat panel on the right for real-time messaging.
Browser A ──┐ ┌── Browser B
│ Socket.io (WS) │
├──────────────────────┤
│ │
│ Nginx (port 8050) │
│ ├── / → React SPA
│ ├── /socket.io/* → backend:3001
│ └── /api/* → backend:3001
│ │
└──────────────────────┘
│
Backend (port 3001)
├── Room management
├── Playback sync relay
├── Chat relay
├── TURN credentials
└── Rate limiting
Important: Local video files are NOT uploaded. Both participants need compatible copies of the same media for local playback synchronization.
An isIncomingSignal ref flag prevents feedback loops:
- When a video action arrives from the server, the flag is set to
true, the video updates, then resets after 100ms. - User-triggered
onPlay/onPause/onSeekedevents skip server emission when the flag istrue.
# Backend
cd backend && npm test
# Frontend
cd frontend && npm test
# Full Docker test
./test.sh- Restrict
ALLOWED_ORIGINSto your domain. - Set
SITE_PASSWORDfor private access. - Configure
CLOUDFLARE_TURN_TOKENfor TURN relay (required when STUN fails behind symmetric NAT). - Both sides must use the exact same video file (same encoding, same duration) for perfect sync.