274 lines
13 KiB
C#
274 lines
13 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();
|
|
_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<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 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<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(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;
|
|
}
|