/** * Port of the lparty.js protocol (Lampa watch-party) to a plain web page. * Wire-compatible: same channels, same messages, same timings. * Original: https://nrsua.github.io/lampa-nrs/lparty.js * * The transport is the itty.ws relay, which is stateless: it broadcasts inside a * channel and adds a server timestamp plus a member list. All room logic lives * here, so there is no server side to write. */ export const DEFAULT_RELAY = "{relay}"; export const LOBBY_CHANNEL = "lparty-lobby-v1"; export const ROOM_PREFIX = "lparty-r-"; /** * Copied verbatim from the original. These are calibrated against their relay and * their players: change them on one side only and two clients pull each other in * different directions, which is worse than no correction at all. */ export const TUNING = { toleranceS: 0.3, // Correction starts above correctOnS and only stops below toleranceS. Without that // hysteresis the rate flapped around a single threshold on every heartbeat. correctOnS: 0.7, hardSeekS: 1.5, // Native Apple HLS seeks coarsely and ignores playbackRate, so it gets a looser bound. hardSeekNativeS: 3.0, rateGain: 0.1, maxRateOffset: 0.1, rateResetMs: 4000, heartbeatMs: 2000, pingIntervalMs: 8000, echoTimeoutMs: 30000, reconnectMs: 4000, joinTimeoutMs: 6000, // The original 1500 was too tight: replies land ~1.2 s after the query and missed // the window. Purely local, so it does not affect wire compatibility. lobbyCollectMs: 2500, userActionMs: 2000, systemSyncMs: 500, initialLockMs: 3000, expectPlayMs: 500, // Buffering barrier, added in plugin 1.3.0. The host pauses everyone at one // position and waits for a `ready` from each participant it is holding for. holdMaxMs: 15000, holdBufferS: 6, holdMinBufferS: 2, holdStallMs: 3000, holdNudgeMs: 4000, rewindGraceMs: 2500, hardSeekCooldownMs: 3000, reconnectHelloMs: 60000, // User seeks seekGuardMs: 4000, // how long a position we set ourselves stays "ours" seekBroadcastMinMs: 2000, // a scrub is throttled into one trailing broadcast, never dropped seekMinJumpS: 1.0, // below this the position barely moved; nothing to announce pendingActMaxMs: 15000, // how long a peer's seek is retried before giving up // End of media. Purely local, like the two above: they change what we say and what we obey, // never the wire, so a Lampa peer needs no matching calibration. endSlackS: 1.0, // a reported position this near our own duration is a file running out reportGapMs: 1500, // how far apart two reports must be to say whether their sender moves }; const NO_RANGES = -2; const OUT_OF_RANGE = -1; // ---------------------------------------------------------------- pure functions /** * The original hashes UTF-8 bytes (unescape(encodeURIComponent(s))), so the built-in * crypto.subtle yields a byte-identical digest — verified against their own code. * Needs a secure context; GitHub Pages is always https, so that costs nothing. */ export async function sha256hex(str) { const buf = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(str), ); return [...new Uint8Array(buf)] .map((b) => b.toString(16).padStart(2, "0")) .join(""); } export async function roomChannel(roomId, password) { const hex = await sha256hex( (roomId || "").toUpperCase() + "|" + (password || ""), ); return ROOM_PREFIX + hex.substr(0, 24); } export function newRoomId() { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no lookalike I/O/0/1 let id = ""; for (let i = 0; i < 6; i++) id += chars.charAt(Math.floor(Math.random() * chars.length)); return id; } /** Where the room should be right now, given it plays from basePosition since atServerTime. */ export function expectedPositionNow(state, basePosition, atServerTime, nowMs) { if (state !== "playing" || !atServerTime || atServerTime <= 0) return basePosition; const elapsedSec = (nowMs - atServerTime) / 1000; if (elapsedSec < 0 || elapsedSec > 3600) return basePosition; // garbage timestamp return basePosition + elapsedSec; } /** * diff = our position minus the expected one. Positive diff means we ran ahead. * * @param opts.correcting already nudging the rate — keep going until inside toleranceS * @param opts.hardSeekS threshold for giving up on rate and jumping * @param opts.canAdjustRate false for players that ignore playbackRate (native Apple HLS) */ export function syncDecision(diff, opts = {}, t = TUNING) { const { correcting = false, hardSeekS = t.hardSeekS, canAdjustRate = true, } = opts; const abs = Math.abs(diff); if (abs > hardSeekS) return { action: "seek", rate: 1 }; // Enter correction only on a real gap, leave it only when properly closed. const enterAt = correcting ? t.toleranceS : t.correctOnS; if (canAdjustRate && abs > enterAt) { const offset = Math.max( -t.maxRateOffset, Math.min(t.maxRateOffset, diff * t.rateGain), ); return { action: "rate", rate: 1 - offset }; } return { action: "reset", rate: 1 }; } /** * Whether a reported position is that sender's file running out rather than a place in it. * Lampa announces the end of an episode as a pause carrying the duration to the millisecond, * and obeyed literally that skips whatever is left here and parks on the last frame. * * Unknown or infinite duration — a player still loading, a live stream — answers no: there is * nothing to compare against, and a live stream has no end to be at. */ export function atMediaEnd(position, duration, t = TUNING) { if (!Number.isFinite(duration) || duration <= 0) return false; return position >= duration - t.endSlackS; } /** * Whether the sender of two reports is standing still while calling itself playing — a peer * refilling its buffer. Lampa's heartbeat repeats position and state without checking that * playback advanced, and each repeat read as the room having jumped backwards. * * @returns true / false, or null when these two reports cannot say (a different sender, a * sender that is paused, less than one heartbeat apart, or a jump backwards, which is * a seek and is heard as one) */ export function reportStalled(prev, report, t = TUNING) { if (!prev || prev.pid !== report.pid || !report.playing) return null; const gap = report.at - prev.at; if (gap < t.reportGapMs) return null; const moved = report.position - prev.position; if (moved < 0) return null; // Against the sender's own rate: at half speed a player covers half the gap, and flat // arithmetic would call that a stall — one peer picking 0.5x would stop everybody else. const due = (gap / 1000) * Math.max(0.05, report.speed || 1); return moved * 2 < due; } /** Take the sample with the lowest RTT — it is the least distorted one. */ export function pickOffset(samples) { if (!samples.length) return 0; let best = samples[0]; for (const s of samples) if (s.rtt < best.rtt) best = s; return best.offset; } // ---------------------------------------------------------------- relay socket class Sock { constructor({ relay, channel, alias = "", echo = false, reconnect = false, onEvent = () => {}, }) { this.relay = relay; this.channel = channel; this.alias = alias; this.echo = echo; this.reconnect = reconnect; this.onEvent = onEvent; this.ws = null; this.uid = null; this.members = []; this.closed = false; this.retryTimer = null; this.onReady = null; } url() { let q = "announce=true&list=true"; if (this.echo) q += "&echo=true"; if (this.alias) q += "&as=" + encodeURIComponent(this.alias); return this.relay + encodeURIComponent(this.channel) + "?" + q; } open() { if (this.closed) return; if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return; try { this.ws = new WebSocket(this.url()); } catch (err) { this.scheduleRetry(); return; } this.ws.onopen = () => this.onEvent({ kind: "open" }); this.ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch (err) { return; } if (!d) return; if (d.type === "join") { if (d.self) { this.uid = d.uid; this.members = []; for (const u of d.users || []) { if (!this.findMember(u.uid)) this.members.push({ uid: u.uid, alias: u.alias || "" }); } // The relay does not always list us among the users; host election counts // members, so we have to be in our own list. if (!this.findMember(this.uid)) this.members.push({ uid: this.uid, alias: this.alias }); this.onEvent({ kind: "ready", date: d.date, total: d.total, members: this.members, }); } else { if (!this.findMember(d.uid)) this.members.push({ uid: d.uid, alias: d.alias || "" }); this.onEvent({ kind: "join", date: d.date, uid: d.uid, alias: d.alias || "", total: d.total, }); } return; } if (d.type === "leave") { this.members = this.members.filter((m) => m.uid !== d.uid); this.onEvent({ kind: "leave", date: d.date, uid: d.uid, alias: d.alias || "", total: d.total, }); return; } if (d.type === "error") { this.onEvent({ kind: "error", date: d.date, text: d.message }); return; } if (d.message && typeof d.message === "object") { this.onEvent({ kind: "msg", date: d.date, uid: d.uid, alias: d.alias || "", msg: d.message, mine: d.uid === this.uid, }); } }; this.ws.onclose = () => { this.uid = null; this.members = []; this.onEvent({ kind: "close" }); this.scheduleRetry(); }; this.ws.onerror = () => {}; } scheduleRetry() { if (!this.reconnect || this.closed) return; clearTimeout(this.retryTimer); this.retryTimer = setTimeout(() => { this.retryTimer = null; this.open(); }, TUNING.reconnectMs); } findMember(uid) { return this.members.find((m) => m.uid === uid) || null; } alive() { return !!this.ws && this.ws.readyState === 1; } send(obj) { if (!this.alive()) return false; try { this.ws.send(JSON.stringify(obj)); return true; } catch (err) { return false; } } close() { this.closed = true; clearTimeout(this.retryTimer); this.retryTimer = null; if (this.ws) { try { this.ws.close(); } catch (err) {} this.ws = null; } } } // ---------------------------------------------------------------- room const emptyMeta = () => ({ title: "", poster: "", url: "", tmdb_id: 0, source: "", type: "", }); export class Party { /** * @param video the