using System.Text.Json; using Avalonia.Threading; using Lumi.Companion.Abstractions; namespace Lumi.Companion.Overlay; public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDisposable { private const string PluginId = "lumi_overlay"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly OverlaySettingsStore _store; private readonly ICompanionPluginTransport _transport; private readonly NativeOverlayWindow _window; private readonly GlobalHotkeyManager _hotkeys; private readonly CancellationTokenSource _lifetime = new(); private readonly List _catalog = []; private readonly List _eventCatalog = []; private readonly List _feedStatuses = []; private ICompanionPluginLiveChannel? _channel; private Task? _feedTask; private bool? _visibilityOverride; private bool _preview; private bool _initialized; private string _hotkeyStatus = "Hotkeys are not registered yet."; public LumiOverlayRuntime(string dataDirectory, ICompanionPluginTransport transport) { Directory.CreateDirectory(dataDirectory); _store = new OverlaySettingsStore(Path.Combine(dataDirectory, "settings.json")); _transport = transport; _window = new NativeOverlayWindow(); _window.NativeStatusChanged += () => RaiseChanged(); _hotkeys = new GlobalHotkeyManager(); Actions = [ new("visibility", () => VisibilityPresentation.Action, _ => { ToggleVisibilityOverride(); return Task.CompletedTask; }, () => _initialized && VisibilityPresentation.CanToggle, 10), new("preview", () => _preview ? "End configuration preview" : "Start configuration preview", _ => { TogglePreview(); return Task.CompletedTask; }, () => _initialized, 20) ]; } public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 0), "Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150); public IReadOnlyList Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)]; public IReadOnlyList Actions { get; } public CompanionPluginStatus Status { get; private set; } = new(CompanionPluginHealth.Ready, "Starting", "Loading local overlay settings."); public LumiOverlaySettings Settings => _store.Current; public IReadOnlyList Sources { get { lock (_catalog) return _catalog.ToList(); } } public IReadOnlyList AvailableEventTypes { get { lock (_catalog) return _eventCatalog.ToList(); } } public IReadOnlyList FeedStatuses { get { lock (_catalog) return _feedStatuses.ToList(); } } public CaptureExclusionStatus CaptureExclusion => _window.CaptureExclusion; public string? MonitorWarning => _window.MonitorWarning; public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors(); public bool PreviewActive => _preview; public bool VisibilityOverride => _visibilityOverride.HasValue; public bool IsInitialized => _initialized; public string HotkeyStatus => _hotkeyStatus; public OverlayVisibilityPresentation VisibilityPresentation => OverlayVisibilityPresenter.Describe(Settings, AutomaticVisibility(), _preview, _visibilityOverride); public event Action? Changed; public async Task InitializeAsync(CancellationToken cancellationToken = default) { await _store.LoadAsync(cancellationToken).ConfigureAwait(false); _initialized = true; await Dispatcher.UIThread.InvokeAsync(() => { _window.ApplySettings(Settings); ConfigureHotkeys(); ApplyVisibility(); }); _feedTask = _transport.RunLiveJsonAsync("/plugins/lumi_overlay/live", RunConnectedAsync, OnLiveStatus, _lifetime.Token); RaiseChanged(); } public Task SaveSettingsAsync(CancellationToken cancellationToken = default) => SaveSettingsAsync(Settings, cancellationToken); public async Task SaveSettingsAsync(LumiOverlaySettings settings, CancellationToken cancellationToken = default) { await _store.SaveAsync(settings, cancellationToken).ConfigureAwait(false); await Dispatcher.UIThread.InvokeAsync(() => { _window.ApplySettings(Settings); ConfigureHotkeys(); ApplyVisibility(); }); await SendSubscriptionAsync(cancellationToken).ConfigureAwait(false); RaiseChanged(); } public void ToggleVisibilityOverride() { if (!VisibilityPresentation.CanToggle) return; _visibilityOverride = _visibilityOverride.HasValue ? null : !AutomaticVisibility(); Dispatcher.UIThread.Post(ApplyVisibility); RaiseChanged(); } public void TogglePreview() { _preview = !_preview; if (_preview) { _window.AddChat(new OverlayFeedMessage { Id = $"sample-chat-{Guid.NewGuid():N}", Platform = "twitch", Text = "This is a native Lumi Overlay preview ✨", Author = new() { Name = "Lumi viewer", Badges = [new() { Label = "SUB" }] } }); _window.AddEvent(new($"sample-event-{Guid.NewGuid():N}", "twitch.follow", "twitch", "A new viewer followed the channel", default)); } else _window.EndPreview(); Dispatcher.UIThread.Post(ApplyVisibility); RaiseChanged(); } private async Task RunConnectedAsync(ICompanionPluginLiveChannel channel, CancellationToken cancellationToken) { _channel = channel; await SendSubscriptionAsync(cancellationToken).ConfigureAwait(false); try { await foreach (var json in channel.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { using var document = JsonDocument.Parse(json); var root = document.RootElement; var type = root.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null; if (type is "hello" or "catalog") UpdateCatalog(root); else if (type == "chat") HandleChat(root); else if (type == "event") HandleEvent(root); else if (type == "ping") await channel.SendJsonAsync(new { type = "pong" }, cancellationToken).ConfigureAwait(false); } } finally { if (ReferenceEquals(_channel, channel)) _channel = null; } } private void UpdateCatalog(JsonElement root) { var sources = new List(); if (root.TryGetProperty("sources", out var sourceValues)) foreach (var value in sourceValues.EnumerateArray()) sources.Add(new( Read(value, "id"), Read(value, "platform"), Read(value, "label"), value.TryGetProperty("live", out var live) && live.ValueKind is JsonValueKind.True or JsonValueKind.False ? live.GetBoolean() : null, !value.TryGetProperty("available", out var available) || available.GetBoolean(), Read(value, "status"))); var eventTypes = new List(); if (root.TryGetProperty("event_types", out var eventValues)) foreach (var value in eventValues.EnumerateArray()) eventTypes.Add(new(Read(value, "id"), Read(value, "label"), Read(value, "platform"))); var statuses = new List(); if (root.TryGetProperty("statuses", out var statusValues)) foreach (var value in statusValues.EnumerateArray()) statuses.Add(new(Read(value, "platform"), Read(value, "state"), Read(value, "detail"))); lock (_catalog) { if (sources.Count > 0) { _catalog.Clear(); _catalog.AddRange(sources); } if (eventTypes.Count > 0) { _eventCatalog.Clear(); _eventCatalog.AddRange(eventTypes); } if (statuses.Count > 0) { _feedStatuses.Clear(); _feedStatuses.AddRange(statuses); } } Dispatcher.UIThread.Post(ApplyVisibility); RaiseChanged(); } private void HandleChat(JsonElement root) { if (!root.TryGetProperty("message", out var value)) return; var message = value.Deserialize(JsonOptions); if (message is null || string.IsNullOrWhiteSpace(message.Id)) return; Dispatcher.UIThread.Post(() => _window.AddChat(message)); } private void HandleEvent(JsonElement root) { if (!root.TryGetProperty("event", out var value)) return; var id = Read(value, "id"); var type = Read(value, "type"); if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return; var platform = type.Split('.')[0]; var payload = value.TryGetProperty("payload", out var payloadValue) ? payloadValue.Clone() : default; var summary = EventSummary(type, payload); Dispatcher.UIThread.Post(() => _window.AddEvent(new(id, type, platform, summary, payload))); } private async Task SendSubscriptionAsync(CancellationToken cancellationToken = default) { var channel = _channel; if (channel is null) return; await channel.SendJsonAsync(new { type = "subscribe", sources = Settings.SelectedSourceIds.ToArray(), events = Settings.EventTypes.ToArray() }, cancellationToken).ConfigureAwait(false); } private void ApplyVisibility() { _window.SetRequestedVisible(OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride)); } private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources); private void ConfigureHotkeys() { _hotkeys.Clear(); var visibility = _hotkeys.Register(Settings.ToggleVisibilityHotkey, () => Dispatcher.UIThread.Post(ToggleVisibilityOverride)); var preview = _hotkeys.Register(Settings.TogglePreviewHotkey, () => Dispatcher.UIThread.Post(TogglePreview)); _hotkeyStatus = visibility && preview ? "Both global hotkeys are active." : !visibility && !preview ? "Neither hotkey could be registered. Check the format or choose shortcuts not reserved by another app." : visibility ? "Visibility hotkey is active; the preview shortcut is invalid or reserved by another app." : "Preview hotkey is active; the visibility shortcut is invalid or reserved by another app."; } private void OnLiveStatus(CompanionPluginLiveStatus value) { Status = value.State switch { CompanionPluginLiveState.Connected => new(CompanionPluginHealth.Healthy, "Feed connected", value.Detail), CompanionPluginLiveState.WaitingForPairing => new(CompanionPluginHealth.Warning, "Pairing required", value.Detail), CompanionPluginLiveState.Reconnecting => new(CompanionPluginHealth.Warning, "Reconnecting", value.Detail), CompanionPluginLiveState.Stopped when _lifetime.IsCancellationRequested => new(CompanionPluginHealth.Ready, "Stopped", value.Detail), _ => new(CompanionPluginHealth.Ready, "Connecting", value.Detail) }; RaiseChanged(); } private static string EventSummary(string type, JsonElement payload) { string Field(params string[] names) { foreach (var name in names) if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value)) return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString(); return ""; } var user = Field("user_name", "member_name", "author_name"); return type switch { "twitch.follow" => $"{user.Or("A viewer")} followed", "twitch.raid" => $"{user.Or("A channel")} raided with {Field("viewers").Or("viewers")}", "twitch.subscribe" => $"{user.Or("A viewer")} subscribed", "twitch.subscription_gift" => $"{user.Or("A viewer")} gifted {Field("total").Or("subscriptions")}", "twitch.cheer" => $"{user.Or("A viewer")} cheered {Field("bits").Or("bits")}", "twitch.channel_points" => $"{user.Or("A viewer")} redeemed {Field("reward_title").Or("a reward")}", "discord.member_join" => $"{user.Or("A member")} joined Discord", _ => $"{type.Replace('.', ' ')} · {user}".TrimEnd(' ', '·') }; } private static string Read(JsonElement value, string property) => value.TryGetProperty(property, out var found) && found.ValueKind == JsonValueKind.String ? found.GetString() ?? "" : ""; private void RaiseChanged() => Changed?.Invoke(); public async ValueTask DisposeAsync() { _lifetime.Cancel(); if (_feedTask is not null) try { await _feedTask.ConfigureAwait(false); } catch (OperationCanceledException) { } await Dispatcher.UIThread.InvokeAsync(async () => await _window.DisposeAsync()); _hotkeys.Dispose(); _lifetime.Dispose(); } } internal static class OverlayStringExtensions { public static string Or(this string value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value; }