diff --git a/CHANGELOG.md b/CHANGELOG.md index 186b8db..4d1dfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Lumi changelog +## 0.3.8 + +- Added the native Windows Lumi Overlay using the existing paired-device authentication, normalized provider streams, live-state tracking, and Companion plugin transport without exposing provider credentials. +- Added monitor-aware native layouts, visibility policies, capture-exclusion reporting, bounded animations, Twitch/BTTV emotes, local atomic settings, global hotkeys, preview controls, and direct tray integration. +- Preserved unsaved overlay configuration through live feed/status refreshes, corrected divided-layout percentage presentation, clarified current visibility versus button actions, improved labels and conditional controls, and kept unavailable monitor/source/event selections visible. +- Made single-page Song Overlay and Lumi Overlay plugins direct navigation entries while retaining collapsed grouping for genuinely multi-page plugins. +- Released Companion 0.2.6 through the existing verified installer/updater path while preserving pairing identity, Companion settings, server/plugin data, OBS configuration, and revoked-device state. + ## 0.3.7 - Moved production Stream Testing TLS termination to Nginx Proxy Manager: Companion still receives RTMPS on the paired Lumi hostname while MediaMTX accepts only the decrypted local RTMP stream. diff --git a/companion/Lumi.Companion.sln b/companion/Lumi.Companion.sln index b9c2e55..6196562 100644 --- a/companion/Lumi.Companion.sln +++ b/companion/Lumi.Companion.sln @@ -25,6 +25,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Core.Tests", "tests\Lumi.Companion.Core.Tests\Lumi.Companion.Core.Tests.csproj", "{AAFDF982-BE30-405F-A39B-E82F1B11D46A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Overlay", "plugins\Lumi.Companion.Overlay\Lumi.Companion.Overlay.csproj", "{E6AC503B-9723-441C-8882-D0EC4A845F9D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -131,6 +133,18 @@ Global {AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x64.Build.0 = Release|Any CPU {AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.ActiveCfg = Release|Any CPU {AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.Build.0 = Release|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x64.Build.0 = Debug|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x86.Build.0 = Debug|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|Any CPU.Build.0 = Release|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x64.ActiveCfg = Release|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x64.Build.0 = Release|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x86.ActiveCfg = Release|Any CPU + {E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -144,5 +158,6 @@ Global {49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} {F72BAFAD-17D3-4EF2-8690-B3B5646556C2} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} {AAFDF982-BE30-405F-A39B-E82F1B11D46A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {E6AC503B-9723-441C-8882-D0EC4A845F9D} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} EndGlobalSection EndGlobal diff --git a/companion/README.md b/companion/README.md index 442b8a8..cf5b0a3 100644 --- a/companion/README.md +++ b/companion/README.md @@ -2,9 +2,9 @@ Lumi Companion is the Windows streaming-computer client for Lumi. It provides a single paired-device shell for Companion plugins, including server-hosted -transcription and Song Overlay capture. The app is single-instance, installs per -Windows user, closes to the notification area, and preserves pairing and settings -across updates. +transcription, Song Overlay capture, and the native Lumi monitor overlay. The app +is single-instance, installs per Windows user, closes to the notification area, +and preserves pairing and settings across updates. ## Install and pair @@ -122,6 +122,16 @@ Run Song Overlay's focused verification with: companion/scripts/verify-song-overlay.ps1 ``` +Run the native Lumi Overlay feed, layout/lifecycle, settings, and full managed +build verification with: + +```powershell +companion/scripts/verify-lumi-overlay.ps1 +``` + +Native overlay behavior and capture-exclusion limitations are documented in +[`docs/companion-native-overlay.md`](../docs/companion-native-overlay.md). + Generated installers, pairing packages, credentials, build output, and logs must not be committed. diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index 185ec44..6f5ac21 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -1,5 +1,5 @@ #ifndef AppVersion - #define AppVersion "0.2.5" + #define AppVersion "0.2.6" #endif #ifndef SourceRoot #error SourceRoot must point at the self-contained Companion publish directory. diff --git a/companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs b/companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs new file mode 100644 index 0000000..0bae589 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs @@ -0,0 +1,40 @@ +namespace Lumi.Companion.Overlay; + +public readonly record struct PhysicalRect(int X, int Y, int Width, int Height); + +public static class AnchorLayout +{ + public static void ClampPadding(OverlayContainerSettings settings) + { + var top = settings.Anchor is OverlayAnchor.TopLeft or OverlayAnchor.Top or OverlayAnchor.TopRight or OverlayAnchor.Left or OverlayAnchor.Right; + var bottom = settings.Anchor is OverlayAnchor.BottomLeft or OverlayAnchor.Bottom or OverlayAnchor.BottomRight or OverlayAnchor.Left or OverlayAnchor.Right; + var left = settings.Anchor is OverlayAnchor.TopLeft or OverlayAnchor.Left or OverlayAnchor.BottomLeft or OverlayAnchor.Top or OverlayAnchor.Bottom; + var right = settings.Anchor is OverlayAnchor.TopRight or OverlayAnchor.Right or OverlayAnchor.BottomRight or OverlayAnchor.Top or OverlayAnchor.Bottom; + if (!top) settings.PaddingTop = 0; + if (!right) settings.PaddingRight = 0; + if (!bottom) settings.PaddingBottom = 0; + if (!left) settings.PaddingLeft = 0; + } + + public static PhysicalRect Calculate(OverlayContainerSettings value, int monitorWidth, int monitorHeight) + { + ClampPadding(value); + var availableWidth = Math.Max(1, monitorWidth - value.PaddingLeft - value.PaddingRight); + var availableHeight = Math.Max(1, monitorHeight - value.PaddingTop - value.PaddingBottom); + var width = Math.Clamp(value.Width, 1, availableWidth); + var height = Math.Clamp(value.Height, 1, availableHeight); + var x = value.Anchor switch + { + OverlayAnchor.TopRight or OverlayAnchor.Right or OverlayAnchor.BottomRight => monitorWidth - value.PaddingRight - width, + OverlayAnchor.Top or OverlayAnchor.Bottom => value.PaddingLeft + (availableWidth - width) / 2, + _ => value.PaddingLeft + }; + var y = value.Anchor switch + { + OverlayAnchor.BottomLeft or OverlayAnchor.Bottom or OverlayAnchor.BottomRight => monitorHeight - value.PaddingBottom - height, + OverlayAnchor.Left or OverlayAnchor.Right => value.PaddingTop + (availableHeight - height) / 2, + _ => value.PaddingTop + }; + return new(x, y, width, height); + } +} diff --git a/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs b/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs new file mode 100644 index 0000000..975bc1d --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs @@ -0,0 +1,95 @@ +using System.Runtime.InteropServices; + +namespace Lumi.Companion.Overlay; + +public sealed class GlobalHotkeyManager : IDisposable +{ + private const uint WmHotkey = 0x0312; + private const uint WmQuit = 0x0012; + private readonly Dictionary _actions = []; + private readonly ManualResetEventSlim _ready = new(); + private readonly Thread _thread; + private IntPtr _window; + private uint _threadId; + private int _nextId = 0x4C00; + + public GlobalHotkeyManager() + { + _thread = new Thread(MessageLoop) { IsBackground = true, Name = "Lumi Overlay hotkeys" }; + _thread.Start(); + _ready.Wait(TimeSpan.FromSeconds(3)); + } + + public bool Register(string chord, Action action) + { + if (!TryParse(chord, out var modifiers, out var key) || _window == IntPtr.Zero) return false; + var id = Interlocked.Increment(ref _nextId); + if (!RegisterHotKey(_window, id, modifiers | 0x4000, key)) return false; + lock (_actions) _actions[id] = action; + return true; + } + + public void Clear() + { + lock (_actions) + { + foreach (var id in _actions.Keys) UnregisterHotKey(_window, id); + _actions.Clear(); + } + } + + private void MessageLoop() + { + _threadId = GetCurrentThreadId(); + _window = CreateWindowEx(0, "STATIC", "LumiOverlayHotkeys", 0, 0, 0, 0, 0, new IntPtr(-3), IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + _ready.Set(); + while (GetMessage(out var message, IntPtr.Zero, 0, 0) > 0) + { + if (message.message == WmHotkey) + { + Action? action; + lock (_actions) _actions.TryGetValue(message.wParam.ToInt32(), out action); + try { action?.Invoke(); } catch { } + } + TranslateMessage(ref message); + DispatchMessage(ref message); + } + } + + private static bool TryParse(string chord, out uint modifiers, out uint key) + { + modifiers = 0; + key = 0; + var parts = (chord ?? string.Empty).Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + if (part.Equals("Ctrl", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0002; + else if (part.Equals("Alt", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0001; + else if (part.Equals("Shift", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0004; + else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0008; + else if (part.Length == 1 && char.IsAsciiLetterOrDigit(part[0])) key = char.ToUpperInvariant(part[0]); + else if (part.StartsWith('F') && int.TryParse(part[1..], out var function) && function is >= 1 and <= 24) key = (uint)(0x70 + function - 1); + else return false; + } + return key != 0; + } + + public void Dispose() + { + Clear(); + if (_threadId != 0) PostThreadMessage(_threadId, WmQuit, IntPtr.Zero, IntPtr.Zero); + _thread.Join(TimeSpan.FromSeconds(1)); + _ready.Dispose(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct Msg { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int x; public int y; } + [DllImport("user32.dll", SetLastError = true)] private static extern bool RegisterHotKey(IntPtr hwnd, int id, uint modifiers, uint key); + [DllImport("user32.dll", SetLastError = true)] private static extern bool UnregisterHotKey(IntPtr hwnd, int id); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern IntPtr CreateWindowEx(uint exStyle, string className, string windowName, uint style, int x, int y, int width, int height, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr parameter); + [DllImport("user32.dll")] private static extern int GetMessage(out Msg message, IntPtr hwnd, uint min, uint max); + [DllImport("user32.dll")] private static extern bool TranslateMessage(ref Msg message); + [DllImport("user32.dll")] private static extern IntPtr DispatchMessage(ref Msg message); + [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] private static extern bool PostThreadMessage(uint threadId, uint message, IntPtr wParam, IntPtr lParam); +} diff --git a/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj b/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj new file mode 100644 index 0000000..4753994 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj @@ -0,0 +1,15 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + Lumi.Companion.Overlay + Lumi.Companion.Overlay + + + + + + + + diff --git a/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs b/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs new file mode 100644 index 0000000..4df104c --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs @@ -0,0 +1,273 @@ +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; +} diff --git a/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs b/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs new file mode 100644 index 0000000..59e12d5 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs @@ -0,0 +1,533 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using Microsoft.Win32; + +namespace Lumi.Companion.Overlay; + +public enum CaptureExclusionState { Pending, Active, Unsupported, Failed } +public sealed record CaptureExclusionStatus(CaptureExclusionState State, string Detail); + +public sealed class NativeOverlayWindow : Window, IAsyncDisposable +{ + private const int GwlExStyle = -20; + private const long WsExTransparent = 0x20; + private const long WsExToolWindow = 0x80; + private const long WsExLayered = 0x80000; + private const long WsExNoActivate = 0x08000000; + private const uint SwpNoActivate = 0x0010; + private const uint SwpFrameChanged = 0x0020; + private const uint WdaExcludeFromCapture = 0x11; + private static readonly IntPtr HwndTopmost = new(-1); + + private readonly Grid _root = new(); + private readonly Canvas _surface = new(); + private readonly Grid _unifiedHolder = new(); + private readonly Grid _chatHolder = new(); + private readonly Grid _eventHolder = new(); + private readonly StackPanel _unifiedPanel = new() { Spacing = 8 }; + private readonly StackPanel _chatPanel = new() { Spacing = 8 }; + private readonly StackPanel _eventPanel = new() { Spacing = 8 }; + private readonly Dictionary _cards = []; + private readonly DispatcherTimer _timer; + private readonly HttpsImageCache _images = new(); + private LumiOverlaySettings _settings = new(); + private OverlayLifecycleQueue _chat; + private OverlayLifecycleQueue _events; + private bool _disposed; + private bool _visibleRequested; + private string? _temporaryMonitorWarning; + + public NativeOverlayWindow() + { + WindowDecorations = Avalonia.Controls.WindowDecorations.None; + TransparencyLevelHint = [WindowTransparencyLevel.Transparent]; + Background = Brushes.Transparent; + Topmost = true; + ShowInTaskbar = false; + ShowActivated = false; + CanResize = false; + Focusable = false; + Content = _root; + _root.Children.Add(_surface); + _unifiedHolder.Children.Add(_unifiedPanel); + _chatHolder.Children.Add(_chatPanel); + _eventHolder.Children.Add(_eventPanel); + _surface.Children.Add(_unifiedHolder); + _surface.Children.Add(_chatHolder); + _surface.Children.Add(_eventHolder); + _chat = new(() => ContainerFor("chat")); + _events = new(() => ContainerFor("event")); + _chat.Changed += Render; + _events.Changed += Render; + Opened += (_, _) => { ApplyMonitorAndStyles(); Render(); }; + Screens.Changed += OnScreensChanged; + SystemEvents.DisplaySettingsChanged += OnSystemDisplayChanged; + SystemEvents.PowerModeChanged += OnPowerChanged; + SystemEvents.SessionSwitch += OnSessionSwitch; + _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(33), DispatcherPriority.Render, (_, _) => + { + _chat.Advance(); + _events.Advance(); + }); + _timer.Start(); + } + + public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window."); + public string? MonitorWarning => _temporaryMonitorWarning; + public event Action? NativeStatusChanged; + + public void ApplySettings(LumiOverlaySettings settings) + { + _settings = settings; + _unifiedPanel.Children.Clear(); + _chatPanel.Children.Clear(); + _eventPanel.Children.Clear(); + _cards.Clear(); + ApplyMonitorAndStyles(); + Render(); + } + + public void SetRequestedVisible(bool visible) + { + _visibleRequested = visible; + if (visible) + { + if (!IsVisible) Show(); + ApplyMonitorAndStyles(); + } + else if (IsVisible) Hide(); + } + + public void AddChat(OverlayFeedMessage message) => _chat.Add(message.Id, "chat", message); + public void AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value); + public void Clear() { _chat.Clear(); _events.Clear(); } + public void EndPreview() + { + _chat.ExitWhere(item => item.Id.StartsWith("sample-", StringComparison.Ordinal)); + _events.ExitWhere(item => item.Id.StartsWith("sample-", StringComparison.Ordinal)); + } + + private OverlayContainerSettings ContainerFor(string kind) => + _settings.Layout == OverlayLayoutMode.Unified ? _settings.Unified : kind == "chat" ? _settings.Chat : _settings.Events; + + private void Render() + { + if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(Render); return; } + var screen = ResolveScreen(); + if (screen is null) return; + var width = screen.Bounds.Width; + var height = screen.Bounds.Height; + var scaling = screen.Scaling; + if (_settings.Layout == OverlayLayoutMode.Unified) + { + var combined = _chat.Items.Concat(_events.Items).OrderBy(item => item.QueuedAt).ToList(); + ConfigureContainer(_unifiedHolder, _unifiedPanel, combined, _settings.Unified, + Logical(AnchorLayout.Calculate(_settings.Unified, width, height), scaling), true); + ConfigureContainer(_chatHolder, _chatPanel, [], _settings.Chat, default, false); + ConfigureContainer(_eventHolder, _eventPanel, [], _settings.Events, default, false); + } + else if (_settings.Layout is OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical) + { + var rect = AnchorLayout.Calculate(_settings.Unified, width, height); + if (_settings.Layout == OverlayLayoutMode.DividedHorizontal) + { + var chatWidth = rect.Width * _settings.ChatPercentage / 100; + ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(new(rect.X, rect.Y, chatWidth, rect.Height), scaling), true); + ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(new(rect.X + chatWidth, rect.Y, rect.Width - chatWidth, rect.Height), scaling), true); + } + else + { + var chatHeight = rect.Height * _settings.ChatPercentage / 100; + ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(new(rect.X, rect.Y, rect.Width, chatHeight), scaling), true); + ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(new(rect.X, rect.Y + chatHeight, rect.Width, rect.Height - chatHeight), scaling), true); + } + ConfigureContainer(_unifiedHolder, _unifiedPanel, [], _settings.Unified, default, false); + } + else + { + ConfigureContainer(_unifiedHolder, _unifiedPanel, [], _settings.Unified, default, false); + ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(AnchorLayout.Calculate(_settings.Chat, width, height), scaling), true); + ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(AnchorLayout.Calculate(_settings.Events, width, height), scaling), true); + } + var activeIds = _chat.Items.Concat(_events.Items).Select(item => item.Id).ToHashSet(); + foreach (var stale in _cards.Keys.Where(id => !activeIds.Contains(id)).ToList()) _cards.Remove(stale); + } + + private void ConfigureContainer(Grid holder, StackPanel panel, IEnumerable values, + OverlayContainerSettings settings, PhysicalRect rect, bool visible) + { + holder.IsVisible = visible; + if (!visible) { panel.Children.Clear(); return; } + holder.Width = rect.Width; + holder.Height = rect.Height; + panel.Width = rect.Width; + panel.MaxHeight = rect.Height; + panel.HorizontalAlignment = settings.HorizontalAlignment switch + { + OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left, + OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center, + _ => Avalonia.Layout.HorizontalAlignment.Right + }; + panel.VerticalAlignment = settings.VerticalAlignment switch + { + OverlayVerticalAlignment.Top => Avalonia.Layout.VerticalAlignment.Top, + OverlayVerticalAlignment.Middle => Avalonia.Layout.VerticalAlignment.Center, + _ => Avalonia.Layout.VerticalAlignment.Bottom + }; + Canvas.SetLeft(holder, rect.X); + Canvas.SetTop(holder, rect.Y); + var items = (settings.NewestDirection == OverlayNewestDirection.End ? values : values.Reverse()).ToList(); + var oldPositions = panel.Children.OfType().Where(card => card.Tag is CardVisual) + .ToDictionary(card => ((CardVisual)card.Tag!).Id, card => rect.Y + card.Bounds.Y); + foreach (var other in new[] { _unifiedPanel, _chatPanel, _eventPanel }) + if (!ReferenceEquals(other, panel)) + foreach (var card in other.Children.OfType().Where(card => card.Tag is CardVisual visual && items.Any(item => item.Id == visual.Id)).ToList()) + other.Children.Remove(card); + panel.Children.Clear(); + foreach (var item in items) + { + var visual = GetOrCreateCard(item, settings, out var created); + visual.Card.MaxWidth = rect.Width; + panel.Children.Add(visual.Card); + if (!created) ApplyCardState(visual, item, settings); + } + Dispatcher.UIThread.Post(() => + { + foreach (var visual in panel.Children.OfType().Select(card => card.Tag as CardVisual).Where(value => value is not null)) + { + if (!oldPositions.TryGetValue(visual!.Id, out var old)) continue; + var target = visual.Translate.Y; + var transitions = visual.Translate.Transitions; + visual.Translate.Transitions = null; + visual.Translate.Y += old - (rect.Y + visual.Card.Bounds.Y); + Dispatcher.UIThread.Post(() => + { + visual.Translate.Transitions = transitions; + visual.Translate.Y = target; + }, DispatcherPriority.Render); + } + }, DispatcherPriority.Render); + } + + private CardVisual GetOrCreateCard(OverlayQueueItem item, OverlayContainerSettings settings, out bool created) + { + if (_cards.TryGetValue(item.Id, out var existing)) { created = false; return existing; } + var content = item.Payload switch + { + OverlayFeedMessage message => CreateChatContent(message, settings), + OverlayRenderedEvent value => CreateEventContent(value, settings), + _ => new TextBlock { Text = item.Payload.ToString() ?? "" } + }; + var translate = new TranslateTransform(); + var scale = new ScaleTransform(); + var transforms = new TransformGroup(); + transforms.Children.Add(scale); + transforms.Children.Add(translate); + var border = new Border + { + Background = OverlayColor.BrushFromRgba(settings.CardBackground), + CornerRadius = new CornerRadius(9), + Padding = new Thickness(12, 9), + Child = content, + HorizontalAlignment = settings.HorizontalAlignment switch + { + OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left, + OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center, + _ => Avalonia.Layout.HorizontalAlignment.Right + }, + RenderTransform = transforms, + RenderTransformOrigin = RelativePoint.Center + }; + var visual = new CardVisual(item.Id, border, translate, scale); + border.Tag = visual; + _cards[item.Id] = visual; + created = true; + SetInitialState(visual, settings.EntryAnimation, settings); + Dispatcher.UIThread.Post(() => ApplyCardState(visual, item, settings), DispatcherPriority.Render); + return visual; + } + + private static void SetInitialState(CardVisual visual, OverlayAnimation animation, OverlayContainerSettings settings) + { + visual.Card.Opacity = animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1; + visual.Translate.X = animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? settings.SlideDistance : 0; + visual.Scale.ScaleX = visual.Scale.ScaleY = animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1; + } + + private static void ApplyCardState(CardVisual visual, OverlayQueueItem item, OverlayContainerSettings settings) + { + var exiting = item.State == OverlayItemState.Exiting; + var animation = exiting ? settings.ExitAnimation : settings.EntryAnimation; + var duration = TimeSpan.FromMilliseconds(exiting ? settings.ExitDurationMs : settings.EntryDurationMs); + var easing = EasingFor(settings.Easing); + visual.Card.Transitions = [new DoubleTransition { Property = OpacityProperty, Duration = duration, Easing = easing }]; + visual.Translate.Transitions = + [ + new DoubleTransition { Property = TranslateTransform.XProperty, Duration = duration, Easing = easing }, + new DoubleTransition { Property = TranslateTransform.YProperty, Duration = duration, Easing = easing } + ]; + visual.Scale.Transitions = + [ + new DoubleTransition { Property = ScaleTransform.ScaleXProperty, Duration = duration, Easing = easing }, + new DoubleTransition { Property = ScaleTransform.ScaleYProperty, Duration = duration, Easing = easing } + ]; + visual.Card.Opacity = exiting && animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1; + visual.Translate.X = exiting && animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? -settings.SlideDistance : 0; + var scale = exiting && animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1; + visual.Scale.ScaleX = visual.Scale.ScaleY = scale; + } + + private static Easing EasingFor(string value) => value switch + { + "linear" => new LinearEasing(), + "cubic-in-out" => new CubicEaseInOut(), + _ => new CubicEaseOut() + }; + + private Control CreateChatContent(OverlayFeedMessage message, OverlayContainerSettings settings) + { + var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse(settings.ShowAvatar && message.Author.Avatar is not null ? "40,*" : "*"), ColumnSpacing = 10 }; + var contentColumn = 0; + if (settings.ShowAvatar && message.Author.Avatar is not null) + { + var avatar = new Image { Width = 36, Height = 36, Stretch = Stretch.UniformToFill }; + _ = LoadImageAsync(avatar, message.Author.Avatar); + grid.Children.Add(avatar); + contentColumn = 1; + } + var stack = new StackPanel { Spacing = 3 }; + var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 }; + if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold)); + foreach (var badge in message.Author.Badges.Take(6)) + header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold)); + if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold)); + if (header.Children.Count > 0) stack.Children.Add(header); + stack.Children.Add(CreateMessageBody(message, settings)); + Grid.SetColumn(stack, contentColumn); + grid.Children.Add(stack); + return grid; + } + + private Control CreateMessageBody(OverlayFeedMessage message, OverlayContainerSettings settings) + { + var panel = new WrapPanel { Orientation = Avalonia.Layout.Orientation.Horizontal }; + var cursor = 0; + foreach (var emote in message.Emotes.OrderBy(value => value.Start)) + { + if (emote.Start < cursor || emote.Start > message.Text.Length) continue; + if (emote.Start > cursor) panel.Children.Add(Text(message.Text[cursor..emote.Start], settings, settings.FontSize)); + if (emote.Image is not null) + { + var token = new Grid { Margin = new Thickness(2, 0) }; + var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize); + var image = new Image { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 }; + token.Children.Add(fallback); + token.Children.Add(image); + panel.Children.Add(token); + _ = LoadImageAsync(image, emote.Image, fallback); + } + else panel.Children.Add(Text(emote.Label, settings, settings.FontSize)); + cursor = Math.Min(message.Text.Length, emote.End + 1); + } + if (cursor < message.Text.Length) panel.Children.Add(Text(message.Text[cursor..], settings, settings.FontSize)); + if (panel.Children.Count == 0) panel.Children.Add(Text(message.Text, settings, settings.FontSize)); + return panel; + } + + private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings) + { + var stack = new StackPanel { Spacing = 3 }; + if (settings.ShowPlatform) stack.Children.Add(Text(value.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold)); + stack.Children.Add(Text(value.Summary, settings, settings.FontSize, FontWeight.SemiBold)); + return stack; + } + + private static TextBlock Text(string value, OverlayContainerSettings settings, double size, FontWeight? weight = null) + { + var block = new TextBlock + { + Text = value, + TextWrapping = TextWrapping.Wrap, + Foreground = Brush.Parse(settings.TextColor), + TextAlignment = settings.HorizontalAlignment switch + { + OverlayHorizontalAlignment.Left => TextAlignment.Left, + OverlayHorizontalAlignment.Middle => TextAlignment.Center, + _ => TextAlignment.Right + }, + FontFamily = new FontFamily(settings.FontFamily), + FontSize = size, + FontWeight = weight ?? FontWeight.Normal + }; + block.Effect = new DropShadowEffect + { + Color = OverlayColor.ColorFromRgba(settings.TextShadow), + OffsetX = settings.ShadowX, + OffsetY = settings.ShadowY, + BlurRadius = settings.ShadowBlur + }; + return block; + } + + private async Task LoadImageAsync(Image target, string url, Control? fallback = null) + { + var bitmap = await _images.GetAsync(url).ConfigureAwait(false); + await Dispatcher.UIThread.InvokeAsync(() => + { + if (bitmap is not null) + { + target.Source = bitmap; + target.Opacity = 1; + if (fallback is not null) fallback.IsVisible = false; + } + }); + } + + private Screen? ResolveScreen() + { + var all = Screens.All; + var primary = Screens.Primary ?? all.FirstOrDefault(); + var resolution = OverlayMonitorPolicy.Resolve(_settings.MonitorId, all.Select(ScreenId), primary is null ? null : ScreenId(primary)); + var selected = all.FirstOrDefault(screen => ScreenId(screen) == resolution.MonitorId); + if (resolution.Warning != _temporaryMonitorWarning) + { + _temporaryMonitorWarning = resolution.Warning; + NativeStatusChanged?.Invoke(); + } + return selected; + } + + public IReadOnlyList<(string Id, string Label)> Monitors() => Screens.All.Select((screen, index) => + (ScreenId(screen), $"{(screen.IsPrimary ? "Primary · " : "")}{screen.DisplayName ?? $"Monitor {index + 1}"} · {screen.Bounds.Width}×{screen.Bounds.Height}")).ToList(); + + public static string ScreenId(Screen screen) => !string.IsNullOrWhiteSpace(screen.DisplayName) + ? $"display:{screen.DisplayName}" + : $"bounds:{screen.Bounds.X},{screen.Bounds.Y},{screen.Bounds.Width},{screen.Bounds.Height}"; + + private static PhysicalRect Logical(PhysicalRect value, double scaling) => new( + (int)Math.Round(value.X / scaling), + (int)Math.Round(value.Y / scaling), + Math.Max(1, (int)Math.Round(value.Width / scaling)), + Math.Max(1, (int)Math.Round(value.Height / scaling))); + + private void ApplyMonitorAndStyles() + { + if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(ApplyMonitorAndStyles); return; } + var screen = ResolveScreen(); + if (screen is null) return; + Position = screen.Bounds.Position; + Width = screen.Bounds.Width / screen.Scaling; + Height = screen.Bounds.Height / screen.Scaling; + var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + if (hwnd == IntPtr.Zero) return; + var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate; + SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style)); + SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged); + if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture)) + CaptureExclusion = new(CaptureExclusionState.Active, "Windows capture exclusion is active (best effort; capture software may still include the overlay)."); + else + { + var error = Marshal.GetLastWin32Error(); + CaptureExclusion = error == 87 + ? new(CaptureExclusionState.Unsupported, "This Windows/capture path does not support display-affinity exclusion.") + : new(CaptureExclusionState.Failed, $"Windows could not apply capture exclusion (error {error})."); + } + NativeStatusChanged?.Invoke(); + if (!_visibleRequested && IsVisible) Hide(); + } + + private void OnScreensChanged(object? sender, EventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); + private void OnSystemDisplayChanged(object? sender, EventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); + private void OnPowerChanged(object sender, PowerModeChangedEventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); + private void OnSessionSwitch(object sender, SessionSwitchEventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); + + public ValueTask DisposeAsync() + { + if (_disposed) return ValueTask.CompletedTask; + _disposed = true; + _timer.Stop(); + Screens.Changed -= OnScreensChanged; + SystemEvents.DisplaySettingsChanged -= OnSystemDisplayChanged; + SystemEvents.PowerModeChanged -= OnPowerChanged; + SystemEvents.SessionSwitch -= OnSessionSwitch; + _images.Dispose(); + Close(); + return ValueTask.CompletedTask; + } + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] private static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int index); + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int index, IntPtr value); + [DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint affinity); + + private sealed record CardVisual(string Id, Border Card, TranslateTransform Translate, ScaleTransform Scale); +} + +internal sealed class HttpsImageCache : IDisposable +{ + private const int Limit = 128; + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) }; + private readonly ConcurrentDictionary>> _entries = new(); + private readonly Queue _order = new(); + private readonly object _gate = new(); + + public Task GetAsync(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult(null); + lock (_gate) + { + if (!_entries.ContainsKey(uri.AbsoluteUri)) + { + while (_entries.Count >= Limit && _order.TryDequeue(out var oldest)) + if (_entries.TryRemove(oldest, out var removed) && removed.IsValueCreated && removed.Value.IsCompletedSuccessfully) removed.Value.Result?.Dispose(); + _order.Enqueue(uri.AbsoluteUri); + } + } + return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value; + } + + private async Task DownloadAsync(string url) + { + try + { + using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > 2 * 1024 * 1024) return null; + var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + if (bytes.Length > 2 * 1024 * 1024) return null; + return new Bitmap(new MemoryStream(bytes)); + } + catch { return null; } + } + + public void Dispose() + { + _http.Dispose(); + foreach (var value in _entries.Values) + if (value.IsValueCreated && value.Value.IsCompletedSuccessfully) value.Value.Result?.Dispose(); + } +} + +internal static class OverlayColor +{ + public static Color ColorFromRgba(string value) + { + var hex = value.TrimStart('#'); + if (hex.Length != 8) return Color.Parse(value); + return Color.FromArgb( + Convert.ToByte(hex[6..8], 16), + Convert.ToByte(hex[0..2], 16), + Convert.ToByte(hex[2..4], 16), + Convert.ToByte(hex[4..6], 16)); + } + + public static IBrush BrushFromRgba(string value) => new SolidColorBrush(ColorFromRgba(value)); +} diff --git a/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs b/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs new file mode 100644 index 0000000..104a861 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/OverlayFeedModels.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Lumi.Companion.Overlay; + +public sealed class OverlayFeedAuthor +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("username")] public string Username { get; set; } = ""; + [JsonPropertyName("avatar")] public string? Avatar { get; set; } + [JsonPropertyName("badges")] public List Badges { get; set; } = []; +} + +public sealed class OverlayFeedBadge +{ + [JsonPropertyName("label")] public string Label { get; set; } = ""; + [JsonPropertyName("image")] public string? Image { get; set; } +} + +public sealed class OverlayFeedEmote +{ + [JsonPropertyName("start")] public int Start { get; set; } + [JsonPropertyName("end")] public int End { get; set; } + [JsonPropertyName("label")] public string Label { get; set; } = ""; + [JsonPropertyName("image")] public string? Image { get; set; } +} + +public sealed class OverlayFeedMessage +{ + [JsonPropertyName("id")] public string Id { get; set; } = ""; + [JsonPropertyName("platform")] public string Platform { get; set; } = ""; + [JsonPropertyName("text")] public string Text { get; set; } = ""; + [JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new(); + [JsonPropertyName("emotes")] public List Emotes { get; set; } = []; +} + +public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload); + +public sealed record OverlayFeedStatus(string Platform, string State, string Detail); diff --git a/companion/plugins/Lumi.Companion.Overlay/OverlayLifecycleQueue.cs b/companion/plugins/Lumi.Companion.Overlay/OverlayLifecycleQueue.cs new file mode 100644 index 0000000..54024ef --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/OverlayLifecycleQueue.cs @@ -0,0 +1,90 @@ +namespace Lumi.Companion.Overlay; + +public enum OverlayItemState { Queued, Entering, Visible, Exiting, Removed } + +public sealed class OverlayQueueItem(string id, string kind, object payload, DateTimeOffset queuedAt) +{ + public string Id { get; } = id; + public string Kind { get; } = kind; + public object Payload { get; } = payload; + public DateTimeOffset QueuedAt { get; } = queuedAt; + public DateTimeOffset StateAt { get; internal set; } = queuedAt; + public OverlayItemState State { get; internal set; } = OverlayItemState.Queued; +} + +public sealed class OverlayLifecycleQueue(Func settings, Func? now = null) +{ + private readonly List _items = []; + private readonly Func _now = now ?? (() => DateTimeOffset.UtcNow); + public IReadOnlyList Items => _items; + public event Action? Changed; + + public bool Add(string id, string kind, object payload) + { + if (_items.Any(item => item.Id == id && item.State != OverlayItemState.Removed)) return false; + var value = settings(); + if (_items.Count(item => item.State != OverlayItemState.Removed) >= Math.Max(value.QueueLimit + 1, value.QueueLimit * 2)) + return false; + var active = _items.Where(item => item.State is not OverlayItemState.Exiting and not OverlayItemState.Removed).ToList(); + while (active.Count >= value.QueueLimit) + { + var oldest = active[0]; + oldest.State = OverlayItemState.Exiting; + oldest.StateAt = _now(); + active.RemoveAt(0); + } + var created = new OverlayQueueItem(id, kind, payload, _now()) { State = OverlayItemState.Entering, StateAt = _now() }; + _items.Add(created); + Changed?.Invoke(); + return true; + } + + public void Advance() + { + var current = _now(); + var value = settings(); + var changed = false; + foreach (var item in _items) + { + var elapsed = current - item.StateAt; + if (item.State == OverlayItemState.Entering && elapsed.TotalMilliseconds >= value.EntryDurationMs) + { + item.State = OverlayItemState.Visible; item.StateAt = current; changed = true; + } + else if (item.State == OverlayItemState.Visible && elapsed.TotalMilliseconds >= value.LifetimeMs) + { + item.State = OverlayItemState.Exiting; item.StateAt = current; changed = true; + } + else if (item.State == OverlayItemState.Exiting && elapsed.TotalMilliseconds >= value.ExitDurationMs) + { + item.State = OverlayItemState.Removed; changed = true; + } + } + if (_items.RemoveAll(item => item.State == OverlayItemState.Removed) > 0) changed = true; + if (changed) Changed?.Invoke(); + } + + public void Clear() + { + var current = _now(); + foreach (var item in _items.Where(item => item.State is not OverlayItemState.Exiting and not OverlayItemState.Removed)) + { + item.State = OverlayItemState.Exiting; + item.StateAt = current; + } + Changed?.Invoke(); + } + + public void ExitWhere(Func predicate) + { + var current = _now(); + var changed = false; + foreach (var item in _items.Where(item => item.State is not OverlayItemState.Exiting and not OverlayItemState.Removed && predicate(item))) + { + item.State = OverlayItemState.Exiting; + item.StateAt = current; + changed = true; + } + if (changed) Changed?.Invoke(); + } +} diff --git a/companion/plugins/Lumi.Companion.Overlay/OverlayRuntimePolicy.cs b/companion/plugins/Lumi.Companion.Overlay/OverlayRuntimePolicy.cs new file mode 100644 index 0000000..ffb33ff --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/OverlayRuntimePolicy.cs @@ -0,0 +1,72 @@ +namespace Lumi.Companion.Overlay; + +public static class OverlayVisibilityPolicy +{ + public static bool Automatic(LumiOverlaySettings settings, IEnumerable sources) + { + if (!settings.Enabled || settings.Visibility == OverlayVisibilityMode.Off) return false; + if (settings.Visibility == OverlayVisibilityMode.On) return true; + var selected = settings.SelectedSourceIds.ToHashSet(StringComparer.Ordinal); + return sources.Any(source => source.Live == true && + source.Platform is "twitch" or "youtube" && + selected.Contains(source.Id)); + } + + public static bool Effective(LumiOverlaySettings settings, IEnumerable sources, bool preview, bool? visibilityOverride) => + preview || settings.Enabled && (visibilityOverride ?? Automatic(settings, sources)); +} + +public sealed record OverlayVisibilityPresentation(bool Visible, string State, string Action, bool CanToggle); + +public static class OverlayVisibilityPresenter +{ + public static OverlayVisibilityPresentation Describe( + LumiOverlaySettings settings, + bool automaticVisible, + bool preview, + bool? visibilityOverride) + { + if (preview) + return new(true, "Shown — sample preview is controlling visibility", "End preview to change visibility", false); + if (!settings.Enabled) + return new(false, "Hidden — Lumi Overlay is disabled", "Enable and save Lumi Overlay first", false); + + var visible = visibilityOverride ?? automaticVisible; + if (visibilityOverride.HasValue) + return new(visible, + visible ? "Shown — temporary override" : "Hidden — temporary override", + "Restore automatic visibility", + true); + + var automaticDetail = settings.Visibility switch + { + OverlayVisibilityMode.Off => "visibility is set to Off", + OverlayVisibilityMode.WhenLive when visible => "a selected channel is live", + OverlayVisibilityMode.WhenLive => "waiting for a selected channel to go live", + _ => "visibility is set to Always on" + }; + return new(visible, + $"{(visible ? "Shown" : "Hidden")} — automatic; {automaticDetail}", + visible ? "Hide temporarily" : "Show temporarily", + true); + } +} + +public sealed record MonitorResolution(string? MonitorId, bool UsedFallback, string? Warning); + +public static class OverlayMonitorPolicy +{ + public static MonitorResolution Resolve(string? savedMonitorId, IEnumerable availableMonitorIds, string? primaryMonitorId) + { + var available = availableMonitorIds.Where(value => !string.IsNullOrWhiteSpace(value)).ToHashSet(StringComparer.Ordinal); + if (savedMonitorId is not null && available.Contains(savedMonitorId)) return new(savedMonitorId, false, null); + var fallback = primaryMonitorId is not null && available.Contains(primaryMonitorId) ? primaryMonitorId : available.FirstOrDefault(); + return new(fallback, savedMonitorId is not null, + savedMonitorId is null ? null : "The selected monitor is unavailable. Lumi Overlay is temporarily using the primary monitor."); + } +} + +public static class CompanionLiveReconnectPolicy +{ + public static TimeSpan Delay(int attempt) => TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(Math.Max(1, attempt), 5)))); +} diff --git a/companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs b/companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs new file mode 100644 index 0000000..b0062a5 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs @@ -0,0 +1,192 @@ +using System.Text.Json.Serialization; + +namespace Lumi.Companion.Overlay; + +public enum OverlayVisibilityMode { Off, WhenLive, On } +public enum OverlayLayoutMode { Unified, DividedHorizontal, DividedVertical, Separate } +public enum OverlayAnchor { TopLeft, Top, TopRight, Left, Right, BottomLeft, Bottom, BottomRight } +public enum OverlayHorizontalAlignment { Left, Middle, Right } +public enum OverlayVerticalAlignment { Top, Middle, Bottom } +public enum OverlayNewestDirection { Start, End } +public enum OverlayAnimation { None, Fade, Slide, FadeSlide, ScaleFade } + +public sealed class OverlaySourceSetting +{ + public string Id { get; set; } = ""; + public string Platform { get; set; } = ""; + public string Label { get; set; } = ""; + public bool Selected { get; set; } +} + +public sealed class OverlayContainerSettings +{ + public OverlayAnchor Anchor { get; set; } = OverlayAnchor.BottomLeft; + public int PaddingTop { get; set; } = 24; + public int PaddingRight { get; set; } = 24; + public int PaddingBottom { get; set; } = 24; + public int PaddingLeft { get; set; } = 24; + public int Width { get; set; } = 700; + public int Height { get; set; } = 600; + public OverlayHorizontalAlignment HorizontalAlignment { get; set; } = OverlayHorizontalAlignment.Left; + public OverlayVerticalAlignment VerticalAlignment { get; set; } = OverlayVerticalAlignment.Bottom; + public OverlayNewestDirection NewestDirection { get; set; } = OverlayNewestDirection.End; + public string CardBackground { get; set; } = "#12121266"; + public string TextColor { get; set; } = "#bbbbbb"; + public string FontFamily { get; set; } = "Segoe UI"; + public double FontSize { get; set; } = 22; + public string TextShadow { get; set; } = "#000000aa"; + public int ShadowX { get; set; } = 1; + public int ShadowY { get; set; } = 2; + public int ShadowBlur { get; set; } = 3; + public bool ShowAvatar { get; set; } = true; + public bool ShowUsername { get; set; } = true; + public bool ShowPlatform { get; set; } = true; + public OverlayAnimation EntryAnimation { get; set; } = OverlayAnimation.FadeSlide; + public OverlayAnimation ExitAnimation { get; set; } = OverlayAnimation.FadeSlide; + public int EntryDurationMs { get; set; } = 260; + public int ExitDurationMs { get; set; } = 220; + public string Easing { get; set; } = "cubic-out"; + public int SlideDistance { get; set; } = 24; + public double ScaleAmount { get; set; } = 0.92; + public int LifetimeMs { get; set; } = 12000; + public int QueueLimit { get; set; } = 12; + + public OverlayContainerSettings CreateCopy() => new() + { + Anchor = Anchor, + PaddingTop = PaddingTop, + PaddingRight = PaddingRight, + PaddingBottom = PaddingBottom, + PaddingLeft = PaddingLeft, + Width = Width, + Height = Height, + HorizontalAlignment = HorizontalAlignment, + VerticalAlignment = VerticalAlignment, + NewestDirection = NewestDirection, + CardBackground = CardBackground, + TextColor = TextColor, + FontFamily = FontFamily, + FontSize = FontSize, + TextShadow = TextShadow, + ShadowX = ShadowX, + ShadowY = ShadowY, + ShadowBlur = ShadowBlur, + ShowAvatar = ShowAvatar, + ShowUsername = ShowUsername, + ShowPlatform = ShowPlatform, + EntryAnimation = EntryAnimation, + ExitAnimation = ExitAnimation, + EntryDurationMs = EntryDurationMs, + ExitDurationMs = ExitDurationMs, + Easing = Easing, + SlideDistance = SlideDistance, + ScaleAmount = ScaleAmount, + LifetimeMs = LifetimeMs, + QueueLimit = QueueLimit + }; +} + +public sealed class LumiOverlaySettings +{ + public int SchemaVersion { get; set; } = 1; + public string ActiveProfileId { get; set; } = "default"; + public bool Enabled { get; set; } = true; + public OverlayVisibilityMode Visibility { get; set; } = OverlayVisibilityMode.On; + public string? MonitorId { get; set; } + public List Sources { get; set; } = []; + public HashSet EventTypes { get; set; } = []; + public OverlayLayoutMode Layout { get; set; } = OverlayLayoutMode.Unified; + public int ChatPercentage { get; set; } = 70; + public OverlayContainerSettings Unified { get; set; } = new(); + public OverlayContainerSettings Chat { get; set; } = new(); + public OverlayContainerSettings Events { get; set; } = new() { Anchor = OverlayAnchor.TopRight, Width = 520, Height = 420 }; + public string ToggleVisibilityHotkey { get; set; } = "Ctrl+Alt+L"; + public string TogglePreviewHotkey { get; set; } = "Ctrl+Alt+P"; + + [JsonIgnore] + public IEnumerable SelectedSourceIds => Sources.Where(source => source.Selected).Select(source => source.Id); + + public LumiOverlaySettings CreateCopy() => new() + { + SchemaVersion = SchemaVersion, + ActiveProfileId = ActiveProfileId, + Enabled = Enabled, + Visibility = Visibility, + MonitorId = MonitorId, + Sources = Sources.Select(source => new OverlaySourceSetting + { + Id = source.Id, + Platform = source.Platform, + Label = source.Label, + Selected = source.Selected + }).ToList(), + EventTypes = EventTypes.ToHashSet(StringComparer.Ordinal), + Layout = Layout, + ChatPercentage = ChatPercentage, + Unified = Unified.CreateCopy(), + Chat = Chat.CreateCopy(), + Events = Events.CreateCopy(), + ToggleVisibilityHotkey = ToggleVisibilityHotkey, + TogglePreviewHotkey = TogglePreviewHotkey + }; + + public void Normalize() + { + SchemaVersion = 1; + ActiveProfileId = Clean(ActiveProfileId, 80, "default"); + MonitorId = string.IsNullOrWhiteSpace(MonitorId) ? null : MonitorId.Trim()[..Math.Min(MonitorId.Trim().Length, 300)]; + Sources = Sources.Where(source => !string.IsNullOrWhiteSpace(source.Id)) + .GroupBy(source => source.Id, StringComparer.Ordinal).Select(group => group.First()).Take(250).ToList(); + foreach (var source in Sources) + { + source.Id = Clean(source.Id, 300, ""); + source.Platform = Clean(source.Platform, 24, ""); + source.Label = Clean(source.Label, 300, source.Id); + } + EventTypes = EventTypes.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => Clean(value, 120, "")).Take(100).ToHashSet(); + ChatPercentage = Math.Clamp(ChatPercentage, 10, 90); + NormalizeContainer(Unified); + NormalizeContainer(Chat); + NormalizeContainer(Events); + ToggleVisibilityHotkey = Clean(ToggleVisibilityHotkey, 80, "Ctrl+Alt+L"); + TogglePreviewHotkey = Clean(TogglePreviewHotkey, 80, "Ctrl+Alt+P"); + } + + private static void NormalizeContainer(OverlayContainerSettings value) + { + value.Width = Math.Clamp(value.Width, 160, 16000); + value.Height = Math.Clamp(value.Height, 100, 16000); + value.PaddingTop = Math.Clamp(value.PaddingTop, 0, 4000); + value.PaddingRight = Math.Clamp(value.PaddingRight, 0, 4000); + value.PaddingBottom = Math.Clamp(value.PaddingBottom, 0, 4000); + value.PaddingLeft = Math.Clamp(value.PaddingLeft, 0, 4000); + AnchorLayout.ClampPadding(value); + value.CardBackground = ColorValue(value.CardBackground, true, "#12121266"); + value.TextColor = ColorValue(value.TextColor, false, "#bbbbbb"); + value.TextShadow = ColorValue(value.TextShadow, true, "#000000aa"); + value.FontFamily = Clean(value.FontFamily, 120, "Segoe UI"); + value.FontSize = Math.Clamp(value.FontSize, 8, 120); + value.ShadowX = Math.Clamp(value.ShadowX, -40, 40); + value.ShadowY = Math.Clamp(value.ShadowY, -40, 40); + value.ShadowBlur = Math.Clamp(value.ShadowBlur, 0, 40); + value.EntryDurationMs = Math.Clamp(value.EntryDurationMs, 0, 5000); + value.ExitDurationMs = Math.Clamp(value.ExitDurationMs, 0, 5000); + value.SlideDistance = Math.Clamp(value.SlideDistance, 0, 1000); + value.ScaleAmount = Math.Clamp(value.ScaleAmount, 0.1, 2); + value.LifetimeMs = Math.Clamp(value.LifetimeMs, 1000, 300000); + value.QueueLimit = Math.Clamp(value.QueueLimit, 1, 100); + value.Easing = Clean(value.Easing, 40, "cubic-out"); + } + + private static string Clean(string? value, int max, string fallback) + { + var clean = (value ?? "").Trim(); + return clean.Length == 0 ? fallback : clean[..Math.Min(clean.Length, max)]; + } + + private static string ColorValue(string? value, bool alpha, string fallback) => + System.Text.RegularExpressions.Regex.IsMatch(value ?? "", alpha ? "^#[0-9a-fA-F]{8}$" : "^#[0-9a-fA-F]{6}$") ? value! : fallback; +} + +public sealed record OverlaySource(string Id, string Platform, string Label, bool? Live, bool Available, string Status); +public sealed record OverlayEventType(string Id, string Label, string Platform); diff --git a/companion/plugins/Lumi.Companion.Overlay/OverlaySettingsStore.cs b/companion/plugins/Lumi.Companion.Overlay/OverlaySettingsStore.cs new file mode 100644 index 0000000..b681fa0 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/OverlaySettingsStore.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace Lumi.Companion.Overlay; + +public sealed class OverlaySettingsStore(string path) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + public LumiOverlaySettings Current { get; private set; } = new(); + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(path)) { Current.Normalize(); return; } + try + { + await using var stream = File.OpenRead(path); + Current = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false) ?? new(); + } + catch (JsonException) + { + var backup = path + $".invalid-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"; + File.Copy(path, backup, true); + Current = new(); + } + Current.Normalize(); + } + + public async Task SaveAsync(LumiOverlaySettings value, CancellationToken cancellationToken = default) + { + value.Normalize(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + ".tmp"; + await using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await JsonSerializer.SerializeAsync(stream, value, JsonOptions, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + File.Move(temporary, path, true); + Current = value; + } +} diff --git a/companion/plugins/Lumi.Companion.Overlay/plugin.json b/companion/plugins/Lumi.Companion.Overlay/plugin.json new file mode 100644 index 0000000..d7319b0 --- /dev/null +++ b/companion/plugins/Lumi.Companion.Overlay/plugin.json @@ -0,0 +1,16 @@ +{ + "id": "lumi_overlay", + "name": "Lumi Overlay", + "version": "0.1.0", + "provider_api": 1, + "capabilities": [ + "network.lumi.overlay.read", + "windows.monitor.overlay", + "windows.capture-exclusion", + "windows.global-hotkeys" + ], + "navigation": { + "root": "Lumi Overlay", + "pages": ["LumiOverlay"] + } +} diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index 23633c1..4e894a9 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.2.5", + [string]$Version = "0.2.6", [string]$BridgeVersion = "0.2.5", [string]$ObsVersion = "31.1.1" ) diff --git a/companion/scripts/verify-lumi-overlay.ps1 b/companion/scripts/verify-lumi-overlay.ps1 new file mode 100644 index 0000000..3391fba --- /dev/null +++ b/companion/scripts/verify-lumi-overlay.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$sdk = Join-Path $env:LOCALAPPDATA "Lumi\dotnet-sdk-10\dotnet.exe" +$dotnet = if (Test-Path $sdk) { $sdk } else { (Get-Command dotnet.exe -ErrorAction Stop).Source } +$node = (Get-Command node.exe -ErrorAction Stop).Source +Push-Location $repoRoot +try { + & $node "plugins/lumi_overlay/tests/verify.js" + if ($LASTEXITCODE) { throw "Lumi Overlay server verification failed." } + & $node "plugins/lumi_transcription/tests/verify.js" + if ($LASTEXITCODE) { throw "Companion capability verification failed." } + & $dotnet run --project "companion/tests/Lumi.Companion.Core.Tests/Lumi.Companion.Core.Tests.csproj" --configuration Release + if ($LASTEXITCODE) { throw "Lumi Overlay Companion verification failed." } + & $dotnet build "companion/Lumi.Companion.sln" --configuration Release + if ($LASTEXITCODE) { throw "Lumi Companion build failed." } + Write-Host "Lumi Overlay verification passed." -ForegroundColor Green +} +finally { + Pop-Location +} diff --git a/companion/src/Lumi.Companion.Abstractions/CompanionPluginTransport.cs b/companion/src/Lumi.Companion.Abstractions/CompanionPluginTransport.cs index 1f5950e..e76eb58 100644 --- a/companion/src/Lumi.Companion.Abstractions/CompanionPluginTransport.cs +++ b/companion/src/Lumi.Companion.Abstractions/CompanionPluginTransport.cs @@ -7,6 +7,30 @@ public sealed record CompanionPluginHttpResponse( public bool IsSuccessStatusCode => StatusCode is >= 200 and <= 299; } +public enum CompanionPluginLiveState +{ + Connecting, + Connected, + Reconnecting, + WaitingForPairing, + Stopped +} + +public sealed record CompanionPluginLiveStatus( + CompanionPluginLiveState State, + string Detail, + int Attempt = 0); + +/// +/// An authenticated, plugin-scoped live channel. It intentionally exposes no +/// credential or authorization header. +/// +public interface ICompanionPluginLiveChannel +{ + Task SendJsonAsync(object payload, CancellationToken cancellationToken = default); + IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken = default); +} + /// /// A plugin-scoped view of the Companion shell's authenticated Lumi transport. /// Plugins never receive, store, or refresh the paired device credential. @@ -19,4 +43,14 @@ public interface ICompanionPluginTransport string relativePath, object payload, CancellationToken cancellationToken = default); + + /// + /// Runs a live plugin connection. The Companion shell owns authentication, + /// TLS policy, reconnect backoff and socket lifetime. + /// + Task RunLiveJsonAsync( + string relativePath, + Func connected, + Action? status, + CancellationToken cancellationToken = default); } diff --git a/companion/src/Lumi.Companion.Abstractions/PluginContributions.cs b/companion/src/Lumi.Companion.Abstractions/PluginContributions.cs index e1b6fa7..a73929a 100644 --- a/companion/src/Lumi.Companion.Abstractions/PluginContributions.cs +++ b/companion/src/Lumi.Companion.Abstractions/PluginContributions.cs @@ -32,10 +32,16 @@ public sealed record CompanionPluginAction( Func? CanExecute = null, int Order = 100); +public static class CompanionPluginNavigation +{ + public static bool UsesNestedNavigation(IReadOnlyCollection pages) => pages.Count > 1; +} + /// /// A UI-neutral contribution contract. The Avalonia shell owns rendering and -/// guarantees that each plugin receives one nested root in both the sidebar and -/// tray menu instead of leaking loose actions into the global navigation. +/// gives a single-page plugin one direct sidebar entry. Plugins with multiple +/// pages receive one collapsible sidebar root instead of leaking loose pages +/// into the global navigation. /// public interface ICompanionPluginContribution { diff --git a/companion/src/Lumi.Companion.App/App.axaml.cs b/companion/src/Lumi.Companion.App/App.axaml.cs index 937d5ab..abc0752 100644 --- a/companion/src/Lumi.Companion.App/App.axaml.cs +++ b/companion/src/Lumi.Companion.App/App.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using Lumi.Companion.Abstractions; using Lumi.Companion.SongOverlay; +using Lumi.Companion.Overlay; namespace Lumi.Companion.App; @@ -13,6 +14,7 @@ public partial class App : Application private TrayIcon? _trayIcon; private MainWindow? _mainWindow; private SongOverlayRuntime? _songOverlay; + private LumiOverlayRuntime? _lumiOverlay; private IReadOnlyList _plugins = []; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -29,8 +31,11 @@ public partial class App : Application Path.Combine(paths.Root, "plugins", "now_playing"), paths.LogsDirectory, runtime.CreatePluginTransport("now_playing")); - _plugins = [new TranscriptionPluginContribution(runtime), _songOverlay]; - _mainWindow = new MainWindow(runtime, settings, _songOverlay, _plugins); + _lumiOverlay = new LumiOverlayRuntime( + Path.Combine(paths.Root, "plugins", "lumi_overlay"), + runtime.CreatePluginTransport("lumi_overlay")); + _plugins = [new TranscriptionPluginContribution(runtime), _lumiOverlay, _songOverlay]; + _mainWindow = new MainWindow(runtime, settings, _songOverlay, _lumiOverlay, _plugins); desktop.MainWindow = _mainWindow; ConfigureTray(desktop, runtime, _plugins); runtime.UpdateRestartRequested += async () => @@ -38,21 +43,24 @@ public partial class App : Application _mainWindow.AllowExit(); _trayIcon?.Dispose(); if (_songOverlay is not null) await _songOverlay.DisposeAsync(); + if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync(); await runtime.DisposeAsync(); desktop.Shutdown(); }; Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow); if (!Program.LaunchInBackground) _mainWindow.Show(); - _ = InitializeRuntimesAsync(runtime, _songOverlay); + _ = InitializeRuntimesAsync(runtime, _songOverlay, _lumiOverlay); } base.OnFrameworkInitializationCompleted(); } - private static async Task InitializeRuntimesAsync(CompanionRuntime runtime, SongOverlayRuntime songOverlay) + private static async Task InitializeRuntimesAsync(CompanionRuntime runtime, SongOverlayRuntime songOverlay, LumiOverlayRuntime lumiOverlay) { await runtime.InitializeAsync(); try { await songOverlay.InitializeAsync(); } catch { /* The plugin publishes its own actionable status without taking down the shell. */ } + try { await lumiOverlay.InitializeAsync(); } + catch { /* Native overlay failures remain isolated and visible in the plugin status. */ } } private void ConfigureTray(IClassicDesktopStyleApplicationLifetime desktop, CompanionRuntime runtime, IReadOnlyList plugins) @@ -116,6 +124,7 @@ public partial class App : Application _mainWindow.AllowExit(); _trayIcon?.Dispose(); if (_songOverlay is not null) await _songOverlay.DisposeAsync(); + if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync(); await runtime.DisposeAsync(); desktop.Shutdown(); }; diff --git a/companion/src/Lumi.Companion.App/CompanionRuntime.cs b/companion/src/Lumi.Companion.App/CompanionRuntime.cs index dd1dfa5..20a69ff 100644 --- a/companion/src/Lumi.Companion.App/CompanionRuntime.cs +++ b/companion/src/Lumi.Companion.App/CompanionRuntime.cs @@ -89,7 +89,8 @@ public sealed class CompanionRuntime : IAsyncDisposable public bool TranscriptionEnabled => _settings.Current.TranscriptionEnabled; public ICompanionPluginTransport CreatePluginTransport(string pluginId) => - new CompanionPluginTransport(_http, () => _credentials.Load(), pluginId); + new CompanionPluginTransport(_http, () => _credentials.Load(), pluginId, + (eventName, detail) => _ = WriteLogAsync(eventName, detail)); public async Task InitializeAsync() { diff --git a/companion/src/Lumi.Companion.App/CompanionState.cs b/companion/src/Lumi.Companion.App/CompanionState.cs index 0dd3520..a62e5e8 100644 --- a/companion/src/Lumi.Companion.App/CompanionState.cs +++ b/companion/src/Lumi.Companion.App/CompanionState.cs @@ -1,7 +1,7 @@ namespace Lumi.Companion.App; public enum TrayHealth { Ready, Operating, Degraded, Failed } -public enum CompanionPage { Overview, StreamTesting, Transcription, Test, SongOverlay, Connection, Logs, Settings } +public enum CompanionPage { Overview, StreamTesting, Transcription, Test, LumiOverlay, SongOverlay, Connection, Logs, Settings } public enum TestStageState { Waiting, Running, Passed, Blocked, Failed } public sealed record CompanionState( diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index fb5d2c9..b258bc6 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -6,8 +6,8 @@ enable app.manifest Assets\Lumi.Companion.ico - 0.2.5 - 0.2.5.0 + 0.2.6 + 0.2.6.0 @@ -16,6 +16,7 @@ + diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml b/companion/src/Lumi.Companion.App/MainWindow.axaml index 6fded17..94ebc4f 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml @@ -316,6 +316,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +