release: publish Lumi 0.3.8 native companion overlay
This commit is contained in:
parent
bc7bff0eb9
commit
655547938a
@ -1,5 +1,13 @@
|
|||||||
# Lumi changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@ -25,6 +25,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
|
|||||||
EndProject
|
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}"
|
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
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@ -144,5 +158,6 @@ Global
|
|||||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2} = {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}
|
{AAFDF982-BE30-405F-A39B-E82F1B11D46A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{E6AC503B-9723-441C-8882-D0EC4A845F9D} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Lumi Companion is the Windows streaming-computer client for Lumi. It provides a
|
Lumi Companion is the Windows streaming-computer client for Lumi. It provides a
|
||||||
single paired-device shell for Companion plugins, including server-hosted
|
single paired-device shell for Companion plugins, including server-hosted
|
||||||
transcription and Song Overlay capture. The app is single-instance, installs per
|
transcription, Song Overlay capture, and the native Lumi monitor overlay. The app
|
||||||
Windows user, closes to the notification area, and preserves pairing and settings
|
is single-instance, installs per Windows user, closes to the notification area,
|
||||||
across updates.
|
and preserves pairing and settings across updates.
|
||||||
|
|
||||||
## Install and pair
|
## Install and pair
|
||||||
|
|
||||||
@ -122,6 +122,16 @@ Run Song Overlay's focused verification with:
|
|||||||
companion/scripts/verify-song-overlay.ps1
|
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
|
Generated installers, pairing packages, credentials, build output, and logs must
|
||||||
not be committed.
|
not be committed.
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "0.2.5"
|
#define AppVersion "0.2.6"
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceRoot
|
#ifndef SourceRoot
|
||||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||||
|
|||||||
40
companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs
Normal file
40
companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
273
companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs
Normal file
273
companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs
Normal 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;
|
||||||
|
}
|
||||||
533
companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs
Normal file
533
companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs
Normal 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));
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))));
|
||||||
|
}
|
||||||
192
companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs
Normal file
192
companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs
Normal 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);
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
companion/plugins/Lumi.Companion.Overlay/plugin.json
Normal file
16
companion/plugins/Lumi.Companion.Overlay/plugin.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Version = "0.2.5",
|
[string]$Version = "0.2.6",
|
||||||
[string]$BridgeVersion = "0.2.5",
|
[string]$BridgeVersion = "0.2.5",
|
||||||
[string]$ObsVersion = "31.1.1"
|
[string]$ObsVersion = "31.1.1"
|
||||||
)
|
)
|
||||||
|
|||||||
20
companion/scripts/verify-lumi-overlay.ps1
Normal file
20
companion/scripts/verify-lumi-overlay.ps1
Normal 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
|
||||||
|
}
|
||||||
@ -7,6 +7,30 @@ public sealed record CompanionPluginHttpResponse(
|
|||||||
public bool IsSuccessStatusCode => StatusCode is >= 200 and <= 299;
|
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>
|
/// <summary>
|
||||||
/// A plugin-scoped view of the Companion shell's authenticated Lumi transport.
|
/// A plugin-scoped view of the Companion shell's authenticated Lumi transport.
|
||||||
/// Plugins never receive, store, or refresh the paired device credential.
|
/// Plugins never receive, store, or refresh the paired device credential.
|
||||||
@ -19,4 +43,14 @@ public interface ICompanionPluginTransport
|
|||||||
string relativePath,
|
string relativePath,
|
||||||
object payload,
|
object payload,
|
||||||
CancellationToken cancellationToken = default);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,10 +32,16 @@ public sealed record CompanionPluginAction(
|
|||||||
Func<bool>? CanExecute = null,
|
Func<bool>? CanExecute = null,
|
||||||
int Order = 100);
|
int Order = 100);
|
||||||
|
|
||||||
|
public static class CompanionPluginNavigation
|
||||||
|
{
|
||||||
|
public static bool UsesNestedNavigation(IReadOnlyCollection<CompanionPluginPage> pages) => pages.Count > 1;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A UI-neutral contribution contract. The Avalonia shell owns rendering and
|
/// A UI-neutral contribution contract. The Avalonia shell owns rendering and
|
||||||
/// guarantees that each plugin receives one nested root in both the sidebar and
|
/// gives a single-page plugin one direct sidebar entry. Plugins with multiple
|
||||||
/// tray menu instead of leaking loose actions into the global navigation.
|
/// pages receive one collapsible sidebar root instead of leaking loose pages
|
||||||
|
/// into the global navigation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICompanionPluginContribution
|
public interface ICompanionPluginContribution
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Lumi.Companion.Abstractions;
|
using Lumi.Companion.Abstractions;
|
||||||
using Lumi.Companion.SongOverlay;
|
using Lumi.Companion.SongOverlay;
|
||||||
|
using Lumi.Companion.Overlay;
|
||||||
|
|
||||||
namespace Lumi.Companion.App;
|
namespace Lumi.Companion.App;
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ public partial class App : Application
|
|||||||
private TrayIcon? _trayIcon;
|
private TrayIcon? _trayIcon;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private SongOverlayRuntime? _songOverlay;
|
private SongOverlayRuntime? _songOverlay;
|
||||||
|
private LumiOverlayRuntime? _lumiOverlay;
|
||||||
private IReadOnlyList<ICompanionPluginContribution> _plugins = [];
|
private IReadOnlyList<ICompanionPluginContribution> _plugins = [];
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
@ -29,8 +31,11 @@ public partial class App : Application
|
|||||||
Path.Combine(paths.Root, "plugins", "now_playing"),
|
Path.Combine(paths.Root, "plugins", "now_playing"),
|
||||||
paths.LogsDirectory,
|
paths.LogsDirectory,
|
||||||
runtime.CreatePluginTransport("now_playing"));
|
runtime.CreatePluginTransport("now_playing"));
|
||||||
_plugins = [new TranscriptionPluginContribution(runtime), _songOverlay];
|
_lumiOverlay = new LumiOverlayRuntime(
|
||||||
_mainWindow = new MainWindow(runtime, settings, _songOverlay, _plugins);
|
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;
|
desktop.MainWindow = _mainWindow;
|
||||||
ConfigureTray(desktop, runtime, _plugins);
|
ConfigureTray(desktop, runtime, _plugins);
|
||||||
runtime.UpdateRestartRequested += async () =>
|
runtime.UpdateRestartRequested += async () =>
|
||||||
@ -38,21 +43,24 @@ public partial class App : Application
|
|||||||
_mainWindow.AllowExit();
|
_mainWindow.AllowExit();
|
||||||
_trayIcon?.Dispose();
|
_trayIcon?.Dispose();
|
||||||
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
|
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
|
||||||
|
if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync();
|
||||||
await runtime.DisposeAsync();
|
await runtime.DisposeAsync();
|
||||||
desktop.Shutdown();
|
desktop.Shutdown();
|
||||||
};
|
};
|
||||||
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
|
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
|
||||||
if (!Program.LaunchInBackground) _mainWindow.Show();
|
if (!Program.LaunchInBackground) _mainWindow.Show();
|
||||||
_ = InitializeRuntimesAsync(runtime, _songOverlay);
|
_ = InitializeRuntimesAsync(runtime, _songOverlay, _lumiOverlay);
|
||||||
}
|
}
|
||||||
base.OnFrameworkInitializationCompleted();
|
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();
|
await runtime.InitializeAsync();
|
||||||
try { await songOverlay.InitializeAsync(); }
|
try { await songOverlay.InitializeAsync(); }
|
||||||
catch { /* The plugin publishes its own actionable status without taking down the shell. */ }
|
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)
|
private void ConfigureTray(IClassicDesktopStyleApplicationLifetime desktop, CompanionRuntime runtime, IReadOnlyList<ICompanionPluginContribution> plugins)
|
||||||
@ -116,6 +124,7 @@ public partial class App : Application
|
|||||||
_mainWindow.AllowExit();
|
_mainWindow.AllowExit();
|
||||||
_trayIcon?.Dispose();
|
_trayIcon?.Dispose();
|
||||||
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
|
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
|
||||||
|
if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync();
|
||||||
await runtime.DisposeAsync();
|
await runtime.DisposeAsync();
|
||||||
desktop.Shutdown();
|
desktop.Shutdown();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -89,7 +89,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
public bool TranscriptionEnabled => _settings.Current.TranscriptionEnabled;
|
public bool TranscriptionEnabled => _settings.Current.TranscriptionEnabled;
|
||||||
|
|
||||||
public ICompanionPluginTransport CreatePluginTransport(string pluginId) =>
|
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()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
namespace Lumi.Companion.App;
|
namespace Lumi.Companion.App;
|
||||||
|
|
||||||
public enum TrayHealth { Ready, Operating, Degraded, Failed }
|
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 enum TestStageState { Waiting, Running, Passed, Blocked, Failed }
|
||||||
|
|
||||||
public sealed record CompanionState(
|
public sealed record CompanionState(
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||||
<Version>0.2.5</Version>
|
<Version>0.2.6</Version>
|
||||||
<AssemblyVersion>0.2.5.0</AssemblyVersion>
|
<AssemblyVersion>0.2.6.0</AssemblyVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||||
@ -16,6 +16,7 @@
|
|||||||
<ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
|
<ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
|
||||||
<ProjectReference Include="../../plugins/Lumi.Companion.Transcription/Lumi.Companion.Transcription.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.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" Version="12.1.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||||
|
|||||||
@ -316,6 +316,176 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</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 & 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 & 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 & 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">
|
<ScrollViewer x:Name="ConnectionPage" IsVisible="False">
|
||||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
|
|||||||
@ -6,6 +6,7 @@ using Avalonia.Platform.Storage;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Lumi.Companion.Abstractions;
|
using Lumi.Companion.Abstractions;
|
||||||
using Lumi.Companion.SongOverlay;
|
using Lumi.Companion.SongOverlay;
|
||||||
|
using Lumi.Companion.Overlay;
|
||||||
|
|
||||||
namespace Lumi.Companion.App;
|
namespace Lumi.Companion.App;
|
||||||
|
|
||||||
@ -14,24 +15,31 @@ public partial class MainWindow : Window
|
|||||||
private readonly CompanionRuntime _runtime;
|
private readonly CompanionRuntime _runtime;
|
||||||
private readonly CompanionSettingsStore _settings;
|
private readonly CompanionSettingsStore _settings;
|
||||||
private readonly SongOverlayRuntime _songOverlay;
|
private readonly SongOverlayRuntime _songOverlay;
|
||||||
|
private readonly LumiOverlayRuntime _lumiOverlay;
|
||||||
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
|
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
|
||||||
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
|
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
|
||||||
private readonly Dictionary<CompanionPage, PluginNavigationGroup> _pluginNavigationGroups = [];
|
private readonly Dictionary<CompanionPage, PluginNavigationGroup> _pluginNavigationGroups = [];
|
||||||
private bool _allowExit;
|
private bool _allowExit;
|
||||||
private bool _renderingSources;
|
private bool _renderingSources;
|
||||||
private bool _renderingSongOverlay;
|
private bool _renderingSongOverlay;
|
||||||
|
private bool _renderingLumiOverlay;
|
||||||
private bool _renderingSettings;
|
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()) { }
|
public MainWindow() : this(CreateDefaultServices()) { }
|
||||||
|
|
||||||
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings, SongOverlayRuntime SongOverlay, IReadOnlyList<ICompanionPluginContribution> Plugins) services)
|
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings, SongOverlayRuntime SongOverlay, LumiOverlayRuntime LumiOverlay, IReadOnlyList<ICompanionPluginContribution> Plugins) services)
|
||||||
: this(services.Runtime, services.Settings, services.SongOverlay, services.Plugins) { }
|
: 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;
|
_runtime = runtime;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_songOverlay = songOverlay;
|
_songOverlay = songOverlay;
|
||||||
|
_lumiOverlay = lumiOverlay;
|
||||||
_plugins = plugins;
|
_plugins = plugins;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
BuildPluginNavigation();
|
BuildPluginNavigation();
|
||||||
@ -40,6 +48,8 @@ public partial class MainWindow : Window
|
|||||||
RenderTestStages(runtime.TestStages);
|
RenderTestStages(runtime.TestStages);
|
||||||
RenderBenchmark(runtime.Benchmark);
|
RenderBenchmark(runtime.Benchmark);
|
||||||
RenderSongOverlay();
|
RenderSongOverlay();
|
||||||
|
PrepareLumiOverlayControls();
|
||||||
|
RenderLumiOverlay();
|
||||||
Closing += OnClosing;
|
Closing += OnClosing;
|
||||||
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => { RenderState(state); RenderSongOverlay(); });
|
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => { RenderState(state); RenderSongOverlay(); });
|
||||||
runtime.TestStagesChanged += stages => Dispatcher.UIThread.Post(() => RenderTestStages(stages));
|
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));
|
runtime.LogAdded += line => Dispatcher.UIThread.Post(() => AddLog(line));
|
||||||
settings.Changed += value => Dispatcher.UIThread.Post(() => { RenderSettings(value); RenderState(runtime.State); });
|
settings.Changed += value => Dispatcher.UIThread.Post(() => { RenderSettings(value); RenderState(runtime.State); });
|
||||||
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
|
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
|
||||||
|
lumiOverlay.Changed += () => Dispatcher.UIThread.Post(() => RenderLumiOverlay());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildPluginNavigation()
|
private void BuildPluginNavigation()
|
||||||
@ -60,6 +71,28 @@ public partial class MainWindow : Window
|
|||||||
PluginNavigation.Children.Clear();
|
PluginNavigation.Children.Clear();
|
||||||
foreach (var plugin in _plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
|
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
|
var children = new StackPanel
|
||||||
{
|
{
|
||||||
Spacing = 2,
|
Spacing = 2,
|
||||||
@ -89,9 +122,8 @@ public partial class MainWindow : Window
|
|||||||
root.Classes.Add("nav");
|
root.Classes.Add("nav");
|
||||||
root.Classes.Add("navRoot");
|
root.Classes.Add("navRoot");
|
||||||
root.Click += (_, _) => SetPluginNavigationExpanded(children, arrow, !children.IsVisible);
|
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 };
|
var button = new Button { Content = page.Label, Tag = page.Key, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left };
|
||||||
button.Classes.Add("nav");
|
button.Classes.Add("nav");
|
||||||
button.Click += OnNavigate;
|
button.Click += OnNavigate;
|
||||||
@ -139,6 +171,28 @@ public partial class MainWindow : Window
|
|||||||
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
|
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
|
||||||
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
|
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
|
||||||
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
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);
|
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
|
||||||
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
|
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
|
||||||
{
|
{
|
||||||
@ -166,6 +220,7 @@ public partial class MainWindow : Window
|
|||||||
[CompanionPage.Transcription] = TranscriptionPage,
|
[CompanionPage.Transcription] = TranscriptionPage,
|
||||||
[CompanionPage.Test] = TestPage,
|
[CompanionPage.Test] = TestPage,
|
||||||
[CompanionPage.SongOverlay] = SongOverlayPage,
|
[CompanionPage.SongOverlay] = SongOverlayPage,
|
||||||
|
[CompanionPage.LumiOverlay] = LumiOverlayPage,
|
||||||
[CompanionPage.Connection] = ConnectionPage,
|
[CompanionPage.Connection] = ConnectionPage,
|
||||||
[CompanionPage.Logs] = LogsPage,
|
[CompanionPage.Logs] = LogsPage,
|
||||||
[CompanionPage.Settings] = SettingsPage
|
[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()
|
private void RenderSongOverlay()
|
||||||
{
|
{
|
||||||
_renderingSongOverlay = true;
|
_renderingSongOverlay = true;
|
||||||
@ -589,7 +970,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
public void AllowExit() => _allowExit = true;
|
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 paths = CompanionPaths.ForCurrentUser();
|
||||||
var settings = new CompanionSettingsStore(paths.SettingsPath);
|
var settings = new CompanionSettingsStore(paths.SettingsPath);
|
||||||
@ -598,7 +979,10 @@ public partial class MainWindow : Window
|
|||||||
Path.Combine(paths.Root, "plugins", "now_playing"),
|
Path.Combine(paths.Root, "plugins", "now_playing"),
|
||||||
paths.LogsDirectory,
|
paths.LogsDirectory,
|
||||||
runtime.CreatePluginTransport("now_playing"));
|
runtime.CreatePluginTransport("now_playing"));
|
||||||
ICompanionPluginContribution[] plugins = [new TranscriptionPluginContribution(runtime), songOverlay];
|
var lumiOverlay = new LumiOverlayRuntime(
|
||||||
return (runtime, settings, songOverlay, plugins);
|
Path.Combine(paths.Root, "plugins", "lumi_overlay"),
|
||||||
|
runtime.CreatePluginTransport("lumi_overlay"));
|
||||||
|
ICompanionPluginContribution[] plugins = [new TranscriptionPluginContribution(runtime), lumiOverlay, songOverlay];
|
||||||
|
return (runtime, settings, songOverlay, lumiOverlay, plugins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
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.Abstractions;
|
||||||
using Lumi.Companion.Protocol;
|
using Lumi.Companion.Protocol;
|
||||||
|
|
||||||
@ -8,7 +12,8 @@ namespace Lumi.Companion.Core;
|
|||||||
public sealed class CompanionPluginTransport(
|
public sealed class CompanionPluginTransport(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
Func<DeviceCredential?> credentialProvider,
|
Func<DeviceCredential?> credentialProvider,
|
||||||
string pluginId) : ICompanionPluginTransport
|
string pluginId,
|
||||||
|
Action<string, string>? log = null) : ICompanionPluginTransport
|
||||||
{
|
{
|
||||||
private readonly string _pluginId = ValidatePluginId(pluginId);
|
private readonly string _pluginId = ValidatePluginId(pluginId);
|
||||||
|
|
||||||
@ -65,6 +70,113 @@ public sealed class CompanionPluginTransport(
|
|||||||
return new CompanionPluginHttpResponse((int)response.StatusCode, body);
|
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)
|
private static string ValidatePluginId(string value)
|
||||||
{
|
{
|
||||||
var clean = (value ?? string.Empty).Trim();
|
var clean = (value ?? string.Empty).Trim();
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RollForward>Major</RollForward>
|
<RollForward>Major</RollForward>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
|
<ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
|
||||||
|
<ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Lumi.Companion.Abstractions;
|
using Lumi.Companion.Abstractions;
|
||||||
using Lumi.Companion.Core;
|
using Lumi.Companion.Core;
|
||||||
|
using Lumi.Companion.Overlay;
|
||||||
|
|
||||||
static void Assert(bool condition, string message)
|
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.NormalizeEvent("Update Failed!") == "update_failed", "Companion event normalization drifted.");
|
||||||
Assert(CompanionLogSanitizer.LevelForEvent("update_failed") == "error", "Companion failed events must use the error level.");
|
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.");
|
||||||
|
|||||||
48
docs/companion-native-overlay.md
Normal file
48
docs/companion-native-overlay.md
Normal 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.
|
||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.3.7
|
Version: 0.3.8
|
||||||
## Routes
|
## Routes
|
||||||
- POST /api/diagnostics/v1/run
|
- POST /api/diagnostics/v1/run
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
|
|||||||
26
knowledge/plugins/lumi-overlay.md
Normal file
26
knowledge/plugins/lumi-overlay.md
Normal 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
|
||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_transcription
|
Plugin ID: lumi_transcription
|
||||||
Version: 0.2.5
|
Version: 0.2.6
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/lumi_transcription
|
- /plugins/lumi_transcription
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -24,6 +24,7 @@
|
|||||||
"benchmark:okf": "node scripts/benchmark-okf-search.js",
|
"benchmark:okf": "node scripts/benchmark-okf-search.js",
|
||||||
"verify:content": "node scripts/verify-content-library.js",
|
"verify:content": "node scripts/verify-content-library.js",
|
||||||
"verify:transcription": "node plugins/lumi_transcription/tests/verify.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"
|
"verify:dev-updates": "node scripts/verify-local-development-updates.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
9
plugins/lumi_overlay/CHANGELOG.md
Normal file
9
plugins/lumi_overlay/CHANGELOG.md
Normal 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.
|
||||||
150
plugins/lumi_overlay/backend/feed.js
Normal file
150
plugins/lumi_overlay/backend/feed.js
Normal 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 };
|
||||||
102
plugins/lumi_overlay/backend/protocol.js
Normal file
102
plugins/lumi_overlay/backend/protocol.js
Normal 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
|
||||||
|
};
|
||||||
86
plugins/lumi_overlay/backend/sources.js
Normal file
86
plugins/lumi_overlay/backend/sources.js
Normal 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 };
|
||||||
19
plugins/lumi_overlay/index.js
Normal file
19
plugins/lumi_overlay/index.js
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
10
plugins/lumi_overlay/plugin.json
Normal file
10
plugins/lumi_overlay/plugin.json
Normal 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
|
||||||
|
}
|
||||||
128
plugins/lumi_overlay/tests/verify.js
Normal file
128
plugins/lumi_overlay/tests/verify.js
Normal 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();
|
||||||
@ -1,5 +1,12 @@
|
|||||||
# Lumi Transcription changelog
|
# 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
|
## 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.
|
- Add encrypted Domeneshop DNS-01 automation for RTMPS certificate issuance and renewal when the production HTTPS proxy reserves the HTTP challenge route.
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
const crypto = require("crypto");
|
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 {
|
class DeviceStore {
|
||||||
constructor(db, options = {}) {
|
constructor(db, options = {}) {
|
||||||
@ -24,8 +30,26 @@ class DeviceStore {
|
|||||||
pairing_host TEXT
|
pairing_host TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
|
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");
|
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 }) {
|
issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"signed": false,
|
"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": {
|
"installer": {
|
||||||
"id": "windows-x64-installer",
|
"id": "windows-x64-installer",
|
||||||
"platform": "win32",
|
"platform": "win32",
|
||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 per-user installer",
|
"label": "Windows x64 per-user installer",
|
||||||
"filename": "Lumi.Companion-Setup.exe",
|
"filename": "Lumi.Companion-Setup.exe",
|
||||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-Setup.exe",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-Setup.exe",
|
||||||
"sha256": "bfa2d6aeb79c7f366631ea33bbda1c9f313374c3fa11a18a76ef304fc3947474",
|
"sha256": "0420cdee45d676ded1566fd8da2e25482eca258b1d7d200544e5425d22105bb8",
|
||||||
"bytes": 51919651
|
"bytes": 51922633
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@ -20,9 +20,9 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 self-contained",
|
"label": "Windows x64 self-contained",
|
||||||
"filename": "Lumi.Companion-win-x64.zip",
|
"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",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.6/Lumi.Companion-win-x64.zip",
|
||||||
"sha256": "7b46c1562852c848176813bbe8fe25d04c69f49ccde5848cd36294b3c036500c",
|
"sha256": "1e216f9653d0dc0513ca119f0a8c5cf43bf47328a83cbbf183e1d4b14c111e94",
|
||||||
"bytes": 68486344,
|
"bytes": 66857759,
|
||||||
"entrypoint": "Lumi.Companion.App.exe"
|
"entrypoint": "Lumi.Companion.App.exe"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_transcription",
|
"id": "lumi_transcription",
|
||||||
"name": "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.",
|
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -35,6 +35,7 @@ async function run() {
|
|||||||
try {
|
try {
|
||||||
verifyProtocol();
|
verifyProtocol();
|
||||||
verifyPairingAndRevocation();
|
verifyPairingAndRevocation();
|
||||||
|
verifyOverlayCapabilityMigration();
|
||||||
verifyLocalhostTransportPolicy();
|
verifyLocalhostTransportPolicy();
|
||||||
verifyProxiedHttpsTransport();
|
verifyProxiedHttpsTransport();
|
||||||
verifyCompanionVersionOrdering();
|
verifyCompanionVersionOrdering();
|
||||||
@ -144,6 +145,37 @@ function verifyPairingAndRevocation() {
|
|||||||
db.close();
|
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() {
|
function verifyLocalhostTransportPolicy() {
|
||||||
assert.equal(normalizeHost("http://localhost:3000/path"), "http://localhost:3000");
|
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");
|
assert.equal(normalizeHost("http://127.0.0.1:3000"), "http://127.0.0.1:3000");
|
||||||
|
|||||||
@ -2,6 +2,39 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"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",
|
"version": "0.3.7",
|
||||||
"ref": "refs/tags/v0.3.7",
|
"ref": "refs/tags/v0.3.7",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const checks = [
|
|||||||
"plugins/lumi_ai/tests/verify-tools.js",
|
"plugins/lumi_ai/tests/verify-tools.js",
|
||||||
"plugins/lumi_ai_web_search/tests/verify.js",
|
"plugins/lumi_ai_web_search/tests/verify.js",
|
||||||
"plugins/lumi_transcription/tests/verify.js",
|
"plugins/lumi_transcription/tests/verify.js",
|
||||||
|
"plugins/lumi_overlay/tests/verify.js",
|
||||||
"scripts/verify-assistant-panels.js",
|
"scripts/verify-assistant-panels.js",
|
||||||
"scripts/verify-command-preview-confirmations.js",
|
"scripts/verify-command-preview-confirmations.js",
|
||||||
"scripts/verify-command-policies.js",
|
"scripts/verify-command-policies.js",
|
||||||
|
|||||||
@ -4,12 +4,13 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.3.7";
|
const releaseVersion = "0.3.8";
|
||||||
const previousStableVersion = "0.3.6";
|
const previousStableVersion = "0.3.7";
|
||||||
const priorStableVersion = "0.3.5";
|
const priorStableVersion = "0.3.6";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const introducedPlugins = {
|
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" }
|
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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
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.");
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
|||||||
const coreManifest = readJson("update-manifest.json");
|
const coreManifest = readJson("update-manifest.json");
|
||||||
assert.equal(packageVersion, coreManifest.version);
|
assert.equal(packageVersion, coreManifest.version);
|
||||||
assert.equal(coreManifest.channel, "stable");
|
assert.equal(coreManifest.channel, "stable");
|
||||||
assert.equal(packageVersion, "0.3.7");
|
assert.equal(packageVersion, "0.3.8");
|
||||||
assert.equal(currentRelease.version, "0.3.7");
|
assert.equal(currentRelease.version, "0.3.8");
|
||||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
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 = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
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.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.6", ref: "refs/tags/v0.3.6", rollback_safe: true },
|
||||||
{ version: "0.3.5", ref: "refs/tags/v0.3.5", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -92,7 +92,7 @@ function buildTwitchEventAuthUrl(state, redirectOverride) {
|
|||||||
client_id: clientId || "",
|
client_id: clientId || "",
|
||||||
redirect_uri: redirectUri || "",
|
redirect_uri: redirectUri || "",
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "moderator:read:followers channel:read:subscriptions",
|
scope: "moderator:read:followers channel:read:subscriptions bits:read channel:read:redemptions",
|
||||||
state,
|
state,
|
||||||
force_verify: "true"
|
force_verify: "true"
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.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.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.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" }
|
{ id: "discord.member_join", label: "Discord member joined", description: "A member joins the configured Discord server.", platform: "discord" }
|
||||||
]) registerEventType(definition);
|
]) registerEventType(definition);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const { publishWebEvent } = require("./web-events");
|
|||||||
const { getOverlayModuleType } = require("./overlay-modules");
|
const { getOverlayModuleType } = require("./overlay-modules");
|
||||||
|
|
||||||
const recentByOverlay = new Map();
|
const recentByOverlay = new Map();
|
||||||
|
const liveListeners = new Set();
|
||||||
const MAX_RECENT_PER_OVERLAY = 100;
|
const MAX_RECENT_PER_OVERLAY = 100;
|
||||||
const MAX_RECENT_AGE_MS = 5 * 60 * 1000;
|
const MAX_RECENT_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@ -157,6 +158,11 @@ function remember(overlayId, message) {
|
|||||||
function publishOverlayChatMessage(input = {}) {
|
function publishOverlayChatMessage(input = {}) {
|
||||||
const message = normalizeChatMessage(input);
|
const message = normalizeChatMessage(input);
|
||||||
if (!message) return 0;
|
if (!message) return 0;
|
||||||
|
for (const listener of liveListeners) {
|
||||||
|
try {
|
||||||
|
Promise.resolve(listener(message)).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
for (const overlayId of overlayTargets(message)) {
|
for (const overlayId of overlayTargets(message)) {
|
||||||
remember(overlayId, message);
|
remember(overlayId, message);
|
||||||
@ -165,6 +171,12 @@ function publishOverlayChatMessage(input = {}) {
|
|||||||
return delivered;
|
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) {
|
function recentOverlayChatMessages(overlayId) {
|
||||||
const cutoff = Date.now() - MAX_RECENT_AGE_MS;
|
const cutoff = Date.now() - MAX_RECENT_AGE_MS;
|
||||||
const recent = (recentByOverlay.get(String(overlayId)) || []).filter((entry) => entry.timestamp >= cutoff);
|
const recent = (recentByOverlay.get(String(overlayId)) || []).filter((entry) => entry.timestamp >= cutoff);
|
||||||
@ -176,6 +188,7 @@ module.exports = {
|
|||||||
messageBlockedByConfig,
|
messageBlockedByConfig,
|
||||||
messageMatchesConfig,
|
messageMatchesConfig,
|
||||||
normalizeChatMessage,
|
normalizeChatMessage,
|
||||||
|
onOverlayChatMessage,
|
||||||
publishOverlayChatMessage,
|
publishOverlayChatMessage,
|
||||||
recentOverlayChatMessages
|
recentOverlayChatMessages
|
||||||
};
|
};
|
||||||
|
|||||||
21
src/services/platform-live-state.js
Normal file
21
src/services/platform-live-state.js
Normal 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 };
|
||||||
@ -2,6 +2,7 @@ const WebSocket = require("ws");
|
|||||||
const { getSetting, setSetting } = require("./settings");
|
const { getSetting, setSetting } = require("./settings");
|
||||||
const { emitLumiEvent } = require("./lumi-events");
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
|
const { setPlatformLiveState } = require("./platform-live-state");
|
||||||
|
|
||||||
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
|
const EVENTSUB_URL = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=30";
|
||||||
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
|
const eventLog = createLogger("platform:twitch:eventsub", { category: "integration" });
|
||||||
@ -101,6 +102,7 @@ class TwitchEventSubManager {
|
|||||||
const socket = this.socket;
|
const socket = this.socket;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Lumi shutdown");
|
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 };
|
this.status = { state: "disconnected", detail: "Not connected", subscriptions: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,6 +197,8 @@ class TwitchEventSubManager {
|
|||||||
const definitions = [];
|
const definitions = [];
|
||||||
for (const broadcaster of users.data || []) {
|
for (const broadcaster of users.data || []) {
|
||||||
definitions.push({ type: "channel.raid", version: "1", condition: { to_broadcaster_user_id: broadcaster.id } });
|
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")) {
|
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 } });
|
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.subscribe", version: "1", condition: { broadcaster_user_id: broadcaster.id } });
|
||||||
definitions.push({ type: "channel.subscription.gift", 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;
|
let active = 0;
|
||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
@ -220,7 +238,13 @@ class TwitchEventSubManager {
|
|||||||
this.status = {
|
this.status = {
|
||||||
state: "connected",
|
state: "connected",
|
||||||
detail: active ? `Receiving ${active} Twitch event feed${active === 1 ? "" : "s"}.` : "Connected, but no configured channel events could be subscribed.",
|
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" });
|
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
|
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 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.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.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.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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@ const { incrementMessages } = require("./stats");
|
|||||||
const { ensureUserForIdentity } = require("./users");
|
const { ensureUserForIdentity } = require("./users");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
|
const { emitLumiEvent } = require("./lumi-events");
|
||||||
|
const { setPlatformLiveState } = require("./platform-live-state");
|
||||||
|
|
||||||
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
|
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
|
||||||
|
|
||||||
@ -124,6 +126,7 @@ async function handleChatItem(state, liveChatId, item) {
|
|||||||
const displayName = author.displayName || "YouTube User";
|
const displayName = author.displayName || "YouTube User";
|
||||||
const avatar = author.profileImageUrl || null;
|
const avatar = author.profileImageUrl || null;
|
||||||
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
|
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
|
||||||
|
emitYouTubeEvent(item, state, liveChatId, displayName);
|
||||||
const profile = isSelf ? null : ensureUserForIdentity({
|
const profile = isSelf ? null : ensureUserForIdentity({
|
||||||
provider: "youtube",
|
provider: "youtube",
|
||||||
providerUserId: author.channelId,
|
providerUserId: author.channelId,
|
||||||
@ -200,9 +203,31 @@ async function ensureLiveChatId(state) {
|
|||||||
state.liveChatId = null;
|
state.liveChatId = null;
|
||||||
state.nextPageToken = null;
|
state.nextPageToken = null;
|
||||||
}
|
}
|
||||||
|
if (state.channelId) setPlatformLiveState("youtube", state.channelId, Boolean(state.liveChatId), state.liveChatId ? "live" : "offline");
|
||||||
return state.liveChatId;
|
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) {
|
async function hydrateBotChannel(state) {
|
||||||
const channel = await fetchMyChannel(state);
|
const channel = await fetchMyChannel(state);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-26",
|
"released_at": "2026-07-26",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -433,6 +433,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user