Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
+72
View File
@@ -0,0 +1,72 @@
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;
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
# 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-код>` — ссылку формирует плагин.
+456
View File
@@ -0,0 +1,456 @@
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();
}
}
}
+826
View File
@@ -0,0 +1,826 @@
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;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<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>
@@ -0,0 +1,92 @@
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);
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"enable": true,
"dynamic": true,
"tree": [
"ModInit.cs",
"RelayServer.cs",
"ShadowHost.cs",
"WatchTogetherController.cs"
]
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
/**
* 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
+386
View File
@@ -0,0 +1,386 @@
/**
* 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 };