Lumi/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs
2026-07-29 23:41:34 +02:00

293 lines
14 KiB
C#

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<OverlaySource> _catalog = [];
private readonly List<OverlayEventType> _eventCatalog = [];
private readonly List<OverlayFeedStatus> _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();
_window.EditSettingsChanged += () => 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, 2),
"Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150);
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
public IReadOnlyList<CompanionPluginAction> Actions { get; }
public CompanionPluginStatus Status { get; private set; } = new(CompanionPluginHealth.Ready, "Starting", "Loading local overlay settings.");
public LumiOverlaySettings Settings => _store.Current;
public IReadOnlyList<OverlaySource> Sources { get { lock (_catalog) return _catalog.ToList(); } }
public IReadOnlyList<OverlayEventType> AvailableEventTypes { get { lock (_catalog) return _eventCatalog.ToList(); } }
public IReadOnlyList<OverlayFeedStatus> 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 EditModeActive { get; private set; }
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();
}
public void BeginEdit(LumiOverlaySettings draft)
{
EditModeActive = true;
_window.BeginEdit(draft);
RaiseChanged();
}
public void EndEdit()
{
if (!EditModeActive) return;
EditModeActive = false;
_window.EndEdit();
_window.ApplySettings(Settings);
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<OverlaySource>();
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<OverlayEventType>();
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<OverlayFeedStatus>();
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<OverlayFeedMessage>(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(EditModeActive || 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;
}