Удалить 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).
|
||||
|
||||
Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`, `WatchTogether`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`.
|
||||
Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`. `mods/WatchTogether` тоже удалён (вернуть — той же механикой, он в списке «ещё можно включить» в README).
|
||||
|
||||
### 2. Механика загрузки модуля (4 независимых фильтра)
|
||||
|
||||
@@ -136,11 +136,3 @@ docker exec lampac sh -c '...' # осмотр состояния в конте
|
||||
- `owner_telegram_ids: [143672]` → владелец создаётся как `admin` при старте. `auto_provision_users: true` + `auto_provision_activate_immediately: false` → новые привязки ждут одобрения (`/users` в боте, кнопки Принять/Отклонить).
|
||||
- Данные: `database/tgauth/{users.json,admins.json,user_langs.json}` (том `lampac_database`, владелец `1000:1000`). Живые данные — не «чинить» руками.
|
||||
- Проверка: `GET /tg/auth/status?uid=x` → 200; `GET /tg/auth/admin/users` → 401 без заголовка, 200 с `X-TelegramAuth-Mutations-Secret`.
|
||||
|
||||
### 13. WatchTogether (комнаты синхронного просмотра)
|
||||
|
||||
- Копия в `mods/WatchTogether/` (`enable: true`), секция `WatchTogether` в `init.conf` (`enable`, `allow_anonymous: true`, `relay_max_channels: 500`, `relay_max_clients_per_channel: 32`, `gc_empty_timeout_minutes: 30`, `gc_max_lifetime_hours: 12`).
|
||||
- Клиентский плагин зарегистрирован в `LampaWeb.customPlugins`: `{localhost}/watchtogether.js`.
|
||||
- Роуты: `GET /watchtogether.js` → 200, `GET /watch_together/` → 200, `WS /wt/c/{channel}` → 400 на обычном GET (нужен WebSocket upgrade — это штатно, handshake даёт 101).
|
||||
- Lampac пассивно хранит состояние комнаты и становится **хостом-«тенью»** (`ShadowHost.cs`), если создатель отключился; роль возвращается ему при переподключении. Лаг ≤ 2 с.
|
||||
- При появлении reverse proxy: на `/wt/c/` обязателен WebSocket upgrade, отключённый buffering и таймаут больше длительности просмотра.
|
||||
|
||||
@@ -106,15 +106,14 @@ ls -ln /var/lib/docker/volumes/lampac_database/_data/Sisi.sql # uid/gid 1000
|
||||
| `Tg-notify.bot` | `/tg-notify.js`, `/api/tg/*` — уведомления о новых сериях в Telegram | секция `"TelegramBot"` |
|
||||
| `TelegramAuth` | `/tg/auth/*` — привязка UID устройства к учётке Telegram, HTTP API статуса | секция `"TelegramAuth"` (`enable: false` → без accsdb-гейта) |
|
||||
| `TelegramAuthBot` | `@GatusMonBot` — long-polling бот привязки и админ-команды | секция `"TelegramAuthBot"` |
|
||||
| `WatchTogether` | `/watchtogether.js`, `/watch_together/`, WS `/wt/c/` — комнаты синхронного просмотра | секция `"WatchTogether"` |
|
||||
|
||||
Клиентские плагины `tg-notify.js` и `watchtogether.js` зарегистрированы в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером.
|
||||
Клиентский плагин `tg-notify.js` зарегистрирован в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером.
|
||||
|
||||
> **Модуль `Music` удалён из `mods/`** (коммит `remove Music`): в образе у него `manifest.enable: false`, поэтому `/music.js` → 404, секция `"Music"` из `init.conf` убрана, плагин из `customPlugins` убран. Вернуть музыку — той же механикой: скопировать `lampac:/lampac/module/Music` в `mods/Music`, поставить `enable: true`, вернуть секцию и плагин.
|
||||
|
||||
### Ещё можно включить тем же способом
|
||||
|
||||
В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `Music`.
|
||||
В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `Music`, `WatchTogether`.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
@@ -281,26 +280,6 @@ docker exec lampac rm -rf /lampac/cache/static/59 # bucket /extensions; либ
|
||||
|
||||
**Порядок включения гейта (когда решим):** поставить `TelegramAuth.enable: true` → в `LampaWeb` секцию `telegramAuthGate` (`enabled: true`, `botUsername: "GatusMonBot"`) → подключить клиентский оверлей (`deny.js` / `telegram_auth_gate.js`) → **сначала связать свои устройства через бота**, иначе текущие UID получат отказ.
|
||||
|
||||
## WatchTogether: комнаты синхронного просмотра
|
||||
|
||||
Модуль из группы «клиент и контент»: синхронизирует `play/pause/seek` между участниками комнаты. Подробный разбор — [официальная документация](https://docs.lampac.dev/modules/watch-together) и `mods/WatchTogether/README.md`.
|
||||
|
||||
**Как устроено.** Lampac не отдаёт медиа — он пассивный брокер: хранит состояние комнаты и рассылает команды. Сами видео каждый клиент тянет своим источником, поэтому подходит любой контент и совместим с экосистемой LParty (`lparty.js`, `lparty-web`, JustPlus Player).
|
||||
|
||||
| Клиент | Адрес |
|
||||
|---|---|
|
||||
| Плагин Lampa | `{localhost}/watchtogether.js` (в `LampaWeb.customPlugins`) |
|
||||
| Веб-гость без установки Lampa | `http://<адрес>/watch_together/?room=<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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
"url": "{localhost}/tg-notify.js",
|
||||
"status": 1,
|
||||
"name": "Tg-notify"
|
||||
},
|
||||
{
|
||||
"url": "{localhost}/watchtogether.js",
|
||||
"status": 1,
|
||||
"name": "WatchTogether"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -106,13 +101,5 @@
|
||||
143672
|
||||
],
|
||||
"notify_admins_on_pending_provision": true
|
||||
},
|
||||
"WatchTogether": {
|
||||
"enable": true,
|
||||
"allow_anonymous": true,
|
||||
"relay_max_channels": 500,
|
||||
"relay_max_clients_per_channel": 32,
|
||||
"gc_empty_timeout_minutes": 30,
|
||||
"gc_max_lifetime_hours": 12
|
||||
}
|
||||
}
|
||||
@@ -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