838 lines
38 KiB
C#
838 lines
38 KiB
C#
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));
|
||
}
|