No description
  • TypeScript 88.4%
  • CSS 11.4%
  • HTML 0.2%
Find a file
BPRTS 4a693da4f2 @
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.
@
2026-08-02 00:39:09 +02:00
client @ 2026-08-02 00:39:09 +02:00
server @ 2026-08-02 00:39:09 +02:00
shared @ 2026-08-02 00:39:09 +02:00
.gitignore initial commit 2026-07-24 00:17:56 +02:00
package-lock.json chat fix 2026-07-26 13:32:52 +02:00
package.json initial commit 2026-07-24 00:17:56 +02:00
README.md initial commit 2026-07-24 00:17:56 +02:00
tsconfig.base.json initial commit 2026-07-24 00:17:56 +02:00

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 via yt-dlp), and Magnet (played client-side via WebTorrent). More formats can be added by writing a new Player implementation in client/src/players/ and wiring it into createPlayerForSource.
  • 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 localStorage so 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-dlp binary. The server will download it to bin/yt-dlp on first run if it isn't already there (or set YTDLP_PATH to 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>

  1. Fetches the upstream manifest server-side (no CORS, no browser Referer header — so hotlink checks keyed on Referer are bypassed).
  2. 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)
  3. Returns the rewritten manifest as application/vnd.apple.mpegurl with Access-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 RoomState in memory: current source, playback, users, and a recent chat buffer.
  • On connect, the server emits state:init with the snapshot. The client loads the source into the appropriate Player (HLS, YouTube, or WebTorrent) and seeks to the canonical currentTime.
  • 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 via yt-dlp, and re-broadcasts.
  • A SyncController on 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

  1. Add a new Source variant in shared/src/index.ts.
  2. Add a Player implementation in client/src/players/<name>-player.ts that implements the Player interface in client/src/players/types.ts.
  3. Register it in createPlayerForSource (client/src/players/index.ts).
  4. Add the tab in client/src/ui/source-form.ts and the input form for it.
  5. Add a case to the setSource switch in server/src/room.ts and the equivalent dispatch in server/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.