Watch-Together Cinema
A private cinema you own: upload a movie, get an unguessable room link, send it to one other person, and you both watch in sync (either side controls play/pause/seek) with an optional camera+mic call overlaid and a shared laser pointer. No Kosmi/Teleparty subscription, no per-message limits, no sending big files around.
Reference implementation lives in reference/ — a ~180-line FastAPI backend, a YouTube-style player, an admin page, plus nginx + coturn examples. Copy them, fill placeholders, deploy.
Architecture (the important distinction)
There are two separable problems and only one is hard:
- Sync (mirror play/pause/seek between two browsers) — trivial: a WebSocket relay.
- Delivering the video — the real work. You serve the file over HTTP with byte-range (so seeking works) directly from the host.
browser A ─┐ ┌─ nginx :443 (TLS)
├── wss sync + signaling ──┤ ├── /media/* → static file, byte-range (video)
browser B ─┘ │ └── / → uvicorn :8088 (FastAPI: pages, upload, WS)
└─ coturn :3478 → WebRTC relay (camera/mic, NAT fallback)
- Movie stream: peer downloads the file from your server. One viewer = trivial bandwidth.
- Camera call: WebRTC peer-to-peer (cameras go directly between the two browsers). The WebSocket doubles as the signaling channel; coturn relays only when NAT/CGNAT blocks a direct path (common on mobile/home ISPs across countries).
Components (in reference/)
| File |
What it is |
app.py |
FastAPI: token-gated watch rooms, key-gated admin upload/list/delete, WebSocket that relays sync + WebRTC signaling + pointer, ffmpeg faststart-remux + .srt→.vtt on upload. Serves video via StaticFiles (byte-range capable). Admin key from CINEMA_ADMIN_KEY env — no hardcoded secrets. |
player.html |
The watch room. Custom YouTube-style controls (auto-hide, scrub bar), keyboard (Space/K play, ←/→ 5s, J/L 10s, ↑/↓ volume, M mute, C subs, F fullscreen, P pointer), buffer-aware drift-correction sync, WebRTC call with draggable/resizable tiles, shared laser pointer. Has {{TURN_HOST}} / {{TURN_USER}} / {{TURN_PASS}} placeholders — fill before serving. |
admin.html |
Upload (movie + optional subtitles) with progress, list, copy room link, delete. Key comes from ?key= in the URL. |
nginx.conf.example |
Reverse proxy: serves /media/ statically (native range), proxies everything else + WebSocket upgrade to uvicorn, client_max_body_size 0 for multi-GB uploads. |
turnserver.conf.example |
coturn config (long-term credential, relay port range, external-ip). |
Deploy (once)
- Server + Python:
mkdir -p /opt/cinema/media /opt/cinema/static
python3 -m venv /opt/cinema/venv
/opt/cinema/venv/bin/pip install fastapi "uvicorn[standard]" python-multipart websockets
cp reference/app.py /opt/cinema/app.py
cp reference/player.html reference/admin.html /opt/cinema/static/
apt-get install -y ffmpeg # for faststart remux + srt→vtt
- Admin key (systemd unit):
# /etc/systemd/system/cinema.service
[Service]
Environment=CINEMA_ADMIN_KEY=%GENERATE_A_RANDOM_HEX% # openssl rand -hex 12
WorkingDirectory=/opt/cinema
ExecStart=/opt/cinema/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8088
Restart=always
[Install]
WantedBy=multi-user.target
systemctl enable --now cinema
- Domain + TLS. Point a subdomain straight at the host (A record, DNS-only if behind Cloudflare — CF's free plan caps uploads at 100 MB and restricts video, so the media path MUST bypass the proxy). Then
certbot --nginx -d cinema.example.com with nginx.conf.example.
- TURN (coturn) for the camera call to survive cross-country NAT:
apt-get install -y coturn
cp reference/turnserver.conf.example /etc/turnserver.conf # set your own user:password, realm, external-ip
sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
systemctl enable --now coturn
Put the same TURN host/user/password into player.html's {{TURN_*}} placeholders. Verify the relay actually works: turnutils_uclient -y -u USER -w PASS -n 4 YOUR_HOST should report Total lost packets 0.
Use
- Admin:
https://cinema.example.com/admin?key=YOUR_ADMIN_KEY → upload movie (+ .srt) → wait for ready → copy the room link.
- Send the room link to the other person. Both open it → Start watching together → synced. Press P and move the mouse for the shared pointer. Click the call icon to turn on cameras (earbuds recommended — speakers + open mic = echo).
The buffering fix (this is the #1 real-world problem)
Movies are usually VBR — a file that averages ~2 Mbit/s spikes to ~10 Mbit/s on busy scenes. While a camera call also runs, those spikes outrun a home/mobile connection and the movie stutters. Fix: transcode to a capped constant bitrate before uploading (no spikes, visually identical). On a machine with an NVIDIA GPU:
ffmpeg -i input.mp4 -map 0:v:0 -map 0:a:0 \
-c:v h264_nvenc -preset p5 -rc vbr -b:v 2500k -maxrate 3000k -bufsize 6000k \
-profile:v high -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart output.mp4
# CPU-only: swap -c:v h264_nvenc ... for -c:v libx264 -crf 21 -maxrate 3000k -bufsize 6000k
Extract embedded subtitles to upload alongside: ffmpeg -i input.mp4 -map 0:s:0 subs.srt
Do heavy transcodes on a GPU box (minutes), not a CPU-only VPS (hours). The app's on-upload step only does a fast stream-copy faststart remux (-c copy -movflags +faststart) — instant for already-H.264/AAC MP4s — plus .srt→.vtt.
Design notes worth keeping
- Sync is buffer-aware. Don't hard-seek on small drift (causes visible skipping). Only seek when drift > ~3s (someone scrubbed); for smaller drift the behind side nudges
playbackRate to 1.04 to glide back. Suppress state broadcasts while buffering, so a stalled peer doesn't drag the other backward.
- Fullscreen the wrapper, not the
<video>. Native video-fullscreen hides sibling elements — so the camera tiles and pointer vanish. Call requestFullscreen() on the container that holds video + tiles + pointer layer.
- Cap the call bitrate (
sender.setParameters maxBitrate ~400 kbps, 360p/20fps) so the call never starves the movie stream.
- Privacy: room tokens and media filenames are unguessable random hex; admin is key-gated; pages carry
noindex. It's private-by-obscurity for two people — fine for personal use, not a public multi-tenant service.
- Codec: browsers only reliably play MP4 / H.264 / AAC. MKV/HEVC/AC3 must be transcoded (the capped-bitrate command above also fixes codec).
When NOT to use
- More than a handful of simultaneous viewers, or a public audience → use a real streaming/CDN stack, not this.
- You don't control a server with a domain → use Syncplay (each person keeps their own file copy) or a hosted watch-party service.
1---2name: watch-together-cinema3description: Watch-Together Cinema4---56# Watch-Together Cinema78A private cinema you own: upload a movie, get an unguessable room link, send it to one other person, and you both watch **in sync** (either side controls play/pause/seek) with an optional **camera+mic call** overlaid and a **shared laser pointer**. No Kosmi/Teleparty subscription, no per-message limits, no sending big files around.910Reference implementation lives in `reference/` — a ~180-line FastAPI backend, a YouTube-style player, an admin page, plus nginx + coturn examples. Copy them, fill placeholders, deploy.1112## Architecture (the important distinction)1314There are two separable problems and only one is hard:15- **Sync** (mirror play/pause/seek between two browsers) — trivial: a WebSocket relay.16- **Delivering the video** — the real work. You serve the file over HTTP with byte-range (so seeking works) directly from the host.1718```19browser A ─┐ ┌─ nginx :443 (TLS)20 ├── wss sync + signaling ──┤ ├── /media/* → static file, byte-range (video)21browser B ─┘ │ └── / → uvicorn :8088 (FastAPI: pages, upload, WS)22 └─ coturn :3478 → WebRTC relay (camera/mic, NAT fallback)23```2425- **Movie stream:** peer downloads the file from *your* server. One viewer = trivial bandwidth.26- **Camera call:** WebRTC **peer-to-peer** (cameras go directly between the two browsers). The WebSocket doubles as the signaling channel; **coturn** relays only when NAT/CGNAT blocks a direct path (common on mobile/home ISPs across countries).2728## Components (in `reference/`)2930| File | What it is |31|---|---|32| `app.py` | FastAPI: token-gated watch rooms, key-gated admin upload/list/delete, WebSocket that relays sync + WebRTC signaling + pointer, ffmpeg faststart-remux + `.srt`→`.vtt` on upload. Serves video via `StaticFiles` (byte-range capable). Admin key from `CINEMA_ADMIN_KEY` env — no hardcoded secrets. |33| `player.html` | The watch room. Custom YouTube-style controls (auto-hide, scrub bar), keyboard (Space/K play, ←/→ 5s, J/L 10s, ↑/↓ volume, M mute, C subs, F fullscreen, P pointer), buffer-aware drift-correction sync, WebRTC call with draggable/resizable tiles, shared laser pointer. Has `{{TURN_HOST}}` / `{{TURN_USER}}` / `{{TURN_PASS}}` placeholders — fill before serving. |34| `admin.html` | Upload (movie + optional subtitles) with progress, list, copy room link, delete. Key comes from `?key=` in the URL. |35| `nginx.conf.example` | Reverse proxy: serves `/media/` statically (native range), proxies everything else + WebSocket upgrade to uvicorn, `client_max_body_size 0` for multi-GB uploads. |36| `turnserver.conf.example` | coturn config (long-term credential, relay port range, external-ip). |3738## Deploy (once)39401. **Server + Python:**41 ```bash42 mkdir -p /opt/cinema/media /opt/cinema/static43 python3 -m venv /opt/cinema/venv44 /opt/cinema/venv/bin/pip install fastapi "uvicorn[standard]" python-multipart websockets45 cp reference/app.py /opt/cinema/app.py46 cp reference/player.html reference/admin.html /opt/cinema/static/47 apt-get install -y ffmpeg # for faststart remux + srt→vtt48 ```492. **Admin key** (systemd unit):50 ```ini51 # /etc/systemd/system/cinema.service52 [Service]53 Environment=CINEMA_ADMIN_KEY=%GENERATE_A_RANDOM_HEX% # openssl rand -hex 1254 WorkingDirectory=/opt/cinema55 ExecStart=/opt/cinema/venv/bin/uvicorn app:app --host 0.0.0.0 --port 808856 Restart=always57 [Install]58 WantedBy=multi-user.target59 ```60 `systemctl enable --now cinema`613. **Domain + TLS.** Point a subdomain straight at the host (**A record, DNS-only** if behind Cloudflare — CF's free plan caps uploads at 100 MB and restricts video, so the media path MUST bypass the proxy). Then `certbot --nginx -d cinema.example.com` with `nginx.conf.example`.624. **TURN (coturn)** for the camera call to survive cross-country NAT:63 ```bash64 apt-get install -y coturn65 cp reference/turnserver.conf.example /etc/turnserver.conf # set your own user:password, realm, external-ip66 sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn67 systemctl enable --now coturn68 ```69 Put the same TURN host/user/password into `player.html`'s `{{TURN_*}}` placeholders. Verify the relay actually works: `turnutils_uclient -y -u USER -w PASS -n 4 YOUR_HOST` should report `Total lost packets 0`.7071## Use7273- Admin: `https://cinema.example.com/admin?key=YOUR_ADMIN_KEY` → upload movie (+ `.srt`) → wait for **ready** → copy the room link.74- Send the room link to the other person. Both open it → **Start watching together** → synced. Press **P** and move the mouse for the shared pointer. Click the call icon to turn on cameras (earbuds recommended — speakers + open mic = echo).7576## The buffering fix (this is the #1 real-world problem)7778Movies are usually **VBR** — a file that averages ~2 Mbit/s spikes to ~10 Mbit/s on busy scenes. While a camera call also runs, those spikes outrun a home/mobile connection and the movie stutters. **Fix: transcode to a capped constant bitrate before uploading** (no spikes, visually identical). On a machine with an NVIDIA GPU:7980```bash81ffmpeg -i input.mp4 -map 0:v:0 -map 0:a:0 \82 -c:v h264_nvenc -preset p5 -rc vbr -b:v 2500k -maxrate 3000k -bufsize 6000k \83 -profile:v high -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart output.mp484# CPU-only: swap -c:v h264_nvenc ... for -c:v libx264 -crf 21 -maxrate 3000k -bufsize 6000k85```8687Extract embedded subtitles to upload alongside: `ffmpeg -i input.mp4 -map 0:s:0 subs.srt`8889Do heavy transcodes on a GPU box (minutes), not a CPU-only VPS (hours). The app's on-upload step only does a fast **stream-copy faststart remux** (`-c copy -movflags +faststart`) — instant for already-H.264/AAC MP4s — plus `.srt`→`.vtt`.9091## Design notes worth keeping9293- **Sync is buffer-aware.** Don't hard-seek on small drift (causes visible skipping). Only seek when drift > ~3s (someone scrubbed); for smaller drift the *behind* side nudges `playbackRate` to 1.04 to glide back. Suppress state broadcasts while `buffering`, so a stalled peer doesn't drag the other backward.94- **Fullscreen the wrapper, not the `<video>`.** Native video-fullscreen hides sibling elements — so the camera tiles and pointer vanish. Call `requestFullscreen()` on the container that holds video + tiles + pointer layer.95- **Cap the call bitrate** (`sender.setParameters` `maxBitrate ~400 kbps`, 360p/20fps) so the call never starves the movie stream.96- **Privacy:** room tokens and media filenames are unguessable random hex; admin is key-gated; pages carry `noindex`. It's private-by-obscurity for two people — fine for personal use, not a public multi-tenant service.97- **Codec:** browsers only reliably play **MP4 / H.264 / AAC**. MKV/HEVC/AC3 must be transcoded (the capped-bitrate command above also fixes codec).9899## When NOT to use100101- More than a handful of simultaneous viewers, or a public audience → use a real streaming/CDN stack, not this.102- You don't control a server with a domain → use Syncplay (each person keeps their own file copy) or a hosted watch-party service.