Files
lampac/mods/WatchTogether/web/lparty-web.js
T

1899 lines
57 KiB
JavaScript

/**
* 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 <video> element we drive
* @param relay relay base URL (itty.ws by default)
* @param name display name
* @param publish whether to advertise the room in the lobby (like lparty_publish)
* @param onEvent (type, payload) — notice | members | room | stream | left | error
*
* notice and error carry {key, ...params} rather than finished text: UI strings
* live in i18n.js and the protocol module knows nothing about language.
*/
constructor({
video,
relay = DEFAULT_RELAY,
name = "",
publish = true,
onEvent = () => {},
}) {
this.video = video;
this.name = name;
this.publish = publish;
this.onEvent = onEvent;
this.setRelay(relay);
// sessionStorage rather than localStorage: in this protocol our own pid arriving
// from a foreign uid means "the same device reconnected", and the client drops the
// stale session. With one id shared browser-wide, two tabs would evict each other.
// The pid is opaque to Lampa, so this does not affect compatibility.
this.pid = sessionStorage.getItem("lampac_unic_id");
if (!this.pid) {
this.pid = Math.random().toString(36).slice(2, 10);
sessionStorage.setItem("lampac_unic_id", this.pid);
}
this.room = null;
this.lobbyHost = null;
this.inRoom = false;
this.joining = false;
this.joinTimer = null;
this.createPending = false;
this.createTimer = null;
this.roomId = null;
this.password = "";
this.roomName = "";
this.owner = null;
this.meta = emptyMeta();
this.pidByUid = {};
this.serverTimeOffset = 0;
this.pingSamples = [];
this.echoSeq = 0;
this.echoPending = {};
this.pingTimer = null;
this.watchdogTimer = null;
this.isSystemSyncing = false;
this.lastUserActionTime = 0;
this.initialSyncLock = false;
this.targetInitialState = null;
this.expected = { seek: -1, play: false, pause: false };
this.expectedSeekTimer = null;
this.rateTimer = null;
this.enforceTimer = null;
this.buffering = false;
this.bufferPaused = false;
this.autoplayBlocked = false;
this.rewindUntil = 0;
this.lastHardSeekAt = 0;
this.knownPids = {};
// The last heartbeat we took a position from, to read its sender's own progress off two of
// them, plus whether that sender is currently not moving.
this.lastReport = null;
this.hostStalled = false;
// Set by the page when the stream plays through the browser's own HLS support
// (Safari): that player ignores playbackRate and seeks coarsely.
this.nativePlayback = false;
this.correcting = false;
this.seekGuardUntil = 0;
this.lastKnownPosition = 0;
this.lastSeekBroadcastAt = 0;
this.pendingSeekBroadcast = null;
this.pendingAct = null;
this.holdActive = false;
this.holdPosition = 0;
this.holdWaiting = {};
this.holdReadySent = false;
this.holdTimer = null;
this.holdBufferSeen = -1;
this.holdBufferAt = 0;
this.holdNudgeDone = false;
this.#hookVideo();
this.heartbeat = setInterval(() => this.#heartbeat(), TUNING.heartbeatMs);
this.tick = setInterval(() => this.#tick(), 1000);
}
setRelay(relay) {
let v = (relay || "").trim() || DEFAULT_RELAY;
if (v.charAt(v.length - 1) !== "/") v += "/";
this.relay = v;
}
displayName() {
return (this.name || "").trim() || this.pid;
}
serverNow() {
return Date.now() + this.serverTimeOffset;
}
iAmHost() {
return !!this.owner && this.owner === this.pid;
}
memberCount() {
return this.room ? this.room.members.length : 0;
}
emit(type, payload) {
this.onEvent(type, payload);
}
/** Snapshot for the debug readout. */
debug() {
const best = this.pingSamples.length
? Math.min(...this.pingSamples.map((s) => s.rtt))
: null;
return {
ws:
this.room && this.room.ws
? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][this.room.ws.readyState]
: "NO_WS",
room: this.roomId,
host: this.owner,
iAmHost: this.iAmHost(),
members: this.memberCount(),
offset: Math.round(this.serverTimeOffset),
rtt: this.pingSamples.length
? this.pingSamples[this.pingSamples.length - 1].rtt
: null,
bestRtt: best,
pending: Object.keys(this.echoPending).length,
position: this.video ? this.video.currentTime : null,
rate: this.video ? this.video.playbackRate : null,
lock: this.initialSyncLock,
hold: this.holdActive,
holdWaiting: Object.keys(this.holdWaiting),
buffered: this.video ? this.#bufferedAhead(this.video.currentTime) : null,
};
}
// -------------------------------------------------------------- clock
send(obj) {
if (!this.room || !this.room.alive()) return false;
obj.u = this.pid;
obj.k = ++this.echoSeq;
this.echoPending[obj.k] = Date.now();
return this.room.send(obj);
}
#handleEcho(key, serverDate) {
const t0 = this.echoPending[key];
if (!t0) return;
delete this.echoPending[key];
const t1 = Date.now();
const rtt = t1 - t0;
this.pingSamples.push({ offset: serverDate + rtt / 2 - t1, rtt });
if (this.pingSamples.length > 8) this.pingSamples.shift();
this.serverTimeOffset = pickOffset(this.pingSamples);
}
#startClock() {
this.#stopClock();
this.pingTimer = setInterval(() => {
if (this.room && this.room.alive()) this.send({ t: "ping" });
}, TUNING.pingIntervalMs);
// If no echo comes back within 30 s the socket is alive only on paper — drop and reconnect.
this.watchdogTimer = setInterval(() => {
if (!this.room || !this.room.alive()) return;
const now = Date.now();
for (const key of Object.keys(this.echoPending)) {
if (now - this.echoPending[key] > TUNING.echoTimeoutMs) {
this.echoPending = {};
try {
this.room.ws.close();
} catch (err) {}
return;
}
}
}, 5000);
}
#stopClock() {
clearInterval(this.pingTimer);
this.pingTimer = null;
clearInterval(this.watchdogTimer);
this.watchdogTimer = null;
}
// -------------------------------------------------------------- lobby
#ad() {
return {
id: this.roomId,
name: this.roomName,
title: this.meta.title || "",
poster: this.meta.poster || "",
owner: this.displayName(),
members: this.memberCount(),
pwd: this.password ? 1 : 0,
tmdb: this.meta.tmdb_id || 0,
type: this.meta.type || "movie",
};
}
#startLobbyAgent() {
this.#stopLobbyAgent();
if (!this.publish) return;
// Never advertise a passwordless room, even one we only joined or inherited as host.
if (!this.password) return;
this.lobbyHost = new Sock({
relay: this.relay,
channel: LOBBY_CHANNEL,
echo: false,
reconnect: true,
onEvent: (e) => {
if (e.kind !== "msg" || e.mine) return;
if (!e.msg || e.msg.t !== "who") return;
if (!this.inRoom || !this.roomId) return;
// Spread the replies so a dozen rooms do not answer in one burst.
setTimeout(
() => {
if (this.lobbyHost && this.inRoom)
this.lobbyHost.send({ t: "ad", r: this.#ad() });
},
Math.floor(Math.random() * 350) + 50,
);
},
});
this.lobbyHost.open();
}
#stopLobbyAgent() {
if (this.lobbyHost) {
this.lobbyHost.close();
this.lobbyHost = null;
}
}
/** Collects room advertisements and returns the list. */
browse() {
return new Promise((resolve) => {
const found = {};
let finished = false;
let timer = null;
const finish = () => {
if (finished) return;
finished = true;
clearTimeout(timer);
probe.close();
resolve(
Object.values(found).sort(
(a, b) => (b.members || 0) - (a.members || 0),
),
);
};
const probe = new Sock({
relay: this.relay,
channel: LOBBY_CHANNEL,
echo: false,
reconnect: false,
onEvent: (e) => {
if (e.kind === "ready") {
probe.send({ t: "who" });
// The window starts when the socket is ready, not when browse() is called:
// connecting to the relay takes ~400 ms and ate a third of the budget, while
// rooms answer with up to 400 ms of jitter plus two RTT legs.
clearTimeout(timer);
timer = setTimeout(finish, TUNING.lobbyCollectMs);
return;
}
if (
e.kind === "msg" &&
!e.mine &&
e.msg &&
e.msg.t === "ad" &&
e.msg.r &&
e.msg.r.id
) {
found[e.msg.r.id] = e.msg.r;
}
},
});
probe.open();
// The relay never answered at all — do not hang forever.
timer = setTimeout(finish, TUNING.lobbyCollectMs + TUNING.joinTimeoutMs);
});
}
// -------------------------------------------------------------- join / leave
async #connect(roomId, password, onReady) {
if (this.room) {
this.room.close();
this.room = null;
}
this.roomId = roomId;
this.password = password || "";
const channel = await roomChannel(roomId, password);
this.room = new Sock({
relay: this.relay,
channel,
alias: this.displayName(),
echo: true,
reconnect: true,
onEvent: (e) => this.#onRoomEvent(e),
});
this.room.onReady = onReady;
this.room.open();
this.#startClock();
}
async join(roomId, password, fallbackName) {
this.joining = true;
this.roomName = fallbackName || roomId;
await this.#connect(roomId, password, (ev) => {
// We are alone in the channel — no room exists for this code/password.
if (ev.total <= 1) {
this.#failJoin();
return;
}
this.send({ t: "hello", n: this.displayName() });
});
clearTimeout(this.joinTimer);
this.joinTimer = setTimeout(() => this.#failJoin(), TUNING.joinTimeoutMs);
}
#failJoin() {
if (!this.joining) return;
this.emit("error", { key: "join_failed" });
this.leave(false);
}
async create({
url,
title = "",
name = "",
password = "",
poster = "",
tmdbId = 0,
source = "",
type = "movie",
}) {
if (this.createPending) return null;
if (this.inRoom) {
this.emit("error", {
key: "already_in_room",
name: this.roomName || this.roomId,
});
return null;
}
if (!url) {
this.emit("error", { key: "need_url" });
return null;
}
// A room in the lobby list is visible to strangers on a public relay — it needs a password.
if (this.publish && !password) {
this.emit("error", { key: "need_pwd_public" });
return null;
}
this.createPending = true;
const id = newRoomId();
this.roomName = name || "Room-" + id;
this.owner = this.pid;
this.meta = { title, poster, url, tmdb_id: tmdbId, source, type };
await this.#connect(id, password, () => {
this.createPending = false;
clearTimeout(this.createTimer);
this.createTimer = null;
this.inRoom = true;
this.joining = false;
this.initialSyncLock = false;
this.targetInitialState = null;
this.#startLobbyAgent();
this.emit("room", this.info());
this.emit("stream", {
url: this.meta.url,
title: this.meta.title || this.roomName,
poster: this.meta.poster,
});
this.emit("notice", { key: "room_created", id });
});
clearTimeout(this.createTimer);
this.createTimer = setTimeout(() => {
this.createTimer = null;
if (this.createPending) {
this.createPending = false;
this.emit("error", { key: "create_failed" });
this.leave(false);
}
}, TUNING.joinTimeoutMs);
return id;
}
leave(sendBye = true) {
if (this.room) {
if (sendBye && this.room.alive()) this.send({ t: "bye" });
this.room.close();
this.room = null;
}
this.#stopLobbyAgent();
this.#stopClock();
this.#reset();
this.emit("left");
this.emit("members", 0);
}
#reset() {
this.#clearRate();
clearTimeout(this.expectedSeekTimer);
this.expectedSeekTimer = null;
clearTimeout(this.enforceTimer);
this.enforceTimer = null;
clearTimeout(this.joinTimer);
this.joinTimer = null;
clearTimeout(this.createTimer);
this.createTimer = null;
this.expected = { seek: -1, play: false, pause: false };
this.initialSyncLock = false;
this.targetInitialState = null;
this.isSystemSyncing = false;
this.inRoom = false;
this.joining = false;
this.createPending = false;
this.roomId = null;
this.password = "";
this.roomName = "";
this.owner = null;
this.meta = emptyMeta();
this.pidByUid = {};
this.echoPending = {};
this.pingSamples = [];
this.rewindUntil = 0;
this.lastHardSeekAt = 0;
this.knownPids = {};
this.correcting = false;
this.seekGuardUntil = 0;
this.lastKnownPosition = 0;
this.lastSeekBroadcastAt = 0;
this.#clearPendingSync();
this.holdActive = false;
this.holdPosition = 0;
this.holdWaiting = {};
this.holdReadySent = false;
this.#resetHoldProgress();
clearTimeout(this.holdTimer);
this.holdTimer = null;
}
info() {
return {
id: this.roomId,
name: this.roomName,
owner: this.owner,
iAmHost: this.iAmHost(),
members: this.memberCount(),
meta: this.meta,
password: this.password,
};
}
/** The host switches stream/episode — everyone else follows. */
setStream(url, title) {
if (!this.inRoom || !this.iAmHost() || !url) return;
this.meta.url = url;
this.meta.title = title || "";
// Followers drop the old file's timeline when the `url` reaches them; we are about to be
// somewhere else entirely too, and nothing we recorded about the previous episode survives
// the switch — see clearPendingSync.
this.#clearPendingSync();
this.send({ t: "url", url, ti: this.meta.title });
this.emit("stream", {
url,
title: this.meta.title || this.roomName,
poster: this.meta.poster,
});
}
// -------------------------------------------------------------- buffering barrier
/** Seconds of buffer available ahead of `position`, or NO_RANGES / OUT_OF_RANGE. */
#bufferedAhead(position) {
const vid = this.video;
let ranges;
try {
ranges = vid.buffered;
} catch (err) {
return NO_RANGES;
}
if (!ranges || !ranges.length) return NO_RANGES;
for (let i = 0; i < ranges.length; i++) {
if (position >= ranges.start(i) - 1 && position <= ranges.end(i))
return ranges.end(i) - position;
}
return OUT_OF_RANGE;
}
#resetHoldProgress() {
this.holdBufferSeen = -1;
this.holdBufferAt = Date.now();
this.holdNudgeDone = false;
}
/** Are we buffered enough to resume? Also nudges a player that stopped filling. */
#holdReadyNow() {
const vid = this.video;
if (!vid) return false;
const now = Date.now();
const ahead = this.#bufferedAhead(this.holdPosition);
if (ahead > this.holdBufferSeen + 0.25) {
this.holdBufferSeen = ahead;
this.holdBufferAt = now;
}
if (ahead === NO_RANGES) return vid.readyState >= 3;
if (ahead >= TUNING.holdBufferS) return true;
if (vid.readyState >= 4 && ahead > 0) return true;
const idle = now - (this.holdBufferAt || now);
// The buffer stopped growing but we have something: waiting longer is pointless.
if (ahead >= TUNING.holdMinBufferS && idle > TUNING.holdStallMs)
return true;
// Some players stall until poked; a tiny seek restarts the fill.
if (
!this.holdNudgeDone &&
idle > TUNING.holdNudgeMs &&
vid.readyState >= 1
) {
this.holdNudgeDone = true;
this.#setExpectedSeek(this.holdPosition);
vid.currentTime = this.holdPosition + 0.05;
}
return false;
}
/** Host side: freeze everyone at the current position and wait for the newcomer. */
#startJoinHold(newcomerPid) {
if (!this.inRoom || !this.iAmHost()) return;
const vid = this.video;
if (!this.holdActive) {
this.holdActive = true;
this.holdPosition = vid ? vid.currentTime || 0 : 0;
this.holdWaiting = {};
this.holdReadySent = false;
this.#resetHoldProgress();
if (vid && !vid.paused) {
this.#expectPause();
vid.pause();
}
this.emit("notice", { key: "notice_hold" });
}
if (newcomerPid && newcomerPid !== this.pid)
this.holdWaiting[newcomerPid] = true;
this.send({ t: "hold", p: this.holdPosition });
clearTimeout(this.holdTimer);
this.holdTimer = setTimeout(() => this.#finishHold(), TUNING.holdMaxMs);
}
#markHoldReady(fromPid) {
if (!this.holdActive || !this.iAmHost()) return;
if (fromPid) delete this.holdWaiting[fromPid];
this.#checkHoldDone();
}
#checkHoldDone() {
if (!this.holdActive || !this.iAmHost()) return;
if (Object.keys(this.holdWaiting).length) return;
if (!this.#holdReadyNow()) return;
this.#finishHold();
}
#finishHold() {
if (!this.holdActive) return;
const position = this.holdPosition;
this.send({ t: "go", p: position });
this.#releaseHold(position);
}
#releaseHold(position) {
const wasHolding = this.holdActive;
this.holdActive = false;
this.holdWaiting = {};
this.holdReadySent = false;
this.#resetHoldProgress();
clearTimeout(this.holdTimer);
this.holdTimer = null;
this.initialSyncLock = false;
this.targetInitialState = null;
if (wasHolding) this.emit("notice", { key: "notice_go" });
const vid = this.video;
if (!vid) return;
if (
typeof position === "number" &&
Math.abs((vid.currentTime || 0) - position) > 0.5
) {
this.#setExpectedSeek(position);
vid.currentTime = position;
}
if (vid.paused) {
this.#expectPlay();
const p = vid.play();
if (p && p.catch)
p.catch(() => {
this.expected.play = false;
});
}
}
/** Guest side: obey a hold from the host. */
#applyHold(position) {
this.holdActive = true;
this.holdPosition = position || 0;
this.holdReadySent = false;
this.#resetHoldProgress();
this.emit("notice", { key: "notice_hold" });
const vid = this.video;
if (!vid) return;
if (!vid.paused) {
this.#expectPause();
vid.pause();
}
if (Math.abs((vid.currentTime || 0) - this.holdPosition) > 1) {
this.#setExpectedSeek(this.holdPosition);
vid.currentTime = this.holdPosition;
}
}
#tick() {
// Before the video check: a deferred act must be able to expire even when there is no
// player, otherwise it silences the host heartbeat indefinitely.
this.#applyPendingAct();
const vid = this.video;
if (!vid) return;
this.#holdTick();
// A seek of ours to the position we already occupy never produces a 'seeked' event,
// which would keep the guard armed for its whole window and swallow the user's next seek.
if (
this.expected.seek >= 0 &&
!vid.seeking &&
Math.abs((vid.currentTime || 0) - this.expected.seek) <= 0.5
)
this.#clearExpectedSeek();
if (!this.#seekIsOurs()) this.lastKnownPosition = vid.currentTime || 0;
}
#holdTick() {
if (!this.holdActive) return;
if (this.iAmHost()) {
this.#checkHoldDone();
return;
}
if (this.holdReadySent) return;
if (!this.#holdReadyNow()) return;
if (!this.send({ t: "ready" })) return;
this.holdReadySent = true;
}
#isRewinding() {
return Date.now() < this.rewindUntil;
}
/**
* The user is scrubbing. Lampa gets this from its own rewind event; on the web the
* closest signal is a seek we did not initiate. While it lasts we neither obey
* incoming sync nor broadcast intermediate positions.
*/
#onUserRewind() {
if (!this.inRoom || this.initialSyncLock) return;
this.rewindUntil = Date.now() + TUNING.rewindGraceMs;
this.lastUserActionTime = Date.now();
this.#clearExpectedSeek();
}
// -------------------------------------------------------------- receiving
#onRoomEvent(e) {
if (e.kind === "ready") {
this.echoPending = {};
if (this.room && this.room.onReady) {
const cb = this.room.onReady;
this.room.onReady = null;
cb(e);
return;
}
// Reconnected after a drop: introduce ourselves again.
if (this.inRoom) {
this.send({ t: "hello", n: this.displayName() });
if (this.iAmHost()) this.#sendHostState();
}
this.emit("members", this.memberCount());
return;
}
if (e.kind === "close" || e.kind === "join") {
this.emit("members", this.memberCount());
return;
}
if (e.kind === "error") {
this.emit("error", { key: "relay_error", text: e.text });
return;
}
if (e.kind === "leave") {
this.#onMemberLeave(e);
return;
}
if (e.kind !== "msg" || !e.msg) return;
if (e.mine) {
if (e.msg.k) this.#handleEcho(e.msg.k, e.date);
return;
}
this.#handleMessage(e.msg, e);
}
#handleMessage(m, e) {
if (m.u) this.pidByUid[e.uid] = m.u;
// Our own pid from a foreign uid — another tab of ours opened and evicted this one.
if (m.u === this.pid && m.t === "hello") {
this.emit("error", { key: "kicked" });
this.leave(false);
return;
}
if (m.t === "hello") {
if (!this.inRoom) return;
this.emit("notice", { key: "joined", name: m.n || e.alias });
// A hello from someone we saw within the last minute is a reconnect, not a new
// viewer — freezing the room again for it would be pure noise.
const seenAt = m.u ? this.knownPids[m.u] : 0;
const reconnected =
seenAt && Date.now() - seenAt < TUNING.reconnectHelloMs;
if (m.u) this.knownPids[m.u] = Date.now();
if (this.iAmHost() && !reconnected) this.#startJoinHold(m.u);
setTimeout(
() => {
if (!this.inRoom) return;
this.send({ t: "me", n: this.displayName() });
if (this.iAmHost()) this.#sendHostState();
},
Math.floor(Math.random() * 250) + 50,
);
this.emit("members", this.memberCount());
return;
}
if (m.t === "hold") {
if (!this.inRoom && !this.joining) return;
if (this.owner && m.u !== this.owner) return; // only the host may hold
this.#applyHold(m.p || 0);
return;
}
if (m.t === "ready") {
this.#markHoldReady(m.u);
return;
}
if (m.t === "go") {
if (!this.inRoom && !this.joining) return;
if (this.owner && m.u !== this.owner) return;
this.#releaseHold(typeof m.p === "number" ? m.p : this.holdPosition);
return;
}
if (m.t === "me") {
this.emit("members", this.memberCount());
return;
}
if (m.t === "state") {
if (this.joining) this.#acceptRoomState(m, e.date);
return;
}
if (m.t === "host") {
this.owner = m.u;
this.#clearPendingSync();
this.emit("notice", { key: "host_changed", name: m.n || e.alias });
this.emit("room", this.info());
return;
}
if (m.t === "url") {
if (!this.inRoom || !m.url) return;
if (this.owner && m.u !== this.owner) return; // only the host may change the stream
this.meta.url = m.url;
this.meta.title = m.ti || this.meta.title;
this.#clearPendingSync();
this.emit("stream", {
url: m.url,
title: m.ti || this.roomName,
poster: this.meta.poster,
});
return;
}
if (m.t === "buf") {
// Our own player announces when it starts and stops refilling; Lampa never does, which is
// what the reading off two heartbeats below is for. Only the reference member's, same rule
// as a periodic sync — a guest's buffering is its own business.
if (!this.inRoom) return;
if (this.owner && m.u !== this.owner) return;
this.hostStalled = !!m.v;
if (this.hostStalled) this.#waitForHost();
return;
}
if (m.t === "sync" || m.t === "act") {
if (!this.inRoom) return;
// A report at the end of our own file is that sender's episode running out, not a place to
// follow it to: their client announces the end of an episode as a pause carrying the
// duration, and obeyed literally it skipped whatever was left here and parked us on the
// last frame. Dropped before the notice, because nobody paused anything: our own copy
// plays out and the room's next `url` is heard as usual.
if (atMediaEnd(m.p || 0, this.video ? this.video.duration : NaN)) return;
if (m.t === "act" && m.v) {
const key = {
resumed: "act_resumed",
playing: "act_resumed",
paused: "act_paused",
seeked: "act_seeked",
}[m.v];
if (key) this.emit("notice", { key, name: m.n || e.alias });
}
// Periodic sync is the host's word alone: the host ignores everyone else's and
// guests ignore each other's. Explicit user actions (act) still come from anyone.
if (
m.t === "sync" &&
(this.iAmHost() || (this.owner && m.u !== this.owner))
)
return;
const state = m.s === "playing" ? "playing" : "paused";
const position = m.p || 0;
if (this.initialSyncLock) {
this.targetInitialState = { state, position, atServerTime: e.date };
return;
}
const vid = this.video;
if (!vid) return;
if (this.#isRewinding() || this.holdActive) return;
// Our own action newer than two seconds outranks theirs. Answering with a counter
// sync used to cause ping-pong, so now we simply stay put.
if (Date.now() - this.lastUserActionTime < TUNING.userActionMs) return;
// A seek of ours is waiting out the debounce, so the heartbeat cannot know about it
// yet and its position is stale by definition. Explicit acts still get through.
if (m.t === "sync" && this.pendingSeekBroadcast) return;
// Whether the member we take positions from is actually moving, which its own two reports
// answer and its state field does not: a peer that is refilling goes on saying "playing" at
// the same position every heartbeat, and each of those read as the room having jumped
// backwards — seeks in alternating directions while the room in truth sat still.
if (m.t === "sync") {
const report = {
pid: m.u,
position,
playing: state === "playing",
at: Date.now(),
// Their protocol has no speed field, so its silence means 1 rather than nothing.
speed: typeof m.sp === "number" ? m.sp : 1,
};
const verdict = reportStalled(this.lastReport, report);
this.lastReport = report;
if (verdict !== null) this.hostStalled = verdict;
}
if (this.hostStalled) {
// A deliberate action outranks the wait: whoever sent it is plainly not stuck, and the
// room is being moved on purpose. Only the heartbeats of a stalled member are ignored.
if (m.t === "sync") {
this.#waitForHost();
return;
}
this.hostStalled = false;
}
// Anything newer supersedes a deferred seek; a stale snapshot must not outlive it.
this.pendingAct = null;
this.isSystemSyncing = true;
this.#applySync(state, position, e.date);
setTimeout(() => {
this.isSystemSyncing = false;
}, TUNING.systemSyncMs);
// If an explicit seek could not land, keep it and retry rather than dropping it.
// The threshold matches applySync's hard seek, so a drift it chose to close with
// playbackRate is not mistaken for a missed jump.
if (m.t === "act" && m.v === "seeked") {
const target = expectedPositionNow(
state,
position,
e.date,
this.serverNow(),
);
if (
Math.abs((vid.currentTime || 0) - target) > this.#hardSeekThreshold()
) {
this.pendingAct = {
state,
position,
atServerTime: e.date,
at: Date.now(),
until: Date.now() + TUNING.pendingActMaxMs,
};
}
}
return;
}
if (m.t === "bye") {
this.emit("members", this.memberCount());
}
}
#sendHostState() {
this.send({
t: "state",
rn: this.roomName,
own: this.owner || this.pid,
url: this.meta.url,
ti: this.meta.title,
po: this.meta.poster,
tm: this.meta.tmdb_id,
src: this.meta.source,
ty: this.meta.type,
s: this.video && !this.video.paused ? "playing" : "paused",
p: this.video ? this.video.currentTime || 0 : 0,
});
}
#acceptRoomState(msg, atServerTime) {
clearTimeout(this.joinTimer);
this.joinTimer = null;
this.joining = false;
if (!msg.url) {
this.emit("error", { key: "no_stream" });
this.leave(true);
return;
}
this.inRoom = true;
this.roomName = msg.rn || this.roomName;
this.owner = msg.own || null;
this.meta = {
title: msg.ti || "",
poster: msg.po || "",
url: msg.url,
tmdb_id: msg.tm || 0,
source: msg.src || "",
type: msg.ty || "movie",
};
const state = msg.s === "playing" ? "playing" : "paused";
const position = msg.p || 0;
// A room paused at the very start has nothing to catch up to; the lock would only hurt.
const needsInitialSync = state === "playing" || position > 0.5;
this.initialSyncLock = needsInitialSync;
this.targetInitialState = needsInitialSync
? { state, position, atServerTime }
: null;
this.emit("room", this.info());
this.emit("notice", { key: "join_ok", name: this.roomName });
this.emit("stream", {
url: this.meta.url,
title: this.meta.title || this.roomName,
poster: this.meta.poster,
});
this.#startLobbyAgent();
}
#onMemberLeave(e) {
const leaverPid = this.pidByUid[e.uid];
delete this.pidByUid[e.uid];
this.emit("members", this.memberCount());
if (!this.inRoom) return;
this.emit("notice", { key: "left_room", name: e.alias || null });
// Someone we were holding for is gone — stop waiting for their `ready`.
if (leaverPid && this.holdWaiting[leaverPid])
this.#markHoldReady(leaverPid);
if (!this.owner || leaverPid !== this.owner) return;
// The host left: after half a second the member with the lowest uid takes over.
setTimeout(() => {
if (!this.inRoom || !this.room || !this.room.uid) return;
if (this.#hostStillPresent()) return;
const uids = [
this.room.uid,
...this.room.members
.map((m) => m.uid)
.filter((u) => u !== this.room.uid),
].sort();
if (uids[0] === this.room.uid) {
this.owner = this.pid;
this.#clearPendingSync();
this.send({ t: "host", n: this.displayName() });
this.emit("notice", { key: "you_are_host" });
this.emit("room", this.info());
this.#startLobbyAgent();
}
}, 500);
}
#hostStillPresent() {
if (!this.owner) return false;
if (this.owner === this.pid) return true;
if (!this.room) return false;
return this.room.members.some((m) => this.pidByUid[m.uid] === this.owner);
}
// -------------------------------------------------------------- sending and sync
#sendSync(state, verb) {
if (!this.inRoom || this.initialSyncLock || this.holdActive) return;
const vid = this.video;
if (!vid) return;
if (verb)
this.send({
t: "act",
s: state,
p: vid.currentTime || 0,
v: verb,
n: this.displayName(),
});
else this.send({ t: "sync", s: state, p: vid.currentTime || 0 });
}
/** Only the host beats, and it reports pause as well — guests just follow. */
#heartbeat() {
if (
!this.inRoom ||
this.initialSyncLock ||
this.isSystemSyncing ||
this.holdActive
)
return;
if (!this.iAmHost()) return;
if (this.expected.seek !== -1) return;
if (this.pendingAct) return;
const vid = this.video;
if (!vid) return;
// A finished file reports its last frame for ever, and offered as the room's position that
// drags everybody onto the end credits. Silence lets them play their own copy out and reach
// the end themselves; the next `url` is what moves the room on.
if (vid.ended) return;
this.#sendSync(vid.paused ? "paused" : "playing", null);
}
#setExpectedSeek(pos) {
this.expected.seek = pos;
this.seekGuardUntil = Date.now() + TUNING.seekGuardMs;
clearTimeout(this.expectedSeekTimer);
this.expectedSeekTimer = setTimeout(() => {
this.expectedSeekTimer = null;
this.expected.seek = -1;
}, TUNING.seekGuardMs);
}
#clearExpectedSeek() {
clearTimeout(this.expectedSeekTimer);
this.expectedSeekTimer = null;
this.expected.seek = -1;
this.seekGuardUntil = 0;
}
/** True while the position currently settling is one we set, not the user. */
#seekIsOurs() {
return Date.now() < this.seekGuardUntil;
}
#hardSeekThreshold() {
return this.nativePlayback ? TUNING.hardSeekNativeS : TUNING.hardSeekS;
}
#canAdjustRate() {
return !this.nativePlayback;
}
/**
* Everything here carries a position that only means anything for the video that was
* playing when it was recorded, so it must die with it or it lands on the next episode.
* Called wherever the room's timeline is replaced: a media change, a new reference member,
* leaving the room.
*/
#clearPendingSync() {
clearTimeout(this.pendingSeekBroadcast);
this.pendingSeekBroadcast = null;
this.pendingAct = null;
// A reading off two reports needs both of them to come from one file, and a stall belongs to
// the file it happened in.
this.lastReport = null;
this.hostStalled = false;
// The cooldown belongs to the file we have left too: kept, a jump made at the end of the old
// episode spent the arrival in the new one waiting it out.
this.lastHardSeekAt = 0;
}
/**
* Announce a seek the user made. Throttling defers the burst rather than dropping it:
* a dropped seek means the host heartbeat drags everyone back to the old position two
* seconds later, which is exactly the bug this replaces.
*/
#broadcastUserSeek() {
const vid = this.video;
if (!vid) return;
if (
Math.abs((vid.currentTime || 0) - this.lastKnownPosition) <
TUNING.seekMinJumpS
)
return;
this.lastKnownPosition = vid.currentTime || 0;
this.lastUserActionTime = Date.now();
if (Date.now() - this.lastSeekBroadcastAt < TUNING.seekBroadcastMinMs) {
// A debounce, not a periodic flush: a long scrub must end in ONE extra broadcast,
// not one every interval — each of those costs everyone a hard seek.
clearTimeout(this.pendingSeekBroadcast);
this.pendingSeekBroadcast = setTimeout(() => {
this.pendingSeekBroadcast = null;
if (!this.inRoom || this.initialSyncLock || this.holdActive) return;
const v = this.video;
if (!v) return;
this.lastSeekBroadcastAt = Date.now();
this.lastKnownPosition = v.currentTime || 0;
this.lastUserActionTime = Date.now();
this.#sendSync(v.paused ? "paused" : "playing", "seeked");
}, TUNING.seekBroadcastMinMs);
return;
}
clearTimeout(this.pendingSeekBroadcast);
this.pendingSeekBroadcast = null;
this.lastSeekBroadcastAt = Date.now();
this.#sendSync(vid.paused ? "paused" : "playing", "seeked");
}
/**
* A peer's explicit seek that could not land — we were buffering, mid-seek or inside
* the hard-seek cooldown — is kept and retried instead of dropped. Dropping it lets
* our own heartbeat drag the peer back to where they came from.
*/
#applyPendingAct() {
const act = this.pendingAct;
if (!act) return;
if (Date.now() > act.until) {
this.pendingAct = null;
return;
}
// A retry must never outrank something the user did afterwards.
if (this.lastUserActionTime > act.at) {
this.pendingAct = null;
return;
}
if (
!this.inRoom ||
this.initialSyncLock ||
this.#isRewinding() ||
this.holdActive
)
return;
if (Date.now() - this.lastUserActionTime < TUNING.userActionMs) return;
const vid = this.video;
if (!vid || this.#videoBusy()) return;
if (Date.now() - this.lastHardSeekAt <= TUNING.hardSeekCooldownMs) return;
this.pendingAct = null;
this.isSystemSyncing = true;
this.#applySync(act.state, act.position, act.atServerTime);
setTimeout(() => {
this.isSystemSyncing = false;
}, TUNING.systemSyncMs);
}
#expectPlay() {
this.expected.play = true;
setTimeout(() => {
this.expected.play = false;
}, TUNING.expectPlayMs);
}
#expectPause() {
this.expected.pause = true;
setTimeout(() => {
this.expected.pause = false;
}, TUNING.expectPlayMs);
}
#clearRate() {
clearTimeout(this.rateTimer);
this.rateTimer = null;
if (this.video && this.video.playbackRate !== 1)
this.video.playbackRate = 1;
}
/**
* The member we follow is not moving, so neither are we: stopping where we stand is the whole
* of the wait, and the first report that moves resumes us through the normal path. Deliberately
* not announced — nobody paused anything, and saying it would stop the room for real.
*
* The room is not told we are waiting: the protocol has no message for it, and a member that is
* waiting has nothing to add anyway.
*/
#waitForHost() {
const vid = this.video;
if (!vid || vid.paused) return;
if (this.initialSyncLock || this.holdActive || this.#isRewinding()) return;
this.#expectPause();
vid.pause();
}
#applySync(state, basePosition, atServerTime) {
const vid = this.video;
if (!vid || vid.currentTime === undefined) return;
// While the player is starving there is nothing to correct: seeking or changing
// rate mid-buffering only makes the stall longer.
const busy = this.#videoBusy();
if (busy) {
this.#clearRate();
} else {
const expected = expectedPositionNow(
state,
basePosition,
atServerTime,
this.serverNow(),
);
const { action, rate } = syncDecision(vid.currentTime - expected, {
correcting: this.correcting,
hardSeekS: this.#hardSeekThreshold(),
canAdjustRate: this.#canAdjustRate(),
});
if (action === "seek") {
this.#clearRate();
this.correcting = false;
// Chained hard seeks fight each other; one every few seconds is enough.
if (Date.now() - this.lastHardSeekAt > TUNING.hardSeekCooldownMs) {
this.lastHardSeekAt = Date.now();
this.#setExpectedSeek(expected);
vid.currentTime = expected;
}
} else if (action === "rate") {
this.correcting = true;
if (Math.abs(vid.playbackRate - rate) > 0.005) vid.playbackRate = rate;
clearTimeout(this.rateTimer);
this.rateTimer = setTimeout(() => {
this.rateTimer = null;
if (vid.playbackRate !== 1) vid.playbackRate = 1;
}, TUNING.rateResetMs);
} else {
this.correcting = false;
this.#clearRate();
}
}
if (state === "paused") this.autoplayBlocked = false;
if (state === "paused" && !vid.paused) {
this.#expectPause();
if (vid.playbackRate !== 1) vid.playbackRate = 1;
vid.pause();
return;
}
// Not on a file that has run out: play() on an ended element seeks to the start first, so the
// room's next heartbeat would restart the episode from zero and the correction would then drag
// us back to the end. If the correction above moved us back into the file, we are no longer
// ended and this does not bite — which is the case where playing on is what we want.
if (state === "playing" && vid.paused && !busy && !vid.ended) {
this.#expectPlay();
const p = vid.play();
if (p && p.catch)
p.catch(() => {
this.expected.play = false;
this.autoplayBlocked = true;
this.emit("error", { key: "autoplay_blocked" });
});
}
}
#videoBusy() {
const vid = this.video;
// A seek in flight is the loader working — same rule as buffering, do not fight it.
// A long jump can stay seeking for many seconds, and correcting the position
// mid-flight is exactly what dragged the seeker back to the old spot.
return !!this.buffering || !vid || vid.readyState < 3 || !!vid.seeking;
}
#enforceInitial() {
if (!this.initialSyncLock || !this.targetInitialState) return;
const vid = this.video;
const { state, position, atServerTime } = this.targetInitialState;
const expected = expectedPositionNow(
state,
position,
atServerTime,
this.serverNow(),
);
if (Math.abs(vid.currentTime - expected) > 1) {
this.#setExpectedSeek(expected);
vid.currentTime = expected;
}
if (state === "paused") {
this.#expectPause();
vid.pause();
} else {
this.#expectPlay();
const p = vid.play();
if (p && p.catch)
p.catch(() => {
this.autoplayBlocked = true;
this.emit("error", { key: "autoplay_blocked" });
});
}
if (!this.enforceTimer) {
this.enforceTimer = setTimeout(() => {
this.enforceTimer = null;
this.initialSyncLock = false;
this.targetInitialState = null;
this.expected.play = false;
this.expected.pause = false;
}, TUNING.initialLockMs);
}
}
#hookVideo() {
const vid = this.video;
if (!vid) return;
const enforce = () => this.#enforceInitial();
vid.addEventListener("loadedmetadata", enforce);
vid.addEventListener("canplay", enforce);
// A pause caused by buffering is not a user action and must not be broadcast.
vid.addEventListener("waiting", () => {
this.buffering = true;
});
vid.addEventListener("canplay", () => {
this.buffering = false;
});
vid.addEventListener("playing", () => {
this.buffering = false;
});
vid.addEventListener("play", () => {
if (this.initialSyncLock) {
if (
this.targetInitialState &&
this.targetInitialState.state === "paused"
)
vid.pause();
return;
}
const wasExpected = this.expected.play;
this.expected.play = false;
if (wasExpected) return;
if (this.bufferPaused) {
this.bufferPaused = false;
return;
}
// The click that lifts a blocked autoplay only catches us up with a room that is
// already playing. Announcing it would put a "resumed" in everyone's face for
// something nobody resumed.
if (this.autoplayBlocked) {
this.autoplayBlocked = false;
return;
}
if (this.#isRewinding()) {
this.lastUserActionTime = Date.now();
return;
}
this.lastUserActionTime = Date.now();
this.#sendSync("playing", "resumed");
});
vid.addEventListener("pause", () => {
clearTimeout(this.rateTimer);
this.rateTimer = null;
vid.playbackRate = 1;
if (this.initialSyncLock) return;
const wasExpected = this.expected.pause;
this.expected.pause = false;
if (wasExpected) return;
// Running out of file is not the viewer pausing, though the element leaves exactly the same
// state behind: paused, at the duration, buffer full. Announced as an act it stopped the
// whole room — including whoever had already moved on to the next episode — and put
// "<us> paused" on every screen. Said nothing, the room plays on.
if (vid.ended) return;
if (this.buffering || vid.readyState < 3) {
this.bufferPaused = true;
return;
}
if (this.#isRewinding()) {
this.lastUserActionTime = Date.now();
return;
}
this.lastUserActionTime = Date.now();
this.#sendSync("paused", "paused");
});
// Announce the jump when it STARTS, not when it settles: currentTime already holds the
// target, while a long jump can stay unsettled for many seconds — and an unannounced
// seeker gets dragged back by the host heartbeat the moment its buffer is ready.
vid.addEventListener("seeking", () => {
// Deliberately NOT gated on isSystemSyncing: that flag stays up for 500 ms after
// every applied sync, a quarter of the time at a 2 s heartbeat, and it swallowed any
// user seek started in that window. seekIsOurs() asks the real question — did we
// move the position, or did the user?
if (this.initialSyncLock || this.#seekIsOurs()) return;
this.#onUserRewind();
this.#broadcastUserSeek();
});
vid.addEventListener("seeked", () => {
if (!this.isSystemSyncing) {
clearTimeout(this.rateTimer);
this.rateTimer = null;
vid.playbackRate = 1;
}
if (this.initialSyncLock) {
if (this.targetInitialState) {
const { state, position, atServerTime } = this.targetInitialState;
const expected = expectedPositionNow(
state,
position,
atServerTime,
this.serverNow(),
);
if (Math.abs(vid.currentTime - expected) > 1) {
this.#setExpectedSeek(expected);
vid.currentTime = expected;
}
}
return;
}
if (this.isSystemSyncing) return;
// We moved it, so there is nothing to announce — just stop guarding.
if (this.#seekIsOurs()) {
this.#clearExpectedSeek();
this.lastKnownPosition = vid.currentTime || 0;
return;
}
if (this.expected.seek !== -1) this.#clearExpectedSeek();
// Usually already announced on 'seeking'; this covers players that skip that event.
this.#broadcastUserSeek();
});
}
destroy() {
clearInterval(this.heartbeat);
clearInterval(this.tick);
this.leave(true);
}
}