release: publish Lumi 0.3.11 companion experience
This commit is contained in:
parent
0fb0089e1c
commit
5f05251155
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,4 +34,5 @@ twitch-credentials-lumi.png
|
||||
DEVNOTES.md
|
||||
.QWEN.md
|
||||
_*_changes.md
|
||||
lumi_current.zip
|
||||
Lumi-current.zip
|
||||
agent-task*.md
|
||||
@ -1,5 +1,12 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
- Companion plugins inherit the shell's paired-device authentication. Plugins do
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@ -13,8 +13,8 @@ Plugin actions are caught at the shell boundary so a routine plugin exception do
|
||||
|
||||
## Song Overlay flow
|
||||
|
||||
1. Windows Global System Media Transport Controls exposes Spotify playback state.
|
||||
2. The provider raises media, playback, timeline, and session-availability events.
|
||||
1. Windows Global System Media Transport Controls exposes playback from enabled desktop and browser sources.
|
||||
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.
|
||||
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.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.2.7"
|
||||
#define AppVersion "0.2.8"
|
||||
#endif
|
||||
#ifndef SourceRoot
|
||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||
|
||||
@ -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;
|
||||
key = 0;
|
||||
@ -69,11 +69,21 @@ public sealed class GlobalHotkeyManager : IDisposable
|
||||
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0008;
|
||||
else if (part.Length == 1 && char.IsAsciiLetterOrDigit(part[0])) key = char.ToUpperInvariant(part[0]);
|
||||
else if (part.StartsWith('F') && int.TryParse(part[1..], out var function) && function is >= 1 and <= 24) key = (uint)(0x70 + function - 1);
|
||||
else if (VirtualKeys.TryGetValue(part, out var virtualKey)) key = virtualKey;
|
||||
else return false;
|
||||
}
|
||||
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()
|
||||
{
|
||||
Clear();
|
||||
|
||||
@ -30,6 +30,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
_transport = transport;
|
||||
_window = new NativeOverlayWindow();
|
||||
_window.NativeStatusChanged += () => RaiseChanged();
|
||||
_window.EditSettingsChanged += () => RaiseChanged();
|
||||
_hotkeys = new GlobalHotkeyManager();
|
||||
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);
|
||||
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
|
||||
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||
@ -51,6 +52,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
public string? MonitorWarning => _window.MonitorWarning;
|
||||
public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors();
|
||||
public bool PreviewActive => _preview;
|
||||
public bool EditModeActive { get; private set; }
|
||||
public bool VisibilityOverride => _visibilityOverride.HasValue;
|
||||
public bool IsInitialized => _initialized;
|
||||
public string HotkeyStatus => _hotkeyStatus;
|
||||
@ -113,6 +115,23 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
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)
|
||||
{
|
||||
_channel = channel;
|
||||
@ -197,7 +216,7 @@ public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
|
||||
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);
|
||||
|
||||
@ -5,6 +5,7 @@ using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
@ -37,6 +38,9 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
||||
private readonly StackPanel _unifiedPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _chatPanel = 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 DispatcherTimer _timer;
|
||||
private readonly HttpsImageCache _images = new();
|
||||
@ -46,6 +50,12 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
||||
private bool _disposed;
|
||||
private bool _visibleRequested;
|
||||
private string? _temporaryMonitorWarning;
|
||||
private bool _editing;
|
||||
private Border? _dragEditor;
|
||||
private OverlayContainerSettings? _dragSettings;
|
||||
private Point _dragOrigin;
|
||||
private PhysicalRect _dragRect;
|
||||
private bool _resizing;
|
||||
|
||||
public NativeOverlayWindow()
|
||||
{
|
||||
@ -65,6 +75,12 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
||||
_surface.Children.Add(_unifiedHolder);
|
||||
_surface.Children.Add(_chatHolder);
|
||||
_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"));
|
||||
_events = new(() => ContainerFor("event"));
|
||||
_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 string? MonitorWarning => _temporaryMonitorWarning;
|
||||
public event Action? NativeStatusChanged;
|
||||
public event Action? EditSettingsChanged;
|
||||
|
||||
public void ApplySettings(LumiOverlaySettings settings)
|
||||
{
|
||||
@ -108,6 +125,35 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
||||
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 AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value);
|
||||
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();
|
||||
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,
|
||||
@ -471,7 +629,9 @@ public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
||||
Height = screen.Bounds.Height / screen.Scaling;
|
||||
var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate;
|
||||
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExToolWindow | WsExLayered;
|
||||
if (_editing) style &= ~(WsExTransparent | WsExNoActivate);
|
||||
else style |= WsExTransparent | WsExNoActivate;
|
||||
SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style));
|
||||
SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged);
|
||||
if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "lumi_overlay",
|
||||
"name": "Lumi Overlay",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"provider_api": 1,
|
||||
"capabilities": [
|
||||
"network.lumi.overlay.read",
|
||||
|
||||
@ -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";
|
||||
}
|
||||
@ -7,7 +7,7 @@ using Windows.Storage.Streams;
|
||||
|
||||
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 MaxCoverOutputBytes = 500 * 1024;
|
||||
@ -18,18 +18,19 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||
private GlobalSystemMediaTransportControlsSession? _session;
|
||||
private MediaSnapshot? _last;
|
||||
private MediaTrack? _trackCache;
|
||||
private string _sourceId = "windows-media";
|
||||
private bool _started;
|
||||
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;
|
||||
_enrich = enrich;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public string Id => "spotify";
|
||||
public string DisplayName => "Spotify";
|
||||
public string Id => "windows-media";
|
||||
public string DisplayName => "Windows media discovery";
|
||||
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
|
||||
public event EventHandler<string>? AvailabilityChanged;
|
||||
|
||||
@ -62,22 +63,22 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var selected = _manager?.GetSessions().FirstOrDefault(IsSpotifySession);
|
||||
var selected = await SelectEnabledSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
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;
|
||||
}
|
||||
DetachSession();
|
||||
_session = selected;
|
||||
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)
|
||||
{
|
||||
_last = null;
|
||||
@ -88,7 +89,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||
_session.MediaPropertiesChanged += OnMediaPropertiesChanged;
|
||||
_session.PlaybackInfoChanged += OnPlaybackInfoChanged;
|
||||
_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);
|
||||
}
|
||||
|
||||
@ -104,20 +105,20 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||
|
||||
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
|
||||
{
|
||||
try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception error) { _log("spotify_media_property_update_failed", "Spotify media-property update failed", error); }
|
||||
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception error) { _log("media_property_update_failed", "Windows media-property update failed", error); }
|
||||
}
|
||||
|
||||
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@ -162,16 +163,18 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||
var title = Clean(media.Title);
|
||||
var artist = Clean(media.Artist);
|
||||
var album = Clean(media.AlbumTitle);
|
||||
_sourceId = MediaSourceCatalog.DetectEnabled(
|
||||
_session.SourceAppUserModelId, title, artist, album, _settings().EnabledSources) ?? _sourceId;
|
||||
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
var key = Fingerprint(title, artist, album, duration);
|
||||
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)))
|
||||
: "";
|
||||
var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover);
|
||||
_trackCache = track;
|
||||
if (enrichTrack) _ = EnrichAndPublishAsync(track);
|
||||
if (enrichTrack && _sourceId == "spotify") _ = EnrichAndPublishAsync(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -183,7 +186,7 @@ internal sealed class SpotifyWindowsMediaProvider : IMediaProvider
|
||||
_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;
|
||||
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); }
|
||||
}
|
||||
|
||||
private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session)
|
||||
private async Task<GlobalSystemMediaTransportControlsSession?> SelectEnabledSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var source = session.SourceAppUserModelId ?? "";
|
||||
return source.Contains("spotify", StringComparison.OrdinalIgnoreCase);
|
||||
if (_manager is null) return null;
|
||||
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)
|
||||
@ -45,7 +45,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
public CompanionPluginDescriptor Descriptor { get; } = new(
|
||||
PluginId,
|
||||
"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.",
|
||||
200);
|
||||
|
||||
@ -68,7 +68,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
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
|
||||
// and connection-key fields are removed after the shared transport upgrade.
|
||||
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)
|
||||
{
|
||||
Settings.HeartbeatSeconds = Math.Clamp(Settings.HeartbeatSeconds, 15, 300);
|
||||
Settings.SeekThresholdMilliseconds = Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000);
|
||||
Settings.ProviderId = string.IsNullOrWhiteSpace(Settings.ProviderId) ? "spotify" : Settings.ProviderId.Trim().ToLowerInvariant();
|
||||
Settings.Normalize();
|
||||
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
||||
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
|
||||
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 (_provider is null) throw new InvalidOperationException("No media provider is running.");
|
||||
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);
|
||||
}
|
||||
|
||||
@ -130,11 +128,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
_lastObserved = null;
|
||||
_lastDelivered = null;
|
||||
_lifetime = new CancellationTokenSource();
|
||||
_provider = Settings.ProviderId switch
|
||||
{
|
||||
"spotify" => new SpotifyWindowsMediaProvider(() => Settings, EnrichTrackAsync, Log),
|
||||
_ => throw new InvalidOperationException($"Provider '{Settings.ProviderId}' is not installed.")
|
||||
};
|
||||
_provider = new WindowsMediaProvider(() => Settings, EnrichTrackAsync, Log);
|
||||
_provider.StateChanged += OnProviderStateChanged;
|
||||
_provider.AvailabilityChanged += OnAvailabilityChanged;
|
||||
try
|
||||
@ -143,15 +137,15 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
StartHeartbeat();
|
||||
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
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)
|
||||
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
|
||||
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -204,9 +198,10 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
|
||||
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,
|
||||
waiting ? "Waiting for Spotify" : "Monitoring", message);
|
||||
waiting ? "Waiting for media" : "Monitoring", message);
|
||||
}
|
||||
|
||||
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
|
||||
@ -318,7 +313,7 @@ public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDis
|
||||
if (enriched is null) return track;
|
||||
CoverPayload? cover = track.Cover;
|
||||
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
|
||||
{
|
||||
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,
|
||||
|
||||
@ -4,10 +4,22 @@ public sealed class SongOverlaySettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string ProviderId { get; set; } = "spotify";
|
||||
public HashSet<string> EnabledSources { get; set; } = MediaSourceCatalog.Defaults();
|
||||
public int HeartbeatSeconds { get; set; } = 30;
|
||||
public int SeekThresholdMilliseconds { get; set; } = 1500;
|
||||
public bool SendCoverArt { get; set; } = true;
|
||||
public bool UseSearchLinkFallback { get; set; } = true;
|
||||
public string SpotifyClientId { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"name": "Song Overlay",
|
||||
"version": "0.1.2",
|
||||
"provider_api": 1,
|
||||
"providers": ["spotify"],
|
||||
"providers": ["windows-media", "spotify", "wmp", "youtube", "vlc", "apple-music", "soundcloud", "tidal", "bandcamp", "qobuz"],
|
||||
"capabilities": [
|
||||
"windows.media-session.read",
|
||||
"network.lumi",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
param(
|
||||
[string]$Version = "0.2.7",
|
||||
[string]$Version = "0.2.8",
|
||||
[string]$BridgeVersion = "0.2.5",
|
||||
[string]$ObsVersion = "31.1.1"
|
||||
)
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$sdk = Join-Path $env:LOCALAPPDATA "Lumi\dotnet-sdk-10\dotnet.exe"
|
||||
$dotnet = if (Test-Path $sdk) { $sdk } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
|
||||
$node = (Get-Command node.exe -ErrorAction Stop).Source
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
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...'
|
||||
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
|
||||
}
|
||||
|
||||
134
companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs
Normal file
134
companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
@ -6,8 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||
<Version>0.2.7</Version>
|
||||
<AssemblyVersion>0.2.7.0</AssemblyVersion>
|
||||
<Version>0.2.8</Version>
|
||||
<AssemblyVersion>0.2.8.0</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||
|
||||
@ -252,7 +252,7 @@
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="MEDIA" Classes="eyebrow" />
|
||||
<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>
|
||||
|
||||
<Border Classes="soft">
|
||||
@ -277,16 +277,16 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Playback provider" Classes="sectionTitle" />
|
||||
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12">
|
||||
<TextBlock Text="Provider" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1" x:Name="SongOverlayProviderPicker" SelectedIndex="0" IsEnabled="False">
|
||||
<ComboBoxItem Content="Spotify" Tag="spotify" />
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Row="1" 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" />
|
||||
<TextBlock Grid.Row="2" 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" />
|
||||
<TextBlock Text="Playback sources" Classes="sectionTitle" />
|
||||
<TextBlock Text="Only enabled sources are observed. Browser sessions without an exposed service name are treated as YouTube." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
<Border Classes="soft">
|
||||
<StackPanel x:Name="SongOverlaySourcesPanel" Spacing="9" />
|
||||
</Border>
|
||||
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="12">
|
||||
<TextBlock Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" />
|
||||
<TextBlock Grid.Row="1" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
|
||||
</Grid>
|
||||
<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" />
|
||||
@ -383,6 +383,18 @@
|
||||
</Grid>
|
||||
|
||||
<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 & lifecycle">
|
||||
<Border Classes="card" Margin="0,10,0,0">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="Container styling & lifecycle" Classes="sectionTitle" />
|
||||
<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" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Global hotkeys" Classes="sectionTitle" />
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
|
||||
<TextBlock Text="Visibility override" Classes="muted" />
|
||||
<TextBox Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" PlaceholderText="Ctrl+Alt+L" />
|
||||
<Button Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" Classes="secondary" />
|
||||
<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>
|
||||
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
@ -26,8 +26,12 @@ public partial class MainWindow : Window
|
||||
private bool _renderingSettings;
|
||||
private readonly Dictionary<string, CheckBox> _overlaySourceChecks = [];
|
||||
private readonly Dictionary<string, CheckBox> _overlayEventChecks = [];
|
||||
private readonly Dictionary<string, CheckBox> _songOverlaySourceChecks = [];
|
||||
private bool _songOverlaySourcesDirty;
|
||||
private string _editingOverlayContainer = "unified";
|
||||
private LumiOverlaySettings? _lumiOverlayDraft;
|
||||
private readonly HotkeyCaptureBinding _visibilityHotkeyCapture;
|
||||
private readonly HotkeyCaptureBinding _previewHotkeyCapture;
|
||||
|
||||
public MainWindow() : this(CreateDefaultServices()) { }
|
||||
|
||||
@ -42,6 +46,8 @@ public partial class MainWindow : Window
|
||||
_lumiOverlay = lumiOverlay;
|
||||
_plugins = plugins;
|
||||
InitializeComponent();
|
||||
_visibilityHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayVisibilityHotkeyBox);
|
||||
_previewHotkeyCapture = new HotkeyCaptureBinding(LumiOverlayPreviewHotkeyBox);
|
||||
BuildPluginNavigation();
|
||||
WireActions();
|
||||
RenderState(runtime.State);
|
||||
@ -172,6 +178,7 @@ public partial class MainWindow : Window
|
||||
TranscriptionEnabledToggle.IsCheckedChanged += async (_, _) => await SaveTranscriptionEnabledAsync();
|
||||
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
||||
SaveLumiOverlayButton.Click += async (_, _) => await SaveLumiOverlayAsync();
|
||||
LumiOverlayEditButton.Click += (_, _) => ToggleLumiOverlayEditMode();
|
||||
LumiOverlayPreviewButton.Click += (_, _) => { _lumiOverlay.TogglePreview(); RenderLumiOverlay(); };
|
||||
LumiOverlayVisibilityButton.Click += (_, _) => { _lumiOverlay.ToggleVisibilityOverride(); RenderLumiOverlay(); };
|
||||
LumiOverlayContainerPicker.SelectionChanged += (_, _) =>
|
||||
@ -213,6 +220,11 @@ public partial class MainWindow : Window
|
||||
|
||||
public void ShowPage(CompanionPage page)
|
||||
{
|
||||
if (page != CompanionPage.LumiOverlay && _lumiOverlay.EditModeActive)
|
||||
{
|
||||
_lumiOverlay.EndEdit();
|
||||
RenderLumiOverlay(preserveDraft: false);
|
||||
}
|
||||
var pages = new Dictionary<CompanionPage, Control>
|
||||
{
|
||||
[CompanionPage.Overview] = OverviewPage,
|
||||
@ -294,6 +306,10 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var settings = _songOverlay.Settings;
|
||||
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.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
|
||||
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
|
||||
@ -301,6 +317,7 @@ public partial class MainWindow : Window
|
||||
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
|
||||
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
|
||||
await _songOverlay.SaveSettingsAsync(restartProvider);
|
||||
_songOverlaySourcesDirty = false;
|
||||
SongOverlayFeedback.Text = "Song Overlay settings saved.";
|
||||
}
|
||||
catch (Exception error)
|
||||
@ -586,7 +603,7 @@ public partial class MainWindow : Window
|
||||
if (!_lumiOverlay.IsInitialized) return;
|
||||
if (_lumiOverlayDraft is null)
|
||||
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
||||
else if (preserveDraft && !_renderingLumiOverlay)
|
||||
else if (preserveDraft && !_renderingLumiOverlay && !_lumiOverlay.EditModeActive)
|
||||
CaptureLumiOverlayForm(_lumiOverlayDraft);
|
||||
_renderingLumiOverlay = true;
|
||||
try
|
||||
@ -611,9 +628,13 @@ public partial class MainWindow : Window
|
||||
LumiOverlayVisibilityState.Text = $"Current display: {visibility.State}";
|
||||
LumiOverlayVisibilityButton.Content = visibility.Action;
|
||||
LumiOverlayVisibilityButton.IsEnabled = visibility.CanToggle;
|
||||
LumiOverlayVisibilityHotkeyBox.Text = settings.ToggleVisibilityHotkey;
|
||||
LumiOverlayPreviewHotkeyBox.Text = settings.TogglePreviewHotkey;
|
||||
_visibilityHotkeyCapture.Hotkey = settings.ToggleVisibilityHotkey;
|
||||
_previewHotkeyCapture.Hotkey = settings.TogglePreviewHotkey;
|
||||
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();
|
||||
if (settings.MonitorId is { Length: > 0 } savedMonitor &&
|
||||
@ -756,8 +777,8 @@ public partial class MainWindow : Window
|
||||
settings.Layout = layout.Value;
|
||||
settings.ChatPercentage = Decimal.ToInt32(LumiOverlayChatPercentageBox.Value ?? settings.ChatPercentage);
|
||||
if (LumiOverlayMonitorPicker.SelectedItem is OverlayPickerItem monitor) settings.MonitorId = monitor.Id;
|
||||
settings.ToggleVisibilityHotkey = LumiOverlayVisibilityHotkeyBox.Text?.Trim() ?? "";
|
||||
settings.TogglePreviewHotkey = LumiOverlayPreviewHotkeyBox.Text?.Trim() ?? "";
|
||||
settings.ToggleVisibilityHotkey = _visibilityHotkeyCapture.Hotkey;
|
||||
settings.TogglePreviewHotkey = _previewHotkeyCapture.Hotkey;
|
||||
|
||||
var currentSources = _lumiOverlay.Sources.ToDictionary(source => source.Id, StringComparer.Ordinal);
|
||||
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));
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (_renderingLumiOverlay) return;
|
||||
@ -829,6 +864,11 @@ public partial class MainWindow : Window
|
||||
try
|
||||
{
|
||||
var settings = _lumiOverlayDraft ?? _lumiOverlay.Settings.CreateCopy();
|
||||
if (_lumiOverlay.EditModeActive)
|
||||
{
|
||||
_lumiOverlay.EndEdit();
|
||||
RenderLumiOverlay(preserveDraft: false);
|
||||
}
|
||||
CaptureLumiOverlayForm(settings);
|
||||
await _lumiOverlay.SaveSettingsAsync(settings);
|
||||
_lumiOverlayDraft = _lumiOverlay.Settings.CreateCopy();
|
||||
@ -865,6 +905,30 @@ public partial class MainWindow : Window
|
||||
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
|
||||
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
|
||||
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;
|
||||
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
|
||||
? "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)
|
||||
{
|
||||
if (_allowExit) return;
|
||||
if (_lumiOverlay.EditModeActive) _lumiOverlay.EndEdit();
|
||||
args.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
|
||||
@ -9,5 +9,6 @@
|
||||
<ItemGroup>
|
||||
<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.SongOverlay/Lumi.Companion.SongOverlay.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using Lumi.Companion.Abstractions;
|
||||
using Lumi.Companion.Core;
|
||||
using Lumi.Companion.Overlay;
|
||||
using Lumi.Companion.SongOverlay;
|
||||
|
||||
static void Assert(bool condition, string message)
|
||||
{
|
||||
@ -112,6 +113,17 @@ Assert(!CompanionPluginNavigation.UsesNestedNavigation(
|
||||
CompanionPluginNavigation.UsesNestedNavigation(
|
||||
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
|
||||
"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,
|
||||
"Capture-exclusion unsupported state must remain distinct from failure.");
|
||||
var visibilitySettings = new LumiOverlaySettings
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Lumi is the core web UI and bot runtime.
|
||||
## Runtime
|
||||
Package: lumi-bot
|
||||
Version: 0.3.10
|
||||
Version: 0.3.11
|
||||
## Routes
|
||||
- POST /api/diagnostics/v1/run
|
||||
- GET /api/events
|
||||
@ -93,6 +93,7 @@ Version: 0.3.10
|
||||
- POST /admin/theming/custom/:id/delete
|
||||
- POST /admin/theming
|
||||
- GET /admin/diagnostics
|
||||
- GET /admin/platform-tenure
|
||||
- GET /admin/stream-testing
|
||||
- POST /admin/stream-testing/reverse-proxy
|
||||
- POST /admin/stream-testing/reverse-proxy/check
|
||||
@ -920,6 +921,15 @@ Version: 0.3.10
|
||||
- Side effects: Usually read-only.
|
||||
- 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
|
||||
|
||||
- Purpose: Renders the admin stream testing WebUI page.
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||
## Metadata
|
||||
Plugin ID: lumi_transcription
|
||||
Version: 0.2.7
|
||||
Version: 0.2.8
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/lumi_transcription
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi-bot",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.11",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.6.0",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.11",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- Release Companion 0.2.7 with animated native overlay emotes, Discord GIF
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"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": {
|
||||
"id": "windows-x64-installer",
|
||||
"platform": "win32",
|
||||
"architecture": "x64",
|
||||
"label": "Windows x64 per-user installer",
|
||||
"filename": "Lumi.Companion-Setup.exe",
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-Setup.exe",
|
||||
"sha256": "b23686d0e8ecd0fbc89034f9dc6a37350bee17212d60f385eeaf2e4de634fab6",
|
||||
"bytes": 51930364
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-Setup.exe",
|
||||
"sha256": "15d632160d29b29773e2ec692ef1061dc6d662a4a80a128ea1674ee97bdeef99",
|
||||
"bytes": 51926217
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
@ -20,9 +20,9 @@
|
||||
"architecture": "x64",
|
||||
"label": "Windows x64 self-contained",
|
||||
"filename": "Lumi.Companion-win-x64.zip",
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.7/Lumi.Companion-win-x64.zip",
|
||||
"sha256": "c36983e1f83a9f278475db31819be0acaa6af72d5d03e1a1c5d3ba62b556e81c",
|
||||
"bytes": 66860208,
|
||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.8/Lumi.Companion-win-x64.zip",
|
||||
"sha256": "df94e2cbccb54a8f002940f25099572fb92fc3448da2ec463f85446df55fb1a3",
|
||||
"bytes": 66863322,
|
||||
"entrypoint": "Lumi.Companion.App.exe"
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "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.",
|
||||
"main": "index.js",
|
||||
"channel": "stable",
|
||||
|
||||
@ -2,6 +2,39 @@
|
||||
"schema_version": 1,
|
||||
"channel": "stable",
|
||||
"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",
|
||||
"ref": "refs/tags/v0.3.10",
|
||||
|
||||
@ -12,6 +12,7 @@ const checks = [
|
||||
"scripts/verify-logging.js",
|
||||
"scripts/verify-placeholders.js",
|
||||
"scripts/verify-user-age-statistics.js",
|
||||
"scripts/verify-user-favorites.js",
|
||||
"scripts/verify-release-metadata.js",
|
||||
"scripts/verify-update-system.js",
|
||||
"scripts/verify-local-development-updates.js",
|
||||
|
||||
@ -4,14 +4,14 @@ const path = require("path");
|
||||
const { findSafeTarget } = require("../src/services/versioning");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const releaseVersion = "0.3.10";
|
||||
const previousStableVersion = "0.3.9";
|
||||
const priorStableVersion = "0.3.8";
|
||||
const releaseVersion = "0.3.11";
|
||||
const previousStableVersion = "0.3.10";
|
||||
const priorStableVersion = "0.3.9";
|
||||
const earliestCompatibleCoreVersion = "0.1.9";
|
||||
const introducedPlugins = {
|
||||
"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_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" }
|
||||
};
|
||||
|
||||
@ -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(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.");
|
||||
|
||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
||||
|
||||
const releaseIndex = readJson("release-index.json");
|
||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||
assert.deepEqual(releaseVersions, ["0.3.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");
|
||||
for (const release of releaseIndex.releases) {
|
||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
||||
const coreManifest = readJson("update-manifest.json");
|
||||
assert.equal(packageVersion, coreManifest.version);
|
||||
assert.equal(coreManifest.channel, "stable");
|
||||
assert.equal(packageVersion, "0.3.10");
|
||||
assert.equal(currentRelease.version, "0.3.10");
|
||||
assert.equal(packageVersion, "0.3.11");
|
||||
assert.equal(currentRelease.version, "0.3.11");
|
||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
||||
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
||||
const baseTarget = {
|
||||
current_version: "0.2.4",
|
||||
available_versions: [
|
||||
{ version: "0.3.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.9", ref: "refs/tags/v0.3.9", 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"
|
||||
});
|
||||
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.blocked, false);
|
||||
|
||||
|
||||
@ -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");
|
||||
const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
|
||||
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" });
|
||||
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" });
|
||||
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");
|
||||
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({
|
||||
platform: "discord",
|
||||
|
||||
32
scripts/verify-user-favorites.js
Normal file
32
scripts/verify-user-favorites.js
Normal 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.");
|
||||
@ -153,7 +153,7 @@ function createCommandRouter({ settings }) {
|
||||
if (typeof result === "string" && result) {
|
||||
await safeReply(reply, result);
|
||||
await lease.commit();
|
||||
recordCommandUsage(handler.commandId);
|
||||
recordCommandUsage(handler.commandId, user.id);
|
||||
incrementCommands(user.id);
|
||||
commandLog.debug("Command completed", {
|
||||
command_id: handler.commandId,
|
||||
@ -165,7 +165,7 @@ function createCommandRouter({ settings }) {
|
||||
}
|
||||
if (result === true) {
|
||||
await lease.commit();
|
||||
recordCommandUsage(handler.commandId);
|
||||
recordCommandUsage(handler.commandId, user.id);
|
||||
incrementCommands(user.id);
|
||||
commandLog.debug("Command completed", {
|
||||
command_id: handler.commandId,
|
||||
@ -289,7 +289,7 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
||||
} else {
|
||||
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
|
||||
}
|
||||
recordCommandUsage(`custom:${trigger}`);
|
||||
recordCommandUsage(`custom:${trigger}`, ctx.user.id);
|
||||
await lease.commit();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -356,7 +356,7 @@ async function safeReply(reply, content) {
|
||||
}
|
||||
}
|
||||
|
||||
function recordCommandUsage(commandId) {
|
||||
function recordCommandUsage(commandId, userId = null) {
|
||||
if (!commandId) {
|
||||
return;
|
||||
}
|
||||
@ -365,6 +365,12 @@ function recordCommandUsage(commandId) {
|
||||
"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"
|
||||
).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 = {
|
||||
|
||||
@ -182,6 +182,17 @@ function migrate() {
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
|
||||
@ -175,7 +175,9 @@ function getTopBoards({ 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 sectionMap = new Map();
|
||||
boards.forEach((board) => {
|
||||
|
||||
@ -299,14 +299,16 @@ class UserAgeStatistics {
|
||||
scoped.push(row);
|
||||
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 open = scopedRows.find((row) => row.end_at === null);
|
||||
const scopeLabel = definition.platform === "discord" ? "Server" : "Channel";
|
||||
output.push({
|
||||
platform: definition.platform,
|
||||
type: definition.type,
|
||||
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) : "",
|
||||
total: formatDuration(total),
|
||||
value: formatDuration(total)
|
||||
@ -326,19 +328,24 @@ class UserAgeStatistics {
|
||||
}
|
||||
params.push(Math.max(1, Math.min(Number(limit) || 25, 100)));
|
||||
return this.db.prepare(
|
||||
`SELECT p.internal_username AS username, i.scope_id,
|
||||
SUM(MAX(0, COALESCE(i.end_at, ?) - i.start_at)) AS duration_ms
|
||||
FROM user_age_intervals i
|
||||
JOIN user_identities identity
|
||||
ON identity.provider = i.platform AND identity.provider_user_id = i.platform_user_id
|
||||
JOIN user_profiles p ON p.id = identity.user_id
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
GROUP BY p.id, p.internal_username, i.scope_id
|
||||
ORDER BY duration_ms DESC, p.internal_username ASC
|
||||
`WITH scoped_tenure AS (
|
||||
SELECT p.id AS profile_id, p.internal_username AS username, i.scope_id,
|
||||
SUM(MAX(0, COALESCE(i.end_at, ?) - i.start_at)) AS duration_ms
|
||||
FROM user_age_intervals i
|
||||
JOIN user_identities identity
|
||||
ON identity.provider = i.platform AND identity.provider_user_id = i.platform_user_id
|
||||
JOIN user_profiles p ON p.id = identity.user_id
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
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 ?`
|
||||
).all(...params).map((row) => ({
|
||||
username: row.username,
|
||||
label: `${row.username} · ${row.scope_id}`,
|
||||
label: row.username,
|
||||
value: formatDuration(row.duration_ms, "D-H-m"),
|
||||
numericValue: row.duration_ms
|
||||
}));
|
||||
|
||||
33
src/services/user-favorites.js
Normal file
33
src/services/user-favorites.js
Normal 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
|
||||
};
|
||||
@ -1784,11 +1784,35 @@ body.stats-compare-mode .stats-compare {
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.95rem;
|
||||
|
||||
@ -81,6 +81,10 @@ const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
||||
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
||||
const { streamTestingService } = require("../services/stream-testing");
|
||||
const { userAgeStatistics } = require("../services/user-age-statistics");
|
||||
const {
|
||||
getFavoriteCommand,
|
||||
getFavoriteExpression
|
||||
} = require("../services/user-favorites");
|
||||
const {
|
||||
getReverseProxyIngestSettings,
|
||||
saveReverseProxyIngestSettings
|
||||
@ -375,12 +379,13 @@ function getExpressionUserSummary(userId) {
|
||||
},
|
||||
{ given: 0, received: 0 }
|
||||
);
|
||||
return { totals };
|
||||
const favorite = getFavoriteExpression(db, userId);
|
||||
return { totals, favorite };
|
||||
}
|
||||
|
||||
function buildUserStatsPayload(userId) {
|
||||
if (!userId) {
|
||||
return { stats: null, expression: null, tenureStats: [], pluginStats: [] };
|
||||
return { stats: null, expression: null, favoriteCommand: null, tenureStats: [], pluginStats: [] };
|
||||
}
|
||||
const stats = db
|
||||
.prepare("SELECT * FROM stats WHERE user_id = ?")
|
||||
@ -388,6 +393,7 @@ function buildUserStatsPayload(userId) {
|
||||
return {
|
||||
stats,
|
||||
expression: getExpressionUserSummary(userId),
|
||||
favoriteCommand: getFavoriteCommand(db, userId, getSetting("command_prefix", "!")),
|
||||
tenureStats: userAgeStatistics.getProfileStatistics(userId),
|
||||
pluginStats: getPluginProfileStats(userId)
|
||||
};
|
||||
@ -430,8 +436,14 @@ function buildCompareRows(leftStats, rightStats) {
|
||||
if (leftStats.tenureStats?.length || rightStats.tenureStats?.length) {
|
||||
pushSection(
|
||||
"Platform tenure",
|
||||
(leftStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total })),
|
||||
(rightStats.tenureStats || []).map((entry) => ({ label: entry.label, value: entry.total }))
|
||||
(leftStats.tenureStats || []).flatMap((entry) => [
|
||||
{ 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",
|
||||
stats: payload.stats,
|
||||
expression: payload.expression,
|
||||
favoriteCommand: payload.favoriteCommand,
|
||||
tenureStats: payload.tenureStats,
|
||||
pluginStats: payload.pluginStats,
|
||||
statsOwner: {
|
||||
@ -5335,6 +5348,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
title: `${profile.internal_username}'s stats`,
|
||||
stats: payload.stats,
|
||||
expression: payload.expression,
|
||||
favoriteCommand: payload.favoriteCommand,
|
||||
tenureStats: payload.tenureStats,
|
||||
pluginStats: payload.pluginStats,
|
||||
statsOwner: {
|
||||
|
||||
@ -35,6 +35,28 @@
|
||||
<% } %>
|
||||
</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">
|
||||
<h2>Platform tenure</h2>
|
||||
<% if (!tenureStats || !tenureStats.length) { %>
|
||||
@ -44,10 +66,10 @@
|
||||
<% tenureStats.forEach((stat) => { %>
|
||||
<div class="stat">
|
||||
<span class="stat-label"><%= stat.label %></span>
|
||||
<span class="stat-value"><%= stat.total %></span>
|
||||
<% if (stat.current) { %>
|
||||
<span class="help">Current period: <%= stat.current %></span>
|
||||
<% } %>
|
||||
<span class="stat-period-label">Current period</span>
|
||||
<span class="stat-value"><%= stat.current || "Not currently active" %></span>
|
||||
<span class="stat-period-label stat-period-total-label">Recorded total</span>
|
||||
<span class="stat-period-total"><%= stat.total %></span>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.11",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-27",
|
||||
"released_at": "2026-07-29",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"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,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer"
|
||||
@ -469,6 +469,18 @@
|
||||
],
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user