release: publish Lumi 0.3.8 native companion overlay

This commit is contained in:
Franz Rolfsvaag 2026-07-26 22:06:34 +02:00
parent bc7bff0eb9
commit 655547938a
57 changed files with 3114 additions and 54 deletions

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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<int, Action> _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);
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Lumi.Companion.Overlay</AssemblyName>
<RootNamespace>Lumi.Companion.Overlay</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@ -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<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;
}

View File

@ -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<string, CardVisual> _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<OverlayQueueItem> 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<Border>().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<Border>().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<Border>().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<string, Lazy<Task<Bitmap?>>> _entries = new();
private readonly Queue<string> _order = new();
private readonly object _gate = new();
public Task<Bitmap?> GetAsync(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<Bitmap?>(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<Bitmap?> 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));
}

View File

@ -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<OverlayFeedBadge> 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<OverlayFeedEmote> 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);

View File

@ -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<OverlayContainerSettings> settings, Func<DateTimeOffset>? now = null)
{
private readonly List<OverlayQueueItem> _items = [];
private readonly Func<DateTimeOffset> _now = now ?? (() => DateTimeOffset.UtcNow);
public IReadOnlyList<OverlayQueueItem> 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<OverlayQueueItem, bool> 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();
}
}

View File

@ -0,0 +1,72 @@
namespace Lumi.Companion.Overlay;
public static class OverlayVisibilityPolicy
{
public static bool Automatic(LumiOverlaySettings settings, IEnumerable<OverlaySource> 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<OverlaySource> 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<string> 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))));
}

View File

@ -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<OverlaySourceSetting> Sources { get; set; } = [];
public HashSet<string> 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<string> 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);

View File

@ -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<LumiOverlaySettings>(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;
}
}

View File

@ -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"]
}
}

View File

@ -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"
)

View File

@ -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
}

View File

@ -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);
/// <summary>
/// An authenticated, plugin-scoped live channel. It intentionally exposes no
/// credential or authorization header.
/// </summary>
public interface ICompanionPluginLiveChannel
{
Task SendJsonAsync(object payload, CancellationToken cancellationToken = default);
IAsyncEnumerable<string> ReadAllAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 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);
/// <summary>
/// Runs a live plugin connection. The Companion shell owns authentication,
/// TLS policy, reconnect backoff and socket lifetime.
/// </summary>
Task RunLiveJsonAsync(
string relativePath,
Func<ICompanionPluginLiveChannel, CancellationToken, Task> connected,
Action<CompanionPluginLiveStatus>? status,
CancellationToken cancellationToken = default);
}

View File

@ -32,10 +32,16 @@ public sealed record CompanionPluginAction(
Func<bool>? CanExecute = null,
int Order = 100);
public static class CompanionPluginNavigation
{
public static bool UsesNestedNavigation(IReadOnlyCollection<CompanionPluginPage> pages) => pages.Count > 1;
}
/// <summary>
/// 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.
/// </summary>
public interface ICompanionPluginContribution
{

View File

@ -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<ICompanionPluginContribution> _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<ICompanionPluginContribution> 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();
};

View File

@ -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()
{

View File

@ -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(

View File

@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.2.5</Version>
<AssemblyVersion>0.2.5.0</AssemblyVersion>
<Version>0.2.6</Version>
<AssemblyVersion>0.2.6.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
@ -16,6 +16,7 @@
<ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.Transcription/Lumi.Companion.Transcription.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.SongOverlay/Lumi.Companion.SongOverlay.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />

View File

@ -316,6 +316,176 @@
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="LumiOverlayPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="820" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="NATIVE MONITOR OVERLAY" Classes="eyebrow" />
<TextBlock Text="Lumi Overlay" Classes="pageTitle" />
<TextBlock Text="Show Lumi's normalized chat and stream events directly on one Windows monitor. This is separate from browser-source overlays and never receives provider credentials." Classes="muted" FontSize="15" TextWrapping="Wrap" />
<TextBlock Text="Changes on this page stay as an unsaved draft until you select Save Lumi Overlay. Live feed refreshes will not replace the draft." Classes="muted" FontSize="12" TextWrapping="Wrap" />
</StackPanel>
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Border Classes="soft">
<StackPanel Spacing="4">
<TextBlock Text="Feed" Classes="muted" FontSize="11" />
<TextBlock x:Name="LumiOverlayFeedStatus" Text="Starting" FontWeight="SemiBold" />
<TextBlock x:Name="LumiOverlayFeedDetail" Text="Loading the native overlay…" Classes="muted" TextWrapping="Wrap" />
<TextBlock x:Name="LumiOverlayProviderStatus" Text="Provider status will appear after Lumi connects." Classes="muted" FontSize="11" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Grid.Column="1" Classes="soft">
<StackPanel Spacing="4">
<TextBlock Text="Capture exclusion" Classes="muted" FontSize="11" />
<TextBlock x:Name="LumiOverlayCaptureStatus" Text="Pending" FontWeight="SemiBold" />
<TextBlock x:Name="LumiOverlayCaptureDetail" Text="Waiting for the overlay window." Classes="muted" TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
<TextBlock x:Name="LumiOverlayMonitorWarning" Foreground="{DynamicResource LumiWarningBrush}" TextWrapping="Wrap" IsVisible="False" />
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock Text="Display &amp; visibility" Classes="sectionTitle" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12">
<TextBlock Text="Monitor" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" x:Name="LumiOverlayMonitorPicker" />
<TextBlock Grid.Row="1" Text="Visibility" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayVisibilityPicker" />
<TextBlock Grid.Row="2" Text="Layout" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="2" Grid.Column="1" x:Name="LumiOverlayLayoutPicker" />
<TextBlock Grid.Row="3" x:Name="LumiOverlayChatShareLabel" Text="Chat share in divided layout" Classes="muted" VerticalAlignment="Center" />
<StackPanel Grid.Row="3" Grid.Column="1" x:Name="LumiOverlayChatShareEditor" Orientation="Horizontal" Spacing="7">
<NumericUpDown x:Name="LumiOverlayChatPercentageBox" Minimum="10" Maximum="90" Increment="5" FormatString="0" HorizontalAlignment="Left" Width="112" />
<TextBlock Text="%" Classes="muted" VerticalAlignment="Center" />
</StackPanel>
</Grid>
<CheckBox x:Name="LumiOverlayEnabledToggle" Content="Run Lumi Overlay automatically with Companion" />
<TextBlock x:Name="LumiOverlayVisibilityState" Text="Current display: checking…" Classes="muted" FontSize="12" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="LumiOverlayPreviewButton" Classes="secondary" Content="Start sample preview" />
<Button x:Name="LumiOverlayVisibilityButton" Classes="secondary" Content="Show temporarily" />
</StackPanel>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,*" ColumnSpacing="16">
<StackPanel Spacing="10">
<TextBlock Text="Sources" Classes="sectionTitle" />
<TextBlock Text="Only checked sources are sent over this device feed. Saved unavailable sources stay listed." Classes="muted" FontSize="12" TextWrapping="Wrap" />
<Border Classes="soft"><StackPanel x:Name="LumiOverlaySourcesPanel" Spacing="8"><TextBlock Text="Waiting for Lumi…" Classes="muted" /></StackPanel></Border>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="10">
<TextBlock Text="Event types" Classes="sectionTitle" />
<TextBlock Text="Chat is controlled by Sources. Choose which non-chat events should appear." Classes="muted" FontSize="12" TextWrapping="Wrap" />
<Border Classes="soft"><StackPanel x:Name="LumiOverlayEventsPanel" Spacing="8"><TextBlock Text="Waiting for Lumi…" Classes="muted" /></StackPanel></Border>
</StackPanel>
</Grid>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock Text="Container styling &amp; lifecycle" Classes="sectionTitle" />
<ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240">
<ComboBoxItem x:Name="LumiOverlayUnifiedContainerOption" Content="Unified container" Tag="unified" />
<ComboBoxItem x:Name="LumiOverlayChatContainerOption" Content="Chat container" Tag="chat" />
<ComboBoxItem x:Name="LumiOverlayEventContainerOption" Content="Event container" Tag="events" />
</ComboBox>
<Grid ColumnDefinitions="170,180,170,180" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="10">
<TextBlock Text="Anchor" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" x:Name="LumiOverlayAnchorPicker" />
<TextBlock Grid.Column="2" Text="Newest items" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Column="3" x:Name="LumiOverlayDirectionPicker" />
<TextBlock Grid.Row="1" Text="Width (physical px)" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayWidthBox" Minimum="160" Maximum="16000" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="Height (physical px)" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayHeightBox" Minimum="100" Maximum="16000" />
<TextBlock Grid.Row="2" Text="Card RGBA" Classes="muted" VerticalAlignment="Center" />
<TextBox Grid.Row="2" Grid.Column="1" x:Name="LumiOverlayCardColorBox" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="Text RGB" Classes="muted" VerticalAlignment="Center" />
<TextBox Grid.Row="2" Grid.Column="3" x:Name="LumiOverlayTextColorBox" />
<TextBlock Grid.Row="3" Text="Font" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="3" Grid.Column="1" x:Name="LumiOverlayFontBox" />
<TextBlock Grid.Row="3" Grid.Column="2" Text="Font size" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="3" Grid.Column="3" x:Name="LumiOverlayFontSizeBox" Minimum="8" Maximum="120" />
<TextBlock Grid.Row="4" Text="Entry animation" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="4" Grid.Column="1" x:Name="LumiOverlayEntryPicker" />
<TextBlock Grid.Row="4" Grid.Column="2" Text="Exit animation" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="4" Grid.Column="3" x:Name="LumiOverlayExitPicker" />
<TextBlock Grid.Row="5" Text="Lifetime (ms)" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="5" Grid.Column="1" x:Name="LumiOverlayLifetimeBox" Minimum="1000" Maximum="300000" Increment="1000" />
<TextBlock Grid.Row="5" Grid.Column="2" Text="Queue limit" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="5" Grid.Column="3" x:Name="LumiOverlayQueueBox" Minimum="1" Maximum="100" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="14">
<CheckBox x:Name="LumiOverlayAvatarToggle" Content="Avatar" />
<CheckBox x:Name="LumiOverlayUsernameToggle" Content="Username" />
<CheckBox x:Name="LumiOverlayPlatformToggle" Content="Platform identity" />
</StackPanel>
<Expander Header="Position, shadow &amp; animation details">
<StackPanel Spacing="12" Margin="0,12,0,0">
<Grid ColumnDefinitions="130,120,130,120" RowDefinitions="Auto,Auto,Auto" RowSpacing="8" ColumnSpacing="10">
<TextBlock Text="Top padding" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Column="1" x:Name="LumiOverlayPaddingTopBox" Minimum="0" Maximum="4000" />
<TextBlock Grid.Column="2" Text="Right padding" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Column="3" x:Name="LumiOverlayPaddingRightBox" Minimum="0" Maximum="4000" />
<TextBlock Grid.Row="1" Text="Bottom padding" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPaddingBottomBox" Minimum="0" Maximum="4000" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="Left padding" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayPaddingLeftBox" Minimum="0" Maximum="4000" />
<TextBlock Grid.Row="2" Text="Horizontal align" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="2" Grid.Column="1" x:Name="LumiOverlayHorizontalAlignPicker" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="Vertical align" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="2" Grid.Column="3" x:Name="LumiOverlayVerticalAlignPicker" />
</Grid>
<Grid ColumnDefinitions="130,120,130,120" RowDefinitions="Auto,Auto" RowSpacing="8" ColumnSpacing="10">
<TextBlock Text="Shadow RGBA" Classes="muted" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="LumiOverlayShadowColorBox" />
<TextBlock Grid.Column="2" Text="Shadow blur" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Column="3" x:Name="LumiOverlayShadowBlurBox" Minimum="0" Maximum="40" />
<TextBlock Grid.Row="1" Text="Shadow X / Y" Classes="muted" VerticalAlignment="Center" />
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Spacing="6">
<NumericUpDown x:Name="LumiOverlayShadowXBox" Minimum="-40" Maximum="40" Width="56" />
<NumericUpDown x:Name="LumiOverlayShadowYBox" Minimum="-40" Maximum="40" Width="56" />
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="2" Text="Easing" Classes="muted" VerticalAlignment="Center" />
<ComboBox Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayEasingPicker" />
</Grid>
<Grid ColumnDefinitions="130,120,130,120" RowDefinitions="Auto,Auto" RowSpacing="8" ColumnSpacing="10">
<TextBlock Text="Entry duration" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Column="1" x:Name="LumiOverlayEntryDurationBox" Minimum="0" Maximum="5000" Increment="20" FormatString="0 ms" />
<TextBlock Grid.Column="2" Text="Exit duration" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Column="3" x:Name="LumiOverlayExitDurationBox" Minimum="0" Maximum="5000" Increment="20" FormatString="0 ms" />
<TextBlock Grid.Row="1" Text="Slide distance" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="LumiOverlaySlideDistanceBox" Minimum="0" Maximum="1000" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="Scale amount" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayScaleAmountBox" Minimum="0.1" Maximum="2" Increment="0.05" FormatString="0.00" />
</Grid>
</StackPanel>
</Expander>
<TextBlock Text="Disabled padding edges are always stored as zero. Width and height are physical pixels and clamp to the selected monitor." Classes="muted" FontSize="12" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="soft">
<StackPanel Spacing="12">
<TextBlock Text="Global hotkeys" Classes="sectionTitle" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
<TextBlock Text="Visibility override" Classes="muted" />
<TextBox Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" PlaceholderText="Ctrl+Alt+L" />
<TextBlock Grid.Row="1" Text="Preview override" Classes="muted" />
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" PlaceholderText="Ctrl+Alt+P" />
</Grid>
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="SaveLumiOverlayButton" Classes="primary" Content="Save Lumi Overlay" />
</StackPanel>
<TextBlock x:Name="LumiOverlayFeedback" Classes="muted" TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="ConnectionPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">

View File

@ -6,6 +6,7 @@ using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay;
using Lumi.Companion.Overlay;
namespace Lumi.Companion.App;
@ -14,24 +15,31 @@ public partial class MainWindow : Window
private readonly CompanionRuntime _runtime;
private readonly CompanionSettingsStore _settings;
private readonly SongOverlayRuntime _songOverlay;
private readonly LumiOverlayRuntime _lumiOverlay;
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
private readonly Dictionary<CompanionPage, PluginNavigationGroup> _pluginNavigationGroups = [];
private bool _allowExit;
private bool _renderingSources;
private bool _renderingSongOverlay;
private bool _renderingLumiOverlay;
private bool _renderingSettings;
private readonly Dictionary<string, CheckBox> _overlaySourceChecks = [];
private readonly Dictionary<string, CheckBox> _overlayEventChecks = [];
private string _editingOverlayContainer = "unified";
private LumiOverlaySettings? _lumiOverlayDraft;
public MainWindow() : this(CreateDefaultServices()) { }
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings, SongOverlayRuntime SongOverlay, IReadOnlyList<ICompanionPluginContribution> Plugins) services)
: this(services.Runtime, services.Settings, services.SongOverlay, services.Plugins) { }
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings, SongOverlayRuntime SongOverlay, LumiOverlayRuntime LumiOverlay, IReadOnlyList<ICompanionPluginContribution> Plugins) services)
: this(services.Runtime, services.Settings, services.SongOverlay, services.LumiOverlay, services.Plugins) { }
public MainWindow(CompanionRuntime runtime, CompanionSettingsStore settings, SongOverlayRuntime songOverlay, IReadOnlyList<ICompanionPluginContribution> plugins)
public MainWindow(CompanionRuntime runtime, CompanionSettingsStore settings, SongOverlayRuntime songOverlay, LumiOverlayRuntime lumiOverlay, IReadOnlyList<ICompanionPluginContribution> plugins)
{
_runtime = runtime;
_settings = settings;
_songOverlay = songOverlay;
_lumiOverlay = lumiOverlay;
_plugins = plugins;
InitializeComponent();
BuildPluginNavigation();
@ -40,6 +48,8 @@ public partial class MainWindow : Window
RenderTestStages(runtime.TestStages);
RenderBenchmark(runtime.Benchmark);
RenderSongOverlay();
PrepareLumiOverlayControls();
RenderLumiOverlay();
Closing += OnClosing;
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => { RenderState(state); RenderSongOverlay(); });
runtime.TestStagesChanged += stages => Dispatcher.UIThread.Post(() => RenderTestStages(stages));
@ -53,6 +63,7 @@ public partial class MainWindow : Window
runtime.LogAdded += line => Dispatcher.UIThread.Post(() => AddLog(line));
settings.Changed += value => Dispatcher.UIThread.Post(() => { RenderSettings(value); RenderState(runtime.State); });
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
lumiOverlay.Changed += () => Dispatcher.UIThread.Post(() => RenderLumiOverlay());
}
private void BuildPluginNavigation()
@ -60,6 +71,28 @@ public partial class MainWindow : Window
PluginNavigation.Children.Clear();
foreach (var plugin in _plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
{
var pages = new List<(CompanionPluginPage Page, CompanionPage Parsed)>();
foreach (var page in plugin.Pages.OrderBy(item => item.Order))
if (Enum.TryParse<CompanionPage>(page.Key, out var parsed))
pages.Add((page, parsed));
if (pages.Count == 0) continue;
if (!CompanionPluginNavigation.UsesNestedNavigation(pages.Select(item => item.Page).ToList()))
{
var only = pages[0];
var button = new Button
{
Content = plugin.Descriptor.Name,
Tag = only.Page.Key,
HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left
};
button.Classes.Add("nav");
button.Click += OnNavigate;
PluginNavigation.Children.Add(button);
_navigationButtons[only.Parsed] = button;
continue;
}
var children = new StackPanel
{
Spacing = 2,
@ -89,9 +122,8 @@ public partial class MainWindow : Window
root.Classes.Add("nav");
root.Classes.Add("navRoot");
root.Click += (_, _) => SetPluginNavigationExpanded(children, arrow, !children.IsVisible);
foreach (var page in plugin.Pages.OrderBy(item => item.Order))
foreach (var (page, parsed) in pages)
{
if (!Enum.TryParse<CompanionPage>(page.Key, out var parsed)) continue;
var button = new Button { Content = page.Label, Tag = page.Key, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left };
button.Classes.Add("nav");
button.Click += OnNavigate;
@ -139,6 +171,28 @@ public partial class MainWindow : Window
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
SaveLumiOverlayButton.Click += async (_, _) => await SaveLumiOverlayAsync();
LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); };
LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); };
LumiOverlayContainerPicker.SelectionChanged += (_, _) =>
{
if (_renderingLumiOverlay) return;
if (_lumiOverlayDraft is not null)
CaptureLumiOverlayContainer(OverlayContainer(_editingOverlayContainer, _lumiOverlayDraft));
_editingOverlayContainer = (LumiOverlayContainerPicker.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "unified";
RenderLumiOverlayContainer();
};
LumiOverlayLayoutPicker.SelectionChanged += (_, _) =>
{
if (_renderingLumiOverlay || _lumiOverlayDraft is null) return;
CaptureLumiOverlayForm(_lumiOverlayDraft);
RenderLumiOverlay(preserveDraft: false);
};
LumiOverlayAnchorPicker.SelectionChanged += (_, _) =>
{
if (_renderingLumiOverlay) return;
UpdateLumiOverlayPaddingAvailability();
};
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
{
@ -166,6 +220,7 @@ public partial class MainWindow : Window
[CompanionPage.Transcription] = TranscriptionPage,
[CompanionPage.Test] = TestPage,
[CompanionPage.SongOverlay] = SongOverlayPage,
[CompanionPage.LumiOverlay] = LumiOverlayPage,
[CompanionPage.Connection] = ConnectionPage,
[CompanionPage.Logs] = LogsPage,
[CompanionPage.Settings] = SettingsPage
@ -467,6 +522,332 @@ public partial class MainWindow : Window
}
private void PrepareLumiOverlayControls()
{
LumiOverlayVisibilityPicker.ItemsSource = new[]
{
new OverlayChoice<OverlayVisibilityMode>(OverlayVisibilityMode.Off, "Off"),
new OverlayChoice<OverlayVisibilityMode>(OverlayVisibilityMode.WhenLive, "When a selected channel is live"),
new OverlayChoice<OverlayVisibilityMode>(OverlayVisibilityMode.On, "Always on")
};
LumiOverlayLayoutPicker.ItemsSource = new[]
{
new OverlayChoice<OverlayLayoutMode>(OverlayLayoutMode.Unified, "Unified chat and events"),
new OverlayChoice<OverlayLayoutMode>(OverlayLayoutMode.DividedHorizontal, "Divided horizontally"),
new OverlayChoice<OverlayLayoutMode>(OverlayLayoutMode.DividedVertical, "Divided vertically"),
new OverlayChoice<OverlayLayoutMode>(OverlayLayoutMode.Separate, "Separate containers")
};
LumiOverlayAnchorPicker.ItemsSource = new[]
{
new OverlayChoice<OverlayAnchor>(OverlayAnchor.TopLeft, "Top left"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.Top, "Top"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.TopRight, "Top right"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.Left, "Left"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.Right, "Right"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.BottomLeft, "Bottom left"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.Bottom, "Bottom"),
new OverlayChoice<OverlayAnchor>(OverlayAnchor.BottomRight, "Bottom right")
};
LumiOverlayDirectionPicker.ItemsSource = new[]
{
new OverlayChoice<OverlayNewestDirection>(OverlayNewestDirection.Start, "New items first"),
new OverlayChoice<OverlayNewestDirection>(OverlayNewestDirection.End, "New items last")
};
LumiOverlayHorizontalAlignPicker.ItemsSource = new[]
{
new OverlayChoice<OverlayHorizontalAlignment>(OverlayHorizontalAlignment.Left, "Left"),
new OverlayChoice<OverlayHorizontalAlignment>(OverlayHorizontalAlignment.Middle, "Center"),
new OverlayChoice<OverlayHorizontalAlignment>(OverlayHorizontalAlignment.Right, "Right")
};
LumiOverlayVerticalAlignPicker.ItemsSource = new[]
{
new OverlayChoice<OverlayVerticalAlignment>(OverlayVerticalAlignment.Top, "Top"),
new OverlayChoice<OverlayVerticalAlignment>(OverlayVerticalAlignment.Middle, "Center"),
new OverlayChoice<OverlayVerticalAlignment>(OverlayVerticalAlignment.Bottom, "Bottom")
};
var animations = new[]
{
new OverlayChoice<OverlayAnimation>(OverlayAnimation.None, "None"),
new OverlayChoice<OverlayAnimation>(OverlayAnimation.Fade, "Fade"),
new OverlayChoice<OverlayAnimation>(OverlayAnimation.Slide, "Slide"),
new OverlayChoice<OverlayAnimation>(OverlayAnimation.FadeSlide, "Fade + slide"),
new OverlayChoice<OverlayAnimation>(OverlayAnimation.ScaleFade, "Scale + fade")
};
LumiOverlayEntryPicker.ItemsSource = animations;
LumiOverlayExitPicker.ItemsSource = animations;
LumiOverlayEasingPicker.ItemsSource = new[] { "linear", "cubic-out", "cubic-in-out" };
LumiOverlayFontBox.ItemsSource = FontManager.Current.SystemFonts.Select(font => font.Name)
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList();
LumiOverlayContainerPicker.SelectedIndex = 0;
}
private void RenderLumiOverlay(bool preserveDraft = true)
{
if (!_lumiOverlay.IsInitialized) return;
if (_lumiOverlayDraft is null)
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
else if (preserveDraft && !_renderingLumiOverlay)
CaptureLumiOverlayForm(_lumiOverlayDraft);
_renderingLumiOverlay = true;
try
{
var settings = _lumiOverlayDraft;
LumiOverlayFeedStatus.Text = _lumiOverlay.Status.Summary;
LumiOverlayFeedDetail.Text = _lumiOverlay.Status.Detail ?? _lumiOverlay.Status.Summary;
LumiOverlayProviderStatus.Text = _lumiOverlay.FeedStatuses.Count == 0
? "Provider status will appear after Lumi connects."
: string.Join(" · ", _lumiOverlay.FeedStatuses.Select(status => $"{status.Platform}: {status.Detail}"));
LumiOverlayCaptureStatus.Text = _lumiOverlay.CaptureExclusion.State.ToString();
LumiOverlayCaptureDetail.Text = _lumiOverlay.CaptureExclusion.Detail;
LumiOverlayMonitorWarning.Text = _lumiOverlay.MonitorWarning ?? "";
LumiOverlayMonitorWarning.IsVisible = _lumiOverlay.MonitorWarning is not null;
LumiOverlayEnabledToggle.IsChecked = settings.Enabled;
SelectOverlayChoice(LumiOverlayVisibilityPicker, settings.Visibility);
SelectOverlayChoice(LumiOverlayLayoutPicker, settings.Layout);
LumiOverlayChatPercentageBox.Value = settings.ChatPercentage;
UpdateLumiOverlayLayoutPresentation(settings.Layout);
LumiOverlayPreviewButton.Content = _lumiOverlay.PreviewActive ? "End sample preview" : "Start sample preview";
var visibility = _lumiOverlay.VisibilityPresentation;
LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}";
LumiOverlayVisibilityButton.Content = visibility.Action;
LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle;
LumiOverlayVisibilityHotkeyBox.Text = settings.ToggleVisibilityHotkey;
LumiOverlayPreviewHotkeyBox.Text = settings.TogglePreviewHotkey;
LumiOverlayHotkeyStatus.Text = _lumiOverlay.HotkeyStatus;
var monitorItems = _lumiOverlay.Monitors.Select(value => new OverlayPickerItem(value.Id, value.Label)).ToList();
if (settings.MonitorId is { Length: > 0 } savedMonitor &&
monitorItems.All(value => !string.Equals(value.Id, savedMonitor, StringComparison.Ordinal)))
monitorItems.Insert(0, new OverlayPickerItem(savedMonitor, "Previously selected monitor · unavailable"));
LumiOverlayMonitorPicker.ItemsSource = monitorItems;
LumiOverlayMonitorPicker.SelectedItem = monitorItems.FirstOrDefault(value => value.Id == settings.MonitorId) ?? monitorItems.FirstOrDefault();
_overlaySourceChecks.Clear();
LumiOverlaySourcesPanel.Children.Clear();
var available = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
var merged = settings.Sources.Select(source =>
new OverlaySourceView(source, available.GetValueOrDefault(source.Id))).ToList();
foreach (var source in _lumiOverlay.Sources.Where(source => settings.Sources.All(saved => saved.Id != source.Id)))
merged.Add(new(new OverlaySourceSetting { Id = source.Id, Platform = source.Platform, Label = source.Label }, source));
foreach (var entry in merged.OrderBy(value => value.Saved.Label, StringComparer.OrdinalIgnoreCase))
{
var check = new CheckBox
{
Content = entry.Current is null ? $"{entry.Saved.Label} · unavailable" : $"{entry.Current.Label} · {entry.Current.Status}",
IsChecked = entry.Saved.Selected
};
LumiOverlaySourcesPanel.Children.Add(check);
_overlaySourceChecks[entry.Saved.Id] = check;
}
if (_overlaySourceChecks.Count == 0) LumiOverlaySourcesPanel.Children.Add(new TextBlock { Text = "No configured provider sources are available.", Classes = { "muted" } });
_overlayEventChecks.Clear();
LumiOverlayEventsPanel.Children.Clear();
var availableEvents = _lumiOverlay.AvailableEventTypes.ToDictionary(value => value.Id, StringComparer.Ordinal);
foreach (var eventType in availableEvents.Values.OrderBy(value => value.Platform).ThenBy(value => value.Label))
{
var check = new CheckBox { Content = $"{eventType.Label} · {eventType.Platform}", IsChecked = settings.EventTypes.Contains(eventType.Id) };
LumiOverlayEventsPanel.Children.Add(check);
_overlayEventChecks[eventType.Id] = check;
}
foreach (var eventId in settings.EventTypes.Where(id => !availableEvents.ContainsKey(id)).OrderBy(id => id))
{
var check = new CheckBox { Content = $"{eventId} · unavailable", IsChecked = true };
LumiOverlayEventsPanel.Children.Add(check);
_overlayEventChecks[eventId] = check;
}
if (_overlayEventChecks.Count == 0) LumiOverlayEventsPanel.Children.Add(new TextBlock { Text = "No event types were reported by Lumi.", Classes = { "muted" } });
RenderLumiOverlayContainer();
}
finally { _renderingLumiOverlay = false; }
}
private OverlayContainerSettings SelectedOverlayContainer()
{
var tag = (LumiOverlayContainerPicker.SelectedItem as ComboBoxItem)?.Tag?.ToString();
return OverlayContainer(tag, _lumiOverlayDraft ?? _lumiOverlay.Settings);
}
private static OverlayContainerSettings OverlayContainer(string? tag, LumiOverlaySettings settings) => tag switch
{
"chat" => settings.Chat,
"events" => settings.Events,
_ => settings.Unified
};
private void RenderLumiOverlayContainer()
{
var value = SelectedOverlayContainer();
SelectOverlayChoice(LumiOverlayAnchorPicker, value.Anchor);
SelectOverlayChoice(LumiOverlayDirectionPicker, value.NewestDirection);
SelectOverlayChoice(LumiOverlayHorizontalAlignPicker, value.HorizontalAlignment);
SelectOverlayChoice(LumiOverlayVerticalAlignPicker, value.VerticalAlignment);
LumiOverlayWidthBox.Value = value.Width;
LumiOverlayHeightBox.Value = value.Height;
LumiOverlayCardColorBox.Text = value.CardBackground;
LumiOverlayTextColorBox.Text = value.TextColor;
LumiOverlayFontBox.SelectedItem = value.FontFamily;
LumiOverlayFontSizeBox.Value = Convert.ToDecimal(value.FontSize);
SelectOverlayChoice(LumiOverlayEntryPicker, value.EntryAnimation);
SelectOverlayChoice(LumiOverlayExitPicker, value.ExitAnimation);
LumiOverlayLifetimeBox.Value = value.LifetimeMs;
LumiOverlayQueueBox.Value = value.QueueLimit;
LumiOverlayPaddingTopBox.Value = value.PaddingTop;
LumiOverlayPaddingRightBox.Value = value.PaddingRight;
LumiOverlayPaddingBottomBox.Value = value.PaddingBottom;
LumiOverlayPaddingLeftBox.Value = value.PaddingLeft;
LumiOverlayShadowColorBox.Text = value.TextShadow;
LumiOverlayShadowXBox.Value = value.ShadowX;
LumiOverlayShadowYBox.Value = value.ShadowY;
LumiOverlayShadowBlurBox.Value = value.ShadowBlur;
LumiOverlayEntryDurationBox.Value = value.EntryDurationMs;
LumiOverlayExitDurationBox.Value = value.ExitDurationMs;
LumiOverlaySlideDistanceBox.Value = value.SlideDistance;
LumiOverlayScaleAmountBox.Value = Convert.ToDecimal(value.ScaleAmount);
LumiOverlayEasingPicker.SelectedItem = value.Easing;
UpdateLumiOverlayPaddingAvailability();
LumiOverlayAvatarToggle.IsChecked = value.ShowAvatar;
LumiOverlayUsernameToggle.IsChecked = value.ShowUsername;
LumiOverlayPlatformToggle.IsChecked = value.ShowPlatform;
}
private void CaptureLumiOverlayContainer(OverlayContainerSettings? target = null)
{
var value = target ?? SelectedOverlayContainer();
if (LumiOverlayAnchorPicker.SelectedItem is OverlayChoice<OverlayAnchor> anchor) value.Anchor = anchor.Value;
if (LumiOverlayDirectionPicker.SelectedItem is OverlayChoice<OverlayNewestDirection> direction) value.NewestDirection = direction.Value;
if (LumiOverlayHorizontalAlignPicker.SelectedItem is OverlayChoice<OverlayHorizontalAlignment> horizontal) value.HorizontalAlignment = horizontal.Value;
if (LumiOverlayVerticalAlignPicker.SelectedItem is OverlayChoice<OverlayVerticalAlignment> vertical) value.VerticalAlignment = vertical.Value;
if (LumiOverlayEntryPicker.SelectedItem is OverlayChoice<OverlayAnimation> entry) value.EntryAnimation = entry.Value;
if (LumiOverlayExitPicker.SelectedItem is OverlayChoice<OverlayAnimation> exit) value.ExitAnimation = exit.Value;
value.Width = Decimal.ToInt32(LumiOverlayWidthBox.Value ?? value.Width);
value.Height = Decimal.ToInt32(LumiOverlayHeightBox.Value ?? value.Height);
value.CardBackground = LumiOverlayCardColorBox.Text?.Trim() ?? value.CardBackground;
value.TextColor = LumiOverlayTextColorBox.Text?.Trim() ?? value.TextColor;
value.FontFamily = LumiOverlayFontBox.SelectedItem?.ToString()?.Trim() ?? value.FontFamily;
value.FontSize = Convert.ToDouble(LumiOverlayFontSizeBox.Value ?? Convert.ToDecimal(value.FontSize));
value.LifetimeMs = Decimal.ToInt32(LumiOverlayLifetimeBox.Value ?? value.LifetimeMs);
value.QueueLimit = Decimal.ToInt32(LumiOverlayQueueBox.Value ?? value.QueueLimit);
value.PaddingTop = Decimal.ToInt32(LumiOverlayPaddingTopBox.Value ?? value.PaddingTop);
value.PaddingRight = Decimal.ToInt32(LumiOverlayPaddingRightBox.Value ?? value.PaddingRight);
value.PaddingBottom = Decimal.ToInt32(LumiOverlayPaddingBottomBox.Value ?? value.PaddingBottom);
value.PaddingLeft = Decimal.ToInt32(LumiOverlayPaddingLeftBox.Value ?? value.PaddingLeft);
value.TextShadow = LumiOverlayShadowColorBox.Text?.Trim() ?? value.TextShadow;
value.ShadowX = Decimal.ToInt32(LumiOverlayShadowXBox.Value ?? value.ShadowX);
value.ShadowY = Decimal.ToInt32(LumiOverlayShadowYBox.Value ?? value.ShadowY);
value.ShadowBlur = Decimal.ToInt32(LumiOverlayShadowBlurBox.Value ?? value.ShadowBlur);
value.EntryDurationMs = Decimal.ToInt32(LumiOverlayEntryDurationBox.Value ?? value.EntryDurationMs);
value.ExitDurationMs = Decimal.ToInt32(LumiOverlayExitDurationBox.Value ?? value.ExitDurationMs);
value.SlideDistance = Decimal.ToInt32(LumiOverlaySlideDistanceBox.Value ?? value.SlideDistance);
value.ScaleAmount = Convert.ToDouble(LumiOverlayScaleAmountBox.Value ?? Convert.ToDecimal(value.ScaleAmount));
value.Easing = LumiOverlayEasingPicker.SelectedItem?.ToString() ?? value.Easing;
value.ShowAvatar = LumiOverlayAvatarToggle.IsChecked == true;
value.ShowUsername = LumiOverlayUsernameToggle.IsChecked == true;
value.ShowPlatform = LumiOverlayPlatformToggle.IsChecked == true;
}
private void CaptureLumiOverlayForm(LumiOverlaySettings settings)
{
CaptureLumiOverlayContainer(OverlayContainer(_editingOverlayContainer, settings));
settings.Enabled = LumiOverlayEnabledToggle.IsChecked == true;
if (LumiOverlayVisibilityPicker.SelectedItem is OverlayChoice<OverlayVisibilityMode> visibility)
settings.Visibility = visibility.Value;
if (LumiOverlayLayoutPicker.SelectedItem is OverlayChoice<OverlayLayoutMode> layout)
settings.Layout = layout.Value;
settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage);
if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id;
settings.ToggleVisibilityHotkey = LumiOverlayVisibilityHotkeyBox.Text?.Trim() ?? "";
settings.TogglePreviewHotkey = LumiOverlayPreviewHotkeyBox.Text?.Trim() ?? "";
var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
foreach (var saved in settings.Sources)
{
if (_overlaySourceChecks.TryGetValue(saved.Id, out var check)) saved.Selected = check.IsChecked == true;
if (currentSources.TryGetValue(saved.Id, out var current))
{
saved.Platform = current.Platform;
saved.Label = current.Label;
}
}
foreach (var source in currentSources.Values.Where(source => settings.Sources.All(saved => saved.Id != source.Id)))
settings.Sources.Add(new OverlaySourceSetting
{
Id = source.Id,
Platform = source.Platform,
Label = source.Label,
Selected = _overlaySourceChecks.GetValueOrDefault(source.Id)?.IsChecked == true
});
settings.EventTypes = _overlayEventChecks
.Where(item => item.Value.IsChecked == true)
.Select(item => item.Key)
.ToHashSet(StringComparer.Ordinal);
}
private void UpdateLumiOverlayLayoutPresentation(OverlayLayoutMode layout)
{
var divided = layout is OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical;
LumiOverlayChatShareLabel.IsVisible = divided;
LumiOverlayChatShareEditor.IsVisible = divided;
var unified = layout == OverlayLayoutMode.Unified;
LumiOverlayUnifiedContainerOption.IsVisible = unified;
LumiOverlayChatContainerOption.IsVisible = !unified;
LumiOverlayEventContainerOption.IsVisible = !unified;
var wanted = unified ? "unified" : _editingOverlayContainer == "unified" ? "chat" : _editingOverlayContainer;
var selected = LumiOverlayContainerPicker.Items
.OfType<ComboBoxItem>()
.First(item => string.Equals(item.Tag?.ToString(), wanted, StringComparison.Ordinal));
_editingOverlayContainer = wanted;
LumiOverlayContainerPicker.SelectedItem = selected;
}
private void UpdateLumiOverlayPaddingAvailability()
{
var anchor = LumiOverlayAnchorPicker.SelectedItem is OverlayChoice<OverlayAnchor> choice
? choice.Value
: OverlayAnchor.BottomLeft;
LumiOverlayPaddingTopBox.IsEnabled =
anchor is OverlayAnchor.TopLeft or OverlayAnchor.Top or OverlayAnchor.TopRight or OverlayAnchor.Left or OverlayAnchor.Right;
LumiOverlayPaddingRightBox.IsEnabled =
anchor is OverlayAnchor.TopRight or OverlayAnchor.Right or OverlayAnchor.BottomRight or OverlayAnchor.Top or OverlayAnchor.Bottom;
LumiOverlayPaddingBottomBox.IsEnabled =
anchor is OverlayAnchor.BottomLeft or OverlayAnchor.Bottom or OverlayAnchor.BottomRight or OverlayAnchor.Left or OverlayAnchor.Right;
LumiOverlayPaddingLeftBox.IsEnabled =
anchor is OverlayAnchor.TopLeft or OverlayAnchor.Left or OverlayAnchor.BottomLeft or OverlayAnchor.Top or OverlayAnchor.Bottom;
}
private static void SelectOverlayChoice<T>(ComboBox picker, T value) where T : struct, Enum
{
picker.SelectedItem = picker.ItemsSource?.OfType<OverlayChoice<T>>().FirstOrDefault(item => EqualityComparer<T>.Default.Equals(item.Value, value));
}
private async Task SaveLumiOverlayAsync()
{
if (_renderingLumiOverlay) return;
SaveLumiOverlayButton.IsEnabled = false;
LumiOverlayFeedback.Text = "Saving…";
try
{
var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy();
CaptureLumiOverlayForm(settings);
await _lumiOverlay.SaveSettingsAsync(settings);
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
LumiOverlayFeedback.Text = "Lumi Overlay settings saved and applied.";
}
catch (Exception error) { LumiOverlayFeedback.Text = $"Could not save Lumi Overlay: {error.Message}"; }
finally { SaveLumiOverlayButton.IsEnabled = true; RenderLumiOverlay(); }
}
private sealed record OverlayPickerItem(string Id, string Label)
{
public override string ToString() => Label;
}
private sealed record OverlayChoice<T>(T Value, string Label) where T : struct, Enum
{
public override string ToString() => Label;
}
private sealed record OverlaySourceView(OverlaySourceSetting Saved, OverlaySource? Current);
private void RenderSongOverlay()
{
_renderingSongOverlay = true;
@ -589,7 +970,7 @@ public partial class MainWindow : Window
public void AllowExit() => _allowExit = true;
private static (CompanionRuntime, CompanionSettingsStore, SongOverlayRuntime, IReadOnlyList<ICompanionPluginContribution>) CreateDefaultServices()
private static (CompanionRuntime, CompanionSettingsStore, SongOverlayRuntime, LumiOverlayRuntime, IReadOnlyList<ICompanionPluginContribution>) CreateDefaultServices()
{
var paths = CompanionPaths.ForCurrentUser();
var settings = new CompanionSettingsStore(paths.SettingsPath);
@ -598,7 +979,10 @@ public partial class MainWindow : Window
Path.Combine(paths.Root, "plugins", "now_playing"),
paths.LogsDirectory,
runtime.CreatePluginTransport("now_playing"));
ICompanionPluginContribution[] plugins = [new TranscriptionPluginContribution(runtime), songOverlay];
return (runtime, settings, songOverlay, plugins);
var lumiOverlay = new LumiOverlayRuntime(
Path.Combine(paths.Root, "plugins", "lumi_overlay"),
runtime.CreatePluginTransport("lumi_overlay"));
ICompanionPluginContribution[] plugins = [new TranscriptionPluginContribution(runtime), lumiOverlay, songOverlay];
return (runtime, settings, songOverlay, lumiOverlay, plugins);
}
}

View File

@ -1,5 +1,9 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Lumi.Companion.Abstractions;
using Lumi.Companion.Protocol;
@ -8,7 +12,8 @@ namespace Lumi.Companion.Core;
public sealed class CompanionPluginTransport(
HttpClient http,
Func<DeviceCredential?> credentialProvider,
string pluginId) : ICompanionPluginTransport
string pluginId,
Action<string, string>? log = null) : ICompanionPluginTransport
{
private readonly string _pluginId = ValidatePluginId(pluginId);
@ -65,6 +70,113 @@ public sealed class CompanionPluginTransport(
return new CompanionPluginHttpResponse((int)response.StatusCode, body);
}
public async Task RunLiveJsonAsync(
string relativePath,
Func<ICompanionPluginLiveChannel, CancellationToken, Task> connected,
Action<CompanionPluginLiveStatus>? status,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connected);
var attempt = 0;
while (!cancellationToken.IsCancellationRequested)
{
var credential = credentialProvider();
if (credential is null)
{
status?.Invoke(new(CompanionPluginLiveState.WaitingForPairing, "Pair Companion with Lumi to use this plugin.", attempt));
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
continue;
}
try
{
var endpoint = ValidateLiveEndpoint(credential, relativePath);
status?.Invoke(new(attempt == 0 ? CompanionPluginLiveState.Connecting : CompanionPluginLiveState.Reconnecting,
attempt == 0 ? "Connecting to Lumi…" : "Lumi is unavailable; reconnecting silently.", attempt));
using var socket = new ClientWebSocket();
socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
socket.Options.SetRequestHeader("User-Agent", "Lumi-Companion/1.0");
socket.Options.SetRequestHeader("X-Lumi-Companion-Plugin", _pluginId);
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
attempt = 0;
log?.Invoke("plugin_live_connected", $"Plugin {_pluginId} connected to its authenticated Lumi live feed.");
status?.Invoke(new(CompanionPluginLiveState.Connected, "Live feed connected."));
await connected(new LiveChannel(socket), cancellationToken).ConfigureAwait(false);
if (socket.State == WebSocketState.Open)
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "reconnect", cancellationToken).ConfigureAwait(false);
if (!cancellationToken.IsCancellationRequested) throw new WebSocketException("The Lumi live feed closed.");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; }
catch (Exception error)
{
attempt += 1;
var detail = error.Message.Contains("403", StringComparison.Ordinal)
? "Lumi has not granted this paired device access to the plugin feed."
: error.Message.Contains("401", StringComparison.Ordinal)
? "Lumi no longer accepts this device pairing."
: "Lumi is unavailable; reconnecting silently.";
if (attempt == 1 || attempt % 10 == 0)
log?.Invoke("plugin_live_reconnecting", $"Plugin {_pluginId} live feed is reconnecting after {error.GetType().Name}.");
status?.Invoke(new(CompanionPluginLiveState.Reconnecting, detail, attempt));
var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(attempt, 5))));
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
status?.Invoke(new(CompanionPluginLiveState.Stopped, "Live feed stopped."));
}
private Uri ValidateLiveEndpoint(DeviceCredential credential, string relativePath)
{
var host = new Uri(credential.Host);
if (!CompanionTransportPolicy.IsPairedHostAllowed(host))
throw new InvalidDataException("Companion transport requires HTTPS unless this device was paired with localhost.");
var expectedPrefix = $"/plugins/{_pluginId}/";
if (!relativePath.StartsWith(expectedPrefix, StringComparison.Ordinal) ||
!Uri.TryCreate(relativePath, UriKind.Relative, out var relative))
throw new InvalidDataException($"Plugin '{_pluginId}' attempted to use an endpoint outside its Lumi plugin scope.");
var httpEndpoint = new Uri(host, relative);
if (!CompanionTransportPolicy.IsEndpointAllowed(httpEndpoint, host))
throw new InvalidDataException("The plugin endpoint does not match the paired Lumi host.");
var builder = new UriBuilder(httpEndpoint)
{
Scheme = httpEndpoint.Scheme == Uri.UriSchemeHttps ? "wss" : "ws",
Port = httpEndpoint.IsDefaultPort ? -1 : httpEndpoint.Port
};
return builder.Uri;
}
private sealed class LiveChannel(ClientWebSocket socket) : ICompanionPluginLiveChannel
{
private readonly SemaphoreSlim _sendLock = new(1, 1);
public async Task SendJsonAsync(object payload, CancellationToken cancellationToken = default)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, ProtocolV1.JsonOptions);
if (bytes.Length > 64 * 1024) throw new InvalidDataException("Plugin live message exceeds the 64 KiB limit.");
await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try { await socket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); }
finally { _sendLock.Release(); }
}
public async IAsyncEnumerable<string> ReadAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var buffer = new byte[16 * 1024];
using var message = new MemoryStream();
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close) yield break;
if (result.MessageType != WebSocketMessageType.Text) continue;
if (message.Length + result.Count > 64 * 1024) throw new InvalidDataException("Lumi sent an oversized plugin live message.");
message.Write(buffer, 0, result.Count);
if (!result.EndOfMessage) continue;
yield return Encoding.UTF8.GetString(message.GetBuffer(), 0, (int)message.Length);
message.SetLength(0);
}
}
}
private static string ValidatePluginId(string value)
{
var clean = (value ?? string.Empty).Trim();

View File

@ -1,12 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RollForward>Major</RollForward>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
</ItemGroup>
</Project>

View File

@ -1,5 +1,6 @@
using Lumi.Companion.Abstractions;
using Lumi.Companion.Core;
using Lumi.Companion.Overlay;
static void Assert(bool condition, string message)
{
@ -50,4 +51,100 @@ Assert(!sanitized.Contains("private-device-secret"), "Companion logs retained a
Assert(CompanionLogSanitizer.NormalizeEvent("Update Failed!") == "update_failed", "Companion event normalization drifted.");
Assert(CompanionLogSanitizer.LevelForEvent("update_failed") == "error", "Companion failed events must use the error level.");
Console.WriteLine("Companion core single-flight and logging regression checks passed.");
var anchored = new OverlayContainerSettings
{
Anchor = OverlayAnchor.Top,
PaddingTop = 20,
PaddingLeft = 40,
PaddingRight = 60,
PaddingBottom = 999,
Width = 5000,
Height = 300
};
var rect = AnchorLayout.Calculate(anchored, 1920, 1080);
Assert(anchored.PaddingBottom == 0, "Top-side anchors must zero disabled bottom padding.");
Assert(rect.Width == 1820 && rect.X == 40 && rect.Y == 20, "Physical-pixel layout did not clamp and center symmetrically inside valid padding.");
var now = DateTimeOffset.UnixEpoch;
var queueSettings = new OverlayContainerSettings { QueueLimit = 2, EntryDurationMs = 100, LifetimeMs = 200, ExitDurationMs = 100 };
var queue = new OverlayLifecycleQueue(() => queueSettings, () => now);
queue.Add("one", "chat", "one");
queue.Add("two", "chat", "two");
queue.Add("three", "chat", "three");
Assert(queue.Items[0].State == OverlayItemState.Exiting && queue.Items.Count == 3,
"Overflow must animate the oldest item out while keeping it rendered.");
now += TimeSpan.FromMilliseconds(100);
queue.Advance();
Assert(queue.Items.Count == 2 && queue.Items.All(item => item.State == OverlayItemState.Visible),
"Entry and exit lifecycle transitions did not advance deterministically.");
now += TimeSpan.FromMilliseconds(201);
queue.Advance();
Assert(queue.Items.All(item => item.State == OverlayItemState.Exiting), "Lifetime must begin only after entry completes.");
for (var index = 0; index < 20; index++) queue.Add($"burst-{index}", "chat", index);
Assert(queue.Items.Count <= queueSettings.QueueLimit * 2, "Burst handling must keep the rendered lifecycle queue strictly bounded.");
var settingsPath = Path.Combine(Path.GetTempPath(), $"lumi-overlay-test-{Guid.NewGuid():N}", "settings.json");
var overlayStore = new OverlaySettingsStore(settingsPath);
await overlayStore.LoadAsync();
overlayStore.Current.Sources.Add(new OverlaySourceSetting
{
Id = "twitch:renamed-channel",
Platform = "twitch",
Label = "Previously configured channel",
Selected = true
});
overlayStore.Current.Unified.Anchor = OverlayAnchor.BottomRight;
overlayStore.Current.Unified.PaddingTop = 42;
await overlayStore.SaveAsync(overlayStore.Current);
var restoredStore = new OverlaySettingsStore(settingsPath);
await restoredStore.LoadAsync();
Assert(restoredStore.Current.Sources.Single().Selected && restoredStore.Current.Sources.Single().Label == "Previously configured channel",
"Unavailable source selections were not retained by atomic local settings persistence.");
Assert(restoredStore.Current.Unified.PaddingTop == 0,
"Settings normalization must clear padding that is invalid for the selected anchor.");
var unsavedDraft = restoredStore.Current.CreateCopy();
unsavedDraft.ChatPercentage = 60;
unsavedDraft.Sources[0].Selected = false;
Assert(restoredStore.Current.ChatPercentage != unsavedDraft.ChatPercentage && restoredStore.Current.Sources[0].Selected,
"Editable overlay drafts must not mutate or get replaced by the last saved settings.");
Assert(!CompanionPluginNavigation.UsesNestedNavigation(
[new CompanionPluginPage("SongOverlay", "Overview & settings")]) &&
CompanionPluginNavigation.UsesNestedNavigation(
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
"Single-page plugins must be direct navigation entries while multi-page plugins remain grouped.");
Assert(new CaptureExclusionStatus(CaptureExclusionState.Unsupported, "unsupported").State == CaptureExclusionState.Unsupported,
"Capture-exclusion unsupported state must remain distinct from failure.");
var visibilitySettings = new LumiOverlaySettings
{
Visibility = OverlayVisibilityMode.WhenLive,
Sources = [new() { Id = "twitch:lumi", Platform = "twitch", Label = "Lumi", Selected = true }]
};
var liveSources = new[] { new OverlaySource("twitch:lumi", "twitch", "Lumi", true, true, "live") };
Assert(OverlayVisibilityPolicy.Automatic(visibilitySettings, liveSources), "A selected live Twitch source must activate When live.");
Assert(!OverlayVisibilityPolicy.Automatic(visibilitySettings,
[new OverlaySource("discord:guild:channel", "discord", "Discord", true, true, "connected")]),
"Discord must never activate When live by itself.");
Assert(OverlayVisibilityPolicy.Effective(visibilitySettings, [], true, null), "Configuration preview must override live-state visibility.");
visibilitySettings.Enabled = false;
Assert(OverlayVisibilityPolicy.Effective(visibilitySettings, [], true, null), "Configuration preview must remain available while the runtime is disabled.");
visibilitySettings.Enabled = true;
Assert(!OverlayVisibilityPolicy.Effective(visibilitySettings, liveSources, false, false), "A manual hidden override must win until automatic visibility is restored.");
var automaticPresentation = OverlayVisibilityPresenter.Describe(visibilitySettings, true, false, null);
Assert(automaticPresentation.Visible && automaticPresentation.Action == "Hide temporarily" &&
automaticPresentation.State.Contains("automatic", StringComparison.Ordinal),
"Visibility presentation must separate the current automatic state from the button action.");
var overridePresentation = OverlayVisibilityPresenter.Describe(visibilitySettings, true, false, false);
Assert(!overridePresentation.Visible && overridePresentation.Action == "Restore automatic visibility",
"Visibility presentation must identify a temporary override and explain how to leave it.");
var previewPresentation = OverlayVisibilityPresenter.Describe(visibilitySettings, false, true, null);
Assert(previewPresentation.Visible && !previewPresentation.CanToggle && previewPresentation.Action.Contains("End preview", StringComparison.Ordinal),
"Preview-controlled visibility must be explicit and must not offer an ineffective override action.");
var monitorFallback = OverlayMonitorPolicy.Resolve("missing", ["primary", "second"], "primary");
Assert(monitorFallback.MonitorId == "primary" && monitorFallback.UsedFallback && monitorFallback.Warning is not null,
"Monitor loss must fall back without erasing the saved monitor or hiding the warning.");
Assert(CompanionLiveReconnectPolicy.Delay(1) == TimeSpan.FromSeconds(2) &&
CompanionLiveReconnectPolicy.Delay(99) == TimeSpan.FromSeconds(30),
"Live feed reconnect backoff must be bounded and deterministic.");
Directory.Delete(Path.GetDirectoryName(settingsPath)!, true);
Console.WriteLine("Companion core, native overlay layout/lifecycle, settings persistence, and capture-status regression checks passed.");

View File

@ -0,0 +1,48 @@
# Companion native overlay
Lumi Companion includes a Windows-native monitor overlay under **Plugins → Lumi
Overlay**. It is independent of Lumi's OBS browser-source overlays: it uses
Avalonia controls in a transparent, topmost, click-through window and never
loads HTML or provider credentials.
## Data and authorization
The server endpoint is `/plugins/lumi_overlay/live`. It authenticates the same
paired device credential as Companion and requires `overlay.read.v1`. New
pairings receive the capability. Existing active devices receive it through a
one-time migration; a later capability removal is not silently undone.
Lumi reuses its normalized Twitch, YouTube, and Discord chat/event streams.
Selections are filtered on the server, messages are sanitized and bounded, and
a connection receives live items only—no history is replayed after startup or
reconnect. Missing provider scopes disable only their affected event types and
are reported in feed status.
## Window behavior
The selected monitor, layout, styling, sources, event types, visibility mode,
animation lifecycle, and hotkeys are stored atomically under the current
Windows user's Companion data directory. A missing monitor temporarily falls
back to the primary screen without changing the saved monitor.
The overlay reapplies click-through, non-activating, tool-window and topmost
styles after display, power, and Windows session changes. It calls
`SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` and reports the result in the
settings page. This is best-effort Windows protection: OBS and other capture
software can use capture paths that do not honor display affinity.
`When live` is activated only by a selected Twitch or YouTube source. Discord
does not activate it. Sample preview and the visibility override temporarily
take precedence, then restore the saved mode.
## Verification
From PowerShell:
```powershell
companion/scripts/verify-lumi-overlay.ps1
```
The regular `npm run verify:all` suite also exercises the server protocol,
filtering, no-backlog behavior, queue bounds, capability migration, and
revocation behavior.

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.3.7
Version: 0.3.8
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events

View File

@ -0,0 +1,26 @@
---
id: plugin.lumi_overlay
title: Lumi Overlay Feed
scope: plugins
status: active
priority: 10
visibility: user
category: Plugin
tags: plugin, lumi_overlay
generated: true
editable: false
---
# Lumi Overlay Feed
Authenticated live chat and event feed for the native Lumi Companion monitor overlay.
## Metadata
Plugin ID: lumi_overlay
Version: 0.1.0
Default state: enabled
## Web Routes
- No plugin routes detected.
## Route Reference
No route details detected.
## Commands
- No plugin command triggers detected.
## Source
Plugin folder: plugins/lumi_overlay

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata
Plugin ID: lumi_transcription
Version: 0.2.5
Version: 0.2.6
Default state: enabled
## Web Routes
- /plugins/lumi_transcription

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.3.7",
"version": "0.3.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.3.7",
"version": "0.3.8",
"dependencies": {
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.3.7",
"version": "0.3.8",
"private": true,
"type": "commonjs",
"scripts": {
@ -24,6 +24,7 @@
"benchmark:okf": "node scripts/benchmark-okf-search.js",
"verify:content": "node scripts/verify-content-library.js",
"verify:transcription": "node plugins/lumi_transcription/tests/verify.js",
"verify:companion-overlay": "node plugins/lumi_overlay/tests/verify.js",
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
},
"engines": {

View File

@ -0,0 +1,9 @@
# Lumi Overlay Feed changelog
## 0.1.0
- Add the paired-device `overlay.read.v1` live feed for the native Companion
monitor overlay.
- Reuse Lumi's normalized Twitch, YouTube, and Discord chat/event streams with
server-side source filtering, bounded queues, sanitization, deduplication,
live-state catalog updates, and no history replay.

View File

@ -0,0 +1,150 @@
"use strict";
const { WebSocketServer, WebSocket } = require("ws");
const { isSecureRequest, requestOrigin } = require("../../../src/services/proxy-security");
const { insecureDeviceAllowed } = require("../../lumi_transcription/backend/companion/device_store");
const { onOverlayChatMessage } = require("../../../src/services/overlay-chat");
const { onLumiEvent } = require("../../../src/services/lumi-events");
const { parseClientMessage, sanitizeChat, sanitizeEvent, MAX_MESSAGE_BYTES } = require("./protocol");
const { listSources, listStatuses, sourceIdForChat, sourceIdForEvent, listEventTypes } = require("./sources");
const MAX_QUEUE = 300;
const MAX_DEDUPE = 2000;
class OverlayFeed {
constructor({ devices, logger }) {
this.devices = devices;
this.logger = logger || { info() {}, warn() {}, error() {} };
this.wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES, perMessageDeflate: false });
this.sessions = new Set();
this.publishedSeen = new Map();
this.sequence = 0;
this.stopChat = onOverlayChatMessage((message) => this.publishChat(message));
this.stopEvent = onLumiEvent((event) => this.publishEvent(event));
this.wss.on("connection", (socket, request, device) => this.connected(socket, request, device));
}
upgrade(request, socket, head) {
const auth = this.devices.authenticate(request.headers.authorization, "overlay.read.v1");
if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason);
let origin;
try { origin = requestOrigin(request); } catch { return reject(socket, 400, "invalid_host"); }
if (!isSecureRequest(request) && !insecureDeviceAllowed(auth.device, origin, request.socket.remoteAddress))
return reject(socket, 426, "tls_required");
this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device));
}
connected(socket, _request, device) {
const session = {
socket, device, sources: new Set(), events: new Set(), subscribed: false,
queue: [], draining: false, seen: new Map(), lastPong: Date.now()
};
this.sessions.add(session);
this.send(session, "hello", {
protocol_version: 1,
capabilities: ["overlay.feed.v1", "sources.catalog.v1", "live-state.v1"],
sources: listSources(),
event_types: listEventTypes(),
statuses: listStatuses(),
heartbeat_ms: 15000
});
const heartbeat = setInterval(() => {
if (Date.now() - session.lastPong > 45000) return socket.close(4408, "heartbeat_timeout");
this.send(session, "ping", { at: Date.now() });
this.send(session, "catalog", { sources: listSources(), statuses: listStatuses() });
}, 15000);
heartbeat.unref?.();
socket.on("message", (raw, binary) => {
if (binary) return socket.close(4400, "text_messages_only");
try {
const message = parseClientMessage(raw);
if (message.type === "subscribe") {
session.sources = new Set(message.sources);
session.events = new Set(message.events);
session.subscribed = true;
this.send(session, "subscribed", { sources: [...session.sources], events: [...session.events] });
} else if (message.type === "pong") session.lastPong = Date.now();
else if (message.type === "ping") this.send(session, "pong", { at: Date.now() });
} catch (error) {
this.send(session, "error", { code: error.code || "INVALID_MESSAGE", message: error.message });
}
});
socket.on("close", () => { clearInterval(heartbeat); this.sessions.delete(session); });
socket.on("error", (error) => this.logger.warn?.("Native overlay feed connection failed", { device_id: device.id, error: error.message }, { event: "overlay_feed_socket_error" }));
}
publishChat(message) {
const sourceId = sourceIdForChat(message);
if (!this.acceptPublished(`chat:${message.id}`)) return;
for (const session of this.sessions) {
if (!session.subscribed || !session.sources.has(sourceId)) continue;
this.enqueue(session, "chat", { source_id: sourceId, message: sanitizeChat(message) }, `chat:${message.id}`);
}
}
publishEvent(event) {
const sourceId = sourceIdForEvent(event);
if (!this.acceptPublished(`event:${event.id}`)) return;
for (const session of this.sessions) {
if (!session.subscribed || !session.events.has(event.type) || !matchesSelectedSource(session.sources, sourceId)) continue;
this.enqueue(session, "event", { source_id: sourceId, event: sanitizeEvent(event) }, `event:${event.id}`);
}
}
enqueue(session, type, payload, key) {
const now = Date.now();
if (session.seen.has(key)) return;
session.seen.set(key, now);
while (session.seen.size > MAX_DEDUPE) session.seen.delete(session.seen.keys().next().value);
const envelope = { type, sequence: ++this.sequence, sent_at: now, ...payload };
if (session.queue.length >= MAX_QUEUE) session.queue.shift();
session.queue.push(envelope);
this.drain(session);
}
acceptPublished(key) {
this.publishedSeen ||= new Map();
if (this.publishedSeen.has(key)) return false;
this.publishedSeen.set(key, Date.now());
while (this.publishedSeen.size > MAX_DEDUPE) this.publishedSeen.delete(this.publishedSeen.keys().next().value);
return true;
}
drain(session) {
if (session.draining) return;
session.draining = true;
try {
while (session.queue.length && session.socket.readyState === WebSocket.OPEN) {
session.socket.send(JSON.stringify(session.queue.shift()));
}
} finally { session.draining = false; }
}
send(session, type, payload) {
if (session.socket.readyState === WebSocket.OPEN) session.socket.send(JSON.stringify({ type, sequence: ++this.sequence, sent_at: Date.now(), ...payload }));
}
async close() {
this.stopChat();
this.stopEvent();
for (const session of this.sessions) session.socket.close(1001, "plugin_shutdown");
await new Promise((resolve) => this.wss.close(resolve));
}
}
function matchesSelectedSource(selected, sourceId) {
if (selected.has(sourceId)) return true;
if (sourceId.startsWith("discord:") && sourceId.endsWith(":*"))
return [...selected].some((id) => id.startsWith(sourceId.slice(0, -1)));
if (sourceId.endsWith(":current"))
return [...selected].some((id) => id.startsWith(sourceId.split(":")[0] + ":"));
return false;
}
function reject(socket, status, reason) {
const labels = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 426: "Upgrade Required" };
socket.write(`HTTP/1.1 ${status} ${labels[status] || "Rejected"}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ error: reason })}`);
socket.destroy();
}
module.exports = { OverlayFeed, MAX_QUEUE, matchesSelectedSource };

View File

@ -0,0 +1,102 @@
"use strict";
const MAX_MESSAGE_BYTES = 64 * 1024;
const MAX_SELECTED_SOURCES = 250;
const MAX_SELECTED_EVENTS = 100;
function parseClientMessage(data) {
const body = Buffer.from(data);
if (body.length > MAX_MESSAGE_BYTES) throw coded("MESSAGE_TOO_LARGE", "Overlay control message is too large.");
let value;
try { value = JSON.parse(body.toString("utf8")); } catch { throw coded("INVALID_JSON", "Overlay control message must be valid JSON."); }
if (!value || typeof value !== "object" || Array.isArray(value)) throw coded("INVALID_MESSAGE", "Overlay control message must be an object.");
const type = String(value.type || "").trim();
if (!["subscribe", "ping", "pong"].includes(type)) throw coded("INVALID_MESSAGE", `Unsupported overlay control message '${type || "empty"}'.`);
if (type !== "subscribe") return { type };
return {
type,
sources: uniqueStrings(value.sources, MAX_SELECTED_SOURCES, 300),
events: uniqueStrings(value.events, MAX_SELECTED_EVENTS, 120)
};
}
function uniqueStrings(value, limit, maxLength) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map((entry) => String(entry || "").trim().slice(0, maxLength)).filter(Boolean))].slice(0, limit);
}
function cleanText(value, max = 500) {
return String(value === undefined || value === null ? "" : value).slice(0, max);
}
function sanitizeChat(message) {
return {
id: cleanText(message.id, 256),
platform: cleanText(message.platform, 24),
text: cleanText(message.text, 2000),
timestamp: Number(message.timestamp) || Date.now(),
channel: {
id: cleanText(message.channel?.id, 256),
name: cleanText(message.channel?.name, 256),
key: cleanText(message.channel?.key, 256)
},
author: {
id: cleanText(message.author?.id, 160),
name: cleanText(message.author?.name, 160),
username: cleanText(message.author?.username, 160),
avatar: safeHttps(message.author?.avatar),
color: /^#[0-9a-f]{6}$/i.test(message.author?.color || "") ? message.author.color : null,
badges: (Array.isArray(message.author?.badges) ? message.author.badges : []).slice(0, 12).map((badge) => ({
label: cleanText(badge?.label, 40),
image: safeHttps(badge?.image)
}))
},
emotes: (message.platform === "twitch" && Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({
start: Math.max(0, Number(emote?.start) || 0),
end: Math.max(0, Number(emote?.end) || 0),
label: cleanText(emote?.label, 100),
image: safeHttps(emote?.image)
})).filter((emote) => emote.image)
};
}
function sanitizeEvent(event) {
return {
id: cleanText(event.id, 256),
type: cleanText(event.type, 120),
source: cleanText(event.source, 80),
occurred_at: Number(event.occurredAt) || Date.now(),
payload: sanitizeObject(event.payload, 0)
};
}
function sanitizeObject(value, depth) {
if (depth > 3 || value === null || value === undefined) return null;
if (Array.isArray(value)) return value.slice(0, 20).map((item) => sanitizeObject(item, depth + 1));
if (typeof value === "object") {
const output = {};
for (const [key, item] of Object.entries(value).slice(0, 50)) {
if (/token|secret|authorization|credential|oauth/i.test(key)) continue;
output[cleanText(key, 80)] = sanitizeObject(item, depth + 1);
}
return output;
}
if (typeof value === "string") return cleanText(value, 1000);
return typeof value === "number" || typeof value === "boolean" ? value : null;
}
function safeHttps(value) {
try {
const parsed = new URL(String(value || ""));
return parsed.protocol === "https:" ? parsed.toString() : null;
} catch { return null; }
}
function coded(code, message) { return Object.assign(new Error(message), { code }); }
module.exports = {
MAX_MESSAGE_BYTES,
parseClientMessage,
sanitizeChat,
sanitizeEvent
};

View File

@ -0,0 +1,86 @@
"use strict";
const { configuredChannels } = require("../../../src/services/twitch-eventsub");
const { getClient: getYouTubeClient } = require("../../../src/services/youtube");
const { getClient: getDiscordClient } = require("../../../src/services/discord");
const { twitchEventSubManager } = require("../../../src/services/twitch-eventsub");
const { listEventTypes } = require("../../../src/services/lumi-events");
const { getPlatformLiveState } = require("../../../src/services/platform-live-state");
const { getSetting } = require("../../../src/services/settings");
function listSources() {
const sources = configuredChannels().map((channel) => ({
id: `twitch:${channel}`, platform: "twitch", label: `Twitch · ${channel}`, channel_id: channel,
live: getPlatformLiveState("twitch", channel)?.live ?? null, available: true,
status: getPlatformLiveState("twitch", channel)?.live ? "live" : "configured"
}));
const youtube = getYouTubeClient();
const youtubeChannelId = youtube?.channelId || getSetting("youtube_bot_channel_id", "");
if (youtubeChannelId) sources.push({
id: `youtube:${youtubeChannelId}`, platform: "youtube", label: `YouTube · ${youtube?.channelName || youtubeChannelId}`,
channel_id: youtubeChannelId, live: youtube ? Boolean(youtube.liveChatId) : null, available: Boolean(youtube),
status: youtube ? (youtube.liveChatId ? "live" : "offline") : "not connected"
});
const discord = getDiscordClient();
for (const guild of discord?.guilds?.cache?.values?.() || []) {
for (const channel of guild.channels?.cache?.values?.() || []) {
if (!isReadableTextChannel(channel, discord)) continue;
sources.push({
id: `discord:${guild.id}:${channel.id}`, platform: "discord",
label: `Discord · ${guild.name} / #${channel.name || channel.id}`,
channel_id: channel.id, guild_id: guild.id, live: null, available: true, status: "connected"
});
}
}
return sources.sort((left, right) => left.label.localeCompare(right.label));
}
function listStatuses() {
const youtube = getYouTubeClient();
const discord = getDiscordClient();
const twitch = twitchEventSubManager.getStatus();
const missing = Object.values(twitch.capabilities || {}).filter((value) => String(value).startsWith("missing "));
return [
{ platform: "twitch", ...twitch, detail: `${twitch.detail}${missing.length ? ` Limited events: ${missing.join(", ")}.` : ""}` },
{ platform: "youtube", state: youtube ? "connected" : "unavailable", detail: youtube ? (youtube.liveChatId ? "Receiving live chat." : "Connected; no live chat is active.") : "YouTube is not configured." },
{ platform: "discord", state: discord?.isReady?.() ? "connected" : "unavailable", detail: discord?.isReady?.() ? "Discord is connected." : "Discord is not connected." }
];
}
function sourceIdForChat(message) {
if (message.platform === "twitch") return `twitch:${normalize(message.channel?.name || message.channel?.key || message.channel?.id)}`;
if (message.platform === "youtube") {
const youtube = getYouTubeClient();
return `youtube:${youtube?.channelId || message.channel?.id || normalize(message.channel?.key || message.channel?.name)}`;
}
if (message.platform === "discord") {
const [guildId, channelId] = String(message.channel?.key || "").split("/");
return `discord:${guildId || "unknown"}:${channelId || message.channel?.id || "unknown"}`;
}
return `${normalize(message.platform)}:${normalize(message.channel?.id || message.channel?.key)}`;
}
function sourceIdForEvent(event) {
const payload = event.payload || {};
if (event.type.startsWith("twitch.")) return `twitch:${normalize(payload.broadcaster_name || payload.broadcaster_id)}`;
if (event.type.startsWith("youtube.")) return `youtube:${payload.channel_id || payload.live_chat_id || "current"}`;
if (event.type.startsWith("discord.")) return `discord:${payload.guild_id || "unknown"}:${payload.channel_id || "*"}`;
return "";
}
function isReadableTextChannel(channel, discord) {
const text = typeof channel?.isTextBased === "function" ? channel.isTextBased() : ["GUILD_TEXT", 0].includes(channel?.type);
if (!text || !channel?.guild) return false;
try {
const permissions = channel.permissionsFor?.(discord.user);
if (!permissions) return true;
for (const flag of ["ViewChannel", "VIEW_CHANNEL", 1024n]) {
try { if (permissions.has?.(flag)) return true; } catch {}
}
return false;
} catch { return false; }
}
function normalize(value) { return String(value || "").trim().toLowerCase().replace(/^#/, ""); }
module.exports = { listSources, listStatuses, sourceIdForChat, sourceIdForEvent, listEventTypes, isReadableTextChannel };

View File

@ -0,0 +1,19 @@
"use strict";
const { DeviceStore } = require("../lumi_transcription/backend/companion/device_store");
const { OverlayFeed } = require("./backend/feed");
module.exports = {
id: "lumi_overlay",
init({ web, db, logger }) {
const devices = new DeviceStore(db);
const feed = new OverlayFeed({ devices, logger });
const unregister = web.addUpgradeHandler("/plugins/lumi_overlay/live", (request, socket, head) =>
feed.upgrade(request, socket, head));
logger?.info?.("Native Companion overlay feed started", {}, { event: "overlay_feed_started" });
return async () => {
unregister();
await feed.close();
};
}
};

View File

@ -0,0 +1,10 @@
{
"id": "lumi_overlay",
"name": "Lumi Overlay Feed",
"version": "0.1.0",
"description": "Authenticated live chat and event feed for the native Lumi Companion monitor overlay.",
"main": "index.js",
"channel": "stable",
"compatible_from": "0.1.0",
"rollback_safe": true
}

View File

@ -0,0 +1,128 @@
"use strict";
const assert = require("assert");
const { WebSocket } = require("ws");
const { parseClientMessage, sanitizeChat, sanitizeEvent, MAX_MESSAGE_BYTES } = require("../backend/protocol");
const { OverlayFeed, MAX_QUEUE, matchesSelectedSource } = require("../backend/feed");
const { isReadableTextChannel } = require("../backend/sources");
function run() {
verifyProtocol();
verifyCapabilityGate();
verifyFilteringAndNoBacklog();
verifyDiscordReadability();
verifyBoundedQueueAndDeduplication();
verifySanitization();
console.log("Lumi native overlay verification passed: protocol limits, server-side filtering, no backlog, reconnect-safe deduplication, bounded queues, and payload sanitization.");
}
function verifyDiscordReadability() {
const discord = { user: { id: "bot" } };
const channel = {
guild: { id: "guild" },
isTextBased: () => true,
permissionsFor: () => ({ has: (flag) => flag === 1024n })
};
assert.equal(isReadableTextChannel(channel, discord), true);
assert.equal(isReadableTextChannel({ ...channel, permissionsFor: () => ({ has: () => false }) }, discord), false);
assert.equal(isReadableTextChannel({ ...channel, isTextBased: () => false }, discord), false);
}
function verifyCapabilityGate() {
const feed = Object.create(OverlayFeed.prototype);
let response = "";
const socket = {
write(value) { response += value; },
destroy() {}
};
feed.devices = { authenticate: (_header, capability) => {
assert.equal(capability, "overlay.read.v1");
return { allowed: false, reason: "missing_credentials" };
} };
feed.upgrade({ headers: {}, socket: {} }, socket, Buffer.alloc(0));
assert.match(response, /^HTTP\/1.1 401/);
response = "";
feed.devices = { authenticate: () => ({ allowed: false, reason: "capability_revoked" }) };
feed.upgrade({ headers: { authorization: "redacted" }, socket: {} }, socket, Buffer.alloc(0));
assert.match(response, /^HTTP\/1.1 403/);
}
function verifyProtocol() {
assert.deepEqual(parseClientMessage(JSON.stringify({
type: "subscribe",
sources: ["twitch:lumi", "twitch:lumi", "discord:guild:channel"],
events: ["twitch.follow"]
})), {
type: "subscribe",
sources: ["twitch:lumi", "discord:guild:channel"],
events: ["twitch.follow"]
});
assert.throws(() => parseClientMessage(JSON.stringify({ type: "history" })), /Unsupported/);
assert.throws(() => parseClientMessage(Buffer.alloc(MAX_MESSAGE_BYTES + 1)), /too large/);
}
function verifyFilteringAndNoBacklog() {
const feed = Object.create(OverlayFeed.prototype);
feed.sessions = new Set();
feed.sequence = 0;
feed.publishedSeen = new Map();
feed.publishChat({ id: "before", platform: "twitch", channel: { name: "lumi" } });
const sent = [];
const session = sessionFor(sent, ["twitch:lumi"]);
feed.sessions.add(session);
assert.equal(sent.length, 0, "opening/subscribing must not replay a pre-connection message");
feed.publishChat({
id: "selected", platform: "twitch", text: "hello", timestamp: 1,
channel: { id: "1", name: "lumi", key: "lumi" },
author: { id: "viewer", name: "Viewer", badges: [] }, emotes: []
});
feed.publishChat({
id: "selected", platform: "twitch", text: "duplicate", timestamp: 3,
channel: { id: "1", name: "lumi", key: "lumi" },
author: { id: "viewer", name: "Viewer", badges: [] }, emotes: []
});
feed.publishChat({
id: "other", platform: "twitch", text: "hidden", timestamp: 2,
channel: { id: "2", name: "other", key: "other" },
author: { id: "viewer", name: "Viewer", badges: [] }, emotes: []
});
assert.equal(sent.length, 1, "provider duplicates must remain suppressed across feed sessions/reconnects");
assert.equal(JSON.parse(sent[0]).message.id, "selected");
assert.equal(matchesSelectedSource(new Set(["discord:guild:channel"]), "discord:guild:*"), true);
}
function verifyBoundedQueueAndDeduplication() {
const feed = Object.create(OverlayFeed.prototype);
feed.sequence = 0;
const session = sessionFor([], ["twitch:lumi"], WebSocket.CLOSED);
for (let index = 0; index < MAX_QUEUE + 40; index += 1)
feed.enqueue(session, "chat", { index }, `message:${index}`);
assert.equal(session.queue.length, MAX_QUEUE);
assert.equal(session.queue[0].index, 40, "overflow must deterministically drop the oldest queued item");
feed.enqueue(session, "chat", { index: 999 }, `message:${MAX_QUEUE + 39}`);
assert.equal(session.queue.length, MAX_QUEUE, "a duplicate event must not enter the queue twice");
}
function verifySanitization() {
const chat = sanitizeChat({
id: "id", platform: "twitch", text: "<script>plain text</script>",
channel: {}, author: { id: "user", name: "Viewer", avatar: "http://insecure.test/avatar.png" },
emotes: [{ start: 0, end: 1, image: "https://cdn.test/emote.png" }]
});
assert.equal(chat.text, "<script>plain text</script>", "content remains plain data and is not interpreted server-side");
assert.equal(chat.author.avatar, null);
assert.equal(chat.emotes[0].image, "https://cdn.test/emote.png");
const event = sanitizeEvent({ id: "event", type: "twitch.follow", payload: { user_name: "Viewer", access_token: "secret" } });
assert.equal(event.payload.access_token, undefined);
assert.equal(event.payload.user_name, "Viewer");
}
function sessionFor(sent, sources, state = WebSocket.OPEN) {
return {
socket: { readyState: state, send(value) { sent.push(value); } },
device: {}, sources: new Set(sources), events: new Set(), subscribed: true,
queue: [], draining: false, seen: new Map(), lastPong: Date.now()
};
}
run();

View File

@ -1,5 +1,12 @@
# Lumi Transcription changelog
## 0.2.6
- Release Companion 0.2.6 with the native Lumi Overlay and shell-owned authenticated live feed.
- Preserve unsaved overlay settings through periodic catalog/status refreshes and improve percentage, visibility, layout, monitor, source, and event presentation.
- Keep Song Overlay and Lumi Overlay as direct single-page navigation entries while retaining nested Transcription pages.
- Retain OBS Bridge 0.2.5, current transcription behavior, paired identity, settings, and automatic update compatibility.
## 0.2.5
- Add encrypted Domeneshop DNS-01 automation for RTMPS certificate issuance and renewal when the production HTTPS proxy reserves the HTTP challenge route.

View File

@ -1,6 +1,12 @@
const crypto = require("crypto");
const DEFAULT_CAPABILITIES = Object.freeze(["transcription.capture.v1", "transcription.settings.v1", "obs.caption.native.v1"]);
const DEFAULT_CAPABILITIES = Object.freeze([
"transcription.capture.v1",
"transcription.settings.v1",
"obs.caption.native.v1",
"overlay.read.v1"
]);
const OVERLAY_CAPABILITY_MIGRATION = "overlay-read-v1";
class DeviceStore {
constructor(db, options = {}) {
@ -24,8 +30,26 @@ class DeviceStore {
pairing_host TEXT
);
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
CREATE TABLE IF NOT EXISTS companion_capability_migrations (
id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL
);
`);
ensureColumn(this.db, "transcription_devices", "pairing_host", "TEXT");
this.migrateOverlayCapabilityOnce();
}
migrateOverlayCapabilityOnce() {
if (this.db.prepare("SELECT 1 FROM companion_capability_migrations WHERE id = ?").get(OVERLAY_CAPABILITY_MIGRATION)) return;
const migrate = this.db.transaction(() => {
const rows = this.db.prepare("SELECT id, capabilities_json FROM transcription_devices WHERE revoked_at IS NULL").all();
const update = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL");
for (const row of rows) {
const capabilities = parseArray(row.capabilities_json);
if (!capabilities.includes("overlay.read.v1")) update.run(JSON.stringify([...capabilities, "overlay.read.v1"]), row.id);
}
this.db.prepare("INSERT OR IGNORE INTO companion_capability_migrations (id, applied_at) VALUES (?, ?)").run(OVERLAY_CAPABILITY_MIGRATION, this.now());
});
migrate();
}
issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {

View File

@ -1,17 +1,17 @@
{
"schema_version": 1,
"version": "0.2.5",
"version": "0.2.6",
"signed": false,
"release_notes": "Matches Lumi Transcription 0.2.5 and its managed DNS-01 RTMPS certificate workflow while retaining silent reconnect, caption controls, progressive private captions, and OBS Bridge 0.2.5.",
"release_notes": "Adds the native Lumi Overlay, draft-safe settings, clearer visibility and layout controls, and direct navigation for single-page plugins while retaining transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.",
"installer": {
"id": "windows-x64-installer",
"platform": "win32",
"architecture": "x64",
"label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-Setup.exe",
"sha256": "bfa2d6aeb79c7f366631ea33bbda1c9f313374c3fa11a18a76ef304fc3947474",
"bytes": 51919651
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-Setup.exe",
"sha256": "0420cdee45d676ded1566fd8da2e25482eca258b1d7d200544e5425d22105bb8",
"bytes": 51922633
},
"artifacts": [
{
@ -20,9 +20,9 @@
"architecture": "x64",
"label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-win-x64.zip",
"sha256": "7b46c1562852c848176813bbe8fe25d04c69f49ccde5848cd36294b3c036500c",
"bytes": 68486344,
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-win-x64.zip",
"sha256": "1e216f9653d0dc0513ca119f0a8c5cf43bf47328a83cbbf183e1d4b14c111e94",
"bytes": 66857759,
"entrypoint": "Lumi.Companion.App.exe"
}
]

View File

@ -1,7 +1,7 @@
{
"id": "lumi_transcription",
"name": "Lumi Transcription",
"version": "0.2.5",
"version": "0.2.6",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js",
"channel": "stable",

View File

@ -35,6 +35,7 @@ async function run() {
try {
verifyProtocol();
verifyPairingAndRevocation();
verifyOverlayCapabilityMigration();
verifyLocalhostTransportPolicy();
verifyProxiedHttpsTransport();
verifyCompanionVersionOrdering();
@ -144,6 +145,37 @@ function verifyPairingAndRevocation() {
db.close();
}
function verifyOverlayCapabilityMigration() {
const db = new Database(":memory:");
db.exec(`
CREATE TABLE transcription_pairing_tokens (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, lumi_user_id TEXT NOT NULL,
host TEXT NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, created_at INTEGER NOT NULL
);
CREATE TABLE transcription_devices (
id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL,
credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL,
first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER,
pairing_host TEXT
);
`);
const activeId = crypto.randomUUID();
const revokedId = crypto.randomUUID();
const insert = db.prepare("INSERT INTO transcription_devices VALUES (?, NULL, 'PC', 'admin', 'hash', ?, '{}', 1, 1, ?, 'https://lumi.example')");
insert.run(activeId, JSON.stringify(["transcription.capture.v1"]), null);
insert.run(revokedId, JSON.stringify(["transcription.capture.v1"]), 2);
const store = new DeviceStore(db, { now: () => 50 });
assert.equal(store.list().find((device) => device.id === activeId).capabilities.includes("overlay.read.v1"), true,
"the one-time migration must grant active paired devices overlay access");
assert.equal(store.list({ status: "revoked" }).find((device) => device.id === revokedId).capabilities.includes("overlay.read.v1"), false,
"the migration must not modify revoked devices");
store.setCapabilities(activeId, ["transcription.capture.v1"]);
new DeviceStore(db, { now: () => 100 });
assert.equal(store.list().find((device) => device.id === activeId).capabilities.includes("overlay.read.v1"), false,
"rerunning migrations must not restore a capability removed after the one-time grant");
db.close();
}
function verifyLocalhostTransportPolicy() {
assert.equal(normalizeHost("http://localhost:3000/path"), "http://localhost:3000");
assert.equal(normalizeHost("http://127.0.0.1:3000"), "http://127.0.0.1:3000");

View File

@ -2,6 +2,39 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.3.8",
"ref": "refs/tags/v0.3.8",
"released_at": "2026-07-26",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"lumi_overlay": "0.1.0",
"lumi_transcription": "0.2.6",
"moderation": "0.1.5",
"now_playing": "0.1.3",
"okf": "0.1.2",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.3.7",
"ref": "refs/tags/v0.3.7",

View File

@ -23,6 +23,7 @@ const checks = [
"plugins/lumi_ai/tests/verify-tools.js",
"plugins/lumi_ai_web_search/tests/verify.js",
"plugins/lumi_transcription/tests/verify.js",
"plugins/lumi_overlay/tests/verify.js",
"scripts/verify-assistant-panels.js",
"scripts/verify-command-preview-confirmations.js",
"scripts/verify-command-policies.js",

View File

@ -4,12 +4,13 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.3.7";
const previousStableVersion = "0.3.6";
const priorStableVersion = "0.3.5";
const releaseVersion = "0.3.8";
const previousStableVersion = "0.3.7";
const priorStableVersion = "0.3.6";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
lumi_transcription: { version: "0.2.5", knowledge: "lumi-transcription" },
lumi_overlay: { version: "0.1.0", knowledge: "lumi-overlay" },
lumi_transcription: { version: "0.2.6", knowledge: "lumi-transcription" },
now_playing: { version: "0.1.3", knowledge: "now-playing" }
};
@ -82,4 +83,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.3.7 after 0.3.6 with synchronized Companion plugin metadata.");
console.log("Release metadata verification passed: stable core 0.3.8 after 0.3.7 with synchronized Companion plugin metadata.");

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.3.8", "0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.3.7");
assert.equal(currentRelease.version, "0.3.7");
assert.equal(packageVersion, "0.3.8");
assert.equal(currentRelease.version, "0.3.8");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
{ version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true },
{ version: "0.3.5", ref: "refs/tags/v0.3.5", rollback_safe: true },
@ -156,7 +157,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.3.7");
assert.equal(corrected.safe_target_version, "0.3.8");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
client_id: clientId || "",
redirect_uri: redirectUri || "",
response_type: "code",
scope: "moderator:read:followers channel:read:subscriptions",
scope: "moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions",
state,
force_verify: "true"
});

View File

@ -65,6 +65,12 @@ for (const definition of [
{ id: "twitch.raid", label: "Twitch raid received", description: "A configured Twitch channel receives a raid that appears in chat.", platform: "twitch" },
{ id: "twitch.subscribe", label: "Twitch subscription", description: "A configured Twitch channel receives a new subscription.", platform: "twitch", supportsTier: true },
{ id: "twitch.subscription_gift", label: "Twitch gifted subscriptions", description: "A viewer gifts one or more subscriptions in a configured Twitch channel.", platform: "twitch", supportsTier: true },
{ id: "twitch.cheer", label: "Twitch cheer", description: "A viewer cheers Bits in a configured Twitch channel.", platform: "twitch" },
{ id: "twitch.channel_points", label: "Twitch channel-points redemption", description: "A viewer redeems a channel-points reward.", platform: "twitch" },
{ id: "youtube.membership", label: "YouTube membership", description: "A YouTube viewer becomes or remains a channel member.", platform: "youtube" },
{ id: "youtube.membership_gift", label: "YouTube gifted membership", description: "A YouTube viewer gifts channel memberships.", platform: "youtube" },
{ id: "youtube.super_chat", label: "YouTube Super Chat", description: "A viewer sends a Super Chat.", platform: "youtube" },
{ id: "youtube.super_sticker", label: "YouTube Super Sticker", description: "A viewer sends a Super Sticker.", platform: "youtube" },
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" }
]) registerEventType(definition);

View File

@ -4,6 +4,7 @@ const { publishWebEvent } = require("./web-events");
const { getOverlayModuleType } = require("./overlay-modules");
const recentByOverlay = new Map();
const liveListeners = new Set();
const MAX_RECENT_PER_OVERLAY = 100;
const MAX_RECENT_AGE_MS = 5 * 60 * 1000;
@ -157,6 +158,11 @@ function remember(overlayId, message) {
function publishOverlayChatMessage(input = {}) {
const message = normalizeChatMessage(input);
if (!message) return 0;
for (const listener of liveListeners) {
try {
Promise.resolve(listener(message)).catch(() => {});
} catch {}
}
let delivered = 0;
for (const overlayId of overlayTargets(message)) {
remember(overlayId, message);
@ -165,6 +171,12 @@ function publishOverlayChatMessage(input = {}) {
return delivered;
}
function onOverlayChatMessage(listener) {
if (typeof listener !== "function") throw new Error("Overlay chat listener must be a function.");
liveListeners.add(listener);
return () => liveListeners.delete(listener);
}
function recentOverlayChatMessages(overlayId) {
const cutoff = Date.now() - MAX_RECENT_AGE_MS;
const recent = (recentByOverlay.get(String(overlayId)) || []).filter((entry) => entry.timestamp >= cutoff);
@ -176,6 +188,7 @@ module.exports = {
messageBlockedByConfig,
messageMatchesConfig,
normalizeChatMessage,
onOverlayChatMessage,
publishOverlayChatMessage,
recentOverlayChatMessages
};

View File

@ -0,0 +1,21 @@
"use strict";
const states = new Map();
const listeners = new Set();
function key(platform, source) { return `${String(platform).toLowerCase()}:${String(source).toLowerCase()}`; }
function setPlatformLiveState(platform, source, live, detail = null) {
const id = key(platform, source);
const next = Object.freeze({ platform: String(platform).toLowerCase(), source: String(source).toLowerCase(), live: Boolean(live), detail, updatedAt: Date.now() });
const previous = states.get(id);
states.set(id, next);
if (previous?.live !== next.live || previous?.detail !== next.detail)
for (const listener of listeners) try { listener(next); } catch {}
return next;
}
function getPlatformLiveState(platform, source) { return states.get(key(platform, source)) || null; }
function onPlatformLiveState(listener) { listeners.add(listener); return () => listeners.delete(listener); }
module.exports = { setPlatformLiveState, getPlatformLiveState, onPlatformLiveState };

View File

@ -2,6 +2,7 @@ const WebSocket = require("ws");
const { getSetting, setSetting } = require("./settings");
const { emitLumiEvent } = require("./lumi-events");
const { createLogger } = require("./logger");
const { setPlatformLiveState } = require("./platform-live-state");
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
@ -101,6 +102,7 @@ class TwitchEventSubManager {
const socket = this.socket;
this.socket = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
for (const channel of configuredChannels()) setPlatformLiveState("twitch", channel, false, "disconnected");
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
}
@ -195,6 +197,8 @@ class TwitchEventSubManager {
const definitions = [];
for (const broadcaster of users.data || []) {
definitions.push({ type: "channel.raid", version: "1", condition: { to_broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "stream.online", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "stream.offline", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
if (scopes.has("moderator:read:followers")) {
definitions.push({ type: "channel.follow", version: "2", condition: { broadcaster_user_id: broadcaster.id, moderator_user_id: credentials.validation.user_id } });
}
@ -202,6 +206,20 @@ class TwitchEventSubManager {
definitions.push({ type: "channel.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
definitions.push({ type: "channel.subscription.gift", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
}
if (scopes.has("bits:read") && broadcaster.id === credentials.validation.user_id)
definitions.push({ type: "channel.cheer", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
if ((scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions")) && broadcaster.id === credentials.validation.user_id)
definitions.push({ type: "channel.channel_points_custom_reward_redemption.add", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
}
if (users.data?.length) {
try {
const streams = await twitchRequest(`https://api.twitch.tv/helix/streams?${users.data.map((user) => `user_id=${encodeURIComponent(user.id)}`).join("&")}`, { headers });
const liveIds = new Set((streams.data || []).map((stream) => stream.user_id));
for (const broadcaster of users.data)
setPlatformLiveState("twitch", broadcaster.login || broadcaster.display_name || broadcaster.id, liveIds.has(broadcaster.id), liveIds.has(broadcaster.id) ? "live" : "offline");
} catch (error) {
eventLog.warn("Initial Twitch live state could not be loaded", { error }, { event: "eventsub_live_state_failed" });
}
}
let active = 0;
for (const definition of definitions) {
@ -220,7 +238,13 @@ class TwitchEventSubManager {
this.status = {
state: "connected",
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
subscriptions: active
subscriptions: active,
capabilities: {
cheers: scopes.has("bits:read") ? "available" : "missing bits:read",
channel_points: scopes.has("channel:read:redemptions") || scopes.has("channel:manage:redemptions") ? "available" : "missing channel:read:redemptions",
subscriptions: scopes.has("channel:read:subscriptions") ? "available" : "missing channel:read:subscriptions",
follows: scopes.has("moderator:read:followers") ? "available" : "missing moderator:read:followers"
}
};
eventLog.info("Twitch EventSub connected", { channels: users.data?.length || 0, subscriptions: active }, { event: "eventsub_ready" });
}
@ -235,10 +259,18 @@ class TwitchEventSubManager {
user_name: event.user_name || event.from_broadcaster_user_name || null
};
const metadata = { id: message.metadata?.message_id, source: "twitch-eventsub", occurredAt: message.metadata?.message_timestamp };
const broadcaster = String(event.broadcaster_user_login || event.broadcaster_user_name || event.broadcaster_user_id || "").toLowerCase();
if (subscription.type === "stream.online") return void setPlatformLiveState("twitch", broadcaster, true, "live");
if (subscription.type === "stream.offline") return void setPlatformLiveState("twitch", broadcaster, false, "offline");
if (subscription.type === "channel.follow") emitLumiEvent("twitch.follow", { ...common, followed_at: event.followed_at }, metadata);
if (subscription.type === "channel.raid") emitLumiEvent("twitch.raid", { ...common, viewers: Number(event.viewers || 0) }, metadata);
if (subscription.type === "channel.subscribe") emitLumiEvent("twitch.subscribe", { ...common, tier: event.tier, gifted: Boolean(event.is_gift) }, metadata);
if (subscription.type === "channel.subscription.gift") emitLumiEvent("twitch.subscription_gift", { ...common, tier: event.tier, total: Number(event.total || 0), anonymous: Boolean(event.is_anonymous) }, metadata);
if (subscription.type === "channel.cheer") emitLumiEvent("twitch.cheer", { ...common, bits: Number(event.bits || 0), message: event.message || "", anonymous: Boolean(event.is_anonymous) }, metadata);
if (subscription.type === "channel.channel_points_custom_reward_redemption.add") emitLumiEvent("twitch.channel_points", {
...common, reward_id: event.reward?.id || null, reward_title: event.reward?.title || null,
reward_cost: Number(event.reward?.cost || 0), user_input: event.user_input || ""
}, metadata);
}
}

View File

@ -3,6 +3,8 @@ const { incrementMessages } = require("./stats");
const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { emitLumiEvent } = require("./lumi-events");
const { setPlatformLiveState } = require("./platform-live-state");
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
@ -124,6 +126,7 @@ async function handleChatItem(state, liveChatId, item) {
const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null;
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
emitYouTubeEvent(item, state, liveChatId, displayName);
const profile = isSelf ? null : ensureUserForIdentity({
provider: "youtube",
providerUserId: author.channelId,
@ -200,9 +203,31 @@ async function ensureLiveChatId(state) {
state.liveChatId = null;
state.nextPageToken = null;
}
if (state.channelId) setPlatformLiveState("youtube", state.channelId, Boolean(state.liveChatId), state.liveChatId ? "live" : "offline");
return state.liveChatId;
}
function emitYouTubeEvent(item, state, liveChatId, displayName) {
const snippet = item?.snippet || {};
const details = snippet.superChatDetails || snippet.superStickerDetails || snippet.memberMilestoneChatDetails ||
snippet.newSponsorDetails || snippet.membershipGiftingDetails || {};
const common = {
channel_id: state.channelId,
live_chat_id: liveChatId,
user_id: item?.authorDetails?.channelId || null,
user_name: displayName,
message: snippet.displayMessage || "",
amount_display: details.amountDisplayString || null,
amount_micros: Number(details.amountMicros || 0),
currency: details.currency || null
};
const metadata = { id: item?.id, source: "youtube-live-chat", occurredAt: snippet.publishedAt };
if (["newSponsorEvent", "memberMilestoneChatEvent"].includes(snippet.type)) emitLumiEvent("youtube.membership", common, metadata);
if (["membershipGiftingEvent", "giftMembershipReceivedEvent"].includes(snippet.type)) emitLumiEvent("youtube.membership_gift", { ...common, gift_count: Number(details.giftMembershipsCount || 0) }, metadata);
if (snippet.type === "superChatEvent") emitLumiEvent("youtube.super_chat", common, metadata);
if (snippet.type === "superStickerEvent") emitLumiEvent("youtube.super_sticker", common, metadata);
}
async function hydrateBotChannel(state) {
const channel = await fetchMyChannel(state);
if (!channel) {

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.3.7",
"version": "0.3.8",
"channel": "stable",
"released_at": "2026-07-26",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data.",
"migration_notes": "Adds the native Windows Lumi Overlay through the existing paired-device and provider pipelines, preserves active and revoked device semantics, and releases Companion 0.2.6 with draft-safe settings, direct single-page plugin navigation, and clearer overlay controls. Existing settings, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -433,6 +433,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds encrypted Domeneshop DNS-01 automation for RTMPS certificate issuance and renewal when OpenResty or another HTTPS reverse proxy owns the HTTP challenge path. Lumi creates, verifies, and removes short-lived DNS records without external packages or certificate paths. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
},
{
"version": "0.3.7",
"channel": "stable",
"released_at": "2026-07-26",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data."
}
]
}