using System.Collections.Concurrent; using System.Runtime.InteropServices; using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Threading; using Microsoft.Win32; using SkiaSharp; namespace Lumi.Companion.Overlay; public enum CaptureExclusionState { Pending, Active, Unsupported, Failed } public sealed record CaptureExclusionStatus(CaptureExclusionState State, string Detail); public sealed class NativeOverlayWindow : Window, IAsyncDisposable { private const int GwlExStyle = -20; private const long WsExTransparent = 0x20; private const long WsExToolWindow = 0x80; private const long WsExLayered = 0x80000; private const long WsExNoActivate = 0x08000000; private const uint SwpNoActivate = 0x0010; private const uint SwpFrameChanged = 0x0020; private const uint WdaExcludeFromCapture = 0x11; private static readonly IntPtr HwndTopmost = new(-1); private readonly Grid _root = new(); private readonly Canvas _surface = new(); private readonly Grid _unifiedHolder = new(); private readonly Grid _chatHolder = new(); private readonly Grid _eventHolder = new(); private readonly StackPanel _unifiedPanel = new() { Spacing = 8 }; private readonly StackPanel _chatPanel = new() { Spacing = 8 }; private readonly StackPanel _eventPanel = new() { Spacing = 8 }; private readonly Border _unifiedEditor; private readonly Border _chatEditor; private readonly Border _eventEditor; private readonly Dictionary _cards = []; private readonly DispatcherTimer _timer; private readonly HttpsImageCache _images = new(); private LumiOverlaySettings _settings = new(); private OverlayLifecycleQueue _chat; private OverlayLifecycleQueue _events; private bool _disposed; private bool _visibleRequested; private string? _temporaryMonitorWarning; private bool _editing; private Border? _dragEditor; private OverlayContainerSettings? _dragSettings; private Point _dragOrigin; private PhysicalRect _dragRect; private bool _resizing; public NativeOverlayWindow() { WindowDecorations = Avalonia.Controls.WindowDecorations.None; TransparencyLevelHint = [WindowTransparencyLevel.Transparent]; Background = Brushes.Transparent; Topmost = true; ShowInTaskbar = false; ShowActivated = false; CanResize = false; Focusable = false; Content = _root; _root.Children.Add(_surface); _unifiedHolder.Children.Add(_unifiedPanel); _chatHolder.Children.Add(_chatPanel); _eventHolder.Children.Add(_eventPanel); _surface.Children.Add(_unifiedHolder); _surface.Children.Add(_chatHolder); _surface.Children.Add(_eventHolder); _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; _events.Changed += Render; Opened += (_, _) => { ApplyMonitorAndStyles(); Render(); }; Screens.Changed += OnScreensChanged; SystemEvents.DisplaySettingsChanged += OnSystemDisplayChanged; SystemEvents.PowerModeChanged += OnPowerChanged; SystemEvents.SessionSwitch += OnSessionSwitch; _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(33), DispatcherPriority.Render, (_, _) => { _chat.Advance(); _events.Advance(); }); _timer.Start(); } public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window."); public string? MonitorWarning => _temporaryMonitorWarning; public event Action? NativeStatusChanged; public event Action? EditSettingsChanged; public void ApplySettings(LumiOverlaySettings settings) { _settings = settings; _unifiedPanel.Children.Clear(); _chatPanel.Children.Clear(); _eventPanel.Children.Clear(); _cards.Clear(); ApplyMonitorAndStyles(); Render(); } public void SetRequestedVisible(bool visible) { _visibleRequested = visible; if (visible) { if (!IsVisible) Show(); ApplyMonitorAndStyles(); } else if (IsVisible) Hide(); } public void 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(); } public void EndPreview() { _chat.ExitWhere(item => item.Id.StartsWith("sample-", StringComparison.Ordinal)); _events.ExitWhere(item => item.Id.StartsWith("sample-", StringComparison.Ordinal)); } private OverlayContainerSettings ContainerFor(string kind) => _settings.Layout == OverlayLayoutMode.Unified ? _settings.Unified : kind == "chat" ? _settings.Chat : _settings.Events; private void Render() { if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(Render); return; } var screen = ResolveScreen(); if (screen is null) return; var width = screen.Bounds.Width; var height = screen.Bounds.Height; var scaling = screen.Scaling; if (_settings.Layout == OverlayLayoutMode.Unified) { var combined = _chat.Items.Concat(_events.Items).OrderBy(item => item.QueuedAt).ToList(); ConfigureContainer(_unifiedHolder, _unifiedPanel, combined, _settings.Unified, Logical(AnchorLayout.Calculate(_settings.Unified, width, height), scaling), true); ConfigureContainer(_chatHolder, _chatPanel, [], _settings.Chat, default, false); ConfigureContainer(_eventHolder, _eventPanel, [], _settings.Events, default, false); } else if (_settings.Layout is OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical) { var rect = AnchorLayout.Calculate(_settings.Unified, width, height); if (_settings.Layout == OverlayLayoutMode.DividedHorizontal) { var chatWidth = rect.Width * _settings.ChatPercentage / 100; ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(new(rect.X, rect.Y, chatWidth, rect.Height), scaling), true); ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(new(rect.X + chatWidth, rect.Y, rect.Width - chatWidth, rect.Height), scaling), true); } else { var chatHeight = rect.Height * _settings.ChatPercentage / 100; ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(new(rect.X, rect.Y, rect.Width, chatHeight), scaling), true); ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(new(rect.X, rect.Y + chatHeight, rect.Width, rect.Height - chatHeight), scaling), true); } ConfigureContainer(_unifiedHolder, _unifiedPanel, [], _settings.Unified, default, false); } else { ConfigureContainer(_unifiedHolder, _unifiedPanel, [], _settings.Unified, default, false); ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(AnchorLayout.Calculate(_settings.Chat, width, height), scaling), true); ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(AnchorLayout.Calculate(_settings.Events, width, height), scaling), true); } var activeIds = _chat.Items.Concat(_events.Items).Select(item => item.Id).ToHashSet(); foreach (var stale in _cards.Keys.Where(id => !activeIds.Contains(id)).ToList()) _cards.Remove(stale); 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, OverlayContainerSettings settings, PhysicalRect rect, bool visible) { holder.IsVisible = visible; if (!visible) { panel.Children.Clear(); return; } holder.Width = rect.Width; holder.Height = rect.Height; panel.Width = rect.Width; panel.MaxHeight = rect.Height; panel.HorizontalAlignment = settings.HorizontalAlignment switch { OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left, OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center, _ => Avalonia.Layout.HorizontalAlignment.Right }; panel.VerticalAlignment = settings.VerticalAlignment switch { OverlayVerticalAlignment.Top => Avalonia.Layout.VerticalAlignment.Top, OverlayVerticalAlignment.Middle => Avalonia.Layout.VerticalAlignment.Center, _ => Avalonia.Layout.VerticalAlignment.Bottom }; Canvas.SetLeft(holder, rect.X); Canvas.SetTop(holder, rect.Y); var items = (settings.NewestDirection == OverlayNewestDirection.End ? values : values.Reverse()).ToList(); var oldPositions = panel.Children.OfType().Where(card => card.Tag is CardVisual) .ToDictionary(card => ((CardVisual)card.Tag!).Id, card => rect.Y + card.Bounds.Y); foreach (var other in new[] { _unifiedPanel, _chatPanel, _eventPanel }) if (!ReferenceEquals(other, panel)) foreach (var card in other.Children.OfType().Where(card => card.Tag is CardVisual visual && items.Any(item => item.Id == visual.Id)).ToList()) other.Children.Remove(card); panel.Children.Clear(); foreach (var item in items) { var visual = GetOrCreateCard(item, settings, out var created); visual.Card.MaxWidth = rect.Width; panel.Children.Add(visual.Card); if (!created) ApplyCardState(visual, item, settings); } Dispatcher.UIThread.Post(() => { foreach (var visual in panel.Children.OfType().Select(card => card.Tag as CardVisual).Where(value => value is not null)) { if (!oldPositions.TryGetValue(visual!.Id, out var old)) continue; var target = visual.Translate.Y; var transitions = visual.Translate.Transitions; visual.Translate.Transitions = null; visual.Translate.Y += old - (rect.Y + visual.Card.Bounds.Y); Dispatcher.UIThread.Post(() => { visual.Translate.Transitions = transitions; visual.Translate.Y = target; }, DispatcherPriority.Render); } }, DispatcherPriority.Render); } private CardVisual GetOrCreateCard(OverlayQueueItem item, OverlayContainerSettings settings, out bool created) { if (_cards.TryGetValue(item.Id, out var existing)) { created = false; return existing; } var content = item.Payload switch { OverlayFeedMessage message => CreateChatContent(message, settings), OverlayRenderedEvent value => CreateEventContent(value, settings), _ => new TextBlock { Text = item.Payload.ToString() ?? "" } }; var translate = new TranslateTransform(); var scale = new ScaleTransform(); var transforms = new TransformGroup(); transforms.Children.Add(scale); transforms.Children.Add(translate); var border = new Border { Background = OverlayColor.BrushFromRgba(settings.CardBackground), CornerRadius = new CornerRadius(9), Padding = new Thickness(12, 9), Child = content, HorizontalAlignment = settings.HorizontalAlignment switch { OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left, OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center, _ => Avalonia.Layout.HorizontalAlignment.Right }, RenderTransform = transforms, RenderTransformOrigin = RelativePoint.Center }; var visual = new CardVisual(item.Id, border, translate, scale); border.Tag = visual; _cards[item.Id] = visual; created = true; SetInitialState(visual, settings.EntryAnimation, settings); Dispatcher.UIThread.Post(() => ApplyCardState(visual, item, settings), DispatcherPriority.Render); return visual; } private static void SetInitialState(CardVisual visual, OverlayAnimation animation, OverlayContainerSettings settings) { visual.Card.Opacity = animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1; visual.Translate.X = animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? settings.SlideDistance : 0; visual.Scale.ScaleX = visual.Scale.ScaleY = animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1; } private static void ApplyCardState(CardVisual visual, OverlayQueueItem item, OverlayContainerSettings settings) { var exiting = item.State == OverlayItemState.Exiting; var animation = exiting ? settings.ExitAnimation : settings.EntryAnimation; var duration = TimeSpan.FromMilliseconds(exiting ? settings.ExitDurationMs : settings.EntryDurationMs); var easing = EasingFor(settings.Easing); visual.Card.Transitions = [new DoubleTransition { Property = OpacityProperty, Duration = duration, Easing = easing }]; visual.Translate.Transitions = [ new DoubleTransition { Property = TranslateTransform.XProperty, Duration = duration, Easing = easing }, new DoubleTransition { Property = TranslateTransform.YProperty, Duration = duration, Easing = easing } ]; visual.Scale.Transitions = [ new DoubleTransition { Property = ScaleTransform.ScaleXProperty, Duration = duration, Easing = easing }, new DoubleTransition { Property = ScaleTransform.ScaleYProperty, Duration = duration, Easing = easing } ]; visual.Card.Opacity = exiting && animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1; visual.Translate.X = exiting && animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? -settings.SlideDistance : 0; var scale = exiting && animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1; visual.Scale.ScaleX = visual.Scale.ScaleY = scale; } private static Easing EasingFor(string value) => value switch { "linear" => new LinearEasing(), "cubic-in-out" => new CubicEaseInOut(), _ => new CubicEaseOut() }; private Control CreateChatContent(OverlayFeedMessage message, OverlayContainerSettings settings) { var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse(settings.ShowAvatar && message.Author.Avatar is not null ? "40,*" : "*"), ColumnSpacing = 10 }; var contentColumn = 0; if (settings.ShowAvatar && message.Author.Avatar is not null) { var avatar = new AnimatedOverlayImage { Width = 36, Height = 36, Stretch = Stretch.UniformToFill }; _ = LoadImageAsync(avatar, message.Author.Avatar); grid.Children.Add(avatar); contentColumn = 1; } var stack = new StackPanel { Spacing = 3 }; var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 }; if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold)); foreach (var badge in message.Author.Badges.Take(6)) { if (badge.Image is null) { header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold)); continue; } var token = new Grid(); ToolTip.SetTip(token, badge.Label); var fallback = Text(badge.Label, settings, 10, FontWeight.SemiBold); var image = new AnimatedOverlayImage { Width = 18, Height = 18, Stretch = Stretch.Uniform, Opacity = 0 }; token.Children.Add(fallback); token.Children.Add(image); header.Children.Add(token); _ = LoadImageAsync(image, badge.Image, fallback); } if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold)); if (header.Children.Count > 0) stack.Children.Add(header); stack.Children.Add(CreateMessageBody(message, settings)); var media = CreateMessageMedia(message); if (media is not null) stack.Children.Add(media); Grid.SetColumn(stack, contentColumn); grid.Children.Add(stack); return grid; } private Control CreateMessageBody(OverlayFeedMessage message, OverlayContainerSettings settings) { var panel = new WrapPanel { Orientation = Avalonia.Layout.Orientation.Horizontal }; var cursor = 0; foreach (var emote in message.Emotes.OrderBy(value => value.Start)) { if (emote.Start < cursor || emote.Start > message.Text.Length) continue; if (emote.Start > cursor) panel.Children.Add(Text(message.Text[cursor..emote.Start], settings, settings.FontSize)); if (emote.Image is not null) { var token = new Grid { Margin = new Thickness(2, 0) }; var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize); var image = new AnimatedOverlayImage { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 }; token.Children.Add(fallback); token.Children.Add(image); panel.Children.Add(token); _ = LoadImageAsync(image, emote.Image, fallback); } else panel.Children.Add(Text(emote.Label, settings, settings.FontSize)); cursor = Math.Min(message.Text.Length, emote.End + 1); } if (cursor < message.Text.Length) panel.Children.Add(Text(message.Text[cursor..], settings, settings.FontSize)); if (panel.Children.Count == 0) panel.Children.Add(Text(message.Text, settings, settings.FontSize)); return panel; } private Control? CreateMessageMedia(OverlayFeedMessage message) { var panel = new WrapPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, ItemWidth = 220, ItemHeight = 150 }; foreach (var media in message.Media.Take(4)) { var source = media.Type == "video" ? media.Preview : media.Url; if (string.IsNullOrWhiteSpace(source)) continue; var image = new AnimatedOverlayImage { Width = 220, Height = 150, Stretch = Stretch.Uniform }; if (!string.IsNullOrWhiteSpace(media.Alt)) ToolTip.SetTip(image, media.Alt); panel.Children.Add(image); _ = LoadImageAsync(image, source); } return panel.Children.Count == 0 ? null : panel; } private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings) { var stack = new StackPanel { Spacing = 3 }; if (settings.ShowPlatform) stack.Children.Add(Text(value.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold)); stack.Children.Add(Text(value.Summary, settings, settings.FontSize, FontWeight.SemiBold)); return stack; } private static TextBlock Text(string value, OverlayContainerSettings settings, double size, FontWeight? weight = null) { var block = new TextBlock { Text = value, TextWrapping = TextWrapping.Wrap, Foreground = Brush.Parse(settings.TextColor), TextAlignment = settings.HorizontalAlignment switch { OverlayHorizontalAlignment.Left => TextAlignment.Left, OverlayHorizontalAlignment.Middle => TextAlignment.Center, _ => TextAlignment.Right }, FontFamily = new FontFamily(settings.FontFamily), FontSize = size, FontWeight = weight ?? FontWeight.Normal }; block.Effect = new DropShadowEffect { Color = OverlayColor.ColorFromRgba(settings.TextShadow), OffsetX = settings.ShadowX, OffsetY = settings.ShadowY, BlurRadius = settings.ShadowBlur }; return block; } private async Task LoadImageAsync(AnimatedOverlayImage target, string url, Control? fallback = null) { var asset = await _images.GetAsync(url).ConfigureAwait(false); await Dispatcher.UIThread.InvokeAsync(() => { if (asset is not null) { target.SetAsset(asset); target.Opacity = 1; if (fallback is not null) fallback.IsVisible = false; } }); } private Screen? ResolveScreen() { var all = Screens.All; var primary = Screens.Primary ?? all.FirstOrDefault(); var resolution = OverlayMonitorPolicy.Resolve(_settings.MonitorId, all.Select(ScreenId), primary is null ? null : ScreenId(primary)); var selected = all.FirstOrDefault(screen => ScreenId(screen) == resolution.MonitorId); if (resolution.Warning != _temporaryMonitorWarning) { _temporaryMonitorWarning = resolution.Warning; NativeStatusChanged?.Invoke(); } return selected; } public IReadOnlyList<(string Id, string Label)> Monitors() => Screens.All.Select((screen, index) => (ScreenId(screen), $"{(screen.IsPrimary ? "Primary · " : "")}{screen.DisplayName ?? $"Monitor {index + 1}"} · {screen.Bounds.Width}×{screen.Bounds.Height}")).ToList(); public static string ScreenId(Screen screen) => !string.IsNullOrWhiteSpace(screen.DisplayName) ? $"display:{screen.DisplayName}" : $"bounds:{screen.Bounds.X},{screen.Bounds.Y},{screen.Bounds.Width},{screen.Bounds.Height}"; private static PhysicalRect Logical(PhysicalRect value, double scaling) => new( (int)Math.Round(value.X / scaling), (int)Math.Round(value.Y / scaling), Math.Max(1, (int)Math.Round(value.Width / scaling)), Math.Max(1, (int)Math.Round(value.Height / scaling))); private void ApplyMonitorAndStyles() { if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(ApplyMonitorAndStyles); return; } var screen = ResolveScreen(); if (screen is null) return; Position = screen.Bounds.Position; Width = screen.Bounds.Width / screen.Scaling; Height = screen.Bounds.Height / screen.Scaling; var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; if (hwnd == IntPtr.Zero) return; var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | 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)) CaptureExclusion = new(CaptureExclusionState.Active, "Windows capture exclusion is active (best effort; capture software may still include the overlay)."); else { var error = Marshal.GetLastWin32Error(); CaptureExclusion = error == 87 ? new(CaptureExclusionState.Unsupported, "This Windows/capture path does not support display-affinity exclusion.") : new(CaptureExclusionState.Failed, $"Windows could not apply capture exclusion (error {error})."); } NativeStatusChanged?.Invoke(); if (!_visibleRequested && IsVisible) Hide(); } private void OnScreensChanged(object? sender, EventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); private void OnSystemDisplayChanged(object? sender, EventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); private void OnPowerChanged(object sender, PowerModeChangedEventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); private void OnSessionSwitch(object sender, SessionSwitchEventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles); public ValueTask DisposeAsync() { if (_disposed) return ValueTask.CompletedTask; _disposed = true; _timer.Stop(); Screens.Changed -= OnScreensChanged; SystemEvents.DisplaySettingsChanged -= OnSystemDisplayChanged; SystemEvents.PowerModeChanged -= OnPowerChanged; SystemEvents.SessionSwitch -= OnSessionSwitch; _images.Dispose(); Close(); return ValueTask.CompletedTask; } [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] private static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int index); [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int index, IntPtr value); [DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int cx, int cy, uint flags); [DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint affinity); private sealed record CardVisual(string Id, Border Card, TranslateTransform Translate, ScaleTransform Scale); } internal sealed class HttpsImageCache : IDisposable { private const int Limit = 128; private const int MaxEncodedBytes = 2 * 1024 * 1024; private const int MaxDimension = 384; private const int MaxFrames = 60; private const long MaxDecodedPixels = 12_000_000; private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) }; private readonly ConcurrentDictionary>> _entries = new(); private readonly Queue _order = new(); private readonly object _gate = new(); public Task GetAsync(string value) { if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult(null); lock (_gate) { if (!_entries.ContainsKey(uri.AbsoluteUri)) { while (_entries.Count >= Limit && _order.TryDequeue(out var oldest)) if (_entries.TryRemove(oldest, out var removed) && removed.IsValueCreated && removed.Value.IsCompletedSuccessfully) removed.Value.Result?.Dispose(); _order.Enqueue(uri.AbsoluteUri); } } return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value; } private async Task DownloadAsync(string url) { try { using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxEncodedBytes) return null; var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); if (bytes.Length > MaxEncodedBytes) return null; return Decode(bytes); } catch { return null; } } private static OverlayImageAsset? Decode(byte[] bytes) { using var data = SKData.CreateCopy(bytes); using var codec = SKCodec.Create(data); if (codec is null || codec.Info.Width <= 0 || codec.Info.Height <= 0) return null; var scale = Math.Min(1d, MaxDimension / (double)Math.Max(codec.Info.Width, codec.Info.Height)); var dimensions = codec.GetScaledDimensions((float)scale); var width = Math.Max(1, dimensions.Width); var height = Math.Max(1, dimensions.Height); var frameCount = Math.Clamp(codec.FrameCount, 1, MaxFrames); frameCount = (int)Math.Min(frameCount, Math.Max(1, MaxDecodedPixels / ((long)width * height))); var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); using var working = new SKBitmap(info); var frameInfo = codec.FrameInfo; var frames = new List(frameCount); var delays = new List(frameCount); try { for (var index = 0; index < frameCount; index++) { var requiredFrame = index < frameInfo.Length ? frameInfo[index].RequiredFrame : -1; if (requiredFrame < 0) working.Erase(SKColors.Transparent); var result = codec.GetPixels(info, working.GetPixels(), working.RowBytes, new SKCodecOptions(index, requiredFrame)); if (result is not (SKCodecResult.Success or SKCodecResult.IncompleteInput)) break; using var image = SKImage.FromBitmap(working); using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); frames.Add(new Bitmap(new MemoryStream(encoded.ToArray()))); var duration = index < frameInfo.Length ? frameInfo[index].Duration : 100; delays.Add(TimeSpan.FromMilliseconds(Math.Clamp(duration <= 0 ? 100 : duration, 20, 10_000))); } return frames.Count == 0 ? null : new OverlayImageAsset(frames, delays); } catch { foreach (var frame in frames) frame.Dispose(); return null; } } public void Dispose() { _http.Dispose(); foreach (var value in _entries.Values) if (value.IsValueCreated && value.Value.IsCompletedSuccessfully) value.Value.Result?.Dispose(); } } internal sealed class OverlayImageAsset(IReadOnlyList frames, IReadOnlyList delays) : IDisposable { public IReadOnlyList Frames { get; } = frames; public IReadOnlyList Delays { get; } = delays; public void Dispose() { foreach (var frame in Frames) frame.Dispose(); } } internal sealed class AnimatedOverlayImage : Image { private DispatcherTimer? _timer; private OverlayImageAsset? _asset; private int _frame; public AnimatedOverlayImage() { DetachedFromVisualTree += (_, _) => Stop(); AttachedToVisualTree += (_, _) => Start(); } public void SetAsset(OverlayImageAsset asset) { Stop(); _asset = asset; _frame = 0; Source = asset.Frames[0]; Start(); } private void Start() { if (_asset is null || _asset.Frames.Count < 2 || _timer is not null) return; _timer = new DispatcherTimer { Interval = DelayFor(0) }; _timer.Tick += Advance; _timer.Start(); } private void Advance(object? sender, EventArgs args) { if (_asset is null || _asset.Frames.Count < 2) return; _frame = (_frame + 1) % _asset.Frames.Count; Source = _asset.Frames[_frame]; if (_timer is not null) _timer.Interval = DelayFor(_frame); } private TimeSpan DelayFor(int frame) => _asset is not null && frame < _asset.Delays.Count ? _asset.Delays[frame] : TimeSpan.FromMilliseconds(100); private void Stop() { if (_timer is null) return; _timer.Stop(); _timer.Tick -= Advance; _timer = null; } } internal static class OverlayColor { public static Color ColorFromRgba(string value) { var hex = value.TrimStart('#'); if (hex.Length != 8) return Color.Parse(value); return Color.FromArgb( Convert.ToByte(hex[6..8], 16), Convert.ToByte(hex[0..2], 16), Convert.ToByte(hex[2..4], 16), Convert.ToByte(hex[4..6], 16)); } public static IBrush BrushFromRgba(string value) => new SolidColorBrush(ColorFromRgba(value)); }