(function () {
"use strict";
if (window.WatchTogether_plugin_started) {
console.log("[WatchTogether]", "Already running.");
return;
}
window.WatchTogether_plugin_started = true;
var META = {
name: "WatchTogether",
version: "2.0.0",
author: "arabianq, nrsua, levende",
};
var DEFAULT_RELAY = "{relay}";
var DEFAULT_INVITE = "{invite}";
// Wire-protocol constants shared with all lparty clients (JustPlus etc.) — do not rename.
var LOBBY_CHANNEL = "lparty-lobby-v1";
var ROOM_PREFIX = "lparty-r-";
var _rawLang = (Lampa.Storage.get("language") || "en").toLowerCase();
var i18n = {
uk: {
menu_title: "WatchTogether",
settings_title: "WatchTogether",
param_show_head: "Відображати WatchTogether на головній",
param_show_head_descr:
"Кнопка WatchTogether у верхній панелі головного екрана.",
param_name: "Ім'я користувача",
param_name_descr:
"Як вас бачитимуть інші в кімнатах. Якщо очистити - буде згенеровано випадкове ім'я.",
param_use_pwd: "Використовувати пароль",
param_use_pwd_descr:
"Запитувати пароль при створенні кімнати та підставляти у власні кімнати.",
param_pwd: "Пароль за замовчуванням",
param_pwd_descr: "Буде використано при створенні кімнат із паролем.",
param_publish: "Показувати кімнату в списку",
param_publish_descr:
"Вимкніть, щоб до вашої кімнати можна було зайти лише за кодом.",
param_relay: "Адреса реле",
param_relay_descr: "WebSocket-реле. За замовчуванням " + DEFAULT_RELAY,
param_invite: "Адреса запрошення",
param_invite_descr:
"Базове посилання для QR-коду. За замовчуванням " + DEFAULT_INVITE,
head_title: "WatchTogether - список кімнат",
create_btn: "Створити кімнату за посиланням",
join_code_btn: "Увійти за кодом кімнати",
full_card_btn: "WatchTogether - Дивитися з друзями",
settings_open_rooms: "Відкрити список кімнат",
settings_open_rooms_descr:
"Показує список доступних кімнат і дозволяє створити свою",
empty_list: "Відкритих кімнат немає",
searching: "Пошук кімнат...",
input_url: "Посилання на потік (m3u8 / mp4)",
input_room_name: "Назва кімнати",
input_password: "Пароль кімнати",
input_join_password: "Введіть пароль кімнати",
input_room_code: "Код кімнати",
connecting: "Підключення...",
create_fail: "Не вдалося створити кімнату",
create_ok: function (n) {
return 'Кімнату "' + n + '" створено';
},
room_code: function (c) {
return "Код кімнати: " + c;
},
join_ok: function (n) {
return 'Ви увійшли до кімнати "' + n + '"';
},
no_room: "Кімнату не знайдено або невірний пароль",
no_stream: "У цій кімнаті немає потоку",
kicked: "Ви увійшли до цієї кімнати з іншого пристрою",
host_left: "Хост покинув кімнату - перегляд завершено",
net_err: "Помилка мережі",
need_url: "Не задано посилання на потік",
publish_needs_pwd: "Немає у публічному списку: доступ лише за кодом",
create_from_player: "Поділитися останнім потоком",
pending_share:
"Запустіть відтворення - кімнату буде створено автоматично",
label_owner: "Хост",
label_members: "Глядачів",
badge_room: "Кімната",
badge_viewers: "Глядачів",
notice_joined: function (n) {
return n + " приєднався";
},
notice_left: function (n) {
return n + " покинув кімнату";
},
notice_paused: function (n) {
return n + " поставив паузу";
},
notice_resumed: function (n) {
return n + " продовжив відтворення";
},
notice_seeked: function (n) {
return n + " перемотав";
},
notice_host_changed: function (n) {
return "Новий хост: " + n;
},
player_create_descr: "Створити кімнату на цей потік",
player_qr_descr: "Показати QR-код для запрошення друзів",
qr_title: "Приєднатися до кімнати",
qr_hint: "Скануйте камерою телефона",
qr_copy: "Копіювати посилання",
qr_share: "Поділитися",
qr_copied: "Посилання скопійовано",
qr_copy_fail: "Не вдалося скопіювати посилання",
already_in_room: function (n) {
return 'Ви вже в кімнаті "' + n + '"';
},
leave_btn: "Покинути кімнату",
left_ok: "Ви покинули кімнату",
notice_hold: "Пауза - чекаємо, поки всі завантажать буфер",
notice_go: "Усі готові - продовжуємо",
},
en: {
menu_title: "WatchTogether",
settings_title: "WatchTogether",
param_show_head: "Show WatchTogether on the main screen",
param_show_head_descr:
"WatchTogether button in the top bar of the main screen.",
param_name: "Display name",
param_name_descr:
"How others see you in rooms. Clear it to generate a random name.",
param_use_pwd: "Use password",
param_use_pwd_descr:
"Ask for password when creating a room and prefill your default.",
param_pwd: "Default password",
param_pwd_descr: "Used when creating password-protected rooms.",
param_publish: "List room publicly",
param_publish_descr: "Turn off to make your room reachable by code only.",
param_relay: "Relay address",
param_relay_descr: "WebSocket relay. Default is " + DEFAULT_RELAY,
param_invite: "Invite address",
param_invite_descr:
"Base link used in the QR code. Default is " + DEFAULT_INVITE,
head_title: "WatchTogether - available rooms",
create_btn: "Create room from URL",
join_code_btn: "Join by room code",
full_card_btn: "WatchTogether - Watch with friends",
settings_open_rooms: "Open room browser",
settings_open_rooms_descr:
"Shows the list of available rooms and lets you create your own",
empty_list: "No open rooms",
searching: "Looking for rooms...",
input_url: "Stream URL (m3u8 / mp4)",
input_room_name: "Room name",
input_password: "Room password",
input_join_password: "Enter room password",
input_room_code: "Room code",
connecting: "Connecting...",
create_fail: "Failed to create room",
create_ok: function (n) {
return 'Room "' + n + '" created';
},
room_code: function (c) {
return "Room code: " + c;
},
join_ok: function (n) {
return 'Joined room "' + n + '"';
},
no_room: "Room not found or wrong password",
no_stream: "This room has no stream",
kicked: "You joined this room from another device",
host_left: "Host left the room - session ended",
net_err: "Network error",
need_url: "Stream URL is empty",
publish_needs_pwd: "Not in the public list: access by code only",
create_from_player: "Share last stream",
pending_share: "Start playback - the room will be created automatically",
label_owner: "Host",
label_members: "Viewers",
badge_room: "Room",
badge_viewers: "Viewers",
notice_joined: function (n) {
return n + " joined";
},
notice_left: function (n) {
return n + " left the room";
},
notice_paused: function (n) {
return n + " paused";
},
notice_resumed: function (n) {
return n + " resumed playback";
},
notice_seeked: function (n) {
return n + " seeked";
},
notice_host_changed: function (n) {
return "New host: " + n;
},
player_create_descr: "Create a room for this stream",
player_qr_descr: "Show a QR code to invite friends",
qr_title: "Join the room",
qr_hint: "Scan with your phone camera",
qr_copy: "Copy link",
qr_share: "Share",
qr_copied: "Link copied",
qr_copy_fail: "Could not copy the link",
already_in_room: function (n) {
return 'You are already in room "' + n + '"';
},
leave_btn: "Leave room",
left_ok: "You left the room",
notice_hold: "Paused - waiting for everyone to buffer",
notice_go: "Everyone is ready - resuming",
},
ru: {
menu_title: "WatchTogether",
settings_title: "WatchTogether",
param_show_head: "Показывать WatchTogether на главной",
param_show_head_descr:
"Кнопка WatchTogether в верхней панели главного экрана.",
param_name: "Имя пользователя",
param_name_descr:
"Как вас будут видеть в комнатах. Если очистить - будет сгенерировано случайное имя.",
param_use_pwd: "Использовать пароль",
param_use_pwd_descr:
"Запрашивать пароль при создании комнаты и подставлять в свои комнаты.",
param_pwd: "Пароль по умолчанию",
param_pwd_descr: "Будет использоваться при создании комнат с паролем.",
param_publish: "Показывать комнату в списке",
param_publish_descr:
"Выключите, чтобы в вашу комнату можно было войти только по коду.",
param_relay: "Адрес реле",
param_relay_descr: "WebSocket-реле. По умолчанию " + DEFAULT_RELAY,
param_invite: "Адрес приглашения",
param_invite_descr:
"Базовая ссылка для QR-кода. По умолчанию " + DEFAULT_INVITE,
head_title: "WatchTogether - список комнат",
create_btn: "Создать комнату по ссылке",
join_code_btn: "Войти по коду комнаты",
full_card_btn: "WatchTogether - Смотреть с друзьями",
settings_open_rooms: "Открыть список комнат",
settings_open_rooms_descr:
"Показывает список доступных комнат и позволяет создать свою",
empty_list: "Открытых комнат нет",
searching: "Поиск комнат...",
input_url: "Ссылка на поток (m3u8 / mp4)",
input_room_name: "Название комнаты",
input_password: "Пароль комнаты",
input_join_password: "Введите пароль комнаты",
input_room_code: "Код комнаты",
connecting: "Подключение...",
create_fail: "Не удалось создать комнату",
create_ok: function (n) {
return 'Комната "' + n + '" создана';
},
room_code: function (c) {
return "Код комнаты: " + c;
},
join_ok: function (n) {
return 'Вошли в комнату "' + n + '"';
},
no_room: "Комната не найдена или неверный пароль",
no_stream: "В этой комнате нет потока",
kicked: "Вы вошли в комнату с другого устройства",
host_left: "Хост покинул комнату - просмотр завершён",
net_err: "Ошибка сети",
need_url: "Не задана ссылка на поток",
publish_needs_pwd: "Нет в публичном списке: доступ только по коду",
create_from_player: "Поделиться последним потоком",
pending_share:
"Запустите воспроизведение - комната будет создана автоматически",
label_owner: "Хост",
label_members: "Зрителей",
badge_room: "Комната",
badge_viewers: "Зрителей",
notice_joined: function (n) {
return n + " присоединился";
},
notice_left: function (n) {
return n + " покинул комнату";
},
notice_paused: function (n) {
return n + " поставил паузу";
},
notice_resumed: function (n) {
return n + " продолжил воспроизведение";
},
notice_seeked: function (n) {
return n + " перемотал";
},
notice_host_changed: function (n) {
return "Новый хост: " + n;
},
player_create_descr: "Создать комнату с этим потоком",
player_qr_descr: "Показать QR-код для приглашения друзей",
qr_title: "Присоединиться к комнате",
qr_hint: "Сканируйте камерой телефона",
qr_copy: "Копировать ссылку",
qr_share: "Поделиться",
qr_copied: "Ссылка скопирована",
qr_copy_fail: "Не удалось скопировать ссылку",
already_in_room: function (n) {
return 'Вы уже в комнате "' + n + '"';
},
leave_btn: "Покинуть комнату",
left_ok: "Вы покинули комнату",
notice_hold: "Пауза - ждём, пока все загрузят буфер",
notice_go: "Все готовы - продолжаем",
},
};
var T = i18n[_rawLang] || i18n["en"];
var LOBBY_COLLECT_MS = 1500;
var JOIN_TIMEOUT_MS = 6000;
var JOIN_EMPTY_TIMEOUT_MS = 2500;
var PING_INTERVAL_MS = 8000;
var ECHO_TIMEOUT_MS = 30000;
var RECONNECT_MS = 4000;
var ROOM_ORPHAN_MS = 20000;
var EPISODE_SWITCH_MS = 20000;
var SYNC_HEARTBEAT_MS = 2000;
var SYNC_TOLERANCE_S = 0.3;
var SYNC_CORRECT_ON_S = 0.7;
var SYNC_HARD_SEEK_S = 1.5;
var SYNC_HARD_SEEK_APPLE_S = 3.0;
var SEEK_GUARD_MS = 4000;
var SEEK_BROADCAST_MIN_MS = 2000;
var SEEK_MIN_JUMP_S = 1.0;
var SYNC_RATE_GAIN = 0.1;
var SYNC_MAX_RATE_OFFSET = 0.1;
var SYNC_RATE_RESET_MS = 4000;
var REWIND_GRACE_MS = 2500;
// Playback speed of the room. Announced in the sp field of
// state/sync/act/hold/go frames; the rate correction works around this base.
var roomSpeed = 1.0;
function applyRoomSpeed(m) {
if (!m || typeof m.sp !== "number" || !(m.sp >= 0.25 && m.sp <= 4.0))
return;
if (Math.abs(m.sp - roomSpeed) < 0.005) return;
roomSpeed = m.sp;
var vid = getVideo();
if (
vid &&
canAdjustRate() &&
!vid._lp_correcting &&
!isSystemSyncing &&
Math.abs((vid.playbackRate || 1) - roomSpeed) > 0.01
) {
vid.playbackRate = roomSpeed;
}
}
var HOLD_MAX_MS = 15000;
var HOLD_BUFFER_S = 6;
var HOLD_MIN_BUFFER_S = 2;
var HOLD_STALL_MS = 3000;
var HOLD_NUDGE_MS = 4000;
var NO_RANGES = -2;
var OUT_OF_RANGE = -1;
var PENDING_SHARE_MS = 300000;
var SHARE_START_DELAY_MS = 600;
var HARD_SEEK_COOLDOWN_MS = 3000;
var PENDING_ACT_MAX_MS = 15000;
var RECONNECT_HELLO_MS = 60000;
var LOG_LIMIT = 400;
var logRing = [];
var playerLaunch = Lampa.Storage.get("watchtogether_player", "inner");
function lplog() {
var text = Array.prototype.slice.call(arguments).join(" ");
var stamp = new Date().toISOString().substr(11, 12);
logRing.push(stamp + " " + text);
if (logRing.length > LOG_LIMIT) logRing.shift();
return text;
}
var pid = Lampa.Storage.get("watchtogether_pid", "");
if (!pid) {
pid = Lampa.Utils.uid(12).toLowerCase();
Lampa.Storage.set("watchtogether_pid", pid);
}
var NAME_ADJECTIVES = [
"Amber",
"Arctic",
"Atomic",
"Brave",
"Bright",
"Calm",
"Chilly",
"Clever",
"Cosmic",
"Crimson",
"Curious",
"Dizzy",
"Dusty",
"Electric",
"Emerald",
"Fancy",
"Fearless",
"Frosty",
"Fuzzy",
"Gentle",
"Golden",
"Happy",
"Hidden",
"Humble",
"Indigo",
"Iron",
"Jolly",
"Lively",
"Lucky",
"Lunar",
"Magic",
"Marble",
"Mighty",
"Mystic",
"Neon",
"Nimble",
"Noble",
"Northern",
"Plasma",
"Polar",
"Quiet",
"Rapid",
"Retro",
"Royal",
"Rusty",
"Scarlet",
"Shiny",
"Silent",
"Silver",
"Sleepy",
"Snowy",
"Solar",
"Sunny",
"Swift",
"Thunder",
"Tiny",
"Turbo",
"Velvet",
"Vivid",
"Wild",
];
var NAME_NOUNS = [
"Badger",
"Bandit",
"Bear",
"Beaver",
"Bison",
"Camel",
"Captain",
"Cheetah",
"Comet",
"Condor",
"Cookie",
"Coyote",
"Crane",
"Dingo",
"Dolphin",
"Donkey",
"Dragon",
"Eagle",
"Falcon",
"Ferret",
"Fox",
"Gecko",
"Ghost",
"Gibbon",
"Griffin",
"Hamster",
"Hawk",
"Hedgehog",
"Heron",
"Husky",
"Ibex",
"Jaguar",
"Kitten",
"Koala",
"Kraken",
"Lemur",
"Leopard",
"Lion",
"Llama",
"Lynx",
"Mammoth",
"Marmot",
"Meteor",
"Monkey",
"Moose",
"Narwhal",
"Ninja",
"Nomad",
"Ocelot",
"Octopus",
"Osprey",
"Otter",
"Owl",
"Panda",
"Panther",
"Parrot",
"Pelican",
"Penguin",
"Pilot",
"Pirate",
"Puffin",
"Puma",
"Rabbit",
"Raccoon",
"Ranger",
"Raven",
"Rhino",
"Robot",
"Rocket",
"Salmon",
"Seal",
"Shark",
"Sparrow",
"Tiger",
"Toucan",
"Turtle",
"Viking",
"Walrus",
"Wizard",
"Wolf",
"Wombat",
"Yeti",
"Zebra",
];
function pickOne(list) {
return list[Math.floor(Math.random() * list.length)];
}
function randomDisplayName() {
return (
pickOne(NAME_ADJECTIVES) +
pickOne(NAME_NOUNS) +
(10 + Math.floor(Math.random() * 90))
);
}
function getDisplayName() {
var custom = (Lampa.Storage.get("watchtogether_display_name", "") || "")
.toString()
.trim();
if (!custom) {
custom = randomDisplayName();
Lampa.Storage.set("watchtogether_display_name", custom);
lplog("display name generated: " + custom);
}
return custom;
}
function isUsePassword() {
return Lampa.Storage.field("watchtogether_use_password") === true;
}
function getDefaultPassword() {
return (
Lampa.Storage.get("watchtogether_default_password", "") || ""
).toString();
}
function isPublish() {
return Lampa.Storage.field("watchtogether_publish") !== false;
}
function isShowHead() {
return Lampa.Storage.get("watchtogether_show_head", "true") === true;
}
function getRelay() {
var v = (Lampa.Storage.get("watchtogether_relay", "") || "")
.toString()
.trim();
if (!v) v = DEFAULT_RELAY;
if (v.charAt(v.length - 1) !== "/") v += "/";
return v;
}
function getInvite() {
var v = (Lampa.Storage.get("watchtogether_invite", "") || "")
.toString()
.trim();
return v || DEFAULT_INVITE;
}
function safe(s) {
return (s + "").replace(/[<>&"']/g, function (c) {
return "" + c.charCodeAt(0) + ";";
});
}
var uiPrevController = "content";
function controllerName() {
try {
var e = Lampa.Controller.enabled();
return (e && e.name) || "";
} catch (err) {
return "";
}
}
function rememberController() {
var name = controllerName();
if (
name &&
name !== "select" &&
name !== "keybord" &&
name !== "settings_input"
)
uiPrevController = name;
return uiPrevController;
}
function restoreController(name) {
var target = name || uiPrevController || "content";
if (target === "content" && playerIsOpen()) target = "player";
try {
Lampa.Controller.toggle(target);
if (controllerName() === target) return;
} catch (err) {}
if (playerIsOpen()) {
try {
Lampa.Controller.toggle("player");
} catch (err) {}
if (controllerName() === "player") return;
}
try {
Lampa.Controller.toggle("content");
} catch (err) {}
}
function askInput(params, done) {
Lampa.Input.edit(
{
title: params.title,
value: params.value || "",
free: true,
nosave: true,
},
function (value) {
restoreController();
done(value);
},
);
}
function closeSettings() {
var guard = 0;
while ($("body").hasClass("settings--open") && guard++ < 4) {
var name = controllerName();
if (name !== "settings" && name !== "settings_component") break;
try {
Lampa.Controller.back();
} catch (err) {
break;
}
}
}
function playerIsOpen() {
try {
if (Lampa.Player && typeof Lampa.Player.opened === "function")
return !!Lampa.Player.opened();
} catch (err) {}
return !!document.querySelector(".player");
}
var SHA_K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
function sha256hex(str) {
var msg = unescape(encodeURIComponent(str));
var len = msg.length;
var bitLen = len * 8;
var blocks = (((len + 8) >> 6) + 1) * 16;
var w = [];
var i, j;
for (i = 0; i < blocks; i++) w[i] = 0;
for (i = 0; i < len; i++)
w[i >> 2] |= (msg.charCodeAt(i) & 0xff) << (24 - (i % 4) * 8);
w[len >> 2] |= 0x80 << (24 - (len % 4) * 8);
w[blocks - 1] = bitLen;
var H = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
0x1f83d9ab, 0x5be0cd19,
];
var m = [];
function rr(v, n) {
return (v >>> n) | (v << (32 - n));
}
for (i = 0; i < blocks; i += 16) {
var a = H[0],
b = H[1],
c = H[2],
d = H[3],
e = H[4],
f = H[5],
g = H[6],
h = H[7];
for (j = 0; j < 64; j++) {
if (j < 16) {
m[j] = w[i + j] | 0;
} else {
var s0 = rr(m[j - 15], 7) ^ rr(m[j - 15], 18) ^ (m[j - 15] >>> 3);
var s1 = rr(m[j - 2], 17) ^ rr(m[j - 2], 19) ^ (m[j - 2] >>> 10);
m[j] = (m[j - 16] + s0 + m[j - 7] + s1) | 0;
}
var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
var ch = (e & f) ^ (~e & g);
var t1 = (h + S1 + ch + SHA_K[j] + m[j]) | 0;
var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
var mj = (a & b) ^ (a & c) ^ (b & c);
var t2 = (S0 + mj) | 0;
h = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}
H[0] = (H[0] + a) | 0;
H[1] = (H[1] + b) | 0;
H[2] = (H[2] + c) | 0;
H[3] = (H[3] + d) | 0;
H[4] = (H[4] + e) | 0;
H[5] = (H[5] + f) | 0;
H[6] = (H[6] + g) | 0;
H[7] = (H[7] + h) | 0;
}
var out = "";
for (i = 0; i < 8; i++) {
for (j = 3; j >= 0; j--) {
var byte = (H[i] >>> (j * 8)) & 0xff;
out += (byte < 16 ? "0" : "") + byte.toString(16);
}
}
return out;
}
function roomChannel(roomId, password) {
return (
ROOM_PREFIX +
sha256hex((roomId || "").toUpperCase() + "|" + (password || "")).substr(
0,
24,
)
);
}
function newRoomId() {
var chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var id = "";
for (var i = 0; i < 6; i++)
id += chars.charAt(Math.floor(Math.random() * chars.length));
return id;
}
function Sock(opts) {
var self = this;
this.channel = opts.channel;
this.alias = opts.alias || "";
this.echo = !!opts.echo;
this.onEvent = opts.onEvent || function () {};
this.reconnect = !!opts.reconnect;
this.ws = null;
this.uid = null;
this.members = [];
this.closed = false;
this.retryTimer = null;
this.url = function () {
var q = "announce=true&list=true";
if (self.echo) q += "&echo=true";
if (self.alias) q += "&as=" + encodeURIComponent(self.alias);
return getRelay() + encodeURIComponent(self.channel) + "?" + q;
};
this.open = function () {
if (self.closed) return;
if (self.ws && (self.ws.readyState === 0 || self.ws.readyState === 1))
return;
try {
self.ws = new WebSocket(self.url());
} catch (err) {
self.scheduleRetry();
return;
}
self.ws.onopen = function () {
console.log("[WatchTogether] ws open", self.channel);
self.onEvent({ kind: "open" });
};
self.ws.onmessage = function (e) {
var d;
try {
d = JSON.parse(e.data);
} catch (err) {
return;
}
if (!d) return;
if (d.type === "join") {
if (d.self) {
self.uid = d.uid;
self.members = [];
var list = d.users || [];
for (var i = 0; i < list.length; i++) {
if (!self.findMember(list[i].uid))
self.members.push({
uid: list[i].uid,
alias: list[i].alias || "",
});
}
if (!self.findMember(self.uid))
self.members.push({ uid: self.uid, alias: self.alias });
self.onEvent({
kind: "ready",
date: d.date,
total: d.total,
members: self.members,
});
} else {
if (!self.findMember(d.uid))
self.members.push({ uid: d.uid, alias: d.alias || "" });
self.onEvent({
kind: "join",
date: d.date,
uid: d.uid,
alias: d.alias || "",
total: d.total,
});
}
return;
}
if (d.type === "leave") {
for (var k = self.members.length - 1; k >= 0; k--) {
if (self.members[k].uid === d.uid) self.members.splice(k, 1);
}
self.onEvent({
kind: "leave",
date: d.date,
uid: d.uid,
alias: d.alias || "",
total: d.total,
});
return;
}
if (d.type === "error") {
console.log("[WatchTogether] relay error:", d.message);
self.onEvent({ kind: "error", date: d.date, text: d.message });
return;
}
if (d.message && typeof d.message === "object") {
self.onEvent({
kind: "msg",
date: d.date,
uid: d.uid,
alias: d.alias || "",
msg: d.message,
mine: d.uid === self.uid,
});
}
};
self.ws.onclose = function () {
console.log(
"[WatchTogether] ws close",
self.channel,
self.closed ? "(by us)" : "(remote)",
);
self.uid = null;
self.members = [];
self.onEvent({ kind: "close" });
self.scheduleRetry();
};
self.ws.onerror = function () {};
};
this.scheduleRetry = function () {
if (!self.reconnect || self.closed) return;
if (self.retryTimer) clearTimeout(self.retryTimer);
self.retryTimer = setTimeout(function () {
self.retryTimer = null;
self.open();
}, RECONNECT_MS);
};
this.findMember = function (uid) {
for (var i = 0; i < self.members.length; i++)
if (self.members[i].uid === uid) return self.members[i];
return null;
};
this.alive = function () {
return !!self.ws && self.ws.readyState === 1;
};
this.send = function (obj) {
if (!self.alive()) return false;
try {
self.ws.send(JSON.stringify(obj));
return true;
} catch (err) {
return false;
}
};
this.close = function () {
self.closed = true;
if (self.retryTimer) {
clearTimeout(self.retryTimer);
self.retryTimer = null;
}
if (self.ws) {
try {
self.ws.close();
} catch (err) {}
self.ws = null;
}
};
}
var room = null;
var lobbyHost = null;
var inRoom = false;
var joining = false;
var joinTimer = null;
var currentRoomId = null;
var currentRoomPassword = "";
var currentRoomName = "";
var currentRoomOwner = null;
var currentRoomMeta = {
title: "",
poster: "",
url: "",
tmdb_id: 0,
source: "",
type: "",
};
var pidByUid = {};
var episodeSwitchAt = 0;
var roomAliveAt = 0;
function markEpisodeSwitch() {
episodeSwitchAt = Date.now();
}
function clearEpisodeSwitch() {
episodeSwitchAt = 0;
}
function isEpisodeSwitching() {
return (
episodeSwitchAt > 0 && Date.now() - episodeSwitchAt < EPISODE_SWITCH_MS
);
}
var serverTimeOffset = 0;
var pingSamples = [];
var echoSeq = 0;
var echoPending = {};
var pingTimer = null;
var watchdogTimer = null;
var isSystemSyncing = false;
var lastUserActionTime = 0;
var rewindUntil = 0;
var lastHardSeekAt = 0;
var knownPids = {};
var holdActive = false;
var holdPosition = 0;
var holdWaiting = {};
var holdReadySent = false;
var holdTimer = null;
var holdBufferSeen = -1;
var holdBufferAt = 0;
var holdNudgeDone = false;
var initialSyncLock = false;
var targetInitialState = null;
var expectedState = { seek: -1, play: false, pause: false };
var lastStreamUrl = null;
var lastStreamTitle = null;
var pendingShareCard = null;
var pendingShareAt = 0;
var lastViewedCard = null;
function serverNow() {
return Date.now() + serverTimeOffset;
}
function iAmHost() {
return !!currentRoomOwner && currentRoomOwner === pid;
}
function memberCount() {
return room ? room.members.length : 0;
}
function getTmdbId(card) {
if (!card) return 0;
return card.id || card.tmdb_id || 0;
}
function getCardPoster(card) {
if (!card) return "";
if (card.poster_path)
return "https://image.tmdb.org/t/p/w300" + card.poster_path;
return card.img || card.background_image || "";
}
function expectedPositionNow(state, basePosition, atServerTime) {
if (state !== "playing" || !atServerTime || atServerTime <= 0)
return basePosition;
var elapsedSec = (serverNow() - atServerTime) / 1000;
if (elapsedSec < 0 || elapsedSec > 3600) return basePosition;
return basePosition + elapsedSec;
}
function roomSend(obj) {
if (!room || !room.alive()) return false;
obj.u = pid;
obj.k = ++echoSeq;
echoPending[obj.k] = Date.now();
return room.send(obj);
}
function handleEcho(key, serverDate) {
var t0 = echoPending[key];
if (!t0) return;
delete echoPending[key];
var t1 = Date.now();
var rtt = t1 - t0;
var offset = serverDate + rtt / 2 - t1;
pingSamples.push({ offset: offset, rtt: rtt });
if (pingSamples.length > 8) pingSamples.shift();
var best = pingSamples[0];
for (var i = 1; i < pingSamples.length; i++)
if (pingSamples[i].rtt < best.rtt) best = pingSamples[i];
serverTimeOffset = best.offset;
}
function startClockTimers() {
stopClockTimers();
pingTimer = setInterval(function () {
if (room && room.alive()) roomSend({ t: "ping" });
}, PING_INTERVAL_MS);
watchdogTimer = setInterval(function () {
if (!room || !room.alive()) return;
var now = Date.now();
for (var key in echoPending) {
if (!echoPending.hasOwnProperty(key)) continue;
if (now - echoPending[key] > ECHO_TIMEOUT_MS) {
console.log(
"[WatchTogether]",
lplog("echo watchdog timeout - forcing reconnect"),
);
echoPending = {};
try {
room.ws.close();
} catch (err) {}
return;
}
}
}, 5000);
}
function stopClockTimers() {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
if (watchdogTimer) {
clearInterval(watchdogTimer);
watchdogTimer = null;
}
}
function buildAd() {
return {
id: currentRoomId,
name: currentRoomName,
title: currentRoomMeta.title || "",
poster: currentRoomMeta.poster || "",
owner: getDisplayName(),
members: memberCount(),
pwd: currentRoomPassword ? 1 : 0,
tmdb: currentRoomMeta.tmdb_id || 0,
type: currentRoomMeta.type || "movie",
};
}
function startLobbyAgent() {
stopLobbyAgent();
if (!isPublish()) return;
// Never advertise a passwordless room, even one we only joined or inherited as host:
// the lobby is a public relay, so anyone reading it could walk straight in.
if (!currentRoomPassword) return;
lobbyHost = new Sock({
channel: LOBBY_CHANNEL,
alias: "",
echo: false,
reconnect: true,
onEvent: function (e) {
if (e.kind !== "msg" || e.mine) return;
if (!e.msg || e.msg.t !== "who") return;
if (!inRoom || !currentRoomId) return;
var ad = buildAd();
setTimeout(
function () {
if (lobbyHost && inRoom) lobbyHost.send({ t: "ad", r: ad });
},
Math.floor(Math.random() * 350) + 50,
);
},
});
lobbyHost.open();
}
function stopLobbyAgent() {
if (lobbyHost) {
lobbyHost.close();
lobbyHost = null;
}
}
function discoverRooms(done) {
var found = {};
var finished = false;
var probe = new Sock({
channel: LOBBY_CHANNEL,
alias: "",
echo: false,
reconnect: false,
onEvent: function (e) {
if (e.kind === "ready") {
probe.send({ t: "who" });
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();
setTimeout(function () {
if (finished) return;
finished = true;
probe.close();
var list = [];
for (var id in found) {
if (!found.hasOwnProperty(id)) continue;
list.push(found[id]);
}
list.sort(function (a, b) {
return (b.members || 0) - (a.members || 0);
});
done(list);
}, LOBBY_COLLECT_MS);
}
var ICON_FILM =
'';
function ensureRoomStyle() {
if (document.getElementById("watchtogether-room-style")) return;
var style = document.createElement("style");
style.id = "watchtogether-room-style";
style.textContent =
".selectbox-item__icon > .watchtogether-poster{width:3.4em;height:5.1em;border-radius:0.3em;" +
"background-color:#3e3e3e;object-fit:cover;}" +
".watchtogether-poster--empty{display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.4);}" +
".watchtogether-poster--empty > svg{width:1.6em;height:1.6em;}";
document.head.appendChild(style);
}
function roomPosterStub() {
return (
'
' +
ICON_FILM +
"
"
);
}
function roomPoster(r) {
if (!r.poster) return roomPosterStub();
return '
';
}
function bindPosterFallback(item) {
item.find("img.watchtogether-poster").on("error", function () {
$(this).replaceWith(roomPosterStub());
});
}
var browserBusy = false;
function openRoomBrowser() {
if (browserBusy) return;
browserBusy = true;
rememberController();
dropStaleRoom();
Lampa.Noty.show(T.searching);
discoverRooms(function (rooms) {
browserBusy = false;
rememberController();
var items = [];
if (inRoom) {
items.push({
title:
'✕ ' +
T.leave_btn +
" [" +
safe(currentRoomId || "") +
"]",
action: "leave",
});
}
items.push({
title: '+ ' + T.create_btn + "",
action: "create",
});
items.push({
title: '# ' + T.join_code_btn + "",
action: "code",
});
if (rooms.length) ensureRoomStyle();
for (var i = 0; i < rooms.length; i++) {
var r = rooms[i];
var lock = r.pwd ? " 🔒" : "";
var line2 = [];
if (r.title) line2.push(safe(r.title));
line2.push(T.label_owner + ": " + safe(r.owner || ""));
line2.push(T.label_members + ": " + (r.members || 0));
items.push({
template: "selectbox_icon",
icon: roomPoster(r),
title:
"" +
safe(r.name || r.id) +
' [' +
safe(r.id) +
"]" +
lock,
subtitle: line2.join(" · "),
onDraw: bindPosterFallback,
room: r,
});
}
if (rooms.length === 0) {
items.push({
title: '' + T.empty_list + "",
disabled: true,
});
}
Lampa.Select.show({
title: T.head_title,
items: items,
onSelect: function (a) {
restoreController();
if (a.disabled) return;
if (a.action === "leave") return manualLeaveRoom();
if (a.action === "create") return askCreateRoom();
if (a.action === "code") return askRoomCode();
if (a.room) return tryJoinRoom(a.room);
},
onBack: function () {
restoreController();
},
});
});
}
function manualLeaveRoom() {
if (!inRoom && !joining) return;
leaveRoom(true);
Lampa.Noty.show(T.left_ok);
}
function askRoomCode() {
askInput({ title: T.input_room_code, value: "" }, function (val) {
var id = (val || "").toString().trim().toUpperCase();
if (!id) return;
tryJoinRoom({ id: id, pwd: 1, name: id });
});
}
function tryJoinRoom(r) {
dropStaleRoom();
if (inRoom) {
Lampa.Noty.show(
T.already_in_room(currentRoomName || currentRoomId || ""),
);
return;
}
if (!r.pwd) return joinRoom(r.id, "", r.name || r.id);
var prefill = isUsePassword() ? getDefaultPassword() : "";
askInput({ title: T.input_join_password, value: prefill }, function (val) {
joinRoom(r.id, val || "", r.name || r.id);
});
}
function resetRoomState() {
stopClockTimers();
$(".watchtogether-room-badge").remove();
var vid = getVideo();
if (vid) clearRateAdjust(vid);
if (expectedSeekTimer) {
clearTimeout(expectedSeekTimer);
expectedSeekTimer = null;
}
clearPendingSync();
expectedState = { seek: -1, play: false, pause: false };
initialSyncLock = false;
targetInitialState = null;
isSystemSyncing = false;
rewindUntil = 0;
holdActive = false;
holdPosition = 0;
holdWaiting = {};
holdReadySent = false;
resetHoldProgress();
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
inRoom = false;
joining = false;
currentRoomId = null;
currentRoomPassword = "";
currentRoomName = "";
currentRoomOwner = null;
currentRoomMeta = {
title: "",
poster: "",
url: "",
tmdb_id: 0,
source: "",
type: "",
};
lastStreamUrl = stripRoomParam(lastStreamUrl);
pidByUid = {};
knownPids = {};
echoPending = {};
pingSamples = [];
lastHardSeekAt = 0;
clearEpisodeSwitch();
roomAliveAt = 0;
createPending = false;
if (createTimer) {
clearTimeout(createTimer);
createTimer = null;
}
if (joinTimer) {
clearTimeout(joinTimer);
joinTimer = null;
}
}
function dropStaleRoom() {
if ((inRoom || joining) && !playerIsOpen()) {
console.log(
"[WatchTogether]",
lplog("stale room detected (player closed) - leaving"),
);
leaveRoom(true);
return true;
}
return false;
}
function leaveRoom(sendBye) {
if (inRoom || joining)
console.log("[WatchTogether]", lplog("leave room", currentRoomId || ""));
if (room) {
if (sendBye && room.alive()) roomSend({ t: "bye" });
room.close();
room = null;
}
stopLobbyAgent();
resetRoomState();
}
function stripRoomParam(url) {
if (!url) return url;
return url.replace(/([?&])room=[^&]*&?/, "$1").replace(/[?&]$/, "");
}
function withRoomParam(url) {
if (!url || !currentRoomId || url.search(/[?&]room=/) !== -1) return url;
var data = btoa(
unescape(
encodeURIComponent(currentRoomId + ":" + (currentRoomPassword || "")),
),
);
return (
url +
(url.indexOf("?") === -1 ? "?" : "&") +
"room=" +
encodeURIComponent(data)
);
}
function inviteLink() {
return withRoomParam(getInvite());
}
function connectRoom(roomId, password, onReady) {
if (room) {
room.close();
room = null;
}
currentRoomId = roomId;
currentRoomPassword = password || "";
roomAliveAt = Date.now();
room = new Sock({
channel: roomChannel(roomId, password),
alias: getDisplayName(),
echo: true,
reconnect: true,
onEvent: onRoomEvent,
});
room._onReady = onReady;
room.open();
startClockTimers();
}
function androidRoomHandoff(roomId, password, name, meta) {
currentRoomId = roomId;
currentRoomPassword = password || "";
currentRoomName = name || roomId;
currentRoomMeta = {
title: (meta && meta.title) || "",
poster: (meta && meta.poster) || "",
url: (meta && meta.url) || inviteLink(),
tmdb_id: (meta && meta.tmdb_id) || 0,
source: (meta && meta.source) || "",
type: (meta && meta.type) || "movie",
};
console.log(
"[WatchTogether] android player: room",
roomId,
"handed to player, no relay socket from lampa",
);
closeSettings();
playRoomStream(
currentRoomMeta.url,
currentRoomMeta.title || currentRoomName,
currentRoomMeta.poster,
);
}
function joinRoom(roomId, password, fallbackName) {
if (playerLaunch === "android")
return androidRoomHandoff(roomId, password, fallbackName, null);
Lampa.Noty.show(T.connecting);
joining = true;
currentRoomName = fallbackName || roomId;
connectRoom(roomId, password, function (ev) {
console.log("[WatchTogether] join: channel ready, total", ev.total);
roomSend({
t: "hello",
n: getDisplayName(),
cd: iAmHost() && isPublish() ? currentRoomId : undefined,
});
if (ev.total <= 1) {
if (joinTimer) clearTimeout(joinTimer);
joinTimer = setTimeout(failJoin, JOIN_EMPTY_TIMEOUT_MS);
}
});
if (joinTimer) clearTimeout(joinTimer);
joinTimer = setTimeout(failJoin, JOIN_TIMEOUT_MS);
}
function failJoin() {
if (!joining) return;
console.log("[WatchTogether] join failed - no answer from room");
Lampa.Noty.show(T.no_room);
leaveRoom(false);
}
function extendJoinDeadline() {
if (!joining) return;
if (joinTimer) clearTimeout(joinTimer);
joinTimer = setTimeout(failJoin, JOIN_TIMEOUT_MS);
}
function acceptRoomState(msg, atServerTime) {
if (joinTimer) {
clearTimeout(joinTimer);
joinTimer = null;
}
joining = false;
if (!msg.url) {
Lampa.Noty.show(T.no_stream);
leaveRoom(true);
return;
}
inRoom = true;
roomAliveAt = Date.now();
currentRoomName = msg.rn || currentRoomName;
currentRoomOwner = msg.own || null;
if (typeof msg.sp === "number" && msg.sp >= 0.25 && msg.sp <= 4.0)
roomSpeed = msg.sp;
currentRoomMeta = {
title: msg.ti || "",
poster: msg.po || "",
url: msg.url,
tmdb_id: msg.tm || 0,
source: msg.src || "",
type: msg.ty || "movie",
};
var roomState = msg.s === "playing" ? "playing" : "paused";
var roomPos = msg.p || 0;
var needsInitialSync = roomState === "playing" || roomPos > 0.5;
initialSyncLock = needsInitialSync;
targetInitialState = needsInitialSync
? {
state: roomState,
position: roomPos,
atServerTime: atServerTime,
}
: null;
Lampa.Noty.show(T.join_ok(currentRoomName));
closeSettings();
playRoomStream(
currentRoomMeta.url,
currentRoomMeta.title || currentRoomName,
currentRoomMeta.poster || "",
);
}
function askCreateRoom() {
if (lastStreamUrl) {
Lampa.Select.show({
title: T.create_btn,
items: [
{
title:
T.create_from_player + " (" + safe(lastStreamTitle || "") + ")",
share: true,
},
{ title: T.input_url, share: false },
],
onSelect: function (a) {
restoreController();
if (a.share)
askRoomDetails({
stream_url: lastStreamUrl,
title: lastStreamTitle || "",
});
else promptStreamUrl();
},
onBack: function () {
restoreController();
},
});
} else {
promptStreamUrl();
}
}
function promptStreamUrl() {
askInput({ title: T.input_url, value: "" }, function (val) {
if (!val) return Lampa.Noty.show(T.need_url);
askRoomDetails({ stream_url: val, title: "" });
});
}
function askRoomDetails(seed) {
askInput(
{
title: T.input_room_name,
value: seed.title || "Room " + Math.floor(Math.random() * 1000),
},
function (name) {
if (!name) name = "Room";
seed.name = name;
askRoomPassword(seed, function () {
createRoom(seed, false);
});
},
);
}
// Publishing implies a password, so ask for one whenever either switch wants it.
// An empty answer is allowed - the room is then simply kept out of the lobby list.
function askRoomPassword(seed, done) {
if (!isUsePassword() && !isPublish()) {
seed.password = "";
return done();
}
askInput(
{ title: T.input_password, value: getDefaultPassword() },
function (pwd) {
seed.password = pwd || "";
done();
},
);
}
var createPending = false;
var createTimer = null;
function createRoom(seed, hostAlreadyPlaying) {
if (createPending) return;
if (!hostAlreadyPlaying) dropStaleRoom();
if (inRoom) {
Lampa.Noty.show(
T.already_in_room(currentRoomName || currentRoomId || ""),
);
return;
}
if (!seed.stream_url) {
Lampa.Noty.show(T.need_url);
return;
}
createPending = true;
var id = newRoomId();
currentRoomName = seed.name || "Room-" + id;
currentRoomOwner = pid;
currentRoomMeta = {
title: seed.title || "",
poster: seed.poster || "",
url: seed.stream_url,
tmdb_id: seed.tmdb_id || 0,
source: seed.source || "",
type: seed.type || "movie",
};
connectRoom(id, seed.password || "", function () {
createPending = false;
if (createTimer) {
clearTimeout(createTimer);
createTimer = null;
}
inRoom = true;
joining = false;
roomAliveAt = Date.now();
initialSyncLock = false;
targetInitialState = null;
startLobbyAgent();
// One notification, not a queue: each Noty replaces the previous one, so the
// name, the code and the unlisted warning have to travel together or get eaten.
var lines = [T.create_ok(currentRoomName), T.room_code(id)];
if (!currentRoomPassword && isPublish()) lines.push(T.publish_needs_pwd);
// Noty renders with .html() and its time is in ms - \n would collapse, 8 would blink.
Lampa.Noty.show(lines.join("
"), { time: 8000 });
var vid = getVideo();
if (hostAlreadyPlaying && playerIsOpen() && vid) {
roomSend({
t: "sync",
s: vid.paused ? "paused" : "playing",
p: vid.currentTime || 0,
});
} else {
if (hostAlreadyPlaying)
console.log(
"[WatchTogether]",
lplog("player was expected open but is not - starting it"),
);
closeSettings();
playRoomStream(
currentRoomMeta.url,
currentRoomMeta.title || currentRoomName,
currentRoomMeta.poster || "",
);
}
});
if (createTimer) clearTimeout(createTimer);
createTimer = setTimeout(function () {
createTimer = null;
if (createPending) {
createPending = false;
Lampa.Noty.show(T.create_fail);
leaveRoom(false);
}
}, JOIN_TIMEOUT_MS);
}
function autoCreateRoomFromPending(card, streamUrl) {
var title =
card.title ||
card.name ||
card.original_title ||
card.original_name ||
"";
var type =
card.name || card.number_of_seasons || card.first_air_date
? "tv"
: "movie";
var seed = {
stream_url: streamUrl,
title: title,
poster: getCardPoster(card),
tmdb_id: getTmdbId(card),
source: card.source || "tmdb",
type: type,
name: title || "Room " + Math.floor(Math.random() * 1000),
password: "",
};
askRoomPassword(seed, function () {
createRoom(seed, true);
});
}
function onRoomEvent(e) {
if (e.kind === "ready") {
echoPending = {};
if (room && room._onReady) {
var cb = room._onReady;
room._onReady = null;
cb(e);
return;
}
if (inRoom) {
roomSend({
t: "hello",
n: getDisplayName(),
cd: iAmHost() && isPublish() ? currentRoomId : undefined,
});
if (iAmHost()) sendHostState();
}
updateRoomBadge();
return;
}
if (e.kind === "close") {
updateRoomBadge();
return;
}
if (e.kind === "join") {
updateRoomBadge();
return;
}
if (e.kind === "leave") {
onMemberLeave(e);
return;
}
if (e.kind !== "msg" || !e.msg) return;
if (e.mine) {
if (e.msg.k) handleEcho(e.msg.k, e.date);
return;
}
var m = e.msg;
if (m.u) pidByUid[e.uid] = m.u;
if (m.u === pid && m.t === "hello") {
Lampa.Noty.show(T.kicked);
leaveRoom(false);
return;
}
if (m.t === "hello") {
if (!inRoom) return;
Lampa.Noty.show(T.notice_joined(m.n || e.alias));
var seenAt = m.u ? knownPids[m.u] : 0;
var reconnected = seenAt && Date.now() - seenAt < RECONNECT_HELLO_MS;
if (m.u) knownPids[m.u] = Date.now();
console.log(
"[WatchTogether] hello from",
m.u,
reconnected ? "(reconnect)" : "(new)",
);
if (iAmHost() && !reconnected) startJoinHold(m.u);
setTimeout(
function () {
if (!inRoom) return;
roomSend({ t: "me", n: getDisplayName() });
if (iAmHost()) sendHostState();
},
Math.floor(Math.random() * 250) + 50,
);
updateRoomBadge();
return;
}
if (m.t === "hold") {
if (!inRoom && !joining) return;
if (currentRoomOwner && m.u !== currentRoomOwner) return;
extendJoinDeadline();
applyRoomSpeed(m);
applyHold(m.p || 0);
return;
}
if (m.t === "ready") {
markHoldReady(m.u);
return;
}
if (m.t === "go") {
if (!inRoom && !joining) return;
if (currentRoomOwner && m.u !== currentRoomOwner) return;
applyRoomSpeed(m);
releaseHold(typeof m.p === "number" ? m.p : holdPosition);
return;
}
if (m.t === "me") {
updateRoomBadge();
return;
}
if (m.t === "state") {
if (joining) acceptRoomState(m, e.date);
return;
}
if (m.t === "host") {
currentRoomOwner = m.u;
clearPendingSync();
Lampa.Noty.show(T.notice_host_changed(m.n || e.alias));
return;
}
if (m.t === "url") {
if (!inRoom || !m.url) return;
if (currentRoomOwner && m.u !== currentRoomOwner) return;
currentRoomMeta.url = m.url;
currentRoomMeta.title = m.ti || currentRoomMeta.title;
markEpisodeSwitch();
clearPendingSync();
try {
playRoomStream(m.url, m.ti || currentRoomName, "");
} catch (err) {}
return;
}
if (m.t === "sync" || m.t === "act") {
if (!inRoom) return;
if (
m.t === "sync" &&
(iAmHost() || (currentRoomOwner && m.u !== currentRoomOwner))
)
return;
applyRoomSpeed(m);
if (m.t === "act" && m.v) {
var text = formatNotice(m.v, m.n || e.alias);
if (text) Lampa.Noty.show(text);
}
var state = m.s === "playing" ? "playing" : "paused";
var position = m.p || 0;
// вхідна пауза на кінці серії — це природне завершення відео у партнера, а не дія користувача
if (m.t === "act" && state === "paused") {
var d = getVideo() && getVideo().duration;
if (d && isFinite(d) && d > 0 && position >= d - 1.0) return;
}
if (initialSyncLock) {
targetInitialState = {
state: state,
position: position,
atServerTime: e.date,
};
return;
}
var vid = getVideo();
if (!vid) return;
if (isRewinding() || holdActive) return;
if (Date.now() - lastUserActionTime < 2000) return;
// a seek of ours is waiting out the debounce - the heartbeat cannot know about it yet,
// so its position is stale by definition. explicit acts still get through
if (m.t === "sync" && pendingSeekBroadcast) return;
// anything newer supersedes a deferred seek - never let a stale snapshot outlive it
pendingAct = null;
isSystemSyncing = true;
applySync(vid, state, position, e.date);
setTimeout(function () {
isSystemSyncing = false;
}, 500);
// an explicit seek that could not land (buffering / seek cooldown) is kept and retried
// instead of being dropped - dropping it lets our heartbeat drag the peer back.
// threshold matches applySync's hard seek, so a drift it chose to fix by playbackRate
// is not mistaken for a missed seek
if (m.t === "act" && m.v === "seeked") {
var target = expectedPositionNow(state, position, e.date);
if (Math.abs((vid.currentTime || 0) - target) > hardSeekThreshold()) {
pendingAct = {
state: state,
position: position,
atServerTime: e.date,
at: Date.now(),
until: Date.now() + PENDING_ACT_MAX_MS,
};
console.log(
"[WatchTogether]",
"act seek deferred, target",
target.toFixed(2),
);
}
}
return;
}
if (m.t === "bye") {
updateRoomBadge();
return;
}
}
function sendHostState() {
roomSend({
t: "state",
rn: currentRoomName,
own: currentRoomOwner || pid,
// the code in the clear lets the server re-announce this room in the
// lobby; withheld when publishing is off, so the room stays unlisted
cd: isPublish() ? currentRoomId : undefined,
url: currentRoomMeta.url,
ti: currentRoomMeta.title,
po: currentRoomMeta.poster,
tm: currentRoomMeta.tmdb_id,
src: currentRoomMeta.source,
ty: currentRoomMeta.type,
s: hostPlaybackState(),
p: hostPlaybackPosition(),
sp: roomSpeed,
});
}
function hostPlaybackState() {
var vid = getVideo();
if (!vid) return "paused";
return vid.paused ? "paused" : "playing";
}
function hostPlaybackPosition() {
var vid = getVideo();
return vid ? vid.currentTime || 0 : 0;
}
function onMemberLeave(e) {
if (!inRoom) {
updateRoomBadge();
return;
}
var leaverPid = pidByUid[e.uid];
delete pidByUid[e.uid];
Lampa.Noty.show(T.notice_left(e.alias || ""));
updateRoomBadge();
if (leaverPid && holdWaiting[leaverPid]) markHoldReady(leaverPid);
var hostGone = currentRoomOwner && leaverPid === currentRoomOwner;
if (!hostGone) return;
setTimeout(function () {
if (!inRoom || !room || !room.uid) return;
if (hostStillPresent()) return;
var uids = [room.uid];
for (var i = 0; i < room.members.length; i++) {
if (room.members[i].uid !== room.uid) uids.push(room.members[i].uid);
}
uids.sort();
if (uids[0] === room.uid) {
currentRoomOwner = pid;
clearPendingSync();
roomSend({ t: "host", n: getDisplayName() });
Lampa.Noty.show(T.notice_host_changed(getDisplayName()));
startLobbyAgent();
}
}, 500);
}
function hostStillPresent() {
if (!currentRoomOwner) return false;
if (currentRoomOwner === pid) return true;
if (!room) return false;
for (var i = 0; i < room.members.length; i++) {
if (pidByUid[room.members[i].uid] === currentRoomOwner) return true;
}
return false;
}
function formatNotice(verb, who) {
if (!who) return "";
if (verb === "joined") return T.notice_joined(who);
if (verb === "left") return T.notice_left(who);
if (verb === "paused") return T.notice_paused(who);
if (verb === "resumed" || verb === "playing") return T.notice_resumed(who);
if (verb === "seeked") return T.notice_seeked(who);
if (verb === "host_changed") return T.notice_host_changed(who);
return who + " · " + verb;
}
function getVideo() {
var vid = null;
if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.video)
vid = Lampa.PlayerVideo.video();
if (!vid)
vid =
document.querySelector(".player-video__display video") ||
document.querySelector(".player video") ||
document.querySelector("video");
return vid;
}
function roomMarker() {
if (!currentRoomId) return "";
try {
return btoa(
unescape(
encodeURIComponent(currentRoomId + ":" + (currentRoomPassword || "")),
),
);
} catch (err) {
return "";
}
}
function playRoomStream(url, title, poster) {
console.log(
"[WatchTogether]",
"start room stream via",
playerLaunch,
url ? url.substr(0, 60) : "",
);
if (Lampa.Player.runas) Lampa.Player.runas(playerLaunch);
Lampa.Player.play({
url: url,
title: title || "",
poster: poster || "",
launch_player: playerLaunch,
});
}
function pauseVideo(vid) {
if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.pause)
Lampa.PlayerVideo.pause();
else if (vid) vid.pause();
}
function playVideo(vid, onFail) {
if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.play)
return Lampa.PlayerVideo.play();
if (!vid) return;
var p = vid.play();
if (p && p["catch"])
p["catch"](function () {
if (onFail) onFail();
});
}
function bufferedAhead(vid, position) {
var ranges;
try {
ranges = vid.buffered;
} catch (err) {
return NO_RANGES;
}
if (!ranges || !ranges.length) return NO_RANGES;
for (var 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;
}
function holdReadyNow(vid) {
if (!vid) return false;
var now = Date.now();
var ahead = bufferedAhead(vid, holdPosition);
if (ahead > holdBufferSeen + 0.25) {
holdBufferSeen = ahead;
holdBufferAt = now;
}
if (ahead === NO_RANGES) return vid.readyState >= 3;
if (ahead >= HOLD_BUFFER_S) return true;
if (vid.readyState >= 4 && ahead > 0) return true;
var idle = now - (holdBufferAt || now);
if (ahead >= HOLD_MIN_BUFFER_S && idle > HOLD_STALL_MS) {
console.log(
"[WatchTogether]",
"hold: buffer stopped growing at",
ahead.toFixed(2) + "s - accepting",
);
return true;
}
if (!holdNudgeDone && idle > HOLD_NUDGE_MS && vid.readyState >= 1) {
holdNudgeDone = true;
console.log(
"[WatchTogether]",
"hold: nudging loader, ahead=" +
ahead.toFixed(2) +
" pos=" +
(vid.currentTime || 0).toFixed(2),
);
setExpectedSeek(holdPosition);
vid.currentTime = holdPosition + 0.05;
}
return false;
}
function resetHoldProgress() {
holdBufferSeen = -1;
holdBufferAt = Date.now();
holdNudgeDone = false;
}
function startJoinHold(newcomerPid) {
if (!inRoom || !iAmHost()) return;
var vid = getVideo();
if (!holdActive) {
holdActive = true;
holdPosition = vid ? vid.currentTime || 0 : 0;
holdWaiting = {};
holdReadySent = false;
resetHoldProgress();
if (vid && !vid.paused) {
expectPause();
pauseVideo(vid);
}
Lampa.Noty.show(T.notice_hold);
console.log("[WatchTogether]", "hold start at", holdPosition.toFixed(2));
}
if (newcomerPid && newcomerPid !== pid) holdWaiting[newcomerPid] = true;
roomSend({ t: "hold", p: holdPosition, sp: roomSpeed });
if (holdTimer) clearTimeout(holdTimer);
holdTimer = setTimeout(finishHold, HOLD_MAX_MS);
}
function markHoldReady(fromPid) {
if (!holdActive || !iAmHost()) return;
if (fromPid) delete holdWaiting[fromPid];
checkHoldDone();
}
function checkHoldDone() {
if (!holdActive || !iAmHost()) return;
for (var key in holdWaiting) {
if (holdWaiting.hasOwnProperty(key)) return;
}
if (!holdReadyNow(getVideo())) return;
finishHold();
}
function finishHold() {
if (!holdActive) return;
var position = holdPosition;
console.log("[WatchTogether]", "hold finish at", position.toFixed(2));
roomSend({ t: "go", p: position, sp: roomSpeed });
releaseHold(position);
}
function releaseHold(position) {
var wasHolding = holdActive;
holdActive = false;
holdWaiting = {};
holdReadySent = false;
resetHoldProgress();
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
initialSyncLock = false;
targetInitialState = null;
if (wasHolding) Lampa.Noty.show(T.notice_go);
var vid = getVideo();
if (!vid) return;
if (
typeof position === "number" &&
Math.abs((vid.currentTime || 0) - position) > 0.5
) {
setExpectedSeek(position);
vid.currentTime = position;
}
if (vid.paused) {
expectPlay();
playVideo(vid, function () {
expectedState.play = false;
});
}
}
function applyHold(position) {
holdActive = true;
holdPosition = position || 0;
holdReadySent = false;
resetHoldProgress();
Lampa.Noty.show(T.notice_hold);
console.log("[WatchTogether]", "hold received at", holdPosition.toFixed(2));
var vid = getVideo();
if (!vid) return;
if (!vid.paused) {
expectPause();
pauseVideo(vid);
}
if (Math.abs((vid.currentTime || 0) - holdPosition) > 1) {
setExpectedSeek(holdPosition);
vid.currentTime = holdPosition;
}
}
function holdTick() {
if (!holdActive) return;
if (iAmHost()) return checkHoldDone();
if (holdReadySent) return;
var vid = getVideo();
if (!holdReadyNow(vid)) return;
if (!roomSend({ t: "ready" })) return;
holdReadySent = true;
console.log(
"[WatchTogether]",
"hold: ready sent, ahead=" + bufferedAhead(vid, holdPosition).toFixed(2),
);
}
function sendSync(state, verb) {
if (!inRoom || initialSyncLock || holdActive) return;
var vid = getVideo();
if (!vid) return;
if (verb) {
roomSend({
t: "act",
s: state,
p: vid.currentTime || 0,
v: verb,
n: getDisplayName(),
sp: roomSpeed,
});
} else {
roomSend({ t: "sync", s: state, p: vid.currentTime || 0, sp: roomSpeed });
}
}
var expectedSeekTimer = null;
var seekGuardUntil = 0;
var lastSeekBroadcastAt = 0;
var lastKnownPosition = 0;
var pendingSeekBroadcast = null;
var pendingAct = null;
// both carry a position that only makes sense for the video that was playing when they were
// recorded - they must die with it, or they land on the next episode
function clearPendingSync() {
if (pendingSeekBroadcast) {
clearTimeout(pendingSeekBroadcast);
pendingSeekBroadcast = null;
}
pendingAct = null;
}
function applyPendingAct() {
if (!pendingAct) return;
if (Date.now() > pendingAct.until) {
console.log(
"[WatchTogether]",
"act seek given up after " + PENDING_ACT_MAX_MS + "ms",
);
pendingAct = null;
return;
}
// same gates the live path applies - a retry must never outrank a newer local action
if (lastUserActionTime > pendingAct.at) {
console.log(
"[WatchTogether]",
"act seek dropped, superseded by local action",
);
pendingAct = null;
return;
}
if (!inRoom || initialSyncLock || isRewinding() || holdActive) return;
if (Date.now() - lastUserActionTime < 2000) return;
var vid = getVideo();
if (!vid) return;
if (videoBusy(vid)) return;
if (Date.now() - lastHardSeekAt <= HARD_SEEK_COOLDOWN_MS) return;
var act = pendingAct;
pendingAct = null;
console.log("[WatchTogether]", "act seek retry");
isSystemSyncing = true;
applySync(vid, act.state, act.position, act.atServerTime);
setTimeout(function () {
isSystemSyncing = false;
}, 500);
}
function setExpectedSeek(pos) {
expectedState.seek = pos;
seekGuardUntil = Date.now() + SEEK_GUARD_MS;
if (expectedSeekTimer) clearTimeout(expectedSeekTimer);
expectedSeekTimer = setTimeout(function () {
expectedSeekTimer = null;
expectedState.seek = -1;
}, SEEK_GUARD_MS);
}
function clearExpectedSeek() {
if (expectedSeekTimer) {
clearTimeout(expectedSeekTimer);
expectedSeekTimer = null;
}
expectedState.seek = -1;
seekGuardUntil = 0;
}
function seekIsOurs() {
return Date.now() < seekGuardUntil;
}
function broadcastUserSeek(vid) {
if (
Math.abs((vid.currentTime || 0) - lastKnownPosition) < SEEK_MIN_JUMP_S
) {
console.log(
"[WatchTogether]",
"seek ignored, position barely moved",
(vid.currentTime || 0).toFixed(2),
);
return;
}
if (isRewinding()) rewindUntil = Date.now() + 400;
lastKnownPosition = vid.currentTime || 0;
lastUserActionTime = Date.now();
// throttle defers the burst, never drops it - a dropped seek means the host
// heartbeat drags us back to its old position two seconds later
if (Date.now() - lastSeekBroadcastAt < SEEK_BROADCAST_MIN_MS) {
console.log(
"[WatchTogether]",
"seek broadcast deferred at",
(vid.currentTime || 0).toFixed(2),
);
// debounce, not a periodic flush: a long scrub must end in ONE extra broadcast,
// not one every SEEK_BROADCAST_MIN_MS - each of those costs everyone a hard seek
if (pendingSeekBroadcast) clearTimeout(pendingSeekBroadcast);
pendingSeekBroadcast = setTimeout(function () {
pendingSeekBroadcast = null;
if (!inRoom || initialSyncLock || holdActive) return;
var v = getVideo();
if (!v) return;
lastSeekBroadcastAt = Date.now();
lastKnownPosition = v.currentTime || 0;
lastUserActionTime = Date.now();
sendSync(v.paused ? "paused" : "playing", "seeked");
}, SEEK_BROADCAST_MIN_MS);
return;
}
if (pendingSeekBroadcast) {
clearTimeout(pendingSeekBroadcast);
pendingSeekBroadcast = null;
}
lastSeekBroadcastAt = Date.now();
sendSync(vid.paused ? "paused" : "playing", "seeked");
}
function isRewinding() {
return Date.now() < rewindUntil;
}
function onUserRewind() {
if (!inRoom || initialSyncLock) return;
rewindUntil = Date.now() + REWIND_GRACE_MS;
lastUserActionTime = Date.now();
clearExpectedSeek();
}
function expectPlay() {
expectedState.play = true;
setTimeout(function () {
expectedState.play = false;
}, 500);
}
function expectPause() {
expectedState.pause = true;
setTimeout(function () {
expectedState.pause = false;
}, 500);
}
function clearRateAdjust(vid) {
if (vid._lp_rate_timeout) {
clearTimeout(vid._lp_rate_timeout);
vid._lp_rate_timeout = null;
}
vid._lp_correcting = false;
if (!canAdjustRate()) return;
if (vid.playbackRate !== roomSpeed) vid.playbackRate = roomSpeed;
}
function videoBusy(vid) {
// 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; correcting position mid-flight
// is what dragged the seeker back to the old spot
return !!vid._lp_buffering || vid.readyState < 3 || !!vid.seeking;
}
var appleNative = (function () {
try {
return !!(
Lampa.Platform &&
(Lampa.Platform.is("apple") || Lampa.Platform.is("apple_tv"))
);
} catch (err) {
return false;
}
})();
function canAdjustRate() {
return !appleNative;
}
function hardSeekThreshold() {
return appleNative ? SYNC_HARD_SEEK_APPLE_S : SYNC_HARD_SEEK_S;
}
function applySync(vid, state, basePosition, atServerTime) {
if (vid.currentTime === undefined) return;
var busy = videoBusy(vid);
if (busy) {
clearRateAdjust(vid);
} else {
var expected = expectedPositionNow(state, basePosition, atServerTime);
var diff = vid.currentTime - expected;
var absDiff = Math.abs(diff);
if (absDiff > hardSeekThreshold()) {
clearRateAdjust(vid);
vid._lp_correcting = false;
if (Date.now() - lastHardSeekAt > HARD_SEEK_COOLDOWN_MS) {
lastHardSeekAt = Date.now();
console.log(
"[WatchTogether]",
"hard seek",
vid.currentTime.toFixed(2),
"->",
expected.toFixed(2),
);
setExpectedSeek(expected);
vid.currentTime = expected;
} else {
console.log(
"[WatchTogether]",
"hard seek skipped (cooldown), drift",
absDiff.toFixed(2),
);
}
} else if (
canAdjustRate() &&
(vid._lp_correcting
? absDiff > SYNC_TOLERANCE_S
: absDiff > SYNC_CORRECT_ON_S)
) {
vid._lp_correcting = true;
var raw = diff * SYNC_RATE_GAIN;
var offset = Math.max(
-SYNC_MAX_RATE_OFFSET,
Math.min(SYNC_MAX_RATE_OFFSET, raw),
);
var newRate = roomSpeed - offset;
if (Math.abs(vid.playbackRate - newRate) > 0.005)
vid.playbackRate = newRate;
if (vid._lp_rate_timeout) clearTimeout(vid._lp_rate_timeout);
vid._lp_rate_timeout = setTimeout(function () {
vid._lp_rate_timeout = null;
vid._lp_correcting = false;
if (vid.playbackRate !== roomSpeed) vid.playbackRate = roomSpeed;
}, SYNC_RATE_RESET_MS);
} else {
vid._lp_correcting = false;
clearRateAdjust(vid);
}
}
if (state === "paused" && !vid.paused) {
expectPause();
clearRateAdjust(vid);
pauseVideo(vid);
return;
}
if (state === "playing" && vid.paused && !busy) {
expectPlay();
playVideo(vid, function () {
expectedState.play = false;
});
}
}
setInterval(function () {
if (inRoom || joining) {
if (playerIsOpen()) roomAliveAt = Date.now();
else if (roomAliveAt && Date.now() - roomAliveAt > ROOM_ORPHAN_MS) {
console.log(
"[WatchTogether]",
lplog("room without player - auto leave"),
);
leaveRoom(true);
}
}
if (!inRoom) {
$(".watchtogether-room-badge").remove();
return;
}
// before the video check - a deferred act must be able to expire while there is no player,
// otherwise it silences the host heartbeat indefinitely
applyPendingAct();
var vid = getVideo();
if (!vid) return;
updateRoomBadge();
holdTick();
// our own seek that never produced a 'seeked' event (target equals current position) would
// otherwise keep the guard armed for the full SEEK_GUARD_MS and swallow the next user seek
if (
expectedState.seek >= 0 &&
!vid.seeking &&
Math.abs((vid.currentTime || 0) - expectedState.seek) <= 0.5
)
clearExpectedSeek();
if (!seekIsOurs()) lastKnownPosition = vid.currentTime || 0;
if (vid._lp_hooked) return;
vid._lp_hooked = true;
// A joining client starts at the room's speed before the first sync lands.
if (canAdjustRate() && Math.abs((vid.playbackRate || 1) - roomSpeed) > 0.01)
vid.playbackRate = roomSpeed;
var enforceInitial = function () {
if (!initialSyncLock || !targetInitialState) return;
var expected = expectedPositionNow(
targetInitialState.state,
targetInitialState.position,
targetInitialState.atServerTime,
);
if (Math.abs(vid.currentTime - expected) > 1) {
setExpectedSeek(expected);
vid.currentTime = expected;
}
if (targetInitialState.state === "paused") {
expectPause();
pauseVideo(vid);
} else {
expectPlay();
playVideo(vid);
}
if (!vid._lp_enforce_timeout) {
vid._lp_enforce_timeout = setTimeout(function () {
vid._lp_enforce_timeout = null;
initialSyncLock = false;
targetInitialState = null;
expectedState.play = false;
expectedState.pause = false;
}, 3000);
}
};
if (vid.readyState >= 1) enforceInitial();
else vid.addEventListener("loadedmetadata", enforceInitial);
vid.addEventListener("canplay", enforceInitial);
vid.addEventListener("waiting", function () {
vid._lp_buffering = true;
console.log(
"[WatchTogether]",
"buffering start at",
(vid.currentTime || 0).toFixed(2),
);
});
vid.addEventListener("canplay", function () {
if (vid._lp_buffering)
console.log(
"[WatchTogether]",
"buffering end at",
(vid.currentTime || 0).toFixed(2),
);
vid._lp_buffering = false;
});
vid.addEventListener("playing", function () {
if (vid._lp_buffering)
console.log(
"[WatchTogether]",
"buffering end at",
(vid.currentTime || 0).toFixed(2),
);
vid._lp_buffering = false;
});
vid.addEventListener("error", function () {
console.log(
"[WatchTogether]",
"video error",
vid.error ? "code " + vid.error.code : "",
);
});
vid.addEventListener("stalled", function () {
console.log("[WatchTogether]", "video stalled");
});
vid.addEventListener("ratechange", function () {
if (
!inRoom ||
initialSyncLock ||
holdActive ||
isSystemSyncing ||
vid._lp_correcting
)
return;
var newRate = vid.playbackRate || 1.0;
if (!(newRate >= 0.25 && newRate <= 4.0)) return;
if (Math.abs(newRate - roomSpeed) < 0.01) return;
roomSpeed = newRate;
lastUserActionTime = Date.now();
// Only the host drives the room speed; guests keep it for themselves.
if (iAmHost()) {
roomSend({
t: "act",
s: vid.paused ? "paused" : "playing",
p: vid.currentTime || 0,
n: getDisplayName(),
sp: roomSpeed,
});
}
});
vid.addEventListener("play", function () {
if (initialSyncLock) {
if (targetInitialState && targetInitialState.state === "paused") {
pauseVideo(vid);
}
return;
}
var wasExpected = expectedState.play;
expectedState.play = false;
if (wasExpected) return;
if (holdActive) {
console.log(
"[WatchTogether]",
"user started playback during hold - releasing it",
);
lastUserActionTime = Date.now();
if (iAmHost()) {
finishHold();
} else {
holdReadySent = true;
roomSend({ t: "ready" });
releaseHold(holdPosition);
}
return;
}
if (isRewinding()) {
lastUserActionTime = Date.now();
return;
}
if (vid._lp_buffer_paused) {
vid._lp_buffer_paused = false;
return;
}
lastUserActionTime = Date.now();
sendSync("playing", "resumed");
});
vid.addEventListener("pause", function () {
clearRateAdjust(vid);
if (initialSyncLock) return;
var wasExpected = expectedState.pause;
expectedState.pause = false;
if (wasExpected) return;
if (isRewinding()) {
lastUserActionTime = Date.now();
return;
}
if (vid._lp_buffering || vid.readyState < 3) {
vid._lp_buffer_paused = true;
return;
}
if (vid.ended) return;
lastUserActionTime = Date.now();
sendSync("paused", "paused");
});
vid.addEventListener("seeked", function () {
if (!isSystemSyncing) clearRateAdjust(vid);
if (initialSyncLock) {
if (targetInitialState) {
var expected = expectedPositionNow(
targetInitialState.state,
targetInitialState.position,
targetInitialState.atServerTime,
);
if (Math.abs(vid.currentTime - expected) > 1) {
setExpectedSeek(expected);
vid.currentTime = expected;
}
}
return;
}
if (isSystemSyncing) return;
if (seekIsOurs()) {
console.log(
"[WatchTogether]",
"own seek settled at",
(vid.currentTime || 0).toFixed(2),
);
clearExpectedSeek();
lastKnownPosition = vid.currentTime || 0;
return;
}
if (expectedState.seek !== -1) clearExpectedSeek();
broadcastUserSeek(vid);
});
// 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", function () {
// deliberately NOT gated on isSystemSyncing: that flag stays up 500ms after every
// applied sync, i.e. a quarter of the time at a 2s heartbeat, and it swallowed any
// user seek started in that window. seekIsOurs() is armed only when we actually
// moved the position ourselves, which is the real question here
if (initialSyncLock || seekIsOurs()) return;
broadcastUserSeek(vid);
});
}, 1000);
setInterval(function () {
if (!inRoom || initialSyncLock || isSystemSyncing || holdActive) return;
if (!iAmHost()) return;
if (expectedState.seek !== -1) return;
if (pendingAct) return;
var vid = getVideo();
if (!vid) return;
if (vid.ended) return;
sendSync(vid.paused ? "paused" : "playing", null);
}, SYNC_HEARTBEAT_MS);
var debugRefreshTimer = null;
function buildDebugHtml() {
var wsState =
room && room.ws
? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][room.ws.readyState] ||
"UNKNOWN"
: "NO_WS";
var lastRtt = pingSamples.length
? pingSamples[pingSamples.length - 1].rtt
: "-";
var bestRtt = "-";
for (var i = 0; i < pingSamples.length; i++) {
if (bestRtt === "-" || pingSamples[i].rtt < bestRtt)
bestRtt = pingSamples[i].rtt;
}
var pendingCount = 0;
for (var k in echoPending) {
if (echoPending.hasOwnProperty(k)) pendingCount++;
}
var pos = "-",
bufRdy = "-";
var vid = getVideo();
if (vid) {
pos = (vid.currentTime || 0).toFixed(2);
bufRdy = vid.readyState + (vid._lp_buffering ? "*" : "");
}
return [
"Relay: " + safe(wsState) + "
",
"Room: " +
safe(currentRoomId || "-") +
(currentRoomPassword ? " 🔒" : "") +
"
",
"Role: " + (iAmHost() ? "host" : "guest") + "
",
"Members: " + memberCount() + "
",
"Lobby: " +
(lobbyHost && lobbyHost.alive() ? "published" : "off") +
"
",
"Clock offset: " + Math.round(serverTimeOffset) + " ms
",
"RTT last/best: " + lastRtt + " / " + bestRtt + " ms
",
"Echo pending: " + pendingCount + "
",
"initialSyncLock: " + initialSyncLock + "
",
"Video: " + pos + " s (ready " + bufRdy + ")
",
].join("");
}
function updateRoomBadge() {
if (!inRoom || !currentRoomId) return;
var nameContainer = $(".player-info__name, .player-panel__name");
if (nameContainer.length && !$(".watchtogether-room-badge").length) {
var badge = $(
'",
);
badge.on("mouseenter", function () {
var $p = $(this).find(".watchtogether-debug-panel");
$p.html(buildDebugHtml()).css("display", "block");
if (debugRefreshTimer) clearInterval(debugRefreshTimer);
debugRefreshTimer = setInterval(function () {
if ($p.is(":visible")) $p.html(buildDebugHtml());
else {
clearInterval(debugRefreshTimer);
debugRefreshTimer = null;
}
}, 500);
});
badge.on("mouseleave", function () {
$(this).find(".watchtogether-debug-panel").css("display", "none");
if (debugRefreshTimer) {
clearInterval(debugRefreshTimer);
debugRefreshTimer = null;
}
});
nameContainer.after(badge);
}
var $badge = $(".watchtogether-room-badge");
if (!$badge.length) return;
var ok = room && room.alive();
var dotColor = ok ? "#00e676" : "#ff5252";
var dot =
'';
$badge
.find(".watchtogether-room-badge-text")
.html(
dot +
T.badge_room +
': ' +
safe(currentRoomName || currentRoomId) +
" " +
'[' +
safe(currentRoomId) +
"] | " +
T.badge_viewers +
": " +
memberCount() +
"",
);
}
var passwordParamItem = null;
function updatePasswordVisibility() {
if (passwordParamItem)
passwordParamItem.toggleClass("hide", !isUsePassword());
}
var displayNameParamItem = null;
function syncDisplayNameField() {
var name = getDisplayName();
if (displayNameParamItem)
displayNameParamItem.find(".settings-param__value").text(name);
}
function registerSettings() {
if (!Lampa.SettingsApi) return;
Lampa.SettingsApi.addComponent({
component: "lparty",
name: T.settings_title,
icon: '',
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: { name: "watchtogether_meta", type: "static" },
field: {
name: META.name + " v" + META.version,
description: "Authors: " + META.author,
},
onRender: function (item) {
item.on("hover:enter", function () {});
},
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: { name: "watchtogether_open_rooms", type: "button" },
field: {
name: T.settings_open_rooms,
description: T.settings_open_rooms_descr,
},
onChange: function () {
openRoomBrowser();
},
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_show_head",
type: "trigger",
default: true,
},
field: { name: T.param_show_head, description: T.param_show_head_descr },
onChange: function () {
syncHeadIcon();
},
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_display_name",
type: "input",
values: "",
default: "",
placeholder: getDisplayName(),
},
field: { name: T.param_name, description: T.param_name_descr },
onRender: function (item) {
displayNameParamItem = item;
syncDisplayNameField();
},
onChange: function () {
syncDisplayNameField();
},
});
if (Lampa.Platform.is("android")) {
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_player",
type: "select",
values: {
inner: Lampa.Lang.translate("settings_param_player_inner"),
android: "Android",
},
default: "inner",
},
field: {
name: Lampa.Lang.translate("settings_player_type"),
description: Lampa.Lang.translate("settings_player_type_descr"),
},
onChange: function (value) {
Lampa.Storage.set("watchtogether_player", value);
playerLaunch = value;
},
});
}
Lampa.SettingsApi.addParam({
component: "lparty",
param: { name: "watchtogether_publish", type: "trigger", default: true },
field: { name: T.param_publish, description: T.param_publish_descr },
onChange: function () {
if (inRoom && iAmHost()) {
if (isPublish()) startLobbyAgent();
else stopLobbyAgent();
}
},
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_use_password",
type: "trigger",
default: false,
},
field: { name: T.param_use_pwd, description: T.param_use_pwd_descr },
onChange: function () {
updatePasswordVisibility();
},
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_default_password",
type: "input",
values: "",
default: "",
placeholder: "",
},
field: { name: T.param_pwd, description: T.param_pwd_descr },
onRender: function (item) {
passwordParamItem = item;
updatePasswordVisibility();
},
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_relay",
type: "input",
values: "",
default: DEFAULT_RELAY,
placeholder: DEFAULT_RELAY,
},
field: { name: T.param_relay, description: T.param_relay_descr },
});
Lampa.SettingsApi.addParam({
component: "lparty",
param: {
name: "watchtogether_invite",
type: "input",
values: "",
default: DEFAULT_INVITE,
placeholder: DEFAULT_INVITE,
},
field: { name: T.param_invite, description: T.param_invite_descr },
});
}
var headButton = null;
function addHeadIcon() {
if (window.WatchTogether_head_added) return;
if (!Lampa.Head || typeof Lampa.Head.addIcon !== "function") return;
window.WatchTogether_head_added = true;
var svg =
'';
headButton = Lampa.Head.addIcon(svg, openRoomBrowser);
if (headButton && headButton.attr) headButton.attr("title", T.menu_title);
}
function syncHeadIcon() {
var show = isShowHead();
if (show) addHeadIcon();
if (headButton && headButton.toggleClass)
headButton.toggleClass("hide", !show);
}
if (window.appready) syncHeadIcon();
else
Lampa.Listener.follow("app", function (e) {
if (e.type === "ready") syncHeadIcon();
});
Lampa.Listener.follow("full", function (e) {
if (e.type !== "complite") return;
var cardData =
(e.data && e.data.movie) ||
(e.object && e.object.activity && e.object.activity.object
? e.object.activity.object.item
: null) ||
(e.object && e.object.item);
if (!cardData) return;
lastViewedCard = cardData;
});
function onPlayerStart(e) {
if (!e || !e.url) return;
lastStreamUrl = e.url;
lastStreamTitle =
(e.movie && (e.movie.title || e.movie.name)) || e.title || "";
roomAliveAt = Date.now();
var switching = isEpisodeSwitching();
clearEpisodeSwitch();
if (switching && inRoom) {
if (iAmHost()) {
currentRoomMeta.url = e.url;
currentRoomMeta.title = lastStreamTitle;
roomSend({ t: "url", url: e.url, ti: lastStreamTitle });
}
return;
}
if (pendingShareCard) {
var card = pendingShareCard;
var url = e.url;
var fresh = Date.now() - pendingShareAt < PENDING_SHARE_MS;
pendingShareCard = null;
pendingShareAt = 0;
if (fresh) {
uiPrevController = "player";
setTimeout(function () {
console.log("[WatchTogether]", "auto create room from card menu");
autoCreateRoomFromPending(card, url);
}, SHARE_START_DELAY_MS);
}
}
}
if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.listener) {
Lampa.PlayerVideo.listener.follow("start", onPlayerStart);
Lampa.PlayerVideo.listener.follow("rewind", onUserRewind);
}
if (typeof Lampa.Player !== "undefined" && Lampa.Player.listener) {
Lampa.Player.listener.follow("start", onPlayerStart);
Lampa.Player.listener.follow("destroy", function () {
clearPendingSync();
var vid = getVideo();
if (vid) {
vid._lp_hooked = false;
clearRateAdjust(vid);
}
if (!inRoom && !joining) return;
if (isEpisodeSwitching()) return;
leaveRoom(true);
});
}
if (
typeof Lampa.PlayerPlaylist !== "undefined" &&
Lampa.PlayerPlaylist.listener &&
Lampa.PlayerPlaylist.listener.follow
) {
Lampa.PlayerPlaylist.listener.follow("select", function () {
if (inRoom && iAmHost()) markEpisodeSwitch();
});
}
window.addEventListener("beforeunload", function () {
if (inRoom) leaveRoom(true);
});
var ICON_COPY =
'';
var ICON_SHARE =
'';
function canShare() {
try {
return (
typeof navigator !== "undefined" &&
typeof navigator.share === "function"
);
} catch (err) {
return false;
}
}
function legacyCopy(text) {
try {
var area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.top = "-1000px";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
if (area.setSelectionRange) area.setSelectionRange(0, text.length);
var ok = document.execCommand("copy");
document.body.removeChild(area);
return !!ok;
} catch (err) {
return false;
}
}
function copyToClipboard(text, done) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () {
done(true);
},
function () {
done(legacyCopy(text));
},
);
return;
}
} catch (err) {}
done(legacyCopy(text));
}
function ensureQrStyle() {
if (document.getElementById("watchtogether-qr-style")) return;
var style = document.createElement("style");
style.id = "watchtogether-qr-style";
style.textContent =
".watchtogether-qr-actions{display:flex;justify-content:center;gap:0.8em;margin-top:0.9em;}" +
".watchtogether-qr-btn{display:inline-flex;align-items:center;justify-content:center;width:2.8em;height:2.8em;" +
"border-radius:50%;background:rgba(255,255,255,0.14);color:#fff;cursor:pointer;" +
"transition:background 0.2s,color 0.2s,transform 0.2s;padding:0.7em;}" +
".watchtogether-qr-btn.focus,.watchtogether-qr-btn:hover{background:#fff;color:#000;transform:scale(1.08);}";
document.head.appendChild(style);
}
function showRoomQr() {
var link = inviteLink();
var qrUrl =
"https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=" +
encodeURIComponent(link);
var isMobile =
Lampa.Platform &&
Lampa.Platform.screen &&
Lampa.Platform.screen("mobile");
uiPrevController = isMobile ? "player" : "player_panel";
ensureQrStyle();
var actions =
'' +
'
' +
ICON_COPY +
"
" +
(canShare()
? '
' +
ICON_SHARE +
"
"
: "") +
"
";
var body = $(
'' +
'
' +
T.qr_title +
" - " +
safe(currentRoomName || currentRoomId || "") +
"
" +
'

' +
'
' +
T.qr_hint +
"
" +
'
' +
safe(link) +
"
" +
actions +
"
",
);
Lampa.Modal.open({
title: "",
size: "medium",
html: body,
select: body.find('[data-watchtogether-action="copy"]')[0],
onSelect: function (item) {
var target = $(item);
var action =
target.attr("data-watchtogether-action") ||
target
.closest("[data-watchtogether-action]")
.attr("data-watchtogether-action");
if (action === "copy") {
copyToClipboard(link, function (ok) {
lplog("invite link copy", ok ? "ok" : "failed");
Lampa.Noty.show(ok ? T.qr_copied : T.qr_copy_fail);
});
return;
}
if (action === "share") {
try {
var shared = navigator.share({
title: T.qr_title,
text: currentRoomName || currentRoomId || T.qr_title,
url: link,
});
if (shared && shared["catch"])
shared["catch"](function (err) {
if (err && err.name === "AbortError") return;
lplog("share failed", err && err.name);
});
} catch (err) {
lplog("share threw", err && err.message);
}
}
},
onBack: function () {
Lampa.Modal.close();
restoreController();
},
});
}
function createRoomFromPlayer() {
dropStaleRoom();
if (inRoom) {
Lampa.Noty.show(
T.already_in_room(currentRoomName || currentRoomId || ""),
);
return;
}
if (!lastStreamUrl) {
Lampa.Noty.show(T.need_url);
return;
}
var isMobile =
Lampa.Platform &&
Lampa.Platform.screen &&
Lampa.Platform.screen("mobile");
uiPrevController = isMobile ? "player" : "player_panel";
restoreController();
autoCreateRoomFromPending(lastViewedCard || {}, lastStreamUrl);
}
if (
typeof Lampa.Select !== "undefined" &&
Lampa.Select.listener &&
Lampa.Select.listener.follow
) {
Lampa.Select.listener.follow("preshow", function (e) {
if (!e || !e.active || !Array.isArray(e.active.items)) return;
var items = e.active.items;
var hasPlayer = false,
hasFileMenu = false,
isPlayerSettings = false,
alreadyInjected = false;
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (!it) continue;
if (it.player) hasPlayer = true;
if (it.mark || it.timeclear || it.clearmark) hasFileMenu = true;
if (
it.method === "size" ||
it.method === "speed" ||
it.method === "subs" ||
it.method === "share" ||
it.method === "segments"
)
isPlayerSettings = true;
if (it.watchtogether_inject || it.watchtogether_inject_player)
alreadyInjected = true;
}
if (alreadyInjected) return;
if (hasPlayer && hasFileMenu) {
items.push({
title: T.full_card_btn,
player: "inner",
watchtogether_inject: true,
});
var originalOnSelect = e.active.onSelect;
e.active.onSelect = function (a) {
if (a && a.watchtogether_inject) {
pendingShareCard = lastViewedCard || {};
pendingShareAt = Date.now();
Lampa.Noty.show(T.pending_share);
}
if (originalOnSelect) originalOnSelect(a);
};
return;
}
if (isPlayerSettings) {
items.push({
title: T.full_card_btn,
subtitle: inRoom ? T.player_qr_descr : T.player_create_descr,
method: "watchtogether_create",
watchtogether_inject_player: true,
});
var originalOnSelectP = e.active.onSelect;
e.active.onSelect = function (a) {
if (a && a.watchtogether_inject_player) {
if (inRoom) showRoomQr();
else createRoomFromPlayer();
return;
}
if (originalOnSelectP) originalOnSelectP(a);
};
}
});
}
window.WatchTogether = {
version: META.version,
log: function () {
return logRing.join("\n");
},
dump: function () {
console.log(logRing.join("\n"));
return logRing.length;
},
state: function () {
var vid = getVideo();
return {
room: currentRoomId,
name: currentRoomName,
host: iAmHost(),
owner: currentRoomOwner,
inRoom: inRoom,
joining: joining,
members: memberCount(),
relay: room && room.ws ? room.ws.readyState : null,
lobby: !!(lobbyHost && lobbyHost.alive()),
hold: holdActive,
holdPosition: holdPosition,
holdWaiting: Object.keys(holdWaiting),
initialSyncLock: initialSyncLock,
clockOffset: Math.round(serverTimeOffset),
echoPending: Object.keys(echoPending).length,
video: vid
? {
position: vid.currentTime,
paused: vid.paused,
rate: vid.playbackRate,
readyState: vid.readyState,
buffering: !!vid._lp_buffering,
}
: null,
};
},
leave: function () {
leaveRoom(true);
return "left";
},
};
registerSettings();
console.log(
"[WatchTogether]",
lplog(
"started, relay: " +
getRelay() +
", lang: " +
_rawLang +
", name: " +
getDisplayName(),
),
);
})();