release: publish Lumi 0.3.11 companion experience

This commit is contained in:
Franz Rolfsvaag 2026-07-29 23:41:34 +02:00
parent 0fb0089e1c
commit 5f05251155
44 changed files with 888 additions and 122 deletions

3
.gitignore vendored
View File

@ -34,4 +34,5 @@ twitch-credentials-lumi.png
DEVNOTES.md DEVNOTES.md
.QWEN.md .QWEN.md
_*_changes.md _*_changes.md
lumi_current.zip Lumi-current.zip
agent-task*.md

View File

@ -1,5 +1,12 @@
# Lumi changelog # Lumi changelog
## 0.3.11
- Released Companion 0.2.8 with click-to-capture global hotkeys, independent Windows Media Player, YouTube, Spotify, VLC, Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz discovery, and an interactive edit mode built on the actual capture-excluded native overlay renderer.
- Refined Stats and Leaderboards to hide raw channel/server IDs, omit empty tenure boards, emphasize current tenure separately from recorded totals, and distinguish both values in comparisons.
- Added per-user favorite command tracking and favorite Expression Interaction summaries through an additive usage table; favorite command history begins with this release.
- Preserved existing Companion pairing, overlay and media settings, platform-tenure history, plugin data, OBS configuration, databases, uploads, models, and secrets through the normal updater.
## 0.3.10 ## 0.3.10
- Added an extensible UTC interval-history engine for Twitch follows, subscriptions, moderators, editors, and VIPs; YouTube channel memberships and moderators; and Discord membership and server boosts. - Added an extensible UTC interval-history engine for Twitch follows, subscriptions, moderators, editors, and VIPs; YouTube channel memberships and moderators; and Discord membership and server boosts.

View File

@ -25,6 +25,21 @@ DPAPI-protected device identity, so reinstalling does not create a duplicate
paired device. Pairing packages expire after 15 minutes, work once, and must not paired device. Pairing packages expire after 15 minutes, work once, and must not
be shared or committed. be shared or committed.
## Companion features
Global hotkey fields are buttons: select one, press the desired modifier/key
combination, then release it to save the draft. Capture cancels after seven
seconds without a complete shortcut.
Song Overlay discovers independently enabled Windows media sessions. Windows
Media Player, YouTube/YouTube Music, and Spotify are enabled by default; VLC,
iTunes/Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz can be enabled
individually.
Lumi Overlay Edit Mode opens the real monitor renderer with sample content and
interactive container outlines. Drag a container to move it or its lower-right
corner to resize it; the app retains these as an unsaved draft until Save.
## Security boundaries ## Security boundaries
- Companion plugins inherit the shell's paired-device authentication. Plugins do - Companion plugins inherit the shell's paired-device authentication. Plugins do

View File

@ -1,6 +1,6 @@
# Song Overlay Companion integration # Song Overlay Companion integration
This integration adds a provider-neutral media event source to Lumi Companion. Spotify is the only available provider in this release, but the Companion protocol and runtime isolate provider-specific behavior behind `IMediaProvider`. This integration adds provider-neutral Windows media-session discovery to Lumi Companion. It can identify Spotify, Windows Media Player, YouTube and YouTube Music in Chrome or Edge, VLC, iTunes and Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz. Each source is independently switchable; Windows Media Player, YouTube, and Spotify are enabled by default.
## Navigation contract ## Navigation contract
@ -13,8 +13,8 @@ Plugin actions are caught at the shell boundary so a routine plugin exception do
## Song Overlay flow ## Song Overlay flow
1. Windows Global System Media Transport Controls exposes Spotify playback state. 1. Windows Global System Media Transport Controls exposes playback from enabled desktop and browser sources.
2. The provider raises media, playback, timeline, and session-availability events. 2. The provider identifies the source where Windows exposes enough app or metadata context, then raises media, playback, timeline, and session-availability events.
3. The runtime sends only meaningful deltas to Lumi: track change, inferred next/previous, play, resume, pause, stop, seek, metadata enrichment, or a sparse recovery heartbeat. 3. The runtime sends only meaningful deltas to Lumi: track change, inferred next/previous, play, resume, pause, stop, seek, metadata enrichment, or a sparse recovery heartbeat.
4. Playback progress is projected from the last event. Continuous progress messages are not sent. 4. Playback progress is projected from the last event. Continuous progress messages are not sent.
5. Cover art is resized and sent only with track metadata. 5. Cover art is resized and sent only with track metadata.

View File

@ -1,5 +1,5 @@
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.2.7" #define AppVersion "0.2.8"
#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.

View File

@ -56,7 +56,7 @@ public sealed class GlobalHotkeyManager : IDisposable
} }
} }
private static bool TryParse(string chord, out uint modifiers, out uint key) internal static bool TryParse(string chord, out uint modifiers, out uint key)
{ {
modifiers = 0; modifiers = 0;
key = 0; key = 0;
@ -69,11 +69,21 @@ public sealed class GlobalHotkeyManager : IDisposable
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0008; 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.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 if (part.StartsWith('F') && int.TryParse(part[1..], out var function) && function is >= 1 and <= 24) key = (uint)(0x70 + function - 1);
else if (VirtualKeys.TryGetValue(part, out var virtualKey)) key = virtualKey;
else return false; else return false;
} }
return key != 0; return key != 0;
} }
private static readonly Dictionary<string, uint> VirtualKeys = new(StringComparer.OrdinalIgnoreCase)
{
["Backspace"] = 0x08, ["Tab"] = 0x09, ["Enter"] = 0x0D, ["Pause"] = 0x13,
["CapsLock"] = 0x14, ["Escape"] = 0x1B, ["Space"] = 0x20, ["PageUp"] = 0x21,
["PageDown"] = 0x22, ["End"] = 0x23, ["Home"] = 0x24, ["Left"] = 0x25,
["Up"] = 0x26, ["Right"] = 0x27, ["Down"] = 0x28, ["Insert"] = 0x2D,
["Delete"] = 0x2E, ["NumLock"] = 0x90, ["ScrollLock"] = 0x91
};
public void Dispose() public void Dispose()
{ {
Clear(); Clear();

View File

@ -30,6 +30,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
_transport = transport; _transport = transport;
_window = new NativeOverlayWindow(); _window = new NativeOverlayWindow();
_window.NativeStatusChanged += () => RaiseChanged(); _window.NativeStatusChanged += () => RaiseChanged();
_window.EditSettingsChanged += () => RaiseChanged();
_hotkeys = new GlobalHotkeyManager(); _hotkeys = new GlobalHotkeyManager();
Actions = Actions =
[ [
@ -38,7 +39,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
]; ];
} }
public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 0), public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 2),
"Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150); "Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150);
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)]; public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
public IReadOnlyList<CompanionPluginAction> Actions { get; } public IReadOnlyList<CompanionPluginAction> Actions { get; }
@ -51,6 +52,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public string? MonitorWarning => _window.MonitorWarning; public string? MonitorWarning => _window.MonitorWarning;
public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors(); public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors();
public bool PreviewActive => _preview; public bool PreviewActive => _preview;
public bool EditModeActive { get; private set; }
public bool VisibilityOverride => _visibilityOverride.HasValue; public bool VisibilityOverride => _visibilityOverride.HasValue;
public bool IsInitialized => _initialized; public bool IsInitialized => _initialized;
public string HotkeyStatus => _hotkeyStatus; public string HotkeyStatus => _hotkeyStatus;
@ -113,6 +115,23 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
RaiseChanged(); RaiseChanged();
} }
public void BeginEdit(LumiOverlaySettings draft)
{
EditModeActive = true;
_window.BeginEdit(draft);
RaiseChanged();
}
public void EndEdit()
{
if (!EditModeActive) return;
EditModeActive = false;
_window.EndEdit();
_window.ApplySettings(Settings);
ApplyVisibility();
RaiseChanged();
}
private async Task RunConnectedAsync(ICompanionPluginLiveChannel channel, CancellationToken cancellationToken) private async Task RunConnectedAsync(ICompanionPluginLiveChannel channel, CancellationToken cancellationToken)
{ {
_channel = channel; _channel = channel;
@ -197,7 +216,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
private void ApplyVisibility() private void ApplyVisibility()
{ {
_window.SetRequestedVisible(OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride)); _window.SetRequestedVisible(EditModeActive || OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride));
} }
private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources); private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources);

View File

@ -5,6 +5,7 @@ using Avalonia.Animation;
using Avalonia.Animation.Easings; using Avalonia.Animation.Easings;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform; using Avalonia.Platform;
@ -37,6 +38,9 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
private readonly StackPanel _unifiedPanel = new() { Spacing = 8 }; private readonly StackPanel _unifiedPanel = new() { Spacing = 8 };
private readonly StackPanel _chatPanel = new() { Spacing = 8 }; private readonly StackPanel _chatPanel = new() { Spacing = 8 };
private readonly StackPanel _eventPanel = new() { Spacing = 8 }; private readonly StackPanel _eventPanel = new() { Spacing = 8 };
private readonly Border _unifiedEditor;
private readonly Border _chatEditor;
private readonly Border _eventEditor;
private readonly Dictionary<string, CardVisual> _cards = []; private readonly Dictionary<string, CardVisual> _cards = [];
private readonly DispatcherTimer _timer; private readonly DispatcherTimer _timer;
private readonly HttpsImageCache _images = new(); private readonly HttpsImageCache _images = new();
@ -46,6 +50,12 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
private bool _disposed; private bool _disposed;
private bool _visibleRequested; private bool _visibleRequested;
private string? _temporaryMonitorWarning; private string? _temporaryMonitorWarning;
private bool _editing;
private Border? _dragEditor;
private OverlayContainerSettings? _dragSettings;
private Point _dragOrigin;
private PhysicalRect _dragRect;
private bool _resizing;
public NativeOverlayWindow() public NativeOverlayWindow()
{ {
@ -65,6 +75,12 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
_surface.Children.Add(_unifiedHolder); _surface.Children.Add(_unifiedHolder);
_surface.Children.Add(_chatHolder); _surface.Children.Add(_chatHolder);
_surface.Children.Add(_eventHolder); _surface.Children.Add(_eventHolder);
_unifiedEditor = CreateEditor("Overlay container", "unified");
_chatEditor = CreateEditor("Chat", "chat");
_eventEditor = CreateEditor("Events", "events");
_surface.Children.Add(_unifiedEditor);
_surface.Children.Add(_chatEditor);
_surface.Children.Add(_eventEditor);
_chat = new(() => ContainerFor("chat")); _chat = new(() => ContainerFor("chat"));
_events = new(() => ContainerFor("event")); _events = new(() => ContainerFor("event"));
_chat.Changed += Render; _chat.Changed += Render;
@ -85,6 +101,7 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window."); public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window.");
public string? MonitorWarning => _temporaryMonitorWarning; public string? MonitorWarning => _temporaryMonitorWarning;
public event Action? NativeStatusChanged; public event Action? NativeStatusChanged;
public event Action? EditSettingsChanged;
public void ApplySettings(LumiOverlaySettings settings) public void ApplySettings(LumiOverlaySettings settings)
{ {
@ -108,6 +125,35 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
else if (IsVisible) Hide(); else if (IsVisible) Hide();
} }
public void BeginEdit(LumiOverlaySettings settings)
{
_editing = true;
ApplySettings(settings);
if (!_chat.Items.Any(item => item.Id.StartsWith("edit-sample-", StringComparison.Ordinal)))
{
AddChat(new OverlayFeedMessage
{
Id = $"edit-sample-chat-{Guid.NewGuid():N}",
Platform = "twitch",
Text = "Drag this container to move it · drag the lower-right corner to resize",
Author = new() { Name = "Lumi preview", Badges = [new() { Label = "LIVE" }] }
});
AddEvent(new($"edit-sample-event-{Guid.NewGuid():N}", "twitch.follow", "twitch", "New follower preview", default));
}
SetRequestedVisible(true);
ApplyMonitorAndStyles();
Activate();
}
public void EndEdit()
{
if (!_editing) return;
_editing = false;
Clear();
ApplyMonitorAndStyles();
Render();
}
public void AddChat(OverlayFeedMessage message) => _chat.Add(message.Id, "chat", message); public void AddChat(OverlayFeedMessage message) => _chat.Add(message.Id, "chat", message);
public void AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value); public void AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value);
public void Clear() { _chat.Clear(); _events.Clear(); } public void Clear() { _chat.Clear(); _events.Clear(); }
@ -161,6 +207,118 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
} }
var activeIds = _chat.Items.Concat(_events.Items).Select(item => item.Id).ToHashSet(); 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); foreach (var stale in _cards.Keys.Where(id => !activeIds.Contains(id)).ToList()) _cards.Remove(stale);
RenderEditors(width, height, scaling);
}
private Border CreateEditor(string label, string tag)
{
var editor = new Border
{
Tag = tag,
BorderBrush = Brush.Parse("#28B7C8"),
BorderThickness = new Thickness(3),
Background = Brush.Parse("#082C3333"),
CornerRadius = new CornerRadius(8),
Child = new Border
{
Background = Brush.Parse("#D9121212"),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(9, 5),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
Child = new TextBlock { Text = label, Foreground = Brushes.White, FontWeight = FontWeight.SemiBold, FontSize = 12 }
},
Cursor = new Cursor(StandardCursorType.SizeAll),
IsVisible = false
};
editor.PointerPressed += OnEditorPointerPressed;
editor.PointerMoved += OnEditorPointerMoved;
editor.PointerReleased += OnEditorPointerReleased;
return editor;
}
private void RenderEditors(int width, int height, double scaling)
{
if (!_editing)
{
_unifiedEditor.IsVisible = _chatEditor.IsVisible = _eventEditor.IsVisible = false;
return;
}
if (_settings.Layout is OverlayLayoutMode.Unified or OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical)
{
ConfigureEditor(_unifiedEditor, Logical(AnchorLayout.Calculate(_settings.Unified, width, height), scaling), true);
ConfigureEditor(_chatEditor, default, false);
ConfigureEditor(_eventEditor, default, false);
}
else
{
ConfigureEditor(_unifiedEditor, default, false);
ConfigureEditor(_chatEditor, Logical(AnchorLayout.Calculate(_settings.Chat, width, height), scaling), true);
ConfigureEditor(_eventEditor, Logical(AnchorLayout.Calculate(_settings.Events, width, height), scaling), true);
}
}
private static void ConfigureEditor(Border editor, PhysicalRect rect, bool visible)
{
editor.IsVisible = visible;
if (!visible) return;
editor.Width = rect.Width;
editor.Height = rect.Height;
Canvas.SetLeft(editor, rect.X);
Canvas.SetTop(editor, rect.Y);
}
private void OnEditorPointerPressed(object? sender, PointerPressedEventArgs args)
{
if (!_editing || sender is not Border editor) return;
var screen = ResolveScreen();
if (screen is null) return;
var tag = editor.Tag?.ToString();
_dragSettings = tag == "chat" ? _settings.Chat : tag == "events" ? _settings.Events : _settings.Unified;
_dragRect = AnchorLayout.Calculate(_dragSettings, screen.Bounds.Width, screen.Bounds.Height);
_dragOrigin = args.GetPosition(_surface);
var position = args.GetPosition(editor);
_resizing = position.X >= editor.Bounds.Width - 24 && position.Y >= editor.Bounds.Height - 24;
_dragEditor = editor;
editor.Cursor = new Cursor(_resizing ? StandardCursorType.BottomRightCorner : StandardCursorType.SizeAll);
args.Pointer.Capture(editor);
args.Handled = true;
}
private void OnEditorPointerMoved(object? sender, PointerEventArgs args)
{
if (_dragEditor is null || _dragSettings is null) return;
var screen = ResolveScreen();
if (screen is null) return;
var current = args.GetPosition(_surface);
var dx = (int)Math.Round((current.X - _dragOrigin.X) * screen.Scaling);
var dy = (int)Math.Round((current.Y - _dragOrigin.Y) * screen.Scaling);
if (_resizing)
{
_dragSettings.Width = Math.Clamp(_dragRect.Width + dx, 160, screen.Bounds.Width);
_dragSettings.Height = Math.Clamp(_dragRect.Height + dy, 100, screen.Bounds.Height);
}
else
{
_dragSettings.Anchor = OverlayAnchor.TopLeft;
_dragSettings.PaddingLeft = Math.Clamp(_dragRect.X + dx, 0, Math.Max(0, screen.Bounds.Width - _dragSettings.Width));
_dragSettings.PaddingTop = Math.Clamp(_dragRect.Y + dy, 0, Math.Max(0, screen.Bounds.Height - _dragSettings.Height));
_dragSettings.PaddingRight = 0;
_dragSettings.PaddingBottom = 0;
}
Render();
args.Handled = true;
}
private void OnEditorPointerReleased(object? sender, PointerReleasedEventArgs args)
{
if (_dragEditor is null) return;
args.Pointer.Capture(null);
_dragEditor.Cursor = new Cursor(StandardCursorType.SizeAll);
_dragEditor = null;
_dragSettings = null;
EditSettingsChanged?.Invoke();
args.Handled = true;
} }
private void ConfigureContainer(Grid holder, StackPanel panel, IEnumerable<OverlayQueueItem> values, private void ConfigureContainer(Grid holder, StackPanel panel, IEnumerable<OverlayQueueItem> values,
@ -471,7 +629,9 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
Height = screen.Bounds.Height / screen.Scaling; Height = screen.Bounds.Height / screen.Scaling;
var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
if (hwnd == IntPtr.Zero) return; if (hwnd == IntPtr.Zero) return;
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate; var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExToolWindow | WsExLayered;
if (_editing) style &= ~(WsExTransparent | WsExNoActivate);
else style |= WsExTransparent | WsExNoActivate;
SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style)); SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style));
SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged); SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged);
if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture)) if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture))

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_overlay", "id": "lumi_overlay",
"name": "Lumi Overlay", "name": "Lumi Overlay",
"version": "0.1.1", "version": "0.1.2",
"provider_api": 1, "provider_api": 1,
"capabilities": [ "capabilities": [
"network.lumi.overlay.read", "network.lumi.overlay.read",

View File

@ -0,0 +1,66 @@
namespace Lumi.Companion.SongOverlay;
public sealed record MediaSourceDefinition(string Id, string Label, string Detail, bool EnabledByDefault);
public static class MediaSourceCatalog
{
public static IReadOnlyList<MediaSourceDefinition> All { get; } =
[
new("wmp", "Windows Media Player", "Windows Media Player and the modern Windows Media Player app", true),
new("youtube", "YouTube / YouTube Music", "YouTube playback exposed by Chrome or Edge", true),
new("spotify", "Spotify", "Spotify desktop playback", true),
new("vlc", "VLC", "VLC media playback", false),
new("apple_music", "iTunes / Apple Music", "iTunes and Apple Music for Windows", false),
new("soundcloud", "SoundCloud", "SoundCloud playback exposed by a browser or app", false),
new("tidal", "TIDAL", "TIDAL playback exposed by a browser or app", false),
new("bandcamp", "Bandcamp", "Bandcamp playback exposed by a browser", false),
new("qobuz", "Qobuz", "Qobuz playback exposed by a browser or app", false)
];
public static HashSet<string> Defaults() =>
All.Where(source => source.EnabledByDefault).Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
public static string? Detect(string? sourceAppId, string? title, string? artist, string? album)
{
var source = (sourceAppId ?? "").ToLowerInvariant();
var metadata = string.Join(" ", title, artist, album).ToLowerInvariant();
if (source.Contains("spotify")) return "spotify";
if (source.Contains("vlc")) return "vlc";
if (source.Contains("itunes") || source.Contains("applemusic") || source.Contains("apple music")) return "apple_music";
if (source.Contains("soundcloud")) return "soundcloud";
if (source.Contains("tidal")) return "tidal";
if (source.Contains("bandcamp")) return "bandcamp";
if (source.Contains("qobuz")) return "qobuz";
if (source.Contains("wmplayer") || source.Contains("zunemusic") || source.Contains("mediaplayer")) return "wmp";
var browser = source.Contains("chrome") || source.Contains("msedge") || source.Contains("firefox");
if (!browser) return null;
if (metadata.Contains("soundcloud")) return "soundcloud";
if (metadata.Contains("tidal")) return "tidal";
if (metadata.Contains("bandcamp")) return "bandcamp";
if (metadata.Contains("qobuz")) return "qobuz";
if (metadata.Contains("youtube") || metadata.Contains("yt music")) return "youtube";
// Browser media sessions do not consistently expose their origin. Treat
// an otherwise unidentified browser session as YouTube, the enabled
// browser default, while named services remain independently switchable.
return "youtube";
}
public static string? DetectEnabled(
string? sourceAppId,
string? title,
string? artist,
string? album,
IReadOnlySet<string> enabled)
{
var detected = Detect(sourceAppId, title, artist, album);
if (detected != "youtube" || enabled.Contains("youtube")) return detected;
var source = (sourceAppId ?? "").ToLowerInvariant();
if (!source.Contains("chrome") && !source.Contains("msedge") && !source.Contains("firefox")) return detected;
return new[] { "soundcloud", "tidal", "bandcamp", "qobuz" }.FirstOrDefault(enabled.Contains);
}
public static string Label(string? id) =>
All.FirstOrDefault(source => source.Id == id)?.Label ?? "Windows media";
}

View File

@ -7,7 +7,7 @@ using Windows.Storage.Streams;
namespace Lumi.Companion.SongOverlay.Providers; namespace Lumi.Companion.SongOverlay.Providers;
internal sealed class SpotifyWindowsMediaProvider : IMediaProvider internal sealed class WindowsMediaProvider : IMediaProvider
{ {
private const int MaxCoverInputBytes = 3 * 1024 * 1024; private const int MaxCoverInputBytes = 3 * 1024 * 1024;
private const int MaxCoverOutputBytes = 500 * 1024; private const int MaxCoverOutputBytes = 500 * 1024;
@ -18,18 +18,19 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private GlobalSystemMediaTransportControlsSession? _session; private GlobalSystemMediaTransportControlsSession? _session;
private MediaSnapshot? _last; private MediaSnapshot? _last;
private MediaTrack? _trackCache; private MediaTrack? _trackCache;
private string _sourceId = "windows-media";
private bool _started; private bool _started;
private readonly SemaphoreSlim _refreshLock = new(1, 1); private readonly SemaphoreSlim _refreshLock = new(1, 1);
public SpotifyWindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log) public WindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
{ {
_settings = settings; _settings = settings;
_enrich = enrich; _enrich = enrich;
_log = log; _log = log;
} }
public string Id => "spotify"; public string Id => "windows-media";
public string DisplayName => "Spotify"; public string DisplayName => "Windows media discovery";
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged; public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
public event EventHandler<string>? AvailabilityChanged; public event EventHandler<string>? AvailabilityChanged;
@ -62,22 +63,22 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args) private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
{ {
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); } try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_session_refresh_failed", "Could not refresh Spotify media sessions", error); } catch (Exception error) { _log("media_session_refresh_failed", "Could not refresh Windows media sessions", error); }
} }
private async Task SelectSessionAsync(CancellationToken cancellationToken) private async Task SelectSessionAsync(CancellationToken cancellationToken)
{ {
var selected = _manager?.GetSessions().FirstOrDefault(IsSpotifySession); var selected = await SelectEnabledSessionAsync(cancellationToken).ConfigureAwait(false);
if (ReferenceEquals(selected, _session)) if (ReferenceEquals(selected, _session))
{ {
if (_session is not null) await RefreshAndPublishAsync("snapshot", false, cancellationToken).ConfigureAwait(false); if (_session is not null) await RefreshAndPublishAsync("media", true, cancellationToken).ConfigureAwait(false);
return; return;
} }
DetachSession(); DetachSession();
_session = selected; _session = selected;
if (_session is null) if (_session is null)
{ {
AvailabilityChanged?.Invoke(this, "Spotify is not exposing a Windows media session."); AvailabilityChanged?.Invoke(this, "No enabled source is exposing a Windows media session.");
if (_last is not null) if (_last is not null)
{ {
_last = null; _last = null;
@ -88,7 +89,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
_session.MediaPropertiesChanged += OnMediaPropertiesChanged; _session.MediaPropertiesChanged += OnMediaPropertiesChanged;
_session.PlaybackInfoChanged += OnPlaybackInfoChanged; _session.PlaybackInfoChanged += OnPlaybackInfoChanged;
_session.TimelinePropertiesChanged += OnTimelinePropertiesChanged; _session.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
AvailabilityChanged?.Invoke(this, "Spotify media session connected."); AvailabilityChanged?.Invoke(this, $"{MediaSourceCatalog.Label(_sourceId)} media session connected.");
await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false); await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false);
} }
@ -104,20 +105,20 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args) private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
{ {
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); } try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_media_property_update_failed", "Spotify media-property update failed", error); } catch (Exception error) { _log("media_property_update_failed", "Windows media-property update failed", error); }
} }
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args) private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
{ {
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); } try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_playback_update_failed", "Spotify playback update failed", error); } catch (Exception error) { _log("media_playback_update_failed", "Windows media playback update failed", error); }
} }
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args) private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
{ {
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); } try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
catch (Exception error) { _log("spotify_timeline_update_failed", "Spotify timeline update failed", error); } catch (Exception error) { _log("media_timeline_update_failed", "Windows media timeline update failed", error); }
} }
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken) private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
@ -162,16 +163,18 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
var title = Clean(media.Title); var title = Clean(media.Title);
var artist = Clean(media.Artist); var artist = Clean(media.Artist);
var album = Clean(media.AlbumTitle); var album = Clean(media.AlbumTitle);
_sourceId = MediaSourceCatalog.DetectEnabled(
_session.SourceAppUserModelId, title, artist, album, _settings().EnabledSources) ?? _sourceId;
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist)) if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
{ {
var key = Fingerprint(title, artist, album, duration); var key = Fingerprint(title, artist, album, duration);
var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null; var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null;
var fallbackLink = _settings().UseSearchLinkFallback var fallbackLink = _sourceId == "spotify" && _settings().UseSearchLinkFallback
? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0))) ? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0)))
: ""; : "";
var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover); var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover);
_trackCache = track; _trackCache = track;
if (enrichTrack) _ = EnrichAndPublishAsync(track); if (enrichTrack && _sourceId == "spotify") _ = EnrichAndPublishAsync(track);
} }
else else
{ {
@ -183,7 +186,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
_trackCache = _trackCache with { DurationMilliseconds = duration }; _trackCache = _trackCache with { DurationMilliseconds = duration };
} }
var snapshot = new MediaSnapshot(Id, status, position, duration, rate, _trackCache, capturedAt); var snapshot = new MediaSnapshot(_sourceId, status, position, duration, rate, _trackCache, capturedAt);
_last = snapshot; _last = snapshot;
return snapshot; return snapshot;
} }
@ -209,10 +212,34 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); } catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); }
} }
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session) private async Task<GlobalSystemMediaTransportControlsSession?> SelectEnabledSessionAsync(CancellationToken cancellationToken)
{ {
var source = session.SourceAppUserModelId ?? ""; if (_manager is null) return null;
return source.Contains("spotify", StringComparison.OrdinalIgnoreCase); GlobalSystemMediaTransportControlsSession? fallback = null;
string? fallbackSource = null;
foreach (var session in _manager.GetSessions())
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var media = await session.TryGetMediaPropertiesAsync();
var sourceId = MediaSourceCatalog.DetectEnabled(
session.SourceAppUserModelId, media.Title, media.Artist, media.AlbumTitle, _settings().EnabledSources);
if (sourceId is null || !_settings().EnabledSources.Contains(sourceId)) continue;
var playing = session.GetPlaybackInfo().PlaybackStatus ==
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing;
if (playing)
{
_sourceId = sourceId;
return session;
}
fallback ??= session;
fallbackSource ??= sourceId;
}
catch { }
}
if (fallbackSource is not null) _sourceId = fallbackSource;
return fallback;
} }
private static async Task<CoverPayload?> ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken) private static async Task<CoverPayload?> ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken)

View File

@ -45,7 +45,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public CompanionPluginDescriptor Descriptor { get; } = new( public CompanionPluginDescriptor Descriptor { get; } = new(
PluginId, PluginId,
"Song Overlay", "Song Overlay",
new Version(0, 1, 1), new Version(0, 1, 2),
"Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.", "Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.",
200); 200);
@ -68,7 +68,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public async Task InitializeAsync(CancellationToken cancellationToken = default) public async Task InitializeAsync(CancellationToken cancellationToken = default)
{ {
await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false); await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(Settings.ProviderId)) Settings.ProviderId = "spotify"; Settings.Normalize();
// Re-save through the current schema so legacy plugin-specific Lumi host // Re-save through the current schema so legacy plugin-specific Lumi host
// and connection-key fields are removed after the shared transport upgrade. // and connection-key fields are removed after the shared transport upgrade.
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false); await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
@ -79,9 +79,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default) public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
{ {
Settings.HeartbeatSeconds = Math.Clamp(Settings.HeartbeatSeconds, 15, 300); Settings.Normalize();
Settings.SeekThresholdMilliseconds = Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000);
Settings.ProviderId = string.IsNullOrWhiteSpace(Settings.ProviderId) ? "spotify" : Settings.ProviderId.Trim().ToLowerInvariant();
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false); await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false); if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
RaiseChanged(); RaiseChanged();
@ -115,7 +113,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
if (!_transport.IsConfigured) throw new InvalidOperationException("Pair Companion with Lumi before sending song updates."); if (!_transport.IsConfigured) throw new InvalidOperationException("Pair Companion with Lumi before sending song updates.");
if (_provider is null) throw new InvalidOperationException("No media provider is running."); if (_provider is null) throw new InvalidOperationException("No media provider is running.");
var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false); var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (snapshot is null) throw new InvalidOperationException("Spotify is not currently exposing playback information."); if (snapshot is null) throw new InvalidOperationException("None of the enabled media sources is currently exposing playback information.");
await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false); await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false);
} }
@ -130,11 +128,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
_lastObserved = null; _lastObserved = null;
_lastDelivered = null; _lastDelivered = null;
_lifetime = new CancellationTokenSource(); _lifetime = new CancellationTokenSource();
_provider = Settings.ProviderId switch _provider = new WindowsMediaProvider(() => Settings, EnrichTrackAsync, Log);
{
"spotify" => new SpotifyWindowsMediaProvider(() => Settings, EnrichTrackAsync, Log),
_ => throw new InvalidOperationException($"Provider '{Settings.ProviderId}' is not installed.")
};
_provider.StateChanged += OnProviderStateChanged; _provider.StateChanged += OnProviderStateChanged;
_provider.AvailabilityChanged += OnAvailabilityChanged; _provider.AvailabilityChanged += OnAvailabilityChanged;
try try
@ -143,15 +137,15 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
StartHeartbeat(); StartHeartbeat();
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false); var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (!_transport.IsConfigured) if (!_transport.IsConfigured)
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Spotify monitoring is ready, but Companion must be paired before Lumi can receive updates."); SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Media monitoring is ready, but Companion must be paired before Lumi can receive updates.");
else if (initial is null) else if (initial is null)
SetStatus(CompanionPluginHealth.Warning, "Waiting for Spotify", "Spotify is not currently exposing a Windows media session."); SetStatus(CompanionPluginHealth.Warning, "Waiting for media", "No enabled source is currently exposing a Windows media session.");
else else
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial)); SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
} }
catch (Exception error) catch (Exception error)
{ {
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start the Spotify media provider: " + error.Message); SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start Windows media discovery: " + error.Message);
throw; throw;
} }
} }
@ -204,9 +198,10 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message); SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
return; return;
} }
var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase); var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase) ||
message.Contains("No enabled source", StringComparison.OrdinalIgnoreCase);
SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy, SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy,
waiting ? "Waiting for Spotify" : "Monitoring", message); waiting ? "Waiting for media" : "Monitoring", message);
} }
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) => private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
@ -318,7 +313,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
if (enriched is null) return track; if (enriched is null) return track;
CoverPayload? cover = track.Cover; CoverPayload? cover = track.Cover;
if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt) if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt)
cover = SpotifyWindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover; cover = WindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
return track with return track with
{ {
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link, Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,

View File

@ -4,10 +4,22 @@ public sealed class SongOverlaySettings
{ {
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public string ProviderId { get; set; } = "spotify"; public string ProviderId { get; set; } = "spotify";
public HashSet<string> EnabledSources { get; set; } = MediaSourceCatalog.Defaults();
public int HeartbeatSeconds { get; set; } = 30; public int HeartbeatSeconds { get; set; } = 30;
public int SeekThresholdMilliseconds { get; set; } = 1500; public int SeekThresholdMilliseconds { get; set; } = 1500;
public bool SendCoverArt { get; set; } = true; public bool SendCoverArt { get; set; } = true;
public bool UseSearchLinkFallback { get; set; } = true; public bool UseSearchLinkFallback { get; set; } = true;
public string SpotifyClientId { get; set; } = ""; public string SpotifyClientId { get; set; } = "";
public string ProtectedSpotifyRefreshToken { get; set; } = ""; public string ProtectedSpotifyRefreshToken { get; set; } = "";
public void Normalize()
{
ProviderId = "windows-media";
EnabledSources ??= MediaSourceCatalog.Defaults();
EnabledSources = EnabledSources
.Where(id => MediaSourceCatalog.All.Any(source => source.Id == id))
.ToHashSet(StringComparer.Ordinal);
HeartbeatSeconds = Math.Clamp(HeartbeatSeconds, 15, 300);
SeekThresholdMilliseconds = Math.Clamp(SeekThresholdMilliseconds, 500, 10000);
}
} }

View File

@ -3,7 +3,7 @@
"name": "Song Overlay", "name": "Song Overlay",
"version": "0.1.2", "version": "0.1.2",
"provider_api": 1, "provider_api": 1,
"providers": ["spotify"], "providers": ["windows-media", "spotify", "wmp", "youtube", "vlc", "apple-music", "soundcloud", "tidal", "bandcamp", "qobuz"],
"capabilities": [ "capabilities": [
"windows.media-session.read", "windows.media-session.read",
"network.lumi", "network.lumi",

View File

@ -1,5 +1,5 @@
param( param(
[string]$Version = "0.2.7", [string]$Version = "0.2.8",
[string]$BridgeVersion = "0.2.5", [string]$BridgeVersion = "0.2.5",
[string]$ObsVersion = "31.1.1" [string]$ObsVersion = "31.1.1"
) )

View File

@ -1,12 +1,17 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $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 Push-Location $repoRoot
try { try {
Write-Host 'Building Lumi Companion and all registered Companion plugins...' Write-Host 'Building Lumi Companion and all registered Companion plugins...'
dotnet build 'companion/Lumi.Companion.sln' -c Release -p:EnableWindowsTargeting=true & $dotnet build 'companion/Lumi.Companion.sln' -c Release
if ($LASTEXITCODE) { throw 'Lumi Companion build failed.' }
Write-Host 'Verifying the existing Lumi Song Overlay server plugin...' Write-Host 'Verifying the existing Lumi Song Overlay server plugin...'
node 'plugins/now_playing/tests/verify.js' & $node 'plugins/now_playing/tests/verify.js'
if ($LASTEXITCODE) { throw 'Song Overlay server verification failed.' }
Write-Host 'Song Overlay Companion verification passed.' -ForegroundColor Green Write-Host 'Song Overlay Companion verification passed.' -ForegroundColor Green
} }

View File

@ -0,0 +1,134 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
namespace Lumi.Companion.App;
public sealed class HotkeyCaptureBinding
{
private readonly Button _button;
private readonly DispatcherTimer _timeout;
private readonly HashSet<Key> _pressed = [];
private string _hotkey = "";
private string _previous = "";
private KeyModifiers _modifiers;
private Key? _primary;
private bool _capturing;
public HotkeyCaptureBinding(Button button)
{
_button = button;
_button.HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left;
_button.Focusable = true;
_timeout = new DispatcherTimer { Interval = TimeSpan.FromSeconds(7) };
_timeout.Tick += (_, _) => CancelCapture();
_button.Click += (_, _) => BeginCapture();
_button.KeyDown += OnKeyDown;
_button.KeyUp += OnKeyUp;
}
public string Hotkey
{
get => _hotkey;
set
{
_hotkey = string.IsNullOrWhiteSpace(value) ? "Unassigned" : value.Trim();
if (!_capturing) UpdateContent();
}
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (!_capturing)
{
return;
}
e.Handled = true;
if (e.Key == Key.Escape)
{
CancelCapture();
return;
}
_pressed.Add(e.Key);
_modifiers |= e.KeyModifiers;
if (!IsModifier(e.Key)) _primary = e.Key;
_button.Content = _primary is null ? "Hold modifiers, then press a key…" : $"{Format(_modifiers, _primary.Value)} · release to save";
}
private void OnKeyUp(object? sender, KeyEventArgs e)
{
if (!_capturing)
{
return;
}
e.Handled = true;
_pressed.Remove(e.Key);
if (_primary is not null && !_pressed.Any(key => !IsModifier(key)))
{
_hotkey = Format(_modifiers, _primary.Value);
FinishCapture();
}
}
private void BeginCapture()
{
if (_capturing) return;
_capturing = true;
_previous = _hotkey;
_pressed.Clear();
_modifiers = KeyModifiers.None;
_primary = null;
_button.Content = "Press a shortcut…";
_button.Focus();
_timeout.Start();
}
private void CancelCapture()
{
if (!_capturing) return;
_hotkey = _previous;
FinishCapture();
}
private void FinishCapture()
{
_capturing = false;
_timeout.Stop();
_pressed.Clear();
UpdateContent();
}
private void UpdateContent() => _button.Content = $"{_hotkey} (Set hotkey)";
private static bool IsModifier(Key key) => key is
Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or
Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin;
internal static string Format(KeyModifiers modifiers, Key key)
{
var parts = new List<string>(5);
if (modifiers.HasFlag(KeyModifiers.Control)) parts.Add("Ctrl");
if (modifiers.HasFlag(KeyModifiers.Alt)) parts.Add("Alt");
if (modifiers.HasFlag(KeyModifiers.Shift)) parts.Add("Shift");
if (modifiers.HasFlag(KeyModifiers.Meta)) parts.Add("Win");
parts.Add(KeyName(key));
return string.Join("+", parts);
}
private static string KeyName(Key key) => key switch
{
Key.PageUp => "PageUp",
Key.PageDown => "PageDown",
Key.Left => "Left",
Key.Right => "Right",
Key.Up => "Up",
Key.Down => "Down",
Key.Return => "Enter",
Key.Space => "Space",
Key.Back => "Backspace",
_ => key.ToString()
};
}

View File

@ -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.7</Version> <Version>0.2.8</Version>
<AssemblyVersion>0.2.7.0</AssemblyVersion> <AssemblyVersion>0.2.8.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" /> <AvaloniaResource Include="Assets\Lumi.Companion.ico" />

View File

@ -252,7 +252,7 @@
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="MEDIA" Classes="eyebrow" /> <TextBlock Text="MEDIA" Classes="eyebrow" />
<TextBlock Text="Song Overlay" Classes="pageTitle" /> <TextBlock Text="Song Overlay" Classes="pageTitle" />
<TextBlock Text="Spotify is the first media provider. The provider contract remains generic so later services can use the same event and overlay pipeline." Classes="muted" FontSize="15" /> <TextBlock Text="Choose which Windows media sessions Lumi may discover. Song delivery continues to use the Companion's shared secure connection." Classes="muted" FontSize="15" TextWrapping="Wrap" />
</StackPanel> </StackPanel>
<Border Classes="soft"> <Border Classes="soft">
@ -277,16 +277,16 @@
</StackPanel> </StackPanel>
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Playback provider" Classes="sectionTitle" /> <TextBlock Text="Playback sources" Classes="sectionTitle" />
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12"> <TextBlock Text="Only enabled sources are observed. Browser sessions without an exposed service name are treated as YouTube." Classes="muted" FontSize="12" TextWrapping="Wrap" />
<TextBlock Text="Provider" Classes="muted" VerticalAlignment="Center" /> <Border Classes="soft">
<ComboBox Grid.Column="1" x:Name="SongOverlayProviderPicker" SelectedIndex="0" IsEnabled="False"> <StackPanel x:Name="SongOverlaySourcesPanel" Spacing="9" />
<ComboBoxItem Content="Spotify" Tag="spotify" /> </Border>
</ComboBox> <Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="12">
<TextBlock Grid.Row="1" Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" /> <TextBlock Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" /> <NumericUpDown Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" />
<TextBlock Grid.Row="2" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" /> <TextBlock Grid.Row="1" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" /> <NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
</Grid> </Grid>
<CheckBox x:Name="SongOverlayCoverToggle" Content="Send cover art only with track metadata" /> <CheckBox x:Name="SongOverlayCoverToggle" Content="Send cover art only with track metadata" />
<CheckBox x:Name="SongOverlaySearchLinkToggle" Content="Use a Spotify search link when an exact song link is unavailable" /> <CheckBox x:Name="SongOverlaySearchLinkToggle" Content="Use a Spotify search link when an exact song link is unavailable" />
@ -383,6 +383,18 @@
</Grid> </Grid>
<Border Classes="card"> <Border Classes="card">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
<StackPanel Spacing="5">
<TextBlock Text="Interactive layout editor" Classes="sectionTitle" />
<TextBlock Text="Edit the real monitor overlay in place. Drag a highlighted container to move it, or drag its lower-right corner to resize it. The preview uses the same renderer, typography, media and animations as the live overlay." Classes="muted" TextWrapping="Wrap" />
<TextBlock x:Name="LumiOverlayEditStatus" Text="Edit mode is off. Changes remain a draft until saved." Classes="muted" FontSize="12" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1" x:Name="LumiOverlayEditButton" Classes="primary" Content="Start edit mode" VerticalAlignment="Center" />
</Grid>
</Border>
<Expander Header="Advanced appearance &amp; lifecycle">
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock Text="Container styling &amp; lifecycle" Classes="sectionTitle" /> <TextBlock Text="Container styling &amp; lifecycle" Classes="sectionTitle" />
<ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240"> <ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240">
@ -465,15 +477,16 @@
<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" /> <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> </StackPanel>
</Border> </Border>
</Expander>
<Border Classes="soft"> <Border Classes="soft">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Global hotkeys" Classes="sectionTitle" /> <TextBlock Text="Global hotkeys" Classes="sectionTitle" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10"> <Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
<TextBlock Text="Visibility override" Classes="muted" /> <TextBlock Text="Visibility override" Classes="muted" />
<TextBox Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" PlaceholderText="Ctrl+Alt+L" /> <Button Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" Classes="secondary" />
<TextBlock Grid.Row="1" Text="Preview override" Classes="muted" /> <TextBlock Grid.Row="1" Text="Preview override" Classes="muted" />
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" PlaceholderText="Ctrl+Alt+P" /> <Button Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" Classes="secondary" />
</Grid> </Grid>
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" /> <TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
</StackPanel> </StackPanel>

View File

@ -26,8 +26,12 @@ public partial class MainWindow : Window
private bool _renderingSettings; private bool _renderingSettings;
private readonly Dictionary<string, CheckBox> _overlaySourceChecks = []; private readonly Dictionary<string, CheckBox> _overlaySourceChecks = [];
private readonly Dictionary<string, CheckBox> _overlayEventChecks = []; private readonly Dictionary<string, CheckBox> _overlayEventChecks = [];
private readonly Dictionary<string, CheckBox> _songOverlaySourceChecks = [];
private bool _songOverlaySourcesDirty;
private string _editingOverlayContainer = "unified"; private string _editingOverlayContainer = "unified";
private LumiOverlaySettings? _lumiOverlayDraft; private LumiOverlaySettings? _lumiOverlayDraft;
private readonly HotkeyCaptureBinding _visibilityHotkeyCapture;
private readonly HotkeyCaptureBinding _previewHotkeyCapture;
public MainWindow() : this(CreateDefaultServices()) { } public MainWindow() : this(CreateDefaultServices()) { }
@ -42,6 +46,8 @@ public partial class MainWindow : Window
_lumiOverlay = lumiOverlay; _lumiOverlay = lumiOverlay;
_plugins = plugins; _plugins = plugins;
InitializeComponent(); InitializeComponent();
_visibilityHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayVisibilityHotkeyBox);
_previewHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayPreviewHotkeyBox);
BuildPluginNavigation(); BuildPluginNavigation();
WireActions(); WireActions();
RenderState(runtime.State); RenderState(runtime.State);
@ -172,6 +178,7 @@ public partial class MainWindow : Window
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(); SaveLumiOverlayButton.Click += async (_, _) => await SaveLumiOverlayAsync();
LumiOverlayEditButton.Click += (_, _) => ToggleLumiOverlayEditMode();
LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); }; LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); };
LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); }; LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); };
LumiOverlayContainerPicker.SelectionChanged += (_, _) => LumiOverlayContainerPicker.SelectionChanged += (_, _) =>
@ -213,6 +220,11 @@ public partial class MainWindow : Window
public void ShowPage(CompanionPage page) public void ShowPage(CompanionPage page)
{ {
if (page != CompanionPage.LumiOverlay && _lumiOverlay.EditModeActive)
{
_lumiOverlay.EndEdit();
RenderLumiOverlay(preserveDraft: false);
}
var pages = new Dictionary<CompanionPage, Control> var pages = new Dictionary<CompanionPage, Control>
{ {
[CompanionPage.Overview] = OverviewPage, [CompanionPage.Overview] = OverviewPage,
@ -294,6 +306,10 @@ public partial class MainWindow : Window
{ {
var settings = _songOverlay.Settings; var settings = _songOverlay.Settings;
settings.Enabled = SongOverlayEnabledToggle.IsChecked == true; settings.Enabled = SongOverlayEnabledToggle.IsChecked == true;
settings.EnabledSources = _songOverlaySourceChecks
.Where(item => item.Value.IsChecked == true)
.Select(item => item.Key)
.ToHashSet(StringComparer.Ordinal);
settings.ProviderId = "spotify"; settings.ProviderId = "spotify";
settings.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30); settings.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500); settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
@ -301,6 +317,7 @@ public partial class MainWindow : Window
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true; settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? ""; settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
await _songOverlay.SaveSettingsAsync(restartProvider); await _songOverlay.SaveSettingsAsync(restartProvider);
_songOverlaySourcesDirty = false;
SongOverlayFeedback.Text = "Song Overlay settings saved."; SongOverlayFeedback.Text = "Song Overlay settings saved.";
} }
catch (Exception error) catch (Exception error)
@ -586,7 +603,7 @@ public partial class MainWindow : Window
if (!_lumiOverlay.IsInitialized) return; if (!_lumiOverlay.IsInitialized) return;
if (_lumiOverlayDraft is null) if (_lumiOverlayDraft is null)
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy(); _lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
else if (preserveDraft && !_renderingLumiOverlay) else if (preserveDraft && !_renderingLumiOverlay && !_lumiOverlay.EditModeActive)
CaptureLumiOverlayForm(_lumiOverlayDraft); CaptureLumiOverlayForm(_lumiOverlayDraft);
_renderingLumiOverlay = true; _renderingLumiOverlay = true;
try try
@ -611,9 +628,13 @@ public partial class MainWindow : Window
LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}"; LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}";
LumiOverlayVisibilityButton.Content = visibility.Action; LumiOverlayVisibilityButton.Content = visibility.Action;
LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle; LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle;
LumiOverlayVisibilityHotkeyBox.Text = settings.ToggleVisibilityHotkey; _visibilityHotkeyCapture.Hotkey = settings.ToggleVisibilityHotkey;
LumiOverlayPreviewHotkeyBox.Text = settings.TogglePreviewHotkey; _previewHotkeyCapture.Hotkey = settings.TogglePreviewHotkey;
LumiOverlayHotkeyStatus.Text = _lumiOverlay.HotkeyStatus; LumiOverlayHotkeyStatus.Text = _lumiOverlay.HotkeyStatus;
LumiOverlayEditButton.Content = _lumiOverlay.EditModeActive ? "End edit mode" : "Start edit mode";
LumiOverlayEditStatus.Text = _lumiOverlay.EditModeActive
? "Edit mode is active on the selected monitor. Drag to move; use the lower-right corner to resize."
: "Edit mode is off. Changes remain a draft until saved.";
var monitorItems = _lumiOverlay.Monitors.Select(value => new OverlayPickerItem(value.Id, value.Label)).ToList(); var monitorItems = _lumiOverlay.Monitors.Select(value => new OverlayPickerItem(value.Id, value.Label)).ToList();
if (settings.MonitorId is { Length: > 0 } savedMonitor && if (settings.MonitorId is { Length: > 0 } savedMonitor &&
@ -756,8 +777,8 @@ public partial class MainWindow : Window
settings.Layout = layout.Value; settings.Layout = layout.Value;
settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage); settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage);
if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id; if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id;
settings.ToggleVisibilityHotkey = LumiOverlayVisibilityHotkeyBox.Text?.Trim() ?? ""; settings.ToggleVisibilityHotkey = _visibilityHotkeyCapture.Hotkey;
settings.TogglePreviewHotkey = LumiOverlayPreviewHotkeyBox.Text?.Trim() ?? ""; settings.TogglePreviewHotkey = _previewHotkeyCapture.Hotkey;
var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal); var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
foreach (var saved in settings.Sources) foreach (var saved in settings.Sources)
@ -821,6 +842,20 @@ public partial class MainWindow : Window
picker.SelectedItem = picker.ItemsSource?.OfType<OverlayChoice<T>>().FirstOrDefault(item => EqualityComparer<T>.Default.Equals(item.Value, value)); picker.SelectedItem = picker.ItemsSource?.OfType<OverlayChoice<T>>().FirstOrDefault(item => EqualityComparer<T>.Default.Equals(item.Value, value));
} }
private void ToggleLumiOverlayEditMode()
{
if (_lumiOverlayDraft is null) _lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
if (_lumiOverlay.EditModeActive)
{
_lumiOverlay.EndEdit();
RenderLumiOverlay(preserveDraft: false);
return;
}
CaptureLumiOverlayForm(_lumiOverlayDraft);
_lumiOverlay.BeginEdit(_lumiOverlayDraft);
RenderLumiOverlay(preserveDraft: false);
}
private async Task SaveLumiOverlayAsync() private async Task SaveLumiOverlayAsync()
{ {
if (_renderingLumiOverlay) return; if (_renderingLumiOverlay) return;
@ -829,6 +864,11 @@ public partial class MainWindow : Window
try try
{ {
var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy(); var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy();
if (_lumiOverlay.EditModeActive)
{
_lumiOverlay.EndEdit();
RenderLumiOverlay(preserveDraft: false);
}
CaptureLumiOverlayForm(settings); CaptureLumiOverlayForm(settings);
await _lumiOverlay.SaveSettingsAsync(settings); await _lumiOverlay.SaveSettingsAsync(settings);
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy(); _lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
@ -865,6 +905,30 @@ public partial class MainWindow : Window
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000); SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt; SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
SongOverlaySearchLinkToggle.IsChecked = settings.UseSearchLinkFallback; SongOverlaySearchLinkToggle.IsChecked = settings.UseSearchLinkFallback;
if (_songOverlaySourceChecks.Count == 0)
{
foreach (var source in MediaSourceCatalog.All)
{
var check = new CheckBox
{
Content = source.Label,
IsChecked = settings.EnabledSources.Contains(source.Id)
};
ToolTip.SetTip(check, source.Detail);
check.IsCheckedChanged += (_, _) =>
{
if (!_renderingSongOverlay) _songOverlaySourcesDirty = true;
};
SongOverlaySourcesPanel.Children.Add(check);
_songOverlaySourceChecks[source.Id] = check;
}
}
else if (!_songOverlaySourcesDirty)
{
foreach (var source in MediaSourceCatalog.All)
if (_songOverlaySourceChecks.TryGetValue(source.Id, out var check))
check.IsChecked = settings.EnabledSources.Contains(source.Id);
}
if (!SongOverlaySpotifyClientIdBox.IsFocused) SongOverlaySpotifyClientIdBox.Text = settings.SpotifyClientId; if (!SongOverlaySpotifyClientIdBox.IsFocused) SongOverlaySpotifyClientIdBox.Text = settings.SpotifyClientId;
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
? "Connected. Exact links, release year and official artwork can be enriched on song changes." ? "Connected. Exact links, release year and official artwork can be enriched on song changes."
@ -957,6 +1021,7 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, WindowClosingEventArgs args) private void OnClosing(object? sender, WindowClosingEventArgs args)
{ {
if (_allowExit) return; if (_allowExit) return;
if (_lumiOverlay.EditModeActive) _lumiOverlay.EndEdit();
args.Cancel = true; args.Cancel = true;
Hide(); Hide();
} }

View File

@ -9,5 +9,6 @@
<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" /> <ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
<ProjectReference Include="../../plugins/Lumi.Companion.SongOverlay/Lumi.Companion.SongOverlay.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,6 +1,7 @@
using Lumi.Companion.Abstractions; using Lumi.Companion.Abstractions;
using Lumi.Companion.Core; using Lumi.Companion.Core;
using Lumi.Companion.Overlay; using Lumi.Companion.Overlay;
using Lumi.Companion.SongOverlay;
static void Assert(bool condition, string message) static void Assert(bool condition, string message)
{ {
@ -112,6 +113,17 @@ Assert(!CompanionPluginNavigation.UsesNestedNavigation(
CompanionPluginNavigation.UsesNestedNavigation( CompanionPluginNavigation.UsesNestedNavigation(
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]), [new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
"Single-page plugins must be direct navigation entries while multi-page plugins remain grouped."); "Single-page plugins must be direct navigation entries while multi-page plugins remain grouped.");
var defaultMediaSources = MediaSourceCatalog.Defaults();
Assert(defaultMediaSources.SetEquals(["wmp", "youtube", "spotify"]),
"Song discovery defaults must enable only Windows Media Player, YouTube, and Spotify.");
Assert(MediaSourceCatalog.Detect("Spotify.exe", "Track", "Artist", "") == "spotify" &&
MediaSourceCatalog.Detect("chrome.exe", "Track · SoundCloud", "Artist", "") == "soundcloud" &&
MediaSourceCatalog.Detect("chrome.exe", "Unlabelled browser track", "Artist", "") == "youtube" &&
MediaSourceCatalog.Detect("vlc.exe", "Track", "", "") == "vlc",
"Windows media-session discovery did not classify desktop and browser sources independently.");
Assert(MediaSourceCatalog.DetectEnabled("chrome.exe", "Unlabelled track", "Artist", "",
new HashSet<string>(["soundcloud"], StringComparer.Ordinal)) == "soundcloud",
"An independently enabled browser source must remain discoverable when Windows withholds the tab origin.");
Assert(new CaptureExclusionStatus(CaptureExclusionState.Unsupported, "unsupported").State == CaptureExclusionState.Unsupported, Assert(new CaptureExclusionStatus(CaptureExclusionState.Unsupported, "unsupported").State == CaptureExclusionState.Unsupported,
"Capture-exclusion unsupported state must remain distinct from failure."); "Capture-exclusion unsupported state must remain distinct from failure.");
var visibilitySettings = new LumiOverlaySettings var visibilitySettings = new LumiOverlaySettings

View File

@ -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.10 Version: 0.3.11
## Routes ## Routes
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events
@ -93,6 +93,7 @@ Version: 0.3.10
- POST /admin/theming/custom/:id/delete - POST /admin/theming/custom/:id/delete
- POST /admin/theming - POST /admin/theming
- GET /admin/diagnostics - GET /admin/diagnostics
- GET /admin/platform-tenure
- GET /admin/stream-testing - GET /admin/stream-testing
- POST /admin/stream-testing/reverse-proxy - POST /admin/stream-testing/reverse-proxy
- POST /admin/stream-testing/reverse-proxy/check - POST /admin/stream-testing/reverse-proxy/check
@ -920,6 +921,15 @@ Version: 0.3.10
- Side effects: Usually read-only. - Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/platform-tenure
- Purpose: Renders the admin platform tenure WebUI page.
- Inputs: query: `platform`, `scope`, `type`, `user`
- Response format: HTML page rendered from an EJS view
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/stream-testing ### GET /admin/stream-testing
- Purpose: Renders the admin stream testing WebUI page. - Purpose: Renders the admin stream testing WebUI page.

View File

@ -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.7 Version: 0.2.8
Default state: enabled Default state: enabled
## Web Routes ## Web Routes
- /plugins/lumi_transcription - /plugins/lumi_transcription

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.10", "version": "0.3.11",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -1,5 +1,10 @@
# Lumi Transcription changelog # Lumi Transcription changelog
## 0.2.8
- Release Companion 0.2.8 with reusable click-to-capture hotkeys, independently switchable Windows media sources, and direct drag/resize editing through the actual native Lumi Overlay renderer.
- Preserve paired identity, transcription settings and behavior, OBS Bridge 0.2.5, Song Overlay settings, native overlay settings, and automatic update compatibility.
## 0.2.7 ## 0.2.7
- Release Companion 0.2.7 with animated native overlay emotes, Discord GIF - Release Companion 0.2.7 with animated native overlay emotes, Discord GIF

View File

@ -1,17 +1,17 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.2.7", "version": "0.2.8",
"signed": false, "signed": false,
"release_notes": "Restores animated Twitch, BetterTTV, and Discord emotes, Discord GIF media, and Twitch badge artwork in the native Lumi Overlay while retaining the existing OBS browser renderer, transcription, Stream Testing, Song Overlay, silent reconnect, and OBS Bridge 0.2.5.", "release_notes": "Adds click-to-capture hotkeys, independent Windows media-source discovery, and direct drag/resize editing through the actual native Lumi Overlay renderer while retaining paired identity, transcription, Stream Testing, 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.7/Lumi.Companion-Setup.exe", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-Setup.exe",
"sha256": "b23686d0e8ecd0fbc89034f9dc6a37350bee17212d60f385eeaf2e4de634fab6", "sha256": "15d632160d29b29773e2ec692ef1061dc6d662a4a80a128ea1674ee97bdeef99",
"bytes": 51930364 "bytes": 51926217
}, },
"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.7/Lumi.Companion-win-x64.zip", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-win-x64.zip",
"sha256": "c36983e1f83a9f278475db31819be0acaa6af72d5d03e1a1c5d3ba62b556e81c", "sha256": "df94e2cbccb54a8f002940f25099572fb92fc3448da2ec463f85446df55fb1a3",
"bytes": 66860208, "bytes": 66863322,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_transcription", "id": "lumi_transcription",
"name": "Lumi Transcription", "name": "Lumi Transcription",
"version": "0.2.7", "version": "0.2.8",
"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",

View File

@ -2,6 +2,39 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.3.11",
"ref": "refs/tags/v0.3.11",
"released_at": "2026-07-29",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Releases Companion 0.2.8 with captured hotkeys, multi-source Windows media discovery, and an interactive native overlay editor; improves tenure presentation and adds per-user command and expression favorites. The additive command-usage table preserves all existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets.",
"plugins": {
"auto-vc": "0.1.7",
"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.1",
"lumi_transcription": "0.2.8",
"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.10", "version": "0.3.10",
"ref": "refs/tags/v0.3.10", "ref": "refs/tags/v0.3.10",

View File

@ -12,6 +12,7 @@ const checks = [
"scripts/verify-logging.js", "scripts/verify-logging.js",
"scripts/verify-placeholders.js", "scripts/verify-placeholders.js",
"scripts/verify-user-age-statistics.js", "scripts/verify-user-age-statistics.js",
"scripts/verify-user-favorites.js",
"scripts/verify-release-metadata.js", "scripts/verify-release-metadata.js",
"scripts/verify-update-system.js", "scripts/verify-update-system.js",
"scripts/verify-local-development-updates.js", "scripts/verify-local-development-updates.js",

View File

@ -4,14 +4,14 @@ 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.10"; const releaseVersion = "0.3.11";
const previousStableVersion = "0.3.9"; const previousStableVersion = "0.3.10";
const priorStableVersion = "0.3.8"; const priorStableVersion = "0.3.9";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = { const introducedPlugins = {
"auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" }, "auto-vc": { version: "0.1.7", knowledge: "auto-vc", compatibleFrom: "0.1.5" },
lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" }, lumi_overlay: { version: "0.1.1", knowledge: "lumi-overlay", compatibleFrom: "0.1.0" },
lumi_transcription: { version: "0.2.7", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" }, lumi_transcription: { version: "0.2.8", knowledge: "lumi-transcription", compatibleFrom: "0.1.0" },
now_playing: { version: "0.1.3", knowledge: "now-playing", compatibleFrom: "0.1.0" } now_playing: { version: "0.1.3", knowledge: "now-playing", compatibleFrom: "0.1.0" }
}; };
@ -84,4 +84,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.10 after 0.3.9 with synchronized Companion and plugin metadata."); console.log("Release metadata verification passed: stable core 0.3.11 after 0.3.10 with synchronized Companion and plugin metadata.");

View File

@ -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.10", "0.3.9", "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.deepEqual(releaseVersions, ["0.3.11", "0.3.10", "0.3.9", "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.10"); assert.equal(packageVersion, "0.3.11");
assert.equal(currentRelease.version, "0.3.10"); assert.equal(currentRelease.version, "0.3.11");
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.11", ref: "refs/tags/v0.3.11", rollback_safe: true },
{ version: "0.3.10", ref: "refs/tags/v0.3.10", rollback_safe: true }, { version: "0.3.10", ref: "refs/tags/v0.3.10", rollback_safe: true },
{ version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true }, { version: "0.3.9", ref: "refs/tags/v0.3.9", rollback_safe: true },
{ version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true }, { version: "0.3.8", ref: "refs/tags/v0.3.8", rollback_safe: true },
@ -159,7 +160,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.10"); assert.equal(corrected.safe_target_version, "0.3.11");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -67,7 +67,7 @@ assert.strictEqual(durations.current, 3 * day, "current should include only the
assert.strictEqual(durations.total, 15 * day, "total should combine completed and active intervals"); assert.strictEqual(durations.total, 15 * day, "total should combine completed and active intervals");
const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 }); const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
assert.strictEqual(leaders[0].username, "ViewerOne", "leaderboards should resolve linked Lumi profiles"); assert.strictEqual(leaders[0].username, "ViewerOne", "leaderboards should resolve linked Lumi profiles");
assert.ok(leaders.some((entry) => entry.label.includes("channel-1")), "leaderboards should keep channel scopes visible and separate"); assert.ok(leaders.every((entry) => !entry.label.includes("channel-1")), "leaderboards must not expose channel identifiers beside usernames");
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-2" }); service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-2" });
durations = service.getDurations(base); durations = service.getDurations(base);
@ -76,6 +76,17 @@ assert.strictEqual(durations.current, null, "inactive state should have no curre
service.setState({ ...base, scopeId: "channel-2", active: true, occurredAt: now, eventId: "channel-2-follow" }); service.setState({ ...base, scopeId: "channel-2", active: true, occurredAt: now, eventId: "channel-2-follow" });
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-1" }).current, null, "channels must remain isolated"); assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-1" }).current, null, "channels must remain isolated");
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-2" }).current, 0, "second channel should have its own interval"); assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-2" }).current, 0, "second channel should have its own interval");
const profileStatistics = service.getProfileStatistics("profile-1");
assert.ok(profileStatistics.every((entry) => !entry.label.includes("channel-1") && !entry.label.includes("channel-2")),
"profile statistics must use friendly channel labels without exposing IDs");
assert.ok(profileStatistics.some((entry) => entry.label.includes("Channel 1")) &&
profileStatistics.some((entry) => entry.label.includes("Channel 2")),
"multiple isolated scopes must remain distinguishable without raw IDs");
const multiScopeLeaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
assert.strictEqual(multiScopeLeaders.filter((entry) => entry.username === "ViewerOne").length, 1,
"leaderboards must collapse hidden channel scopes into one user row");
assert.strictEqual(multiScopeLeaders[0].numericValue, 15 * day,
"leaderboards must rank a user's longest scoped tenure without double-counting concurrent channels");
service.setState({ service.setState({
platform: "discord", platform: "discord",

View File

@ -0,0 +1,32 @@
const assert = require("assert");
const Database = require("better-sqlite3");
const { getFavoriteCommand, getFavoriteExpression } = require("../src/services/user-favorites");
const db = new Database(":memory:");
db.exec(`
CREATE TABLE command_user_usage (
command_id TEXT NOT NULL,
user_id TEXT NOT NULL,
count INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (command_id, user_id)
);
CREATE TABLE expression_user_stats (
action TEXT NOT NULL,
user_id TEXT NOT NULL,
given_count INTEGER NOT NULL,
received_count INTEGER NOT NULL,
PRIMARY KEY (action, user_id)
);
`);
db.prepare("INSERT INTO command_user_usage VALUES (?, ?, ?, ?)").run("custom:hello", "user-1", 4, 10);
db.prepare("INSERT INTO command_user_usage VALUES (?, ?, ?, ?)").run("music", "user-1", 7, 20);
db.prepare("INSERT INTO expression_user_stats VALUES (?, ?, ?, ?)").run("hug", "user-1", 3, 6);
db.prepare("INSERT INTO expression_user_stats VALUES (?, ?, ?, ?)").run("wave", "user-1", 4, 1);
assert.deepStrictEqual(getFavoriteCommand(db, "user-1", "!"), { command: "!music", count: 7 });
assert.deepStrictEqual(getFavoriteExpression(db, "user-1"), { action: "hug", count: 9 });
assert.strictEqual(getFavoriteCommand(db, "missing", "!"), null);
assert.strictEqual(getFavoriteExpression(db, "missing"), null);
db.close();
console.log("User favorites verification passed.");

View File

@ -153,7 +153,7 @@ function createCommandRouter({ settings }) {
if (typeof result === "string" && result) { if (typeof result === "string" && result) {
await safeReply(reply, result); await safeReply(reply, result);
await lease.commit(); await lease.commit();
recordCommandUsage(handler.commandId); recordCommandUsage(handler.commandId, user.id);
incrementCommands(user.id); incrementCommands(user.id);
commandLog.debug("Command completed", { commandLog.debug("Command completed", {
command_id: handler.commandId, command_id: handler.commandId,
@ -165,7 +165,7 @@ function createCommandRouter({ settings }) {
} }
if (result === true) { if (result === true) {
await lease.commit(); await lease.commit();
recordCommandUsage(handler.commandId); recordCommandUsage(handler.commandId, user.id);
incrementCommands(user.id); incrementCommands(user.id);
commandLog.debug("Command completed", { commandLog.debug("Command completed", {
command_id: handler.commandId, command_id: handler.commandId,
@ -289,7 +289,7 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
} else { } else {
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform)); await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
} }
recordCommandUsage(`custom:${trigger}`); recordCommandUsage(`custom:${trigger}`, ctx.user.id);
await lease.commit(); await lease.commit();
return true; return true;
} catch (error) { } catch (error) {
@ -356,7 +356,7 @@ async function safeReply(reply, content) {
} }
} }
function recordCommandUsage(commandId) { function recordCommandUsage(commandId, userId = null) {
if (!commandId) { if (!commandId) {
return; return;
} }
@ -365,6 +365,12 @@ function recordCommandUsage(commandId) {
"INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, 1, ?) " + "INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, 1, ?) " +
"ON CONFLICT(command_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at" "ON CONFLICT(command_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at"
).run(commandId, now); ).run(commandId, now);
if (userId) {
db.prepare(
"INSERT INTO command_user_usage (command_id, user_id, count, updated_at) VALUES (?, ?, 1, ?) " +
"ON CONFLICT(command_id, user_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at"
).run(commandId, userId, now);
}
} }
module.exports = { module.exports = {

View File

@ -182,6 +182,17 @@ function migrate() {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS command_user_usage (
command_id TEXT NOT NULL,
user_id TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (command_id, user_id),
FOREIGN KEY (user_id) REFERENCES user_profiles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_command_user_usage_user
ON command_user_usage(user_id, count DESC, updated_at DESC);
CREATE TABLE IF NOT EXISTS command_groups ( CREATE TABLE IF NOT EXISTS command_groups (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE, name TEXT NOT NULL UNIQUE COLLATE NOCASE,

View File

@ -175,7 +175,9 @@ function getTopBoards({ limit = 10 } = {}) {
} }
function getLeaderboardSections({ limit = 10 } = {}) { function getLeaderboardSections({ limit = 10 } = {}) {
const boards = getTopBoards({ limit }); const boards = getTopBoards({ limit }).filter(
(board) => board.section !== "Platform tenure" || (board.rows && board.rows.length)
);
const sections = []; const sections = [];
const sectionMap = new Map(); const sectionMap = new Map();
boards.forEach((board) => { boards.forEach((board) => {

View File

@ -299,14 +299,16 @@ class UserAgeStatistics {
scoped.push(row); scoped.push(row);
byScope.set(row.scope_id, scoped); byScope.set(row.scope_id, scoped);
} }
for (const [scopeId, scopedRows] of byScope.entries()) { const scopeEntries = [...byScope.entries()].sort(([left], [right]) => left.localeCompare(right));
for (const [scopeIndex, [scopeId, scopedRows]] of scopeEntries.entries()) {
const total = scopedRows.reduce((sum, row) => sum + Math.max(0, (row.end_at ?? now) - row.start_at), 0); const total = scopedRows.reduce((sum, row) => sum + Math.max(0, (row.end_at ?? now) - row.start_at), 0);
const open = scopedRows.find((row) => row.end_at === null); const open = scopedRows.find((row) => row.end_at === null);
const scopeLabel = definition.platform === "discord" ? "Server" : "Channel";
output.push({ output.push({
platform: definition.platform, platform: definition.platform,
type: definition.type, type: definition.type,
scopeId, scopeId,
label: `${capitalize(definition.platform)} ${definition.label} · ${scopeId}`, label: `${capitalize(definition.platform)} ${definition.label}${scopeEntries.length > 1 ? ` · ${scopeLabel} ${scopeIndex + 1}` : ""}`,
current: open ? formatDuration(now - open.start_at) : "", current: open ? formatDuration(now - open.start_at) : "",
total: formatDuration(total), total: formatDuration(total),
value: formatDuration(total) value: formatDuration(total)
@ -326,19 +328,24 @@ class UserAgeStatistics {
} }
params.push(Math.max(1, Math.min(Number(limit) || 25, 100))); params.push(Math.max(1, Math.min(Number(limit) || 25, 100)));
return this.db.prepare( return this.db.prepare(
`SELECT p.internal_username AS username, i.scope_id, `WITH scoped_tenure AS (
SUM(MAX(0, COALESCE(i.end_at, ?) - i.start_at)) AS duration_ms SELECT p.id AS profile_id, p.internal_username AS username, i.scope_id,
FROM user_age_intervals i SUM(MAX(0, COALESCE(i.end_at, ?) - i.start_at)) AS duration_ms
JOIN user_identities identity FROM user_age_intervals i
ON identity.provider = i.platform AND identity.provider_user_id = i.platform_user_id JOIN user_identities identity
JOIN user_profiles p ON p.id = identity.user_id ON identity.provider = i.platform AND identity.provider_user_id = i.platform_user_id
WHERE ${conditions.join(" AND ")} JOIN user_profiles p ON p.id = identity.user_id
GROUP BY p.id, p.internal_username, i.scope_id WHERE ${conditions.join(" AND ")}
ORDER BY duration_ms DESC, p.internal_username ASC GROUP BY p.id, p.internal_username, i.scope_id
)
SELECT username, MAX(duration_ms) AS duration_ms
FROM scoped_tenure
GROUP BY profile_id, username
ORDER BY duration_ms DESC, username ASC
LIMIT ?` LIMIT ?`
).all(...params).map((row) => ({ ).all(...params).map((row) => ({
username: row.username, username: row.username,
label: `${row.username} · ${row.scope_id}`, label: row.username,
value: formatDuration(row.duration_ms, "D-H-m"), value: formatDuration(row.duration_ms, "D-H-m"),
numericValue: row.duration_ms numericValue: row.duration_ms
})); }));

View File

@ -0,0 +1,33 @@
function tableExists(db, name) {
return Boolean(db.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
).get(name));
}
function getFavoriteCommand(db, userId, prefix = "!") {
if (!tableExists(db, "command_user_usage")) return null;
const row = db.prepare(
`SELECT command_id, count FROM command_user_usage
WHERE user_id = ? ORDER BY count DESC, updated_at DESC, command_id ASC LIMIT 1`
).get(userId);
if (!row) return null;
return {
command: `${prefix}${String(row.command_id || "").replace(/^custom:/, "")}`,
count: Number(row.count || 0)
};
}
function getFavoriteExpression(db, userId) {
if (!tableExists(db, "expression_user_stats")) return null;
const row = db.prepare(
`SELECT action, given_count + received_count AS count
FROM expression_user_stats WHERE user_id = ?
ORDER BY count DESC, action ASC LIMIT 1`
).get(userId);
return row ? { action: row.action, count: Number(row.count || 0) } : null;
}
module.exports = {
getFavoriteCommand,
getFavoriteExpression
};

View File

@ -1784,11 +1784,35 @@ body.stats-compare-mode .stats-compare {
} }
.stat-value { .stat-value {
display: block;
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
font-family: "Space Grotesk", sans-serif; font-family: "Space Grotesk", sans-serif;
} }
.stat-period-label {
display: block;
margin-top: 10px;
color: var(--ink-soft);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.stat-period-total-label {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.stat-period-total {
display: block;
margin-top: 2px;
color: var(--ink-soft);
font-weight: 650;
}
.hint { .hint {
color: var(--ink-soft); color: var(--ink-soft);
font-size: 0.95rem; font-size: 0.95rem;

View File

@ -81,6 +81,10 @@ const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks"); const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing"); const { streamTestingService } = require("../services/stream-testing");
const { userAgeStatistics } = require("../services/user-age-statistics"); const { userAgeStatistics } = require("../services/user-age-statistics");
const {
getFavoriteCommand,
getFavoriteExpression
} = require("../services/user-favorites");
const { const {
getReverseProxyIngestSettings, getReverseProxyIngestSettings,
saveReverseProxyIngestSettings saveReverseProxyIngestSettings
@ -375,12 +379,13 @@ function getExpressionUserSummary(userId) {
}, },
{ given: 0, received: 0 } { given: 0, received: 0 }
); );
return { totals }; const favorite = getFavoriteExpression(db, userId);
return { totals, favorite };
} }
function buildUserStatsPayload(userId) { function buildUserStatsPayload(userId) {
if (!userId) { if (!userId) {
return { stats: null, expression: null, tenureStats: [], pluginStats: [] }; return { stats: null, expression: null, favoriteCommand: null, tenureStats: [], pluginStats: [] };
} }
const stats = db const stats = db
.prepare("SELECT * FROM stats WHERE user_id = ?") .prepare("SELECT * FROM stats WHERE user_id = ?")
@ -388,6 +393,7 @@ function buildUserStatsPayload(userId) {
return { return {
stats, stats,
expression: getExpressionUserSummary(userId), expression: getExpressionUserSummary(userId),
favoriteCommand: getFavoriteCommand(db, userId, getSetting("command_prefix", "!")),
tenureStats: userAgeStatistics.getProfileStatistics(userId), tenureStats: userAgeStatistics.getProfileStatistics(userId),
pluginStats: getPluginProfileStats(userId) pluginStats: getPluginProfileStats(userId)
}; };
@ -430,8 +436,14 @@ function buildCompareRows(leftStats, rightStats) {
if (leftStats.tenureStats?.length || rightStats.tenureStats?.length) { if (leftStats.tenureStats?.length || rightStats.tenureStats?.length) {
pushSection( pushSection(
"Platform tenure", "Platform tenure",
(leftStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total })), (leftStats.tenureStats || []).flatMap((entry) => [
(rightStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total })) { label: `${entry.label} · Current period`, value: entry.current || "Not currently active" },
{ label: `${entry.label} · Recorded total`, value: entry.total }
]),
(rightStats.tenureStats || []).flatMap((entry) => [
{ label: `${entry.label} · Current period`, value: entry.current || "Not currently active" },
{ label: `${entry.label} · Recorded total`, value: entry.total }
])
); );
} }
@ -5290,6 +5302,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: "Your stats", title: "Your stats",
stats: payload.stats, stats: payload.stats,
expression: payload.expression, expression: payload.expression,
favoriteCommand: payload.favoriteCommand,
tenureStats: payload.tenureStats, tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats, pluginStats: payload.pluginStats,
statsOwner: { statsOwner: {
@ -5335,6 +5348,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
title: `${profile.internal_username}'s stats`, title: `${profile.internal_username}'s stats`,
stats: payload.stats, stats: payload.stats,
expression: payload.expression, expression: payload.expression,
favoriteCommand: payload.favoriteCommand,
tenureStats: payload.tenureStats, tenureStats: payload.tenureStats,
pluginStats: payload.pluginStats, pluginStats: payload.pluginStats,
statsOwner: { statsOwner: {

View File

@ -35,6 +35,28 @@
<% } %> <% } %>
</section> </section>
<% if (favoriteCommand || (expression && expression.favorite)) { %>
<section class="card">
<h2>Community favorites</h2>
<div class="stat-grid">
<% if (favoriteCommand) { %>
<div class="stat">
<span class="stat-label">Favorite command</span>
<span class="stat-value"><code><%= favoriteCommand.command %></code></span>
<span class="help"><%= favoriteCommand.count %> recorded use<%= favoriteCommand.count === 1 ? "" : "s" %></span>
</div>
<% } %>
<% if (expression && expression.favorite) { %>
<div class="stat">
<span class="stat-label">Favorite expression</span>
<span class="stat-value"><%= expression.favorite.action %></span>
<span class="help"><%= expression.favorite.count %> given or received interaction<%= expression.favorite.count === 1 ? "" : "s" %></span>
</div>
<% } %>
</div>
</section>
<% } %>
<section class="card"> <section class="card">
<h2>Platform tenure</h2> <h2>Platform tenure</h2>
<% if (!tenureStats || !tenureStats.length) { %> <% if (!tenureStats || !tenureStats.length) { %>
@ -44,10 +66,10 @@
<% tenureStats.forEach((stat) => { %> <% tenureStats.forEach((stat) => { %>
<div class="stat"> <div class="stat">
<span class="stat-label"><%= stat.label %></span> <span class="stat-label"><%= stat.label %></span>
<span class="stat-value"><%= stat.total %></span> <span class="stat-period-label">Current period</span>
<% if (stat.current) { %> <span class="stat-value"><%= stat.current || "Not currently active" %></span>
<span class="help">Current period: <%= stat.current %></span> <span class="stat-period-label stat-period-total-label">Recorded total</span>
<% } %> <span class="stat-period-total"><%= stat.total %></span>
</div> </div>
<% }) %> <% }) %>
</div> </div>

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.3.10", "version": "0.3.11",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-27", "released_at": "2026-07-29",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "patch", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets remain preserved.", "migration_notes": "Releases Companion 0.2.8 with captured hotkeys, multi-source Windows media discovery, and an interactive native overlay editor; improves tenure presentation and adds per-user command and expression favorites. The additive command-usage table preserves all existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -469,6 +469,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved." "migration_notes": "Restores animated emotes, Twitch badge artwork, and Discord GIF media through the existing OBS and native Companion overlay paths; adds persistent Auto VC welcome controls and timed ownership claiming; and releases Companion 0.2.7. Existing settings, lobbies, rooms, owners, permissions, pairing identities, plugin data, OBS configuration, databases, uploads, models, and secrets remain preserved."
},
{
"version": "0.3.10",
"channel": "stable",
"released_at": "2026-07-27",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds reusable platform-tenure interval history for Twitch, YouTube, and Discord; contextual current and total duration placeholders with deterministic formatting; safe platform reconciliation; admin diagnostics; and Platform tenure sections on Stats and Leaderboards. Existing settings, identities, statistics, intervals, plugins, pairing records, databases, uploads, models, and secrets remain preserved."
} }
] ]
} }