diff --git a/AGENT.md b/AGENT.md index 5617c46..db7c633 100644 --- a/AGENT.md +++ b/AGENT.md @@ -28,7 +28,7 @@ docker cp lampac:/lampac/module/<Имя> "mods/<Имя>" См. также официальные шаги: [Включить модуль](https://docs.lampac.dev/modules/overview), [Пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules). -Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`, `WatchTogether`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`. +Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`. `mods/WatchTogether` тоже удалён (вернуть — той же механикой, он в списке «ещё можно включить» в README). ### 2. Механика загрузки модуля (4 независимых фильтра) @@ -136,11 +136,3 @@ docker exec lampac sh -c '...' # осмотр состояния в конте - `owner_telegram_ids: [143672]` → владелец создаётся как `admin` при старте. `auto_provision_users: true` + `auto_provision_activate_immediately: false` → новые привязки ждут одобрения (`/users` в боте, кнопки Принять/Отклонить). - Данные: `database/tgauth/{users.json,admins.json,user_langs.json}` (том `lampac_database`, владелец `1000:1000`). Живые данные — не «чинить» руками. - Проверка: `GET /tg/auth/status?uid=x` → 200; `GET /tg/auth/admin/users` → 401 без заголовка, 200 с `X-TelegramAuth-Mutations-Secret`. - -### 13. WatchTogether (комнаты синхронного просмотра) - -- Копия в `mods/WatchTogether/` (`enable: true`), секция `WatchTogether` в `init.conf` (`enable`, `allow_anonymous: true`, `relay_max_channels: 500`, `relay_max_clients_per_channel: 32`, `gc_empty_timeout_minutes: 30`, `gc_max_lifetime_hours: 12`). -- Клиентский плагин зарегистрирован в `LampaWeb.customPlugins`: `{localhost}/watchtogether.js`. -- Роуты: `GET /watchtogether.js` → 200, `GET /watch_together/` → 200, `WS /wt/c/{channel}` → 400 на обычном GET (нужен WebSocket upgrade — это штатно, handshake даёт 101). -- Lampac пассивно хранит состояние комнаты и становится **хостом-«тенью»** (`ShadowHost.cs`), если создатель отключился; роль возвращается ему при переподключении. Лаг ≤ 2 с. -- При появлении reverse proxy: на `/wt/c/` обязателен WebSocket upgrade, отключённый buffering и таймаут больше длительности просмотра. diff --git a/README.md b/README.md index 46c41ab..b3d2791 100644 --- a/README.md +++ b/README.md @@ -106,15 +106,14 @@ ls -ln /var/lib/docker/volumes/lampac_database/_data/Sisi.sql # uid/gid 1000 | `Tg-notify.bot` | `/tg-notify.js`, `/api/tg/*` — уведомления о новых сериях в Telegram | секция `"TelegramBot"` | | `TelegramAuth` | `/tg/auth/*` — привязка UID устройства к учётке Telegram, HTTP API статуса | секция `"TelegramAuth"` (`enable: false` → без accsdb-гейта) | | `TelegramAuthBot` | `@GatusMonBot` — long-polling бот привязки и админ-команды | секция `"TelegramAuthBot"` | -| `WatchTogether` | `/watchtogether.js`, `/watch_together/`, WS `/wt/c/` — комнаты синхронного просмотра | секция `"WatchTogether"` | -Клиентские плагины `tg-notify.js` и `watchtogether.js` зарегистрированы в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером. +Клиентский плагин `tg-notify.js` зарегистрирован в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером. > **Модуль `Music` удалён из `mods/`** (коммит `remove Music`): в образе у него `manifest.enable: false`, поэтому `/music.js` → 404, секция `"Music"` из `init.conf` убрана, плагин из `customPlugins` убран. Вернуть музыку — той же механикой: скопировать `lampac:/lampac/module/Music` в `mods/Music`, поставить `enable: true`, вернуть секцию и плагин. ### Ещё можно включить тем же способом -В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `Music`. +В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `Music`, `WatchTogether`. ## Конфигурация @@ -281,26 +280,6 @@ docker exec lampac rm -rf /lampac/cache/static/59 # bucket /extensions; либ **Порядок включения гейта (когда решим):** поставить `TelegramAuth.enable: true` → в `LampaWeb` секцию `telegramAuthGate` (`enabled: true`, `botUsername: "GatusMonBot"`) → подключить клиентский оверлей (`deny.js` / `telegram_auth_gate.js`) → **сначала связать свои устройства через бота**, иначе текущие UID получат отказ. -## WatchTogether: комнаты синхронного просмотра - -Модуль из группы «клиент и контент»: синхронизирует `play/pause/seek` между участниками комнаты. Подробный разбор — [официальная документация](https://docs.lampac.dev/modules/watch-together) и `mods/WatchTogether/README.md`. - -**Как устроено.** Lampac не отдаёт медиа — он пассивный брокер: хранит состояние комнаты и рассылает команды. Сами видео каждый клиент тянет своим источником, поэтому подходит любой контент и совместим с экосистемой LParty (`lparty.js`, `lparty-web`, JustPlus Player). - -| Клиент | Адрес | -|---|---| -| Плагин Lampa | `{localhost}/watchtogether.js` (в `LampaWeb.customPlugins`) | -| Веб-гость без установки Lampa | `http://<адрес>/watch_together/?room=` | -| LParty relay (WebSocket) | `ws://<адрес>/wt/c/` | - -Фирменная фича — **«тень» (`ShadowHost.cs`)**: сервер слушает канал и копирует url, позицию, скорость и список участников, виден в зрителях как `Lampac`, но ничего не рассылает. Как только хост рвётся, он мгновенно шлёт кадр `host` и продолжает timeline (лаг ≤ 2 с), попытки перехвата отклоняет повторным `host=сервер`; создатель вернулся — отдаёт роль обратно и снова уходит в тень. Пустые комнаты GC через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`. - -**Конфиг** (секция `WatchTogether` в `init.conf`): `enable: true`, `allow_anonymous: true` (плагин и веб-гость без аккаунта), `relay_max_channels: 500`, `relay_max_clients_per_channel: 32`, `gc_empty_timeout_minutes: 30`, `gc_max_lifetime_hours: 12`. - -**Требование к reverse proxy:** на `/wt/c/` нужен WebSocket upgrade, отключённый response buffering и таймаут **больше длительности просмотра**. Сейчас прокси нет — relay работает напрямую, ничего настраивать не нужно. - -**Проверка:** `GET /watchtogether.js` → 200, `GET /watch_together/` → 200, `WS /wt/c/` → 400 на обычном GET (штатно: нужен upgrade; handshake даёт 101). - ## Обновление Lampac ```bash diff --git a/config/init.conf b/config/init.conf index 767ba7b..e270694 100644 --- a/config/init.conf +++ b/config/init.conf @@ -37,11 +37,6 @@ "url": "{localhost}/tg-notify.js", "status": 1, "name": "Tg-notify" - }, - { - "url": "{localhost}/watchtogether.js", - "status": 1, - "name": "WatchTogether" } ] }, @@ -106,13 +101,5 @@ 143672 ], "notify_admins_on_pending_provision": true - }, - "WatchTogether": { - "enable": true, - "allow_anonymous": true, - "relay_max_channels": 500, - "relay_max_clients_per_channel": 32, - "gc_empty_timeout_minutes": 30, - "gc_max_lifetime_hours": 12 } } \ No newline at end of file diff --git a/mods/WatchTogether/ModInit.cs b/mods/WatchTogether/ModInit.cs deleted file mode 100644 index 00fc54e..0000000 --- a/mods/WatchTogether/ModInit.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Shared; -using Shared.Models.AppConf; -using Shared.Models.Events; -using Shared.Models.Module; -using Shared.Models.Module.Interfaces; -using System.Collections.Generic; -using Shared.Services; - -namespace WatchTogether -{ - public class WatchTogetherConf - { - public bool enable { get; set; } = true; - public bool allow_anonymous { get; set; } = true; - public int relay_max_channels { get; set; } = 500; - public int relay_max_clients_per_channel { get; set; } = 32; - public int gc_empty_timeout_minutes { get; set; } = 30; - public int gc_max_lifetime_hours { get; set; } = 12; - - public List limit_map { get; set; } = new() - { - new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }), - new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 }) - }; - } - - /// - /// WatchTogether: a lampac module that acts as both the lparty relay and a - /// shadow host keeping rooms alive when their creator disconnects. - /// - public class ModInit : IModuleLoaded, IModuleConfigure - { - public static string modpath; - public static WatchTogetherConf conf; - - public void Configure(ConfigureModel app) - { - app.services.AddTransient(); - } - - public void Loaded(InitspaceModel baseconf) - { - modpath = baseconf.path; - UpdateConf(); - EventListener.UpdateInitFile += UpdateConf; - ShadowHost.Start(); - } - - public void Dispose() - { - EventListener.UpdateInitFile -= UpdateConf; - ShadowHost.Stop(); - } - - private static bool isWafInjected = false; - - private void UpdateConf() - { - conf = ModuleInvoke.Init("WatchTogether", new WatchTogetherConf()); - - if (!isWafInjected && conf.limit_map != null) - { - foreach (var m in conf.limit_map) - { - CoreInit.conf.WAF.limit_map.Insert(0, m); - } - isWafInjected = true; - } - } - } -} diff --git a/mods/WatchTogether/README.md b/mods/WatchTogether/README.md deleted file mode 100644 index 49e74e9..0000000 --- a/mods/WatchTogether/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# WatchTogether - -Модуль синхронизированного просмотра для Lampac. Работает одновременно как **хранитель состояния** и как **тихий lparty-клиент**: комната живёт в канале реле, а сервер незаметно подхватывает роль хоста, если создатель отключился. - -Клиентский плагин основан на [lparty.js](https://nrsua.github.io/lampa-nrs/lparty.js) (nrsua, levende) и полностью wire-совместим с его экосистемой — JustPlus Player, lparty-web и стоковый lparty.js подключаются к тем же комнатам без правок. - -## Как это работает - -1. **Создание комнаты.** Человек создаёт комнату в любом lparty-клиенте — его клиент открывает канал `wss:///wt/c/lparty-r-` и является хостом. -2. **Тень.** Сервер видит адрес подключения и начинает пассивно слушать кадры канала: копирует url, метаданные, позицию, скорость и список участников. В списке зрителей он виден как `Lampac`, но ничего не рассылает. -3. **Подмена.** Как только соединение хоста рвётся, сервер мгновенно шлёт кадр `host` и продолжает timeline с последнего хартбита (лаг ≤ 2с, пауза — точно). Попытки других клиентов перехватить хостство отклоняются повторным `host=сервер`. -4. **Возврат.** Создатель переподключился — сервер отдаёт ему роль и снова уходит в тень. -5. **GC.** Пустые комнаты удаляются через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`. - -## HTTP (обзор) - -| Маршрут | Описание | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `GET /watchtogether.js` | Плагин для клиента Lampa (адаптированный lparty.js). | -| `GET /watch_together/` | Веб-плеер для анонимных гостей (адаптированный lparty-web). Поддерживает `?room=` и `?relay=`. | -| `WS /wt/c/{channel}` | LParty-совместимый релей: каналы, кадры join/leave/message, эхо с серверным временем. | - -## Скорость воспроизведения - -Наши клиенты передают скорость поле `sp` в кадрах `state`/`sync`/`act`/`hold`/`go` и применяют её к `playbackRate`. Стоковые клиенты поле молча игнорируют — обратная совместимость не нарушается. Если автор JustPlus добавит поддержку `sp`, синхронизация заработает у него автоматически. - -## Конфигурация - -Секция в `init.conf`: **`WatchTogether`**. - -| Поле | Смысл | -| ------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `enable` | Включение модуля (по умолчанию **true**). | -| `allow_anonymous` | Доступ к плагину `/watchtogether.js` и веб-плееру lparty-web без авторизации (по умолчанию **true**). | -| `relay_max_channels` | Лимит одновременных каналов реле (по умолчанию **500**). | -| `relay_max_clients_per_channel` | Лимит участников одного канала (по умолчанию **32**). | -| `gc_empty_timeout_minutes` | Удаление пустой комнаты через N минут (по умолчанию **30**). | -| `gc_max_lifetime_hours` | Удаление любой комнаты через N часов (по умолчанию **12**). | - -## Подключение клиентов - -- Плагин Lampa: `/watchtogether.js` — реле уже настроено на собственный сервер lampac. -- JustPlus Player / lparty.js: в настройках клиента указать адрес реле `ws(s)://<адрес-lampac>/wt/c/`. -- Веб-гость: открыть `https:///watch_together/?room=` — ссылку формирует плагин. diff --git a/mods/WatchTogether/RelayServer.cs b/mods/WatchTogether/RelayServer.cs deleted file mode 100644 index a1c7e10..0000000 --- a/mods/WatchTogether/RelayServer.cs +++ /dev/null @@ -1,456 +0,0 @@ -using Core.Middlewares; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Net.WebSockets; -using System.Text.Json; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace WatchTogether -{ - /// - /// Maps the lparty-compatible relay endpoint (/wt/c/{channel}) at app startup. - /// Modules cannot map endpoints themselves, so this hooks the pipeline - /// through an IStartupFilter registered in ModInit.Configure. The branch - /// runs before the global pipeline (like /nws) so auth middlewares cannot - /// gate raw websocket clients; RequestInfo is applied in-branch because - /// the WAF reads its output. - /// - public class RelayStartupFilter : IStartupFilter - { - public Action Configure(Action next) - { - return app => - { - app.Map("/wt/c", relayApp => - { - relayApp.UseRequestInfo(); - relayApp.UseWAF(); - relayApp.UseWebSockets(); - relayApp.Run(RelayServer.HandleWebSocketAsync); - }); - - next(app); - }; - } - } - - public class RelayMember - { - public string Uid { get; set; } - public string Alias { get; set; } - public bool Echo { get; set; } - public WebSocket Socket { get; set; } - public SemaphoreSlim SendLock { get; } = new(1, 1); - - /// LParty peer id (the u field of the member's messages). - public string Pid { get; set; } - public string Name { get; set; } - - public long RateSecond; - public int RateCount; - } - - public class RelayChannel - { - public string Name { get; set; } - public readonly object Lock = new(); - public readonly Dictionary Members = new(); - } - - /// - /// Wire-compatible emulation of the itty.ws channel relay used by lparty - /// clients: stateless broadcast groups with join/leave announcements, a - /// member list and a server date on every frame (the date is what lparty - /// uses for clock sync via message echo). - /// - public static class RelayServer - { - static long uidSeq = 0; - static readonly ConcurrentDictionary Channels = new(); - - public static RelayChannel TryGetChannel(string name) => - Channels.TryGetValue(name, out var channel) ? channel : null; - - /// Member uids excluding the server's virtual member. - public static List HumanUids(RelayChannel channel) - { - lock (channel.Lock) - return channel.Members.Keys - .Where(u => !string.Equals(u, ShadowHost.ServerUid, StringComparison.Ordinal)) - .ToList(); - } - - const int MaxMessageBytes = 16 * 1024; - const int MaxMessagesPerSecond = 50; - - public static async Task HandleWebSocketAsync(HttpContext context) - { - if (!ModInit.conf.enable) - { - context.Response.StatusCode = 404; - return; - } - - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } - - // Inside Map("/wt/c") the request path is already without the prefix: - // "/{channel}", so only the leading slash is stripped here. - string path = context.Request.Path.Value ?? "/"; - string channelName = path.Length > 1 ? Uri.UnescapeDataString(path[1..]) : string.Empty; - if (string.IsNullOrWhiteSpace(channelName) || channelName.Length > 128) - { - context.Response.StatusCode = 404; - return; - } - - bool echo = string.Equals(context.Request.Query["echo"].ToString(), "true", StringComparison.OrdinalIgnoreCase); - string alias = context.Request.Query["as"].ToString() ?? string.Empty; - if (alias.Length > 64) alias = alias[..64]; - - int maxChannels = Math.Max(1, ModInit.conf.relay_max_channels); - if (!Channels.ContainsKey(channelName) && Channels.Count >= maxChannels) - { - context.Response.StatusCode = 429; - return; - } - - var socket = await context.WebSockets.AcceptWebSocketAsync(); - - var member = new RelayMember - { - Uid = "u" + Interlocked.Increment(ref uidSeq), - Alias = alias, - Echo = echo, - Socket = socket - }; - - var channel = Channels.GetOrAdd(channelName, _ => new RelayChannel { Name = channelName }); - - // A server-hosted room keeps its virtual member across channel - // recreation: joiners must see total >= 2 or lparty clients - // declare the room dead on arrival. - if (ShadowHost.ShouldHostChannel(channelName)) - EnsureServerMember(channel); - - bool full = false; - List users; - int total; - lock (channel.Lock) - { - int maxClients = Math.Max(2, ModInit.conf.relay_max_clients_per_channel); - if (channel.Members.Count >= maxClients) - { - full = true; - users = new List(); - total = 0; - } - else - { - channel.Members[member.Uid] = member; - users = new List(channel.Members.Values); - total = users.Count; - } - } - - if (full) - { - _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "channel_full")); - try { await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "channel_full", CancellationToken.None); } catch { } - return; - } - - long now = ServerNowMs(); - _ = SendAsync(member, BuildSelfJoinFrame(member.Uid, now, total, users)); - - byte[] joinFrame = BuildForeignJoinFrame(member.Uid, member.Alias, now, total); - Broadcast(channel, member.Uid, joinFrame); - - ShadowHost.OnMemberJoined(channelName, channel, member); - - try - { - await ReceiveLoop(channelName, channel, member); - } - catch { } - finally - { - RemoveMember(channelName, channel, member); - } - } - - /// The server's virtual lparty member inside a channel: a - /// silent observer while a real host is alive, the acting host after - /// the host is gone. - public static void EnsureServerMember(RelayChannel channel) - { - bool added = false; - int total; - lock (channel.Lock) - { - if (!channel.Members.ContainsKey(ShadowHost.ServerUid)) - { - channel.Members[ShadowHost.ServerUid] = new RelayMember { Uid = ShadowHost.ServerUid, Alias = "Lampac" }; - added = true; - } - total = channel.Members.Count; - } - - if (added) - Broadcast(channel, null, BuildForeignJoinFrame(ShadowHost.ServerUid, "Lampac", ServerNowMs(), total)); - } - - static async Task ReceiveLoop(string channelName, RelayChannel channel, RelayMember member) - { - var socket = member.Socket; - var buffer = new byte[8192]; - - while (socket.State == WebSocketState.Open) - { - var result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) break; - - using var ms = new MemoryStream(); - ms.Write(buffer, 0, result.Count); - while (!result.EndOfMessage) - { - if (ms.Length > MaxMessageBytes) throw new IOException("relay message too large"); - result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) return; - ms.Write(buffer, 0, result.Count); - } - - if (ms.Length == 0 || ms.Length > MaxMessageBytes) continue; - - if (!CheckRate(member)) - { - _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "rate_limited")); - continue; - } - - JsonElement message; - try - { - using var doc = JsonDocument.Parse(ms.ToArray()); - if (doc.RootElement.ValueKind != JsonValueKind.Object) continue; - message = doc.RootElement.Clone(); - } - catch (JsonException) - { - continue; - } - - long now = ServerNowMs(); - - // The echo feeds clock sync: it happens for every message. - // Frames are broadcast verbatim; the shadow host only listens. - if (member.Echo) - { - byte[] echoFrame = BuildMessageFrame(member.Uid, member.Alias, ServerNowMs(), message); - _ = SendAsync(member, echoFrame); - } - - byte[] frame = BuildMessageFrame(member.Uid, member.Alias, now, message); - Broadcast(channel, member.Uid, frame); - - ShadowHost.OnMessage(channelName, channel, member, message, now); - } - } - - static bool CheckRate(RelayMember member) - { - long second = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - if (Interlocked.CompareExchange(ref member.RateSecond, second, second) != second) - { - member.RateSecond = second; - member.RateCount = 0; - } - member.RateCount++; - return member.RateCount <= MaxMessagesPerSecond; - } - - public static void Broadcast(RelayChannel channel, string excludeUid, byte[] frame) - { - RelayMember[] targets; - lock (channel.Lock) - { - if (channel.Members.Count == 0) return; - targets = new RelayMember[channel.Members.Count]; - channel.Members.Values.CopyTo(targets, 0); - } - - foreach (var target in targets) - { - if (string.Equals(target.Uid, excludeUid, StringComparison.Ordinal)) continue; - _ = SendAsync(target, frame); - } - } - - static void RemoveMember(string channelName, RelayChannel channel, RelayMember member) - { - int total; - bool tornDown = false; - lock (channel.Lock) - { - if (!channel.Members.Remove(member.Uid, out _)) return; - total = channel.Members.Count; - bool onlyServer = total == 1 && channel.Members.ContainsKey(ShadowHost.ServerUid); - - if (total == 0 || onlyServer) - { - tornDown = true; - if (Channels.TryRemove(channel.Name, out var stale) && !ReferenceEquals(stale, channel)) - { - // A newer instance took the name; put it back. - Channels.TryAdd(channel.Name, stale); - } - if (onlyServer) - channel.Members.Remove(ShadowHost.ServerUid, out _); - } - } - - byte[] leaveFrame = BuildLeaveFrame(member.Uid, member.Alias, ServerNowMs(), total); - Broadcast(channel, member.Uid, leaveFrame); - - ShadowHost.OnMemberLeft(channelName, channel, member, tornDown); - - try { member.Socket.Dispose(); } catch { } - } - - public static async Task SendAsync(RelayMember member, byte[] payload) - { - try - { - await member.SendLock.WaitAsync(); - try - { - if (member.Socket.State != WebSocketState.Open) return; - await member.Socket.SendAsync(new ArraySegment(payload), WebSocketMessageType.Text, true, CancellationToken.None); - } - finally - { - member.SendLock.Release(); - } - } - catch { } - } - - static long ServerNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - static byte[] BuildSelfJoinFrame(string uid, long date, int total, List users) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "join"); - w.WriteBoolean("self", true); - w.WriteString("uid", uid); - w.WriteNumber("date", date); - w.WriteNumber("total", total); - w.WriteStartArray("users"); - foreach (var u in users) - { - w.WriteStartObject(); - w.WriteString("uid", u.Uid); - w.WriteString("alias", u.Alias ?? string.Empty); - w.WriteEndObject(); - } - w.WriteEndArray(); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - static byte[] BuildForeignJoinFrame(string uid, string alias, long date, int total) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "join"); - w.WriteBoolean("self", false); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WriteNumber("total", total); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - static byte[] BuildLeaveFrame(string uid, string alias, long date, int total) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "leave"); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WriteNumber("total", total); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - static byte[] BuildErrorFrame(long date, string message) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "error"); - w.WriteNumber("date", date); - w.WriteString("message", message); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - public static byte[] BuildMessageFrame(string uid, string alias, long date, JsonElement message) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "message"); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WritePropertyName("message"); - message.WriteTo(w); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - public static byte[] BuildMessageFrame(string uid, string alias, long date, object message) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "message"); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WritePropertyName("message"); - JsonSerializer.Serialize(w, message, message.GetType()); - w.WriteEndObject(); - } - return ms.ToArray(); - } - } -} diff --git a/mods/WatchTogether/ShadowHost.cs b/mods/WatchTogether/ShadowHost.cs deleted file mode 100644 index 52234b4..0000000 --- a/mods/WatchTogether/ShadowHost.cs +++ /dev/null @@ -1,826 +0,0 @@ -using Shared; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace WatchTogether -{ - /// - /// Server-side state of a room the server eavesdrops on. The server never - /// creates rooms: a room exists because a client opened its channel and a - /// host announced a stream. Until the host vanishes, the server is a - /// silent member; when the host is gone, it seamlessly takes over. - /// - public class ShadowRoom - { - public readonly object Lock = new(); - - public string Channel; - - // host bookkeeping - public string HostUid; - public string HostPid; - public string CreatorPid; - public long HostSeen; - public bool Hosting; - public bool Stalled; - - // timeline - public string State = "paused"; - public double Position; - public long AtServerTime; - public double Speed = 1.0; - - // metadata - public string RoomName; - public string Url; - public string Title; - public string Poster; - public string Source; - public string Type; - public int Tmdb; - - /// Room code as announced by the host in the lobby (ad frames); - /// lets the server list and re-announce rooms it keeps alive. - public string Code; - - // server-side hold barrier (only while Hosting) - public HashSet HoldWaiting; - public double HoldPosition; - public Timer HoldTimer; - - public DateTime CreatedUtc = DateTime.UtcNow; - public DateTime LastActivityUtc = DateTime.UtcNow; - - /// Display name of the member that announced the room. - public string OwnerName; - } - - /// A room advertisement overheard in the lobby channel. - public class LobbyAd - { - public string SenderUid; - public long Seen; - public string Id; - public string Name; - public string Title; - public string Poster; - public string Owner; - public int Members; - public int Tmdb; - public string Type; - public bool Pwd; - } - - /// - /// The shadow host: the WatchTogether module acting as a state-keeping - /// lparty client. While a real host is alive it silently listens and keeps - /// a snapshot of the room (url, metadata, timeline). The moment the host's - /// connection drops, the server steps in as the host on the wire - the - /// members see one "host changed" toast and playback continues without a - /// gap. When the original creator returns, the role is handed back. - /// - public static class ShadowHost - { - public const string ServerUid = "lampac"; - - const string LobbyChannel = "lparty-lobby-v1"; - const string RoomPrefix = "lparty-r-"; - const int MetronomeMs = 2000; - const int GcEveryMs = 60000; - const double MaxAdvancableSeconds = 3600; - const int HoldMaxMs = 15000; - const long ReconnectGraceMs = 60000; - const int AdTtlMs = 120000; - - static Timer metronomeTimer; - static Timer gcTimer; - static int initialized; - - static readonly ConcurrentDictionary rooms = new(); - - /// Room advertisements overheard in the lobby channel: the - /// host announces its code publicly, so the server can keep listing - /// rooms (including ones it keeps alive) without knowing passwords. - static readonly ConcurrentDictionary lobbyAds = new(); - - /// pid -> last hello, per channel; used to skip the join hold - /// for members that are reconnecting rather than new. - static readonly ConcurrentDictionary> knownPids = new(); - - /// The wire channel name for a room code. With an empty - /// password this is computable server-side, which lets the server link - /// a lobby advertisement to the room it belongs to. - static string RoomChannelHash(string roomId, string password) - { - if (string.IsNullOrEmpty(roomId)) return null; - string input = roomId.ToUpperInvariant() + "|" + (password ?? string.Empty); - using var sha = SHA256.Create(); - byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); - return RoomPrefix + Convert.ToHexString(hashBytes).ToLowerInvariant()[..24]; - } - - // ---------------------------------------------------------------- lifecycle - - public static void Start() - { - if (Interlocked.Exchange(ref initialized, 1) == 1) return; - metronomeTimer = new Timer(Metronome, null, MetronomeMs, MetronomeMs); - gcTimer = new Timer(Gc, null, GcEveryMs, GcEveryMs); - } - - public static void Stop() - { - if (Interlocked.Exchange(ref initialized, 0) == 0) return; - metronomeTimer?.Dispose(); - gcTimer?.Dispose(); - metronomeTimer = gcTimer = null; - rooms.Clear(); - knownPids.Clear(); - } - - // ---------------------------------------------------------------- relay hooks - - /// Whether the server's virtual member must exist in this - /// channel right now (it is the acting host). - public static bool ShouldHostChannel(string channelName) - { - if (channelName == LobbyChannel) return false; - return rooms.TryGetValue(channelName, out var room) && room.Hosting; - } - - public static void OnMemberJoined(string channelName, RelayChannel channel, RelayMember member) - { - if (channelName == LobbyChannel) return; - if (!rooms.TryGetValue(channelName, out var room)) return; - - room.LastActivityUtc = DateTime.UtcNow; - - RelayServer.EnsureServerMember(channel); - - // Server-hosted room: answer the joiner like a host would. - if (room.Hosting) - { - var pids = knownPids.GetOrAdd(channelName, _ => new ConcurrentDictionary()); - AnswerHelloAsHost(room, channel, member, pids, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - } - } - - public static void OnMemberLeft(string channelName, RelayChannel channel, RelayMember member, bool tornDown) - { - if (channelName == LobbyChannel) - { - // A lobby agent disconnected: its ads die with it. - foreach (var key in lobbyAds.Keys.ToList()) - if (lobbyAds.TryGetValue(key, out var ad) && string.Equals(ad.SenderUid, member.Uid, StringComparison.Ordinal)) - lobbyAds.TryRemove(key, out var dead); - return; - } - - if (!rooms.TryGetValue(channelName, out var room)) return; - - if (!room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal)) - BecomeHost(room, channel, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - } - - public static void OnMessage(string channelName, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - if (channelName == LobbyChannel) - { - HandleLobby(member, msg, now); - return; - } - - var room = rooms.GetOrAdd(channelName, _ => new ShadowRoom { Channel = channelName }); - room.LastActivityUtc = DateTime.UtcNow; - - string t = GetString(msg, "t"); - string pid = GetString(msg, "u"); - string name = GetString(msg, "n"); - - if (!string.IsNullOrEmpty(pid)) member.Pid = pid; - if (!string.IsNullOrEmpty(name)) member.Name = name; - - switch (t) - { - case "state": ObserveState(room, channel, member, msg, now); break; - case "sync": ObserveSync(room, member, msg, now); break; - case "act": ObserveAct(room, msg, now); break; - case "url": ObserveUrl(room, member, msg); break; - case "buf": ObserveBuf(room, member, msg); break; - case "host": HandleHostClaim(room, channel, member, msg, now); break; - case "hello": HandleHello(room, channel, member, msg, now); break; - case "ready": HandleReady(room, channel, member, now); break; - case "bye": HandleBye(room, channel, member, now); break; - } - } - - /// The lobby is server-answered: hosts advertise their code - /// in `ad` frames, the server caches them and re-announces rooms it - /// keeps alive - so the list survives a host handover. - static void HandleLobby(RelayMember member, JsonElement msg, long now) - { - string t = GetString(msg, "t"); - - if (t == "ad") - { - if (msg.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Object) - { - string id = GetString(r, "id"); - if (!string.IsNullOrWhiteSpace(id) && id.Length <= 16) - { - lobbyAds[id] = new LobbyAd - { - SenderUid = member.Uid, - Seen = now, - Id = id, - Name = GetString(r, "name") ?? id, - Title = GetString(r, "title") ?? string.Empty, - Poster = GetString(r, "poster") ?? string.Empty, - Owner = GetString(r, "owner") ?? string.Empty, - Members = GetInt(r, "members"), - Tmdb = GetInt(r, "tmdb"), - Type = GetString(r, "type") ?? "movie", - Pwd = GetInt(r, "pwd") != 0, - }; - } - } - return; - } - - if (t != "who") return; - - var listed = new HashSet(StringComparer.Ordinal); - - // Server-kept rooms first, with live member counts. - foreach (var room in rooms.Values) - { - string code, name, title, poster, owner, type; - int tmdb; - bool hasUrl; - lock (room.Lock) - { - code = room.Code; - hasUrl = !string.IsNullOrEmpty(room.Url); - name = room.RoomName; - title = room.Title; - poster = room.Poster; - owner = room.OwnerName; - type = room.Type; - tmdb = room.Tmdb; - } - - if (string.IsNullOrWhiteSpace(code) || listed.Contains(code) || !hasUrl) continue; - - var channel = RelayServer.TryGetChannel(room.Channel); - int members = channel == null ? 0 : RelayServer.HumanUids(channel).Count; - SendLobbyAd(member, now, code, name, title, poster, owner, members, true, tmdb, type); - listed.Add(code); - } - - // Cached ads from hosts. Provably alive (passwordless) rooms - // outlive the TTL; the rest go stale with their announcer. - foreach (var ad in lobbyAds.Values.OrderByDescending(a => a.Seen)) - { - if (listed.Contains(ad.Id)) continue; - - string hash = RoomChannelHash(ad.Id, string.Empty); - bool tracked = hash != null && rooms.ContainsKey(hash); - if (now - ad.Seen > AdTtlMs && !tracked) continue; - - int members = ad.Members; - if (tracked) - { - var channel = RelayServer.TryGetChannel(hash); - if (channel != null) members = RelayServer.HumanUids(channel).Count; - } - - SendLobbyAd(member, now, ad.Id, ad.Name, ad.Title, ad.Poster, ad.Owner, members, true, ad.Tmdb, ad.Type); - listed.Add(ad.Id); - } - } - - static void SendLobbyAd(RelayMember target, long now, string id, string name, string title, string poster, string owner, int members, bool pwd, int tmdb, string type) - { - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new - { - t = "ad", - r = new { id, name, title, poster, owner, members, pwd = pwd ? 1 : 0, tmdb, type } - }); - _ = RelayServer.SendAsync(target, frame); - } - - // ---------------------------------------------------------------- observation - - static void ObserveState(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - // While hosting, only the creator may take the room back. - if (room.Hosting) - { - if (!string.Equals(member.Pid, room.CreatorPid, StringComparison.Ordinal)) - { - AssertHost(room, channel, now); - return; - } - Handback(room, channel, member, now); - } - - string url = GetString(msg, "url"); - if (string.IsNullOrWhiteSpace(url)) return; - - lock (room.Lock) - { - room.HostUid = member.Uid; - room.HostPid = GetString(msg, "own") ?? member.Pid ?? member.Uid; - room.CreatorPid = room.HostPid; - room.HostSeen = now; - room.Hosting = false; - room.Stalled = false; - - room.RoomName = GetString(msg, "rn") ?? room.RoomName; - room.Url = url.Trim(); - room.Title = GetString(msg, "ti") ?? room.Title; - room.Poster = GetString(msg, "po") ?? room.Poster; - room.Tmdb = GetInt(msg, "tm"); - room.Source = GetString(msg, "src") ?? room.Source; - room.Type = GetString(msg, "ty") ?? room.Type; - room.Code = GetString(msg, "cd") ?? room.Code; - room.OwnerName = member.Alias ?? room.OwnerName; - - room.State = GetString(msg, "s") == "playing" ? "playing" : "paused"; - double p = GetDouble(msg, "p"); - if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; - room.Speed = GetSpeedOr(msg, room.Speed); - room.AtServerTime = now; - } - - RelayServer.EnsureServerMember(channel); - } - - static void ObserveSync(ShadowRoom room, RelayMember member, JsonElement msg, long now) - { - string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; - double p = GetDouble(msg, "p"); - double sp = GetSpeedOr(msg, room.Speed); - - lock (room.Lock) - { - if (room.Hosting) return; - - bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || - (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); - - if (!fromHost && string.IsNullOrEmpty(room.HostUid)) - { - // No known host yet: adopt the sender. - room.HostUid = member.Uid; - room.HostPid = member.Pid; - room.CreatorPid = member.Pid; - fromHost = true; - } - - if (!fromHost) return; - - room.State = s; - if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; - room.Speed = sp; - room.AtServerTime = now; - room.HostSeen = now; - } - } - - static void ObserveAct(ShadowRoom room, JsonElement msg, long now) - { - // Acts from anyone move the room; the host's next sync re-asserts. - if (room.Hosting) return; - - string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; - double p = GetDouble(msg, "p"); - - lock (room.Lock) - { - room.State = s; - if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; - room.Speed = GetSpeedOr(msg, room.Speed); - room.AtServerTime = now; - } - } - - static void ObserveUrl(ShadowRoom room, RelayMember member, JsonElement msg) - { - lock (room.Lock) - { - bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || - (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); - if (!fromHost || room.Hosting) return; - - string url = GetString(msg, "url"); - if (string.IsNullOrWhiteSpace(url)) return; - - room.Url = url.Trim(); - string ti = GetString(msg, "ti"); - if (!string.IsNullOrEmpty(ti)) room.Title = ti.Trim(); - room.State = "paused"; - room.Position = 0; - room.AtServerTime = 0; - room.Stalled = false; - } - } - - static void ObserveBuf(ShadowRoom room, RelayMember member, JsonElement msg) - { - lock (room.Lock) - { - bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || - (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); - if (!fromHost) return; - - room.Stalled = GetBool(msg, "v"); - } - } - - static void HandleHostClaim(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - if (room.Hosting) - { - // Claims are dropped; the server re-asserts itself. - AssertHost(room, channel, now); - return; - } - - string claimer = GetString(msg, "u"); - if (string.IsNullOrEmpty(claimer)) return; - - lock (room.Lock) - { - room.HostUid = member.Uid; - room.HostPid = claimer; - room.CreatorPid = claimer; - room.HostSeen = now; - room.Hosting = false; - } - } - - static void HandleHello(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - string pid = GetString(msg, "u") ?? member.Pid; - string name = GetString(msg, "n"); - string cd = GetString(msg, "cd"); - - var pids = knownPids.GetOrAdd(channel.Name, _ => new ConcurrentDictionary()); - long seenBefore = 0; - if (!string.IsNullOrEmpty(pid)) - { - pids.TryGetValue(pid, out seenBefore); - pids[pid] = now; - } - - // The first participant stating the code links the room to its listing. - if (!string.IsNullOrWhiteSpace(cd)) - { - lock (room.Lock) - { - if (string.IsNullOrEmpty(room.Code)) room.Code = cd.Trim().ToUpperInvariant(); - } - } - - if (!room.Hosting) return; - - if (!string.IsNullOrEmpty(pid) && string.Equals(pid, room.CreatorPid, StringComparison.Ordinal)) - { - Handback(room, channel, member, now); - return; - } - - AnswerHelloAsHost(room, channel, member, pids, seenBefore, now); - } - - /// Server-hosting room, newcomer arrived: introduce the room - /// and, like a real host, freeze it while they buffer. - static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary pids, long seenBefore, long now) - { - if (!room.Hosting) return; - - RelayServer.EnsureServerMember(channel); - SendStateFrame(room, member); - - bool reconnected = now - seenBefore < ReconnectGraceMs; - - bool playing; - lock (room.Lock) playing = room.State == "playing" && !string.IsNullOrEmpty(room.Url); - - if (!playing) return; - if (reconnected) return; - - StartHold(room, channel, now); - } - - static void HandleReady(ShadowRoom room, RelayChannel channel, RelayMember member, long now) - { - lock (room.Lock) - { - if (!room.Hosting || room.HoldWaiting == null) return; - room.HoldWaiting.Remove(member.Uid); - if (room.HoldWaiting.Count > 0) return; - } - ReleaseHold(room, channel, now); - } - - static void HandleBye(ShadowRoom room, RelayChannel channel, RelayMember member, long now) - { - bool wasHost; - lock (room.Lock) - wasHost = !room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal); - - if (wasHost) BecomeHost(room, channel, now); - } - - // ---------------------------------------------------------------- host transitions - - static void BecomeHost(ShadowRoom room, RelayChannel channel, long now) - { - if (room.Hosting) return; - - lock (room.Lock) - { - room.Hosting = true; - room.HostUid = ServerUid; - room.HostSeen = now; - room.Stalled = false; - - if (room.State == "playing" && room.AtServerTime > 0) - { - // Continue exactly where the dead host was last heard. - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) - room.Position += elapsed * room.Speed; - room.AtServerTime = now; - } - } - - RelayServer.EnsureServerMember(channel); - AssertHost(room, channel, now); - - double pos; - string state; - double speed; - lock (room.Lock) { state = room.State; pos = room.Position; speed = room.Speed; } - var sync = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); - RelayServer.Broadcast(channel, null, sync); - } - - static void Handback(ShadowRoom room, RelayChannel channel, RelayMember member, long now) - { - lock (room.Lock) - { - room.Hosting = false; - room.HostUid = member.Uid; - room.HostPid = member.Pid; - room.HostSeen = now; - room.Stalled = false; - } - - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = member.Pid, n = member.Name ?? member.Alias ?? string.Empty }); - RelayServer.Broadcast(channel, null, frame); - } - - static void AssertHost(ShadowRoom room, RelayChannel channel, long now) - { - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = ServerUid, n = "Lampac" }); - RelayServer.Broadcast(channel, null, frame); - } - - // ---------------------------------------------------------------- hold barrier (server as host) - - static void StartHold(ShadowRoom room, RelayChannel channel, long now) - { - lock (room.Lock) - { - if (room.HoldWaiting != null) return; // already holding - - AdvanceClock(room, now); - room.HoldPosition = room.Position; - - room.HoldWaiting = new HashSet(RelayServer.HumanUids(channel), StringComparer.Ordinal); - if (room.HoldWaiting.Count == 0) return; - } - - var holdFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "hold", u = ServerUid, p = room.HoldPosition, sp = room.Speed }); - RelayServer.Broadcast(channel, null, holdFrame); - - lock (room.Lock) - { - room.HoldTimer?.Dispose(); - room.HoldTimer = new Timer(_ => - { - var ch = RelayServer.TryGetChannel(room.Channel); - if (ch != null) ReleaseHold(room, ch, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - }, null, HoldMaxMs, Timeout.Infinite); - } - } - - /// Freeze the clock base at the current position. - static void AdvanceClock(ShadowRoom room, long now) - { - if (room.State == "playing" && room.AtServerTime > 0) - { - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; - } - room.AtServerTime = now; - } - - static void ReleaseHold(ShadowRoom room, RelayChannel channel, long now) - { - double position; - lock (room.Lock) - { - room.HoldWaiting = null; - room.HoldTimer?.Dispose(); - room.HoldTimer = null; - room.State = "playing"; - room.Position = room.HoldPosition; - room.AtServerTime = now; - position = room.HoldPosition; - } - - var goFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "go", u = ServerUid, p = position, sp = room.Speed }); - RelayServer.Broadcast(channel, null, goFrame); - } - - // ---------------------------------------------------------------- metronome - - /// While the server hosts, nobody's player drives the clock: - /// the metronome advances the room by wall time and beats every couple - /// of seconds, exactly like the host heartbeats it replaces. - static void Metronome(object _) - { - try - { - long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - foreach (var room in rooms.Values) - { - if (!room.Hosting) continue; - var channel = RelayServer.TryGetChannel(room.Channel); - if (channel == null) continue; - - bool playing; - lock (room.Lock) - { - playing = room.State == "playing" && !room.Stalled; - if (playing && room.AtServerTime > 0) - { - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) - room.Position += elapsed * room.Speed; - room.AtServerTime = now; - } - } - - if (!playing) continue; - - double pos, speed; - string state; - lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; } - - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); - RelayServer.Broadcast(channel, null, frame); - } - } - catch { } - } - - // ---------------------------------------------------------------- gc - - static void Gc(object _) - { - try - { - var emptyLimit = DateTime.UtcNow.AddMinutes(-Math.Max(5, ModInit.conf.gc_empty_timeout_minutes)); - var lifeLimit = DateTime.UtcNow.AddHours(-Math.Max(1, ModInit.conf.gc_max_lifetime_hours)); - - foreach (var room in rooms.Values.ToArray()) - { - bool hasHumans = false; - var channel = RelayServer.TryGetChannel(room.Channel); - if (channel != null) - { - lock (channel.Lock) - hasHumans = channel.Members.Keys.Any(u => !string.Equals(u, ServerUid, StringComparison.Ordinal)); - } - - bool expired = room.CreatedUtc < lifeLimit || - (!hasHumans && room.LastActivityUtc < emptyLimit); - - if (expired) - { - rooms.TryRemove(room.Channel, out var deadRoom); - knownPids.TryRemove(room.Channel, out var deadPids); - } - } - - long cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ReconnectGraceMs; - foreach (var pids in knownPids.Values) - { - foreach (var key in pids.Keys.ToList()) - { - if (!pids.TryGetValue(key, out var seen) || seen < cutoff) - pids.TryRemove(key, out var deadPid); - } - } - - long adCutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - AdTtlMs; - foreach (var key in lobbyAds.Keys.ToList()) - { - if (!lobbyAds.TryGetValue(key, out var ad) || ad.Seen < adCutoff) - lobbyAds.TryRemove(key, out var deadAd); - } - } - catch { } - } - - // ---------------------------------------------------------------- helpers - - static void SendStateFrame(ShadowRoom room, RelayMember target) - { - long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - double pos; - string state; - double speed; - lock (room.Lock) - { - pos = room.Position; - speed = room.Speed; - state = room.State; - if (state == "playing" && room.AtServerTime > 0) - { - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) pos += elapsed * room.Speed; - } - } - - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new - { - t = "state", - rn = room.RoomName ?? string.Empty, - own = ServerUid, - url = room.Url ?? string.Empty, - ti = room.Title ?? string.Empty, - po = room.Poster ?? string.Empty, - tm = room.Tmdb, - src = room.Source ?? string.Empty, - ty = room.Type ?? "movie", - s = state, - p = pos, - sp = speed, - }); - - _ = RelayServer.SendAsync(target, frame); - } - - static string GetString(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return null; - if (!obj.TryGetProperty(name, out var el)) return null; - if (el.ValueKind == JsonValueKind.String) return el.GetString(); - if (el.ValueKind == JsonValueKind.Null) return null; - return el.ToString(); - } - - static double GetDouble(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return 0; - if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var val)) - return val; - return 0; - } - - static double GetSpeedOr(JsonElement obj, double fallback) - { - double v = GetDouble(obj, "sp"); - return v >= 0.25 && v <= 4.0 ? v : fallback; - } - - static int GetInt(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return 0; - if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var val)) - return val; - return 0; - } - - static bool GetBool(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return false; - return obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.True; - } - } -} diff --git a/mods/WatchTogether/WatchTogether.csproj b/mods/WatchTogether/WatchTogether.csproj deleted file mode 100644 index 74cb30d..0000000 --- a/mods/WatchTogether/WatchTogether.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - true - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/mods/WatchTogether/WatchTogetherController.cs b/mods/WatchTogether/WatchTogetherController.cs deleted file mode 100644 index 698cda3..0000000 --- a/mods/WatchTogether/WatchTogetherController.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; -using Shared; -using System; -using System.IO; - -namespace WatchTogether -{ - /// - /// Static file service: the Lampa plugin (adapted lparty.js) and the web - /// guest page (adapted lparty-web), with lampac hosts injected as - /// placeholders. The relay itself lives at /c/ (RelayStartupFilter). - /// - [Route("watchtogether")] - public class WatchTogetherController : BaseController - { - private bool CheckAuth() - { - if (ModInit.conf.allow_anonymous) return true; - if (!CoreInit.conf.accsdb.enable) return true; - return requestInfo.user != null; - } - - [AllowAnonymous] - [HttpGet] - [Route("/watchtogether.js")] - public ActionResult GetPlugin() - { - if (!CheckAuth()) return StatusCode(401); - return ServeFile("plugin.js", "application/javascript; charset=utf-8", js => - js.Replace("{relay}", RelayAddress()) - .Replace("{invite}", InviteAddress())); - } - - [AllowAnonymous] - [HttpGet] - [Route("/watch_together")] - [Route("/watch_together/{file}")] - public ActionResult GetWebPlayer(string file) - { - if (!CheckAuth()) return StatusCode(401); - - switch (file ?? string.Empty) - { - case "": - case "index.html": - return ServeFile("web/index.html", "text/html; charset=utf-8", html => - html.Replace("{relay}", RelayAddress())); - case "lparty-web.js": - return ServeFile("web/lparty-web.js", "application/javascript; charset=utf-8", js => - js.Replace("{relay}", RelayAddress())); - case "i18n.js": - return ServeFile("web/i18n.js", "application/javascript; charset=utf-8", null); - case "names.js": - return ServeFile("web/names.js", "application/javascript; charset=utf-8", null); - default: - return NotFound(); - } - } - - string RelayAddress() - { - return (host.StartsWith("https", StringComparison.Ordinal) ? "wss://" : "ws://") + - host.Replace("https://", "").Replace("http://", "").TrimEnd('/') + "/wt/c/"; - } - - string InviteAddress() - { - return host.TrimEnd('/') + "/watch_together/"; - } - - ActionResult ServeFile(string name, string contentType, Func transform) - { - string path = Path.Combine(ModInit.modpath, name); - if (!System.IO.File.Exists(path)) - return Content("

" + name + " not found

", "text/html; charset=utf-8"); - - long mtime = System.IO.File.GetLastWriteTimeUtc(path).Ticks; - string memKey = "watchtogether:file:" + name + ":" + mtime; - - if (!memoryCache.TryGetValue(memKey, out string raw)) - { - raw = System.IO.File.ReadAllText(path); - memoryCache.Set(memKey, raw, TimeSpan.FromMinutes(10)); - } - - string result = transform != null ? transform(raw) : raw; - return Content(result, contentType); - } - } -} diff --git a/mods/WatchTogether/manifest.json b/mods/WatchTogether/manifest.json deleted file mode 100644 index c894096..0000000 --- a/mods/WatchTogether/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "enable": true, - "dynamic": true, - "tree": [ - "ModInit.cs", - "RelayServer.cs", - "ShadowHost.cs", - "WatchTogetherController.cs" - ] -} \ No newline at end of file diff --git a/mods/WatchTogether/plugin.js b/mods/WatchTogether/plugin.js deleted file mode 100644 index cd638bd..0000000 --- a/mods/WatchTogether/plugin.js +++ /dev/null @@ -1,3651 +0,0 @@ -(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(), - ), - ); -})(); diff --git a/mods/WatchTogether/web/i18n.js b/mods/WatchTogether/web/i18n.js deleted file mode 100644 index 7d69595..0000000 --- a/mods/WatchTogether/web/i18n.js +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Three languages, no libraries. Auto-detection picks between Ukrainian and English - * only: Russian is in the list but is never selected without an explicit choice. - * - * A dictionary value is either a string or an object of plural forms - * (one/few/many/other) resolved through Intl.PluralRules. - */ - -export const LANGS = [ - { code: "uk", name: "Українська" }, - { code: "en", name: "English" }, - { code: "ru", name: "Русский" }, -]; - -const DICT = { - uk: { - tagline: "Спільний перегляд із Lampa", - lang_label: "Мова", - - join_heading: "Увійти за кодом", - code_ph: "Код кімнати", - pwd_ph: "Пароль", - join_btn: "Увійти", - - rooms_heading: "Відкриті кімнати", - refresh_btn: "Оновити", - searching: "Шукаємо…", - empty_title: "Поки жодної кімнати", - empty_hint: - "Кімната з’явиться тут, щойно хтось у Lampa створить її з увімкненою публікацією. Якщо код уже є — введіть його вище.", - - create_heading: "Створити кімнату", - url_ph: "Посилання на потік (.m3u8 або .mp4)", - title_ph: "Назва", - create_btn: "Створити", - url_hint: "Потрібне https-посилання, і сервер має дозволяти CORS.", - cancel_btn: "Скасувати", - done_btn: "Готово", - - settings_heading: "Налаштування", - name_ph: "Ваше ім’я", - regenerate_name: "Згенерувати інше ім’я", - relay_label: "Реле", - publish_label: "Показувати мою кімнату у списку", - - leave_btn: "Вийти", - host_badge: "ви хост", - switch_ph: "Нове посилання на потік", - switch_btn: "Перемкнути всім", - status_connecting: "З’єднання…", - status_online: "На зв’язку", - status_offline: "Зв’язок втрачено", - viewers: { - one: "{n} глядач", - few: "{n} глядачі", - many: "{n} глядачів", - other: "{n} глядача", - }, - - room_created: "Кімнату створено · код {id}", - join_ok: "Ви в кімнаті {name}", - joined: "{name} приєднався", - left_room: "{name} вийшов", - host_changed: "Новий хост: {name}", - you_are_host: "Тепер ви хост", - notice_hold: "Пауза — чекаємо, поки всі завантажать буфер", - notice_go: "Усі готові — продовжуємо", - act_resumed: "{name} продовжив", - act_paused: "{name} поставив на паузу", - act_seeked: "{name} перемотав", - someone: "учасник", - - connecting: "Підключення…", - password_prompt: "Пароль кімнати {name}", - need_code: "Введіть код кімнати", - need_url: "Потрібне посилання на потік", - need_pwd_public: - "Кімната у списку потребує пароля — задайте його або вимкніть публікацію", - already_in_room: "Ви вже в кімнаті {name}", - create_failed: "Не вдалося створити кімнату", - join_failed: "Кімнату не знайдено або пароль невірний", - kicked: "Ви підключилися з іншого місця", - no_stream: "У кімнаті немає потоку", - relay_error: "Реле: {text}", - autoplay_blocked: "Браузер заблокував автозапуск — натисніть play", - mixed_content: - "Потік віддається по http, а сторінка по https — браузер це блокує", - cors_error: - "Потік не завантажується: схоже, сервер не віддає Access-Control-Allow-Origin", - playback_error: "Помилка відтворення: {details}", - media_error: - "Браузер не зміг відкрити потік (формат, CORS або mixed content)", - }, - - en: { - tagline: "Watch together with Lampa", - lang_label: "Language", - - join_heading: "Join with a code", - code_ph: "Room code", - pwd_ph: "Password", - join_btn: "Join", - - rooms_heading: "Open rooms", - refresh_btn: "Refresh", - searching: "Searching…", - empty_title: "No rooms right now", - empty_hint: - "A room appears here as soon as someone in Lampa creates one with publishing enabled. If you already have a code, enter it above.", - - create_heading: "Create a room", - url_ph: "Stream link (.m3u8 or .mp4)", - title_ph: "Title", - create_btn: "Create", - url_hint: "Needs an https link from a server that allows CORS.", - cancel_btn: "Cancel", - done_btn: "Done", - - settings_heading: "Settings", - name_ph: "Your name", - regenerate_name: "Generate another name", - relay_label: "Relay", - publish_label: "List my room publicly", - - leave_btn: "Leave", - host_badge: "you host", - switch_ph: "New stream link", - switch_btn: "Switch for everyone", - status_connecting: "Connecting…", - status_online: "Connected", - status_offline: "Connection lost", - viewers: { one: "{n} viewer", other: "{n} viewers" }, - - room_created: "Room created · code {id}", - join_ok: "You are in {name}", - joined: "{name} joined", - left_room: "{name} left", - host_changed: "New host: {name}", - you_are_host: "You are the host now", - notice_hold: "Paused — waiting for everyone to buffer", - notice_go: "Everyone is ready — resuming", - act_resumed: "{name} resumed", - act_paused: "{name} paused", - act_seeked: "{name} skipped", - someone: "someone", - - connecting: "Connecting…", - password_prompt: "Password for {name}", - need_code: "Enter a room code", - need_url: "A stream link is required", - need_pwd_public: - "A publicly listed room needs a password — set one or turn off publishing", - already_in_room: "You are already in {name}", - create_failed: "Could not create the room", - join_failed: "Room not found, or the password is wrong", - kicked: "You connected from somewhere else", - no_stream: "The room has no stream", - relay_error: "Relay: {text}", - autoplay_blocked: "The browser blocked autoplay — press play", - mixed_content: - "The stream is served over http while this page is https — the browser blocks that", - cors_error: - "The stream will not load: the server likely omits Access-Control-Allow-Origin", - playback_error: "Playback error: {details}", - media_error: - "The browser could not open the stream (format, CORS or mixed content)", - }, - - ru: { - tagline: "Совместный просмотр с Lampa", - lang_label: "Язык", - - join_heading: "Войти по коду", - code_ph: "Код комнаты", - pwd_ph: "Пароль", - join_btn: "Войти", - - rooms_heading: "Открытые комнаты", - refresh_btn: "Обновить", - searching: "Ищем…", - empty_title: "Пока ни одной комнаты", - empty_hint: - "Комната появится здесь, как только кто-то в Lampa создаст её с включённой публикацией. Если код уже есть — введите его выше.", - - create_heading: "Создать комнату", - url_ph: "Ссылка на поток (.m3u8 или .mp4)", - title_ph: "Название", - create_btn: "Создать", - url_hint: "Нужна https-ссылка, и сервер должен разрешать CORS.", - cancel_btn: "Отмена", - done_btn: "Готово", - - settings_heading: "Настройки", - name_ph: "Ваше имя", - regenerate_name: "Сгенерировать другое имя", - relay_label: "Реле", - publish_label: "Показывать мою комнату в списке", - - leave_btn: "Выйти", - host_badge: "вы хост", - switch_ph: "Новая ссылка на поток", - switch_btn: "Переключить всем", - status_connecting: "Соединение…", - status_online: "На связи", - status_offline: "Связь потеряна", - viewers: { - one: "{n} зритель", - few: "{n} зрителя", - many: "{n} зрителей", - other: "{n} зрителя", - }, - - room_created: "Комната создана · код {id}", - join_ok: "Вы в комнате {name}", - joined: "{name} присоединился", - left_room: "{name} вышел", - host_changed: "Новый хост: {name}", - you_are_host: "Теперь вы хост", - notice_hold: "Пауза — ждём, пока все загрузят буфер", - notice_go: "Все готовы — продолжаем", - act_resumed: "{name} продолжил", - act_paused: "{name} поставил на паузу", - act_seeked: "{name} перемотал", - someone: "участник", - - connecting: "Подключение…", - password_prompt: "Пароль комнаты {name}", - need_code: "Введите код комнаты", - need_url: "Нужна ссылка на поток", - need_pwd_public: - "Комната в списке требует пароль — задайте его или отключите публикацию", - already_in_room: "Вы уже в комнате {name}", - create_failed: "Не удалось создать комнату", - join_failed: "Комната не найдена или пароль неверный", - kicked: "Вы подключились из другого места", - no_stream: "В комнате нет потока", - relay_error: "Реле: {text}", - autoplay_blocked: "Браузер заблокировал автозапуск — нажмите play", - mixed_content: - "Поток отдаётся по http, а страница по https — браузер это блокирует", - cors_error: - "Поток не загружается: похоже, сервер не отдаёт Access-Control-Allow-Origin", - playback_error: "Ошибка воспроизведения: {details}", - media_error: - "Браузер не смог открыть поток (формат, CORS или mixed content)", - }, -}; - -export const DICTS = DICT; // exposed so tests can check the dictionaries are complete - -const STORE_KEY = "lparty_lang"; -const listeners = new Set(); -let current = "uk"; - -/** Russian is deliberately not reachable here: it is a manual choice only. */ -export function detectLang() { - for (const tag of navigator.languages || [navigator.language || ""]) { - const code = String(tag).toLowerCase().split("-")[0]; - if (code === "uk") return "uk"; - if (code === "en") return "en"; - } - return "uk"; -} - -export function getLang() { - return current; -} - -export function setLang(code) { - if (!DICT[code]) return; - current = code; - try { - localStorage.setItem(STORE_KEY, code); - } catch (err) {} - document.documentElement.lang = code; - listeners.forEach((fn) => fn(code)); -} - -export function initLang() { - let saved = null; - try { - saved = localStorage.getItem(STORE_KEY); - } catch (err) {} - current = DICT[saved] ? saved : detectLang(); - document.documentElement.lang = current; - return current; -} - -export function onLangChange(fn) { - listeners.add(fn); -} - -export function t(key, params) { - let value = DICT[current][key]; - if (value === undefined) return key; // visible immediately; silence would be worse - - if (typeof value === "object") { - const n = Number(params && params.n); - const form = new Intl.PluralRules(current).select(n); - value = value[form] ?? value.other; - } - - return params - ? value.replace(/\{(\w+)\}/g, (m, name) => - params[name] === undefined ? m : params[name], - ) - : value; -} - -/** - * Repaints static nodes. data-i18n sets textContent, data-i18n-ph sets the - * placeholder, data-i18n-label sets aria-label. - */ -export function applyTranslations(root = document) { - root.querySelectorAll("[data-i18n]").forEach((el) => { - el.textContent = t(el.dataset.i18n); - }); - root.querySelectorAll("[data-i18n-ph]").forEach((el) => { - el.placeholder = t(el.dataset.i18nPh); - }); - root.querySelectorAll("[data-i18n-label]").forEach((el) => { - el.setAttribute("aria-label", t(el.dataset.i18nLabel)); - }); -} diff --git a/mods/WatchTogether/web/index.html b/mods/WatchTogether/web/index.html deleted file mode 100644 index 7e481ea..0000000 --- a/mods/WatchTogether/web/index.html +++ /dev/null @@ -1,1456 +0,0 @@ - - - - - - - WatchTogether - - - - - - - -
- - -
-
-
- WatchTogether -
-
- -
- -
-
-

-
- - - -
- -
-

- - -
-
- -
- -
-

-
- -

-
- - -
- -
- - -
-
-
- -
-

- - - -
-
-
- -
-
- - - - - - - - -
- -
- -
-
- -
- - - - - - diff --git a/mods/WatchTogether/web/lparty-web.js b/mods/WatchTogether/web/lparty-web.js deleted file mode 100644 index 32e0dc1..0000000 --- a/mods/WatchTogether/web/lparty-web.js +++ /dev/null @@ -1,1898 +0,0 @@ -/** - * 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