A self-hosted, cross-platform server for watching movies and shows together in sync. The host runs the server on their own machine; guests join from their browsers over the internet through an integrated tunnel. Includes real-time chat with floating reactions and GIF search.
Features:
- 🎬 Real-time playback synchronization (pause, play, seek)
- ⏳ The room waits for the viewer who is still buffering (capped at 20 s, so a bad connection doesn't stall the session)
- 🎙️ Independent audio and subtitle track selection per viewer
- 💬 Live chat with floating reactions and GIF search
- 🔗 Automatic remote access via a secure HTTPS tunnel (cloudflared)
- 🖥️ Cross-platform support (Windows and macOS)
- 🎯 Small groups (3–6 people)
- Node.js ≥ 20
- npm or equivalent
- Windows or macOS (prebuilt ffmpeg and cloudflared binaries are included)
npm installThe first time you open the library, a «📁 Choose folder…» button opens your system's native dialog (Finder/File Explorer) so you can pick your videos folder. No file editing required. If you prefer, you can also type the path by hand in the same setup wizard, or edit the configuration directly.
Configuration is read in two layers, from lowest to highest priority:
config.defaults.json(repo root, versioned). A template with generic defaults only. Every optional field ships asnull.config.json(local, outside the repo). Everything specific to your machine: media folders and any API keys or tunnel settings. It lives in the data directory of your platform:- macOS:
~/Library/Application Support/jbg-watchparty/config.json - Windows:
%APPDATA%\jbg-watchparty\config.json - Linux:
~/.local/share/jbg-watchparty/config.json
- macOS:
A value in the local config.json overrides the shared file, unless it is
null: null means "not configured". On first run, the app creates the local
config for you; you can also create it by hand.
Example (all fields are optional except where noted):
{
"mediaFolders": [
"/Users/youruser/Videos/Movies",
"/Users/youruser/Videos/Series"
],
"klipyApiKey": "your-optional-klipy-api-key",
"tmdbApiKey": "your-optional-tmdb-api-key",
"port": 8400,
"cacheLimitGB": 10
}Configuration fields:
mediaFolders(array of strings): Absolute paths to folders containing videos (MKV, MP4, AVI, etc.). Required; you can also add the first folder from the host panel if you start with an empty library. Machine-specific: put it in the localconfig.json, never inconfig.defaults.json.klipyApiKey(string, optional): Klipy API key to search and send GIFs in chat. If absent, the GIF button is hidden.tunnelToken(string, optional): Token of a Cloudflare named tunnel. With it (along withtunnelUrl), the server uses your tunnel with a fixed URL instead of the random Quick Tunnel. See Fixed URL with your own domain.tunnelUrl(string, optional): Fixed public URL of the tunnel, e.g.https://watchparty.yourdomain.com. Required if you usetunnelToken(they must be configured together).streamBaseUrl(string, optional): Origin from which clients fetch video, e.g.https://stream.yourdomain.com. Without it (the default), video is served from the same origin as the app. See Serving video off the CDN.relayPeerPublicKey,relayEndpoint,relayPeerIp,relayLocalIp(strings, optional): Details of your relay VPS, sonpm run setupcan bring up the WireGuard tunnel on a new machine without anyone having to remember anything. They describe the VPS rather than the machine, and none is secret (a public key and an endpoint), so multi-machine owners may keep them inconfig.defaults.json; everyone else should keep them in the localconfig.json.relayLocalIpis fixed on purpose: the reverse_proxy on the VPS points at a single address, so only one host can use it at a time.tmdbApiKey(string, optional): TMDB API key (themoviedb.org → Settings → API). With it, creating a room looks up metadata from the file name: the room title becomes "Title (year)" and an ℹ️ Info button appears with poster, rating and synopsis. Episodes (S01E02in the name) are looked up as series. Without a key, everything works the same but with the bare file name.port(number): HTTP port of the server (default: 8400).cacheLimitGB(number): HLS cache limit in GB (default: 10). Cleaned up automatically when rooms close.
npm startnpm start begins with an environment check that runs on both macOS and Windows:
it fixes on its own whatever can be fixed automatically (bringing up the relay tunnel if
configured) and warns about the rest with the concrete action next to it. It aborts only
when starting would make no sense: Node below 20, no ffmpeg, or the port already taken by
another instance. Warnings (empty library, folder that no longer exists, tunnel down) do
not block anything, because the panel and the local network keep working.
🎬 Watchparty environment check
✅ Node 22
✅ ffmpeg and ffprobe bundled
✅ Web UI built
✅ 1 media folder(s)
✅ Port 8400 free
✅ Relay active toward https://stream.example.com
▶️ All set.
You can run just the check with npm run preflight. Afterwards, the server:
- Scans the configured media folders
- Automatically brings up a secure HTTPS tunnel (cloudflared Quick Tunnel)
- Opens your browser at
http://localhost:8400/?key=<admin-token>. Thekeyis a token generated at startup that authenticates the host panel (stored in a cookie after the first visit) - Shows the secure public URL to share with guests
All commands:
| Command | Purpose |
|---|---|
npm start |
Checks the environment, builds the web client and starts the server |
npm run setup |
Set up a brand-new machine (generates the relay tunnel) |
npm run preflight |
Only the environment check, without starting anything |
npm run tunnel:up / tunnel:down |
Manual control of the relay tunnel |
npm test |
The whole test suite |
Copy the public URL shown in the console or in the web interface. Guests join like this:
- Click the HTTPS link
- Enter their name
- See the live room (without creating a new one)
A room and a movie are different things:
- Creating an empty room gives you a shareable link instantly, before choosing anything. Guests join, type their name and can chat while the host decides; they see a waiting card where the video would be.
- Only the host (whoever holds the admin cookie, i.e. whoever opened the
panel on
localhost) can set or change the movie, with the «🎬 Choose/Change movie» button in the room header. Play, pause and the seek bar belong to everyone. - Changing movies neither closes the room nor changes the link. The new file is probed again, so duration, audio tracks, available subtitles and TMDB metadata are recomputed; playback starts paused at 0:00 and the chat is preserved. Nobody needs to reload.
Each movie in a room is a numbered "generation", and that number goes into the video
URL: /stream/<token>/e2/master.m3u8. It isn't decorative. Segments and init are named
identically across movies (init_0.mp4, seg_0_00000.m4s), so without versioning the
URL the browser cache (or the relay cache, if you use streamBaseUrl) would serve the
previous movie's bytes. It goes in the path rather than a query string so versioning
doesn't depend on how the relay proxy treats queries, and so the relative URIs inside
playlists land within the right generation by themselves.
By default the server uses a cloudflared Quick Tunnel: the URL (*.trycloudflare.com)
changes on every start. If you have a domain managed in Cloudflare, you can get a fixed
URL (e.g. https://watchparty.yourdomain.com) with a one-time setup:
- Sign in at one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel (Cloudflared type) and give it a name (e.g.
watchparty). - At the connector step, don't install anything: just copy the token shown in the command (
cloudflared service install <TOKEN>; the token is the long string). - Under Public Hostnames, add
watchparty.yourdomain.com→ servicehttp://localhost:8400(the port from yourconfig.json). Cloudflare creates the DNS route automatically. - In your local
config.json, add:
{
"tunnelToken": "eyJh...your-token...",
"tunnelUrl": "https://watchparty.yourdomain.com"
}On startup, the server brings up your tunnel with that fixed URL (the same one on every restart). If either field is missing, it warns on the console and falls back to the Quick Tunnel. The app uses its own cloudflared binary; there's nothing to install system-wide.
The Cloudflare CDN terms for Free/Pro/Business plans reserve the right to limit service
for anyone using it "to serve video or a disproportionate percentage of pictures, audio
files, or other large files", with an exemption only if the content is hosted on a
Cloudflare service (Stream, Images, R2). The public hostname of a tunnel is a CNAME to
<uuid>.cfargotunnel.com, which only resolves through the proxy: you can't grey-route
it, so all video goes through the CDN by construction. A 6-person, 2-hour session moves
roughly 30 GB.
streamBaseUrl separates the two planes:
| Plane | What it carries | How it travels |
|---|---|---|
| Control | HTML, API, WebSocket (chat, sync, presence) | Cloudflare tunnel (intended use, negligible traffic) |
| Data | master.m3u8, init_*.mp4, seg_*.m4s, sub_*.vtt |
Your relay, bypassing the CDN |
Pointing the master.m3u8 at the relay is enough: HLS resolves the playlist's relative
names against the playlist URL, so init and segments follow along by themselves.
Oracle is a good fit: 10 TB/month of egress on Always Free, and its acceptable use policy has no content-type clause equivalent to Cloudflare's CDN one.
- Instance and network. Create the VM and open 443 in the VCN Security List (ingress
0.0.0.0/0 → TCP 443). Oracle images also ship persistent restrictive
iptables, so opening the VCN isn't enough on its own:sudo iptables -I INPUT 5 -p tcp --dport 443 -j ACCEPT sudo netfilter-persistent save # Ubuntu; on Oracle Linux: firewall-cmd --add-port=443/tcp --permanent - DNS.
Arecord fromstream.yourdomain.comto the VPS public IP, grey-routed (DNS-only). That's what keeps traffic out of the CDN; the free authoritative DNS has no content restriction because it carries no bytes. - Home → VPS tunnel. WireGuard between the two machines (the VPS as the fixed endpoint). The home host needs neither a public IP nor port forwarding, and works behind CGNAT.
- TLS and proxy on the VPS. With Caddy, the entire
Caddyfileis:Caddy obtains and renews the Let's Encrypt certificate by itself.stream.yourdomain.com { reverse_proxy 10.0.0.2:8400 # the WireGuard IP of the home host } - Host config. In the local
config.json:{ "streamBaseUrl": "https://stream.yourdomain.com" }
Idle reclaim: Oracle may reclaim Always Free instances that stay below the 95th percentile of 20% CPU and 20% network (and 20% memory on A1 shapes) for 7 days. A relay used two hours a week fits that profile exactly. Options: move the instance to paid (cents per month), keep it busy, or accept recreating it occasionally and script the steps above. Paid instances are unaffected.
Without streamBaseUrl everything stays exactly as before (same origin), which is the
right behavior on a LAN.
With the relay* fields in place, a new machine gets ready with a single command.
Install WireGuard first (brew install wireguard-tools on macOS,
the official installer on Windows), then:
npm run setupIt generates that machine's key pair, writes its wg0.conf where the platform expects
it, and prints the only remaining command to run on the VPS, with the public key already
substituted. It's idempotent: if the tunnel is already configured it regenerates nothing,
because doing so would invalidate the key the VPS has authorized.
From then on, npm start brings the tunnel up by itself. Details of how:
- It only asks for elevation if the tunnel isn't already up (administrator password on macOS, UAC on Windows). Starting the server twice the same afternoon doesn't ask again.
- It doesn't tear it down when done. An idle tunnel costs a packet every 25 s, and
tearing it down would force a second authentication per session. To take it down:
npm run tunnel:down. - It checks that the far end actually responds, not just that the interface exists: a down VPS leaves an interface up that looks healthy but serves no video.
Elevation is requested through the OS dialog and not through a NOPASSWD sudoers rule
on purpose: on macOS the Homebrew prefix is user-writable, so giving passwordless sudo to
a binary that same user could replace would be a root escalation for any process running
as them.
For GIF search in chat:
- Go to https://klipy.com/developers
- Create an account and sign in
- Generate a new API key
- Copy the key into the
klipyApiKeyfield of your localconfig.json
Without an API key, chat works perfectly fine; only the GIF button is unavailable.
Video:
- MKV (Matroska): multiple audio and subtitle tracks
- MP4 (H.264)
- AVI
- WebM
Video codecs:
-
Everything is transcoded to H.264 with keyframes forced every 4 s (hardware acceleration when available: VideoToolbox on macOS, NVENC/QSV on Windows).
Stream-copying the video would be cheaper, but the playlist is VOD: the server must declare in advance where each segment will be cut. In copy mode the cuts are chosen by ffmpeg's HLS muxer against its own grid, which the server cannot predict. When the two lists disagree, the playlist ends up short and the room freezes mid-movie. The full reasoning, with measurements, lives in
server/src/media/hlsLayout.ts.Additionally, the server pins the media timeline instead of inheriting it from ffmpeg: it serves a canonical init (stripped of the edit-list entries that depend on the particular process ffmpeg happened to start from; the rest, such as codec delay trim, is preserved) and anchors each segment header to the instant the playlist already declares. Without this, a seek only landed correctly while the room kept running on the same ffmpeg that produced the first init. The MP4 box editing lives in
server/src/media/fmp4.ts.
Audio tracks:
- With a single track, audio travels inside the video segment itself.
- With several, each one is exposed as an independently selectable AAC rendition per viewer.
Subtitles:
- Embedded text tracks (SRT, ASS/SSA)
- External
.srtfiles next to the video (detected automatically) - Converted to WebVTT for browser playback
- Image-based tracks (PGS/VobSub) are silently skipped: they don't appear in the room's subtitle selector
- Each user gets a color assigned on join
- Real-time bidirectional messages
- Quick emoji bar, customizable per viewer
- The «+» button opens a picker with the full catalog and a search field
- Your selection persists in the browser (up to 12 emojis)
- Emojis float upward over the video on both screens (Instagram Live style)
- The emoji also appears, small and for a few seconds, next to the sender's name in the participant list
- They don't appear in the chat history
- Search integrated against the Klipy API (if configured)
- The chosen result is embedded as a chat message
- Only visible if the API key is present in
config.json
- Notifications when someone joins, leaves, pauses, resumes or changes the video
- Button in the controls, double-click on the video or the
Fkey - Chat and reactions float at the bottom right over the video
- Everything hides after a few seconds without activity and comes back on mouse move, keypress or a new message; with the chat focused or the room paused, it never hides
- On iPhone, where the browser disallows fullscreen with overlays, a "cinema mode" that fills the window is used instead
Open two terminals:
Terminal 1: Server (no auto-reload)
npm start -w servertsx runs the server straight from TypeScript, but without --watch: after every change
in server/src, stop the process (Ctrl+C) and launch npm start -w server again.
Terminal 2: Client (with Vite dev server)
npm run dev -w webOpen http://localhost:5173/ for the development client.
npm testRuns unit and integration tests for both packages (server and web).
npx tsc --noEmitIn server/: verifies the server's TypeScript types.
cd web && npx tsc --noEmitIn web/: verifies the client's TypeScript types.
node web/scripts/gen-emoji-catalog.mjsRewrites web/src/chat/emojiCatalog.ts from emojibase-data. Only needed when Unicode
ships new emojis. Requires network access, which is why it stays out of the build and the
tests.
npm run build -w webBuilds the React client for production into web/dist/.
- Image-based subtitles (PGS/VobSub): unsupported; silently skipped (they don't appear as options in the selector)
- Persistence: chat and room history are lost when the room closes
- User accounts: no authentication; only basic host/guest roles
- Native packaging: v1 requires Node.js and
npm start; Electron/installer left for future versions
.
├── server/ # Fastify + Node.js
│ ├── src/
│ │ ├── index.ts # Entry point
│ │ ├── app.ts # Fastify app construction (routes + static files)
│ │ ├── config.ts # config.json loading/saving
│ │ ├── http/ # REST routes (library, rooms, stream, klipy, admin)
│ │ ├── library/ # Media folder scanning
│ │ ├── media/ # Probe, segment planning, ffmpeg, MP4 boxes, subtitles, cache
│ │ ├── rooms/ # Room state and playback synchronization
│ │ ├── ws/ # WebSocket (sync, chat, reactions, presence)
│ │ └── tunnel/ # cloudflared integration
│ └── package.json
├── web/ # React + Vite + hls.js
│ ├── src/
│ │ ├── App.tsx # Root component (simple pathname routing)
│ │ ├── api.ts, ws.ts, types.ts
│ │ ├── pages/ # Library, room
│ │ ├── player/ # HLS player and drift correction
│ │ ├── chat/ # Chat, reactions, GIFs
│ │ └── sync/ # Drift correction math
│ └── package.json
├── package.json # Root workspace
└── README.md # This file
- Verify Node.js ≥ 20 is installed:
node --version - Check that
config.jsonexists and has at least one validmediaFolder - Check nothing else is listening on the configured port (default 8400)
- The public URL requires internet; use mobile data to verify from another device
- If the tunnel drops, the server relaunches it automatically; with a Quick Tunnel the URL changes and the link must be re-shared (a named tunnel keeps its fixed URL)
- Check the browser is up to date (recent Chrome, Safari, Firefox)
- Verify the file is inside a folder listed in
mediaFolders - If it's HEVC/x265, ffmpeg is transcoding; this can take minutes on old hardware
- On error, the room shows the ffmpeg log with a «Retry» button
- If after a seek the position stalls and "X is buffering…" appears, that's expected: the room waits up to 20 s for the straggler
- Seeking mid-MKV used to leave subtitles over a black frame; that sync bug was fixed. If you ever see it again, please report it; it's not a known limitation
- That's expected: each user picks their track independently
- Only playback position is synchronized globally