Lumi/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs
2026-07-29 23:41:34 +02:00

838 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, CardVisual> _cards = [];
private readonly DispatcherTimer _timer;
private readonly HttpsImageCache _images = new();
private LumiOverlaySettings _settings = new();
private OverlayLifecycleQueue _chat;
private OverlayLifecycleQueue _events;
private bool _disposed;
private bool _visibleRequested;
private string? _temporaryMonitorWarning;
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<OverlayQueueItem> values,
OverlayContainerSettings settings, PhysicalRect rect, bool visible)
{
holder.IsVisible = visible;
if (!visible) { panel.Children.Clear(); return; }
holder.Width = rect.Width;
holder.Height = rect.Height;
panel.Width = rect.Width;
panel.MaxHeight = rect.Height;
panel.HorizontalAlignment = settings.HorizontalAlignment switch
{
OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left,
OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center,
_ => Avalonia.Layout.HorizontalAlignment.Right
};
panel.VerticalAlignment = settings.VerticalAlignment switch
{
OverlayVerticalAlignment.Top => Avalonia.Layout.VerticalAlignment.Top,
OverlayVerticalAlignment.Middle => Avalonia.Layout.VerticalAlignment.Center,
_ => Avalonia.Layout.VerticalAlignment.Bottom
};
Canvas.SetLeft(holder, rect.X);
Canvas.SetTop(holder, rect.Y);
var items = (settings.NewestDirection == OverlayNewestDirection.End ? values : values.Reverse()).ToList();
var oldPositions = panel.Children.OfType<Border>().Where(card => card.Tag is CardVisual)
.ToDictionary(card => ((CardVisual)card.Tag!).Id, card => rect.Y + card.Bounds.Y);
foreach (var other in new[] { _unifiedPanel, _chatPanel, _eventPanel })
if (!ReferenceEquals(other, panel))
foreach (var card in other.Children.OfType<Border>().Where(card => card.Tag is CardVisual visual && items.Any(item => item.Id == visual.Id)).ToList())
other.Children.Remove(card);
panel.Children.Clear();
foreach (var item in items)
{
var visual = GetOrCreateCard(item, settings, out var created);
visual.Card.MaxWidth = rect.Width;
panel.Children.Add(visual.Card);
if (!created) ApplyCardState(visual, item, settings);
}
Dispatcher.UIThread.Post(() =>
{
foreach (var visual in panel.Children.OfType<Border>().Select(card => card.Tag as CardVisual).Where(value => value is not null))
{
if (!oldPositions.TryGetValue(visual!.Id, out var old)) continue;
var target = visual.Translate.Y;
var transitions = visual.Translate.Transitions;
visual.Translate.Transitions = null;
visual.Translate.Y += old - (rect.Y + visual.Card.Bounds.Y);
Dispatcher.UIThread.Post(() =>
{
visual.Translate.Transitions = transitions;
visual.Translate.Y = target;
}, DispatcherPriority.Render);
}
}, DispatcherPriority.Render);
}
private CardVisual GetOrCreateCard(OverlayQueueItem item, OverlayContainerSettings settings, out bool created)
{
if (_cards.TryGetValue(item.Id, out var existing)) { created = false; return existing; }
var content = item.Payload switch
{
OverlayFeedMessage message => CreateChatContent(message, settings),
OverlayRenderedEvent value => CreateEventContent(value, settings),
_ => new TextBlock { Text = item.Payload.ToString() ?? "" }
};
var translate = new TranslateTransform();
var scale = new ScaleTransform();
var transforms = new TransformGroup();
transforms.Children.Add(scale);
transforms.Children.Add(translate);
var border = new Border
{
Background = OverlayColor.BrushFromRgba(settings.CardBackground),
CornerRadius = new CornerRadius(9),
Padding = new Thickness(12, 9),
Child = content,
HorizontalAlignment = settings.HorizontalAlignment switch
{
OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left,
OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center,
_ => Avalonia.Layout.HorizontalAlignment.Right
},
RenderTransform = transforms,
RenderTransformOrigin = RelativePoint.Center
};
var visual = new CardVisual(item.Id, border, translate, scale);
border.Tag = visual;
_cards[item.Id] = visual;
created = true;
SetInitialState(visual, settings.EntryAnimation, settings);
Dispatcher.UIThread.Post(() => ApplyCardState(visual, item, settings), DispatcherPriority.Render);
return visual;
}
private static void SetInitialState(CardVisual visual, OverlayAnimation animation, OverlayContainerSettings settings)
{
visual.Card.Opacity = animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1;
visual.Translate.X = animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? settings.SlideDistance : 0;
visual.Scale.ScaleX = visual.Scale.ScaleY = animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1;
}
private static void ApplyCardState(CardVisual visual, OverlayQueueItem item, OverlayContainerSettings settings)
{
var exiting = item.State == OverlayItemState.Exiting;
var animation = exiting ? settings.ExitAnimation : settings.EntryAnimation;
var duration = TimeSpan.FromMilliseconds(exiting ? settings.ExitDurationMs : settings.EntryDurationMs);
var easing = EasingFor(settings.Easing);
visual.Card.Transitions = [new DoubleTransition { Property = OpacityProperty, Duration = duration, Easing = easing }];
visual.Translate.Transitions =
[
new DoubleTransition { Property = TranslateTransform.XProperty, Duration = duration, Easing = easing },
new DoubleTransition { Property = TranslateTransform.YProperty, Duration = duration, Easing = easing }
];
visual.Scale.Transitions =
[
new DoubleTransition { Property = ScaleTransform.ScaleXProperty, Duration = duration, Easing = easing },
new DoubleTransition { Property = ScaleTransform.ScaleYProperty, Duration = duration, Easing = easing }
];
visual.Card.Opacity = exiting && animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1;
visual.Translate.X = exiting && animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? -settings.SlideDistance : 0;
var scale = exiting && animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1;
visual.Scale.ScaleX = visual.Scale.ScaleY = scale;
}
private static Easing EasingFor(string value) => value switch
{
"linear" => new LinearEasing(),
"cubic-in-out" => new CubicEaseInOut(),
_ => new CubicEaseOut()
};
private Control CreateChatContent(OverlayFeedMessage message, OverlayContainerSettings settings)
{
var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse(settings.ShowAvatar && message.Author.Avatar is not null ? "40,*" : "*"), ColumnSpacing = 10 };
var contentColumn = 0;
if (settings.ShowAvatar && message.Author.Avatar is not null)
{
var avatar = new 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<string, Lazy<Task<OverlayImageAsset?>>> _entries = new();
private readonly Queue<string> _order = new();
private readonly object _gate = new();
public Task<OverlayImageAsset?> GetAsync(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<OverlayImageAsset?>(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<OverlayImageAsset?> 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<Bitmap>(frameCount);
var delays = new List<TimeSpan>(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<Bitmap> frames, IReadOnlyList<TimeSpan> delays) : IDisposable
{
public IReadOnlyList<Bitmap> Frames { get; } = frames;
public IReadOnlyList<TimeSpan> 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));
}