Удалить WatchTogether
- mods/WatchTogether/ убран (git rm), секция WatchTogether и плагин watchtogether.js убраны из config/init.conf - README: раздел и строка таблицы модулей убраны, модуль вернулся в список «ещё можно включить» - AGENT: §13 удалён, список mods/ в §1 обновлён
This commit is contained in:
@@ -28,7 +28,7 @@ docker cp lampac:/lampac/module/<Имя> "mods/<Имя>"
|
|||||||
|
|
||||||
См. также официальные шаги: [Включить модуль](https://docs.lampac.dev/modules/overview), [Пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules).
|
См. также официальные шаги: [Включить модуль](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 независимых фильтра)
|
### 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` в боте, кнопки Принять/Отклонить).
|
- `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`). Живые данные — не «чинить» руками.
|
- Данные: `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`.
|
- Проверка: `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 и таймаут больше длительности просмотра.
|
|
||||||
|
|||||||
@@ -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"` |
|
| `Tg-notify.bot` | `/tg-notify.js`, `/api/tg/*` — уведомления о новых сериях в Telegram | секция `"TelegramBot"` |
|
||||||
| `TelegramAuth` | `/tg/auth/*` — привязка UID устройства к учётке Telegram, HTTP API статуса | секция `"TelegramAuth"` (`enable: false` → без accsdb-гейта) |
|
| `TelegramAuth` | `/tg/auth/*` — привязка UID устройства к учётке Telegram, HTTP API статуса | секция `"TelegramAuth"` (`enable: false` → без accsdb-гейта) |
|
||||||
| `TelegramAuthBot` | `@GatusMonBot` — long-polling бот привязки и админ-команды | секция `"TelegramAuthBot"` |
|
| `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`, вернуть секцию и плагин.
|
> **Модуль `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 получат отказ.
|
**Порядок включения гейта (когда решим):** поставить `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=<base64(КОД:пароль)>` |
|
|
||||||
| 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
|
## Обновление Lampac
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -37,11 +37,6 @@
|
|||||||
"url": "{localhost}/tg-notify.js",
|
"url": "{localhost}/tg-notify.js",
|
||||||
"status": 1,
|
"status": 1,
|
||||||
"name": "Tg-notify"
|
"name": "Tg-notify"
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "{localhost}/watchtogether.js",
|
|
||||||
"status": 1,
|
|
||||||
"name": "WatchTogether"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -106,13 +101,5 @@
|
|||||||
143672
|
143672
|
||||||
],
|
],
|
||||||
"notify_admins_on_pending_provision": true
|
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<WafLimitRootMap> limit_map { get; set; } = new()
|
|
||||||
{
|
|
||||||
new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }),
|
|
||||||
new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// WatchTogether: a lampac module that acts as both the lparty relay and a
|
|
||||||
/// shadow host keeping rooms alive when their creator disconnects.
|
|
||||||
/// </summary>
|
|
||||||
public class ModInit : IModuleLoaded, IModuleConfigure
|
|
||||||
{
|
|
||||||
public static string modpath;
|
|
||||||
public static WatchTogetherConf conf;
|
|
||||||
|
|
||||||
public void Configure(ConfigureModel app)
|
|
||||||
{
|
|
||||||
app.services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, RelayStartupFilter>();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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://<lampac>/wt/c/lparty-r-<sha256(КОД|пароль)[0:24]>` и является хостом.
|
|
||||||
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=<base64(КОД:пароль)>` и `?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://<lampac>/watch_together/?room=<invite-код>` — ссылку формирует плагин.
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public class RelayStartupFilter : IStartupFilter
|
|
||||||
{
|
|
||||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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);
|
|
||||||
|
|
||||||
/// <summary>LParty peer id (the u field of the member's messages).</summary>
|
|
||||||
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<string, RelayMember> Members = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
public static class RelayServer
|
|
||||||
{
|
|
||||||
static long uidSeq = 0;
|
|
||||||
static readonly ConcurrentDictionary<string, RelayChannel> Channels = new();
|
|
||||||
|
|
||||||
public static RelayChannel TryGetChannel(string name) =>
|
|
||||||
Channels.TryGetValue(name, out var channel) ? channel : null;
|
|
||||||
|
|
||||||
/// <summary>Member uids excluding the server's virtual member.</summary>
|
|
||||||
public static List<string> 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<RelayMember> 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<RelayMember>();
|
|
||||||
total = 0;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
channel.Members[member.Uid] = member;
|
|
||||||
users = new List<RelayMember>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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<byte>(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<byte>(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<byte>(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<RelayMember> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>Room code as announced by the host in the lobby (ad frames);
|
|
||||||
/// lets the server list and re-announce rooms it keeps alive.</summary>
|
|
||||||
public string Code;
|
|
||||||
|
|
||||||
// server-side hold barrier (only while Hosting)
|
|
||||||
public HashSet<string> HoldWaiting;
|
|
||||||
public double HoldPosition;
|
|
||||||
public Timer HoldTimer;
|
|
||||||
|
|
||||||
public DateTime CreatedUtc = DateTime.UtcNow;
|
|
||||||
public DateTime LastActivityUtc = DateTime.UtcNow;
|
|
||||||
|
|
||||||
/// <summary>Display name of the member that announced the room.</summary>
|
|
||||||
public string OwnerName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A room advertisement overheard in the lobby channel.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<string, ShadowRoom> rooms = new();
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
static readonly ConcurrentDictionary<string, LobbyAd> lobbyAds = new();
|
|
||||||
|
|
||||||
/// <summary>pid -> last hello, per channel; used to skip the join hold
|
|
||||||
/// for members that are reconnecting rather than new.</summary>
|
|
||||||
static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, long>> knownPids = new();
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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
|
|
||||||
|
|
||||||
/// <summary>Whether the server's virtual member must exist in this
|
|
||||||
/// channel right now (it is the acting host).</summary>
|
|
||||||
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<string, long>());
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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<string>(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<string, long>());
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Server-hosting room, newcomer arrived: introduce the room
|
|
||||||
/// and, like a real host, freeze it while they buffer.</summary>
|
|
||||||
static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary<string, long> 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<string>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Freeze the clock base at the current position.</summary>
|
|
||||||
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
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Core\Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Update="manifest.json">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="plugin.js">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Include="web\**\*">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
[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<string, string> transform)
|
|
||||||
{
|
|
||||||
string path = Path.Combine(ModInit.modpath, name);
|
|
||||||
if (!System.IO.File.Exists(path))
|
|
||||||
return Content("<html><body><h2>" + name + " not found</h2></body></html>", "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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"enable": true,
|
|
||||||
"dynamic": true,
|
|
||||||
"tree": [
|
|
||||||
"ModInit.cs",
|
|
||||||
"RelayServer.cs",
|
|
||||||
"ShadowHost.cs",
|
|
||||||
"WatchTogetherController.cs"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,386 +0,0 @@
|
|||||||
/**
|
|
||||||
* Friendly names for a participant — "Clever Mango" rather than a random id.
|
|
||||||
*
|
|
||||||
* The idea, the adjective-then-greengrocer shape and the word lists come from LocalSend
|
|
||||||
* (Apache License 2.0) by way of the JAPP player, which solves the same problem: something
|
|
||||||
* a stranger can read off a screen and repeat out loud, that says nothing about the device
|
|
||||||
* or its owner. 222 x 137 is 30414, which puts two people in a room of five
|
|
||||||
* drawing the same name at around one chance in three thousand.
|
|
||||||
*
|
|
||||||
* Deliberately not localised, exactly as LocalSend is not, so a name generated here reads
|
|
||||||
* the same as one generated in the Android player.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ADJECTIVES = [
|
|
||||||
"Able",
|
|
||||||
"Active",
|
|
||||||
"Adorable",
|
|
||||||
"Agile",
|
|
||||||
"Alert",
|
|
||||||
"Amber",
|
|
||||||
"Amused",
|
|
||||||
"Ancient",
|
|
||||||
"Artful",
|
|
||||||
"Autumn",
|
|
||||||
"Balanced",
|
|
||||||
"Beautiful",
|
|
||||||
"Big",
|
|
||||||
"Bold",
|
|
||||||
"Brave",
|
|
||||||
"Breezy",
|
|
||||||
"Bright",
|
|
||||||
"Brisk",
|
|
||||||
"Calm",
|
|
||||||
"Candid",
|
|
||||||
"Careful",
|
|
||||||
"Cheerful",
|
|
||||||
"Chosen",
|
|
||||||
"Civil",
|
|
||||||
"Classic",
|
|
||||||
"Clean",
|
|
||||||
"Clear",
|
|
||||||
"Clever",
|
|
||||||
"Cool",
|
|
||||||
"Cosmic",
|
|
||||||
"Crafty",
|
|
||||||
"Crimson",
|
|
||||||
"Crisp",
|
|
||||||
"Cunning",
|
|
||||||
"Curious",
|
|
||||||
"Cute",
|
|
||||||
"Dapper",
|
|
||||||
"Daring",
|
|
||||||
"Dazzling",
|
|
||||||
"Deep",
|
|
||||||
"Deft",
|
|
||||||
"Determined",
|
|
||||||
"Devoted",
|
|
||||||
"Diligent",
|
|
||||||
"Distant",
|
|
||||||
"Divine",
|
|
||||||
"Eager",
|
|
||||||
"Early",
|
|
||||||
"Earnest",
|
|
||||||
"Easy",
|
|
||||||
"Efficient",
|
|
||||||
"Elder",
|
|
||||||
"Electric",
|
|
||||||
"Elegant",
|
|
||||||
"Endless",
|
|
||||||
"Energetic",
|
|
||||||
"Epic",
|
|
||||||
"Equal",
|
|
||||||
"Eternal",
|
|
||||||
"Exact",
|
|
||||||
"Fabled",
|
|
||||||
"Faithful",
|
|
||||||
"Famous",
|
|
||||||
"Fantastic",
|
|
||||||
"Fast",
|
|
||||||
"Fearless",
|
|
||||||
"Feisty",
|
|
||||||
"Fertile",
|
|
||||||
"Festive",
|
|
||||||
"Fiery",
|
|
||||||
"Fine",
|
|
||||||
"Firm",
|
|
||||||
"Fond",
|
|
||||||
"Frank",
|
|
||||||
"Free",
|
|
||||||
"Fresh",
|
|
||||||
"Friendly",
|
|
||||||
"Frosty",
|
|
||||||
"Gallant",
|
|
||||||
"Gentle",
|
|
||||||
"Genuine",
|
|
||||||
"Gifted",
|
|
||||||
"Glad",
|
|
||||||
"Gleaming",
|
|
||||||
"Glossy",
|
|
||||||
"Golden",
|
|
||||||
"Good",
|
|
||||||
"Gorgeous",
|
|
||||||
"Graceful",
|
|
||||||
"Grand",
|
|
||||||
"Grateful",
|
|
||||||
"Great",
|
|
||||||
"Handsome",
|
|
||||||
"Happy",
|
|
||||||
"Hardy",
|
|
||||||
"Hearty",
|
|
||||||
"Helpful",
|
|
||||||
"Heroic",
|
|
||||||
"Hidden",
|
|
||||||
"Honest",
|
|
||||||
"Hopeful",
|
|
||||||
"Hot",
|
|
||||||
"Humble",
|
|
||||||
"Ideal",
|
|
||||||
"Immense",
|
|
||||||
"Inner",
|
|
||||||
"Iron",
|
|
||||||
"Jolly",
|
|
||||||
"Joyful",
|
|
||||||
"Keen",
|
|
||||||
"Kind",
|
|
||||||
"Lively",
|
|
||||||
"Lovely",
|
|
||||||
"Loyal",
|
|
||||||
"Lucid",
|
|
||||||
"Lucky",
|
|
||||||
"Lunar",
|
|
||||||
"Magic",
|
|
||||||
"Major",
|
|
||||||
"Merry",
|
|
||||||
"Mighty",
|
|
||||||
"Mild",
|
|
||||||
"Modern",
|
|
||||||
"Modest",
|
|
||||||
"Mystic",
|
|
||||||
"Neat",
|
|
||||||
"Nice",
|
|
||||||
"Nimble",
|
|
||||||
"Noble",
|
|
||||||
"Northern",
|
|
||||||
"Novel",
|
|
||||||
"Open",
|
|
||||||
"Orbital",
|
|
||||||
"Original",
|
|
||||||
"Patient",
|
|
||||||
"Peaceful",
|
|
||||||
"Perfect",
|
|
||||||
"Playful",
|
|
||||||
"Pleasant",
|
|
||||||
"Polished",
|
|
||||||
"Polite",
|
|
||||||
"Powerful",
|
|
||||||
"Precise",
|
|
||||||
"Pretty",
|
|
||||||
"Prime",
|
|
||||||
"Proud",
|
|
||||||
"Prudent",
|
|
||||||
"Quick",
|
|
||||||
"Quiet",
|
|
||||||
"Radiant",
|
|
||||||
"Rapid",
|
|
||||||
"Ready",
|
|
||||||
"Refined",
|
|
||||||
"Regal",
|
|
||||||
"Reliable",
|
|
||||||
"Rich",
|
|
||||||
"Robust",
|
|
||||||
"Royal",
|
|
||||||
"Ruby",
|
|
||||||
"Rustic",
|
|
||||||
"Sage",
|
|
||||||
"Scarlet",
|
|
||||||
"Secret",
|
|
||||||
"Serene",
|
|
||||||
"Sharp",
|
|
||||||
"Shining",
|
|
||||||
"Silent",
|
|
||||||
"Silver",
|
|
||||||
"Sincere",
|
|
||||||
"Skilled",
|
|
||||||
"Sleek",
|
|
||||||
"Smart",
|
|
||||||
"Smooth",
|
|
||||||
"Snowy",
|
|
||||||
"Solar",
|
|
||||||
"Solid",
|
|
||||||
"Southern",
|
|
||||||
"Special",
|
|
||||||
"Spirited",
|
|
||||||
"Splendid",
|
|
||||||
"Spry",
|
|
||||||
"Stately",
|
|
||||||
"Steady",
|
|
||||||
"Sterling",
|
|
||||||
"Stormy",
|
|
||||||
"Strategic",
|
|
||||||
"Strong",
|
|
||||||
"Sturdy",
|
|
||||||
"Subtle",
|
|
||||||
"Sunny",
|
|
||||||
"Superb",
|
|
||||||
"Supreme",
|
|
||||||
"Swift",
|
|
||||||
"Tactful",
|
|
||||||
"Tender",
|
|
||||||
"Thankful",
|
|
||||||
"Thoughtful",
|
|
||||||
"Tidy",
|
|
||||||
"Timely",
|
|
||||||
"Tranquil",
|
|
||||||
"True",
|
|
||||||
"Trusty",
|
|
||||||
"Unique",
|
|
||||||
"Upbeat",
|
|
||||||
"Valiant",
|
|
||||||
"Velvet",
|
|
||||||
"Vibrant",
|
|
||||||
"Vigilant",
|
|
||||||
"Violet",
|
|
||||||
"Vivid",
|
|
||||||
"Warm",
|
|
||||||
"Watchful",
|
|
||||||
"Welcome",
|
|
||||||
"Whole",
|
|
||||||
"Wild",
|
|
||||||
"Willing",
|
|
||||||
"Wise",
|
|
||||||
"Witty",
|
|
||||||
"Wondrous",
|
|
||||||
"Worthy",
|
|
||||||
"Zealous",
|
|
||||||
"Zesty",
|
|
||||||
];
|
|
||||||
|
|
||||||
const FRUITS = [
|
|
||||||
"Almond",
|
|
||||||
"Apple",
|
|
||||||
"Apricot",
|
|
||||||
"Artichoke",
|
|
||||||
"Arugula",
|
|
||||||
"Asparagus",
|
|
||||||
"Aubergine",
|
|
||||||
"Avocado",
|
|
||||||
"Banana",
|
|
||||||
"Barley",
|
|
||||||
"Basil",
|
|
||||||
"Beetroot",
|
|
||||||
"Blackberry",
|
|
||||||
"Blackcurrant",
|
|
||||||
"Blueberry",
|
|
||||||
"Broccoli",
|
|
||||||
"Cabbage",
|
|
||||||
"Cantaloupe",
|
|
||||||
"Caper",
|
|
||||||
"Cardamom",
|
|
||||||
"Carrot",
|
|
||||||
"Cashew",
|
|
||||||
"Cauliflower",
|
|
||||||
"Celery",
|
|
||||||
"Chard",
|
|
||||||
"Cherry",
|
|
||||||
"Chestnut",
|
|
||||||
"Chickpea",
|
|
||||||
"Chicory",
|
|
||||||
"Chili",
|
|
||||||
"Chive",
|
|
||||||
"Cilantro",
|
|
||||||
"Clementine",
|
|
||||||
"Cloudberry",
|
|
||||||
"Clove",
|
|
||||||
"Coconut",
|
|
||||||
"Cranberry",
|
|
||||||
"Cucumber",
|
|
||||||
"Currant",
|
|
||||||
"Damson",
|
|
||||||
"Date",
|
|
||||||
"Dill",
|
|
||||||
"Durian",
|
|
||||||
"Eggplant",
|
|
||||||
"Elderberry",
|
|
||||||
"Endive",
|
|
||||||
"Fennel",
|
|
||||||
"Fig",
|
|
||||||
"Garlic",
|
|
||||||
"Ginger",
|
|
||||||
"Gooseberry",
|
|
||||||
"Grape",
|
|
||||||
"Grapefruit",
|
|
||||||
"Guava",
|
|
||||||
"Hazelnut",
|
|
||||||
"Honeydew",
|
|
||||||
"Horseradish",
|
|
||||||
"Jackfruit",
|
|
||||||
"Jicama",
|
|
||||||
"Kale",
|
|
||||||
"Kiwi",
|
|
||||||
"Kohlrabi",
|
|
||||||
"Kumquat",
|
|
||||||
"Leek",
|
|
||||||
"Lemon",
|
|
||||||
"Lentil",
|
|
||||||
"Lettuce",
|
|
||||||
"Lime",
|
|
||||||
"Longan",
|
|
||||||
"Loquat",
|
|
||||||
"Lychee",
|
|
||||||
"Mandarin",
|
|
||||||
"Mango",
|
|
||||||
"Marrow",
|
|
||||||
"Melon",
|
|
||||||
"Millet",
|
|
||||||
"Mint",
|
|
||||||
"Mulberry",
|
|
||||||
"Mushroom",
|
|
||||||
"Nectarine",
|
|
||||||
"Nutmeg",
|
|
||||||
"Oat",
|
|
||||||
"Okra",
|
|
||||||
"Olive",
|
|
||||||
"Onion",
|
|
||||||
"Orange",
|
|
||||||
"Oregano",
|
|
||||||
"Papaya",
|
|
||||||
"Paprika",
|
|
||||||
"Parsley",
|
|
||||||
"Parsnip",
|
|
||||||
"Passionfruit",
|
|
||||||
"Pea",
|
|
||||||
"Peach",
|
|
||||||
"Peanut",
|
|
||||||
"Pear",
|
|
||||||
"Pecan",
|
|
||||||
"Pepper",
|
|
||||||
"Persimmon",
|
|
||||||
"Pineapple",
|
|
||||||
"Pistachio",
|
|
||||||
"Plantain",
|
|
||||||
"Plum",
|
|
||||||
"Pomegranate",
|
|
||||||
"Pomelo",
|
|
||||||
"Poppy",
|
|
||||||
"Potato",
|
|
||||||
"Pumpkin",
|
|
||||||
"Quince",
|
|
||||||
"Radish",
|
|
||||||
"Raisin",
|
|
||||||
"Raspberry",
|
|
||||||
"Rhubarb",
|
|
||||||
"Rosemary",
|
|
||||||
"Rutabaga",
|
|
||||||
"Saffron",
|
|
||||||
"Sesame",
|
|
||||||
"Shallot",
|
|
||||||
"Sorrel",
|
|
||||||
"Soybean",
|
|
||||||
"Spinach",
|
|
||||||
"Sprout",
|
|
||||||
"Squash",
|
|
||||||
"Strawberry",
|
|
||||||
"Tamarind",
|
|
||||||
"Tangerine",
|
|
||||||
"Tarragon",
|
|
||||||
"Thyme",
|
|
||||||
"Tomato",
|
|
||||||
"Turnip",
|
|
||||||
"Vanilla",
|
|
||||||
"Walnut",
|
|
||||||
"Wasabi",
|
|
||||||
"Watercress",
|
|
||||||
"Watermelon",
|
|
||||||
"Yam",
|
|
||||||
"Zucchini",
|
|
||||||
];
|
|
||||||
|
|
||||||
const pick = (list) =>
|
|
||||||
list[crypto.getRandomValues(new Uint32Array(1))[0] % list.length];
|
|
||||||
|
|
||||||
export function randomAlias() {
|
|
||||||
return pick(ADJECTIVES) + " " + pick(FRUITS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ALIAS_WORDS = { ADJECTIVES, FRUITS };
|
|
||||||
Reference in New Issue
Block a user