diff --git a/.gitignore b/.gitignore index a36d2c7..a75f746 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ twitch-credentials-lumi.png DEVNOTES.md .QWEN.md _*_changes.md -lumi_current.zip +Lumi-current.zip +agent-task*.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e3d39..a91b858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.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. diff --git a/companion/README.md b/companion/README.md index cf5b0a3..aa68119 100644 --- a/companion/README.md +++ b/companion/README.md @@ -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 diff --git a/companion/docs/song-overlay-companion.md b/companion/docs/song-overlay-companion.md index 113b894..ac2db38 100644 --- a/companion/docs/song-overlay-companion.md +++ b/companion/docs/song-overlay-companion.md @@ -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. diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index 8fe1e2d..8c44130 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -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. diff --git a/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs b/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs index 975bc1d..5623dd2 100644 --- a/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs +++ b/companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs @@ -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 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(); diff --git a/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs b/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs index 4df104c..fdd832f 100644 --- a/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs +++ b/companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs @@ -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 Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)]; public IReadOnlyList 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); diff --git a/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs b/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs index c670eca..3c9aee4 100644 --- a/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs +++ b/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs @@ -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 _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 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)) diff --git a/companion/plugins/Lumi.Companion.Overlay/plugin.json b/companion/plugins/Lumi.Companion.Overlay/plugin.json index 430ee99..d60598f 100644 --- a/companion/plugins/Lumi.Companion.Overlay/plugin.json +++ b/companion/plugins/Lumi.Companion.Overlay/plugin.json @@ -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", diff --git a/companion/plugins/Lumi.Companion.SongOverlay/MediaSourceCatalog.cs b/companion/plugins/Lumi.Companion.SongOverlay/MediaSourceCatalog.cs new file mode 100644 index 0000000..cec86b3 --- /dev/null +++ b/companion/plugins/Lumi.Companion.SongOverlay/MediaSourceCatalog.cs @@ -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 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 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 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"; +} diff --git a/companion/plugins/Lumi.Companion.SongOverlay/Providers/SpotifyWindowsMediaProvider.cs b/companion/plugins/Lumi.Companion.SongOverlay/Providers/WindowsMediaProvider.cs similarity index 81% rename from companion/plugins/Lumi.Companion.SongOverlay/Providers/SpotifyWindowsMediaProvider.cs rename to companion/plugins/Lumi.Companion.SongOverlay/Providers/WindowsMediaProvider.cs index 0d120e3..8e1ce60 100644 --- a/companion/plugins/Lumi.Companion.SongOverlay/Providers/SpotifyWindowsMediaProvider.cs +++ b/companion/plugins/Lumi.Companion.SongOverlay/Providers/WindowsMediaProvider.cs @@ -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 settings, Func> enrich, Action log) + public WindowsMediaProvider(Func settings, Func> enrich, Action 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? StateChanged; public event EventHandler? 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 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 ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken) diff --git a/companion/plugins/Lumi.Companion.SongOverlay/SongOverlayRuntime.cs b/companion/plugins/Lumi.Companion.SongOverlay/SongOverlayRuntime.cs index c6635e5..d680f53 100644 --- a/companion/plugins/Lumi.Companion.SongOverlay/SongOverlayRuntime.cs +++ b/companion/plugins/Lumi.Companion.SongOverlay/SongOverlayRuntime.cs @@ -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, diff --git a/companion/plugins/Lumi.Companion.SongOverlay/SongOverlaySettings.cs b/companion/plugins/Lumi.Companion.SongOverlay/SongOverlaySettings.cs index 121dc9b..818c0ba 100644 --- a/companion/plugins/Lumi.Companion.SongOverlay/SongOverlaySettings.cs +++ b/companion/plugins/Lumi.Companion.SongOverlay/SongOverlaySettings.cs @@ -4,10 +4,22 @@ public sealed class SongOverlaySettings { public bool Enabled { get; set; } = true; public string ProviderId { get; set; } = "spotify"; + public HashSet 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); + } } diff --git a/companion/plugins/Lumi.Companion.SongOverlay/plugin.json b/companion/plugins/Lumi.Companion.SongOverlay/plugin.json index 5783816..064a41a 100644 --- a/companion/plugins/Lumi.Companion.SongOverlay/plugin.json +++ b/companion/plugins/Lumi.Companion.SongOverlay/plugin.json @@ -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", diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index 579e884..3fd83ba 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -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" ) diff --git a/companion/scripts/verify-song-overlay.ps1 b/companion/scripts/verify-song-overlay.ps1 index 56b3640..f01a878 100644 --- a/companion/scripts/verify-song-overlay.ps1 +++ b/companion/scripts/verify-song-overlay.ps1 @@ -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 } diff --git a/companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs b/companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs new file mode 100644 index 0000000..1d545a1 --- /dev/null +++ b/companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs @@ -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 _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(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() + }; +} diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index f82c1ae..2325e19 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -6,8 +6,8 @@ enable app.manifest Assets\Lumi.Companion.ico - 0.2.7 - 0.2.7.0 + 0.2.8 + 0.2.8.0 diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml b/companion/src/Lumi.Companion.App/MainWindow.axaml index 94ebc4f..9842f87 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml @@ -252,7 +252,7 @@ - + @@ -277,16 +277,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -383,6 +383,18 @@ + + + + + + +