Lumi/companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs
2026-07-26 22:06:34 +02:00

534 lines
26 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.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
using Microsoft.Win32;
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 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;
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);
_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 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 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);
}
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 Image { 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))
header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold));
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));
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 Image { 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 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(Image target, string url, Control? fallback = null)
{
var bitmap = await _images.GetAsync(url).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (bitmap is not null)
{
target.Source = bitmap;
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() | WsExTransparent | WsExToolWindow | WsExLayered | 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 readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
private readonly ConcurrentDictionary<string, Lazy<Task<Bitmap?>>> _entries = new();
private readonly Queue<string> _order = new();
private readonly object _gate = new();
public Task<Bitmap?> GetAsync(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<Bitmap?>(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<Bitmap?> DownloadAsync(string url)
{
try
{
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > 2 * 1024 * 1024) return null;
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
if (bytes.Length > 2 * 1024 * 1024) return null;
return new Bitmap(new MemoryStream(bytes));
}
catch { return null; }
}
public void Dispose()
{
_http.Dispose();
foreach (var value in _entries.Values)
if (value.IsValueCreated && value.Value.IsCompletedSuccessfully) value.Value.Result?.Dispose();
}
}
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));
}