- TypeScript 88.4%
- CSS 11.4%
- HTML 0.2%
Fix stale play commands and decode errors after refresh - Add LoadedState tune-in variant + overlay for post-refresh autoplay block: server says playing but local play() was blocked by browser policy. Overlay shows a tap-to-join affordance on both devices so neither has to guess-and-disrupt the room to recover sync. - Queue pendingPlayback in SyncController for the loading/idle window and re-apply on player ready. Surface pendingPlayIntent through the Player interface so the overlay can show the hint immediately, not after a seek/render frame. - play() now throws on autoplay rejection so sync can catch and queue instead of silently dropping the command. playLocal() bypasses the server (server is already in the right state). - HLS recovery: suppress lingering <video> decode errors for 3 BUFFER_APPENDED events after hls.recoverMediaError() so the toast no longer fires on top of an already-recovered pipeline. - Server hardening: commandId dedup, hostUserId tracking with auto transfer on disconnect + Take Control, currentTime preserved across source changes, SystemEvent audit trail for chat, per-device HLS config table, proxy diagnostics for hotlink / 403 / 502 errors. @ |
||
|---|---|---|
| client | ||
| server | ||
| shared | ||
| .gitignore | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.base.json | ||
cinema.bprts.com
A single-room, equal-control watch-together app for cinema.bprts.com.
Drop in an m3u8, a YouTube URL, or a magnet link and everyone on the page
sees the same stream, at the same time. Restricted m3u8 streams can be
served through the built-in proxy at /proxy/manifest?url=….
The project is a small npm workspaces monorepo:
.
├── shared/ # types + helpers shared by client and server
├── server/ # Express + Socket.IO, the m3u8 proxy, and the yt-dlp wrapper
└── client/ # Vite + vanilla TypeScript UI
Features
- Three source types out of the box — HLS (
.m3u8), YouTube (resolved server-side viayt-dlp), and Magnet (played client-side via WebTorrent). More formats can be added by writing a newPlayerimplementation inclient/src/players/and wiring it intocreatePlayerForSource. - Built-in m3u8 proxy at
/proxy/manifest?url=…and/proxy/segment?url=…. Manifests are fetched server-side, all segment and AES-128 key URLs are rewritten to also flow through the proxy so the browser never has to deal with the upstream's CORS / hotlink policy. - Equal control for everyone, no accounts, no roles, no rooms. Whoever hits play decides for the room. (A new source replaces the old one.)
- Random username on first visit, editable with one click. The choice
is persisted in
localStorageso refreshing the page doesn't fill chat with "user X left / user X joined" lines. - Drift correction: the server re-broadcasts the canonical playback state every 10s; clients seek to it if they're more than 1.5s out.
Requirements
- Node.js 20+
- For YouTube sources: the
yt-dlpbinary. The server will download it tobin/yt-dlpon first run if it isn't already there (or setYTDLP_PATHto point at a pre-installed binary). - For magnet / torrent sources in the browser: a WebRTC-capable browser
over HTTPS (or
localhost, which is exempt).
Install
npm install
The shared package is built automatically by postinstall.
Development
Two processes. Run them in separate terminals (or use npm run dev which
spawns both via concurrently):
# terminal 1 — Express + Socket.IO on :3000
npm run dev -w server
# terminal 2 — Vite dev server on :5173, proxying /proxy and /socket.io
npm run dev -w client
Open http://localhost:5173.
Build & run in production
npm run build # builds shared, client, then server
npm run start # serves the built client and the API on PORT (default 3000)
By default the server listens on :3000. Override with PORT=8080 npm start.
Environment variables
| Var | Default | What it does |
|---|---|---|
PORT |
3000 |
HTTP listen port. |
PUBLIC_ORIGIN |
http://localhost:3000 |
Used to build absolute URLs for the m3u8 proxy (YouTube embeds, etc.). |
PROXY_PREFIX |
/proxy |
Path prefix for the proxy endpoints. |
PROXY_MAX_BYTES |
67108864 (64 MiB) |
Hard cap on any single proxied response. |
YTDLP_PATH |
./bin/yt-dlp |
Path to the yt-dlp binary (auto-downloaded here on first use). |
YOUTUBE_CACHE_SECONDS |
21600 (6h) |
How long a resolved YouTube stream URL is cached. |
CHAT_HISTORY_LIMIT |
200 |
How many chat messages are kept in memory for late joiners. |
How the proxy works
GET /proxy/manifest?url=<encoded upstream URL>
- Fetches the upstream manifest server-side (no CORS, no browser
Refererheader — so hotlink checks keyed onRefererare bypassed). - Rewrites every URL-bearing line so the browser only talks to us:
#EXT-X-KEY:…URI="…"#EXT-X-MAP:URI="…"#EXT-X-MEDIA:…URI="…"#EXT-X-STREAM-INF:…URI="…"#EXT-X-I-FRAME-STREAM-INF:…URI="…"- bare segment URIs (master + media playlists)
- Returns the rewritten manifest as
application/vnd.apple.mpegurlwithAccess-Control-Allow-Origin: *.
GET /proxy/segment?url=<encoded segment URL> is a passthrough byte
stream for .ts / .m4s / .mp4 segments, with a hard size cap
(PROXY_MAX_BYTES).
The proxy intentionally does not cache response bodies — streams are typically authenticated with short-lived tokens in the query string, and caching would break them.
How sync works
- The server holds a single
RoomStatein memory: current source, playback, users, and a recent chat buffer. - On connect, the server emits
state:initwith the snapshot. The client loads the source into the appropriatePlayer(HLS, YouTube, or WebTorrent) and seeks to the canonicalcurrentTime. - Playback changes are sent as one of
playback:play,playback:pause,playback:seek. The server updates the state and re-broadcasts to everyone in the room. - Setting a new source emits
source:set; the server normalises it, resolves YouTube URLs viayt-dlp, and re-broadcasts. - A
SyncControlleron the client owns the player and the socket, with a guard so applying remote state doesn't re-emit local events back to the server.
Adding a new source type
- Add a new
Sourcevariant inshared/src/index.ts. - Add a
Playerimplementation inclient/src/players/<name>-player.tsthat implements thePlayerinterface inclient/src/players/types.ts. - Register it in
createPlayerForSource(client/src/players/index.ts). - Add the tab in
client/src/ui/source-form.tsand the input form for it. - Add a
caseto thesetSourceswitch inserver/src/room.tsand the equivalent dispatch inserver/src/socket.ts.
Layout
shared/src/ client/src/ server/src/
index.ts dom.ts config.ts
usernames.ts identity.ts proxy.ts
socket.ts youtube.ts
sync.ts room.ts
main.ts socket.ts
style.css index.ts
ui/
source-form.ts
player-stage.ts
chat.ts
players/
types.ts
hls-player.ts
youtube-player.ts
torrent-player.ts
index.ts
License
Personal use.