A two-person watch party. Upload a video to the server, and both of you get the same playhead, a chat sidebar, and each other's webcam.
- Upload from the browser — drag a file anywhere onto the page, or hit
↑in the toolbar. It streams straight to disk with a progress bar, then loads on both screens automatically.🗑removes it again. - Or paste a direct link — paste a video URL anywhere outside the chat box (or
use
🔗) and the server downloads it, so a big file never has to make the round trip through your browser. Both people see the progress bar; either can cancel. - Synced playback — play, pause, seek, and file selection propagate to the other person. The server holds the authoritative playhead, so whoever joins second (or reconnects) lands at the right spot. Drift is corrected silently every 3s.
- Chat — WebSocket, last 50 messages replayed on join.
- Webcam + mic — peer-to-peer WebRTC; the server only relays signaling.
- Room of 2 — a third connection is turned away.
npm install
npm start # http://localhost:3000Open two browser tabs, join with different names, drag in a video.
Camera/mic need a secure context:
localhostworks, plainhttp://<lan-ip>does not. Over HTTPS on Render both work fine.
- Push the repo to GitHub (videos are gitignored — you upload them after deploy).
- On Render: New → Web Service, point it at the repo.
render.yamlalready sets buildnpm installand startnpm start; otherwise enter those manually. - Open the URL, upload your video, share the link.
Optional: set a ROOM_PASSWORD env var in the Render dashboard to gate the room —
the join screen shows a password field automatically when it's set.
- The disk is ephemeral. Uploads live in
media/on the instance and are wiped by any redeploy or restart — including the restart after the instance sleeps. Plan on re-uploading before a session, or attach a Render persistent disk mounted at/opt/render/project/src/media(a paid feature) to keep files around. - Free instances sleep after ~15 min idle and cold-start in ~30s.
- Big uploads over a slow link can time out at Render's proxy. If a large file
keeps failing, compress it first (
ffmpeg -i in.mkv -c:v libx264 -crf 24 out.mp4) or upload from the faster of your two connections. Pasting a link avoids this entirely — the transfer happens server-to-server. - Free instances have only a few GB of ephemeral disk. A 3–4 GB movie may not fit at all, and the download will die partway with a disk error. Re-encode to something in the 1–2 GB range, or attach a persistent disk.
- Bandwidth: two people streaming a movie is a few GB. Watch the free tier's 100 GB/month cap.
- WebRTC without TURN: the webcam link uses public STUN only. That connects for
most home networks; behind symmetric NAT or strict corporate firewalls it won't,
and the camera panel will read
failed. Chat and video sync are unaffected — they go through the server. Add a TURN server (Twilio, Metered, or coturn) toRTC_CONFIGin public/app.js if you need it.
| Action | |
|---|---|
| Space | play / pause |
| ← / → | seek 5s |
| Resync | jump to the server's playhead |
| ↑ / drag-drop | upload a video |
| 🔗 / paste a URL | server-side download from a link |
| ⛶ | fullscreen |
Accepted formats are whatever the browser can play: .mp4, .webm, .mkv, .mov,
.m4v, .ogv. H.264 .mp4 is the safest bet.
.mkv is the usual source of "video plays, no sound." Chrome can't decode AC-3
or E-AC-3 audio, which most .mkv releases ship. Remux to .mp4 with AAC audio
first — this is fast and doesn't re-encode the video:
ffmpeg -i in.mkv -c:v copy -c:a aac -b:a 192k -movflags +faststart out.mp4+faststart matters: it moves the index to the front so the file starts playing
before it's fully buffered, and seeking stays snappy.
- server.js — static files with range requests, upload/delete/list API, and the WebSocket room (state, chat, signaling relay).
- public/app.js — player sync, upload, chat UI, WebRTC negotiation.
- public/index.html, public/style.css — UI.
media/— uploaded videos land here.