2026-04-19 12:55:23 -05:00
|
|
|
using System;
|
2026-05-18 20:07:54 -05:00
|
|
|
using System.Collections.Generic;
|
2026-04-19 12:55:23 -05:00
|
|
|
using System.IO;
|
|
|
|
|
using System.Net.Http;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using Avalonia;
|
|
|
|
|
using Avalonia.Controls;
|
2026-04-30 23:47:15 -05:00
|
|
|
using Avalonia.Media;
|
2026-04-19 12:55:23 -05:00
|
|
|
using Avalonia.Media.Imaging;
|
|
|
|
|
using Avalonia.Threading;
|
2026-06-06 05:49:24 -05:00
|
|
|
using LANCommander.Launcher.Helpers;
|
2026-04-19 12:55:23 -05:00
|
|
|
|
2026-06-06 05:49:24 -05:00
|
|
|
namespace LANCommander.Launcher.Views.Components;
|
2026-04-19 12:55:23 -05:00
|
|
|
|
|
|
|
|
public partial class Cover : UserControl
|
|
|
|
|
{
|
|
|
|
|
private static readonly HttpClient _httpClient = new();
|
|
|
|
|
|
2026-05-18 20:07:54 -05:00
|
|
|
// Strong-reference bitmap cache keyed by "path|decodeWidth" so covers that
|
|
|
|
|
// scroll back into view are displayed instantly without re-reading the file.
|
2026-06-15 19:31:58 -05:00
|
|
|
// Bounded by total decoded bytes (not entry count) because cover bitmap size
|
|
|
|
|
// scales with DPI — a 500-entry cap is ~300 MB at 100% but ~800 MB at 200%.
|
2026-05-18 20:07:54 -05:00
|
|
|
private static readonly Dictionary<string, Bitmap> _bitmapCache = new();
|
|
|
|
|
private static readonly LinkedList<string> _cacheOrder = new();
|
2026-05-24 15:05:29 -05:00
|
|
|
private static readonly Dictionary<string, LinkedListNode<string>> _cacheNodes = new();
|
2026-05-18 20:07:54 -05:00
|
|
|
private static readonly object _cacheLock = new();
|
2026-06-15 19:31:58 -05:00
|
|
|
private static long _cacheBytes;
|
|
|
|
|
private const long MaxCacheBytes = 128L * 1024 * 1024;
|
2026-05-18 20:07:54 -05:00
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
public static readonly StyledProperty<string?> SourceProperty =
|
|
|
|
|
AvaloniaProperty.Register<Cover, string?>(nameof(Source));
|
|
|
|
|
|
|
|
|
|
public static readonly StyledProperty<string?> TitleProperty =
|
|
|
|
|
AvaloniaProperty.Register<Cover, string?>(nameof(Title));
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
public static readonly StyledProperty<string?> MimeTypeProperty =
|
|
|
|
|
AvaloniaProperty.Register<Cover, string?>(nameof(MimeType));
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
public static readonly StyledProperty<object?> OverlayProperty =
|
|
|
|
|
AvaloniaProperty.Register<Cover, object?>(nameof(Overlay));
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
public static readonly StyledProperty<bool> IsPlayingAnimationProperty =
|
|
|
|
|
AvaloniaProperty.Register<Cover, bool>(nameof(IsPlayingAnimation));
|
|
|
|
|
|
|
|
|
|
public static readonly StyledProperty<bool> AlwaysAnimateProperty =
|
|
|
|
|
AvaloniaProperty.Register<Cover, bool>(nameof(AlwaysAnimate));
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
public static readonly DirectProperty<Cover, bool> HasCoverProperty =
|
|
|
|
|
AvaloniaProperty.RegisterDirect<Cover, bool>(nameof(HasCover), o => o.HasCover);
|
|
|
|
|
|
|
|
|
|
public static readonly DirectProperty<Cover, double> FallbackFontSizeProperty =
|
|
|
|
|
AvaloniaProperty.RegisterDirect<Cover, double>(nameof(FallbackFontSize), o => o.FallbackFontSize);
|
|
|
|
|
|
|
|
|
|
private bool _hasCover;
|
|
|
|
|
private double _fallbackFontSize = 12;
|
|
|
|
|
private CancellationTokenSource? _loadCts;
|
2026-04-30 23:47:15 -05:00
|
|
|
private VideoFrameRenderer? _videoRenderer;
|
2026-05-01 01:14:13 -05:00
|
|
|
private bool _isAnimatedCover;
|
|
|
|
|
private bool _receivedFirstFrame;
|
2026-05-24 15:05:29 -05:00
|
|
|
private string? _lastLoadedSource;
|
2026-07-04 00:14:33 -05:00
|
|
|
// Source for an animated cover whose stream is deferred until it should actually
|
|
|
|
|
// play (hover/focus), so the depot doesn't stream every video cover up front.
|
|
|
|
|
private string? _animatedSource;
|
2026-04-19 12:55:23 -05:00
|
|
|
|
|
|
|
|
public string? Source
|
|
|
|
|
{
|
|
|
|
|
get => GetValue(SourceProperty);
|
|
|
|
|
set => SetValue(SourceProperty, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string? Title
|
|
|
|
|
{
|
|
|
|
|
get => GetValue(TitleProperty);
|
|
|
|
|
set => SetValue(TitleProperty, value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
public string? MimeType
|
|
|
|
|
{
|
|
|
|
|
get => GetValue(MimeTypeProperty);
|
|
|
|
|
set => SetValue(MimeTypeProperty, value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
public object? Overlay
|
|
|
|
|
{
|
|
|
|
|
get => GetValue(OverlayProperty);
|
|
|
|
|
set => SetValue(OverlayProperty, value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// When true, an animated cover (video or APNG) will play.
|
|
|
|
|
/// Typically toggled by the parent on hover/focus.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool IsPlayingAnimation
|
|
|
|
|
{
|
|
|
|
|
get => GetValue(IsPlayingAnimationProperty);
|
|
|
|
|
set => SetValue(IsPlayingAnimationProperty, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// When true, the cover always animates (used in game detail view).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool AlwaysAnimate
|
|
|
|
|
{
|
|
|
|
|
get => GetValue(AlwaysAnimateProperty);
|
|
|
|
|
set => SetValue(AlwaysAnimateProperty, value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
public bool HasCover
|
|
|
|
|
{
|
|
|
|
|
get => _hasCover;
|
|
|
|
|
private set => SetAndRaise(HasCoverProperty, ref _hasCover, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public double FallbackFontSize
|
|
|
|
|
{
|
|
|
|
|
get => _fallbackFontSize;
|
|
|
|
|
private set => SetAndRaise(FallbackFontSizeProperty, ref _fallbackFontSize, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Cover()
|
|
|
|
|
{
|
|
|
|
|
InitializeComponent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
|
|
|
{
|
|
|
|
|
base.OnPropertyChanged(change);
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
if (change.Property == SourceProperty || change.Property == MimeTypeProperty)
|
2026-04-19 12:55:23 -05:00
|
|
|
{
|
2026-04-30 23:47:15 -05:00
|
|
|
LoadCover(Source, MimeType);
|
|
|
|
|
}
|
|
|
|
|
else if (change.Property == IsPlayingAnimationProperty || change.Property == AlwaysAnimateProperty)
|
|
|
|
|
{
|
|
|
|
|
UpdateAnimationState();
|
2026-04-19 12:55:23 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override Size MeasureOverride(Size availableSize)
|
|
|
|
|
{
|
|
|
|
|
if (!HasCover && !double.IsInfinity(availableSize.Width))
|
|
|
|
|
{
|
|
|
|
|
var constrained = new Size(availableSize.Width, availableSize.Width * 1.5);
|
|
|
|
|
base.MeasureOverride(constrained);
|
|
|
|
|
return constrained;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return base.MeasureOverride(availableSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override Size ArrangeOverride(Size finalSize)
|
|
|
|
|
{
|
|
|
|
|
var result = base.ArrangeOverride(finalSize);
|
|
|
|
|
UpdateFallbackFontSize(result);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
|
|
|
|
{
|
2026-05-24 15:05:29 -05:00
|
|
|
_lastLoadedSource = null;
|
2026-04-30 23:47:15 -05:00
|
|
|
StopVideo();
|
|
|
|
|
base.OnDetachedFromVisualTree(e);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
private void UpdateFallbackFontSize(Size size)
|
|
|
|
|
{
|
|
|
|
|
var minDimension = Math.Min(size.Width, size.Height);
|
|
|
|
|
FallbackFontSize = Math.Max(8, minDimension * 0.09);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
private static bool IsAnimatedMimeType(string? mimeType)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(mimeType)) return false;
|
|
|
|
|
return mimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|| string.Equals(mimeType, "image/apng", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|| string.Equals(mimeType, "image/gif", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
}
|
2026-04-30 23:47:15 -05:00
|
|
|
|
2026-05-18 20:07:54 -05:00
|
|
|
/// <summary>
|
2026-05-25 03:02:11 -05:00
|
|
|
/// Returns the pixel width to decode covers at, accounting for DPI scaling
|
|
|
|
|
/// and hover zoom (covers scale to 1.1x on hover). We round up to the
|
|
|
|
|
/// nearest multiple of 20 device pixels so a small layout change doesn't
|
2026-05-18 20:07:54 -05:00
|
|
|
/// invalidate the cache.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private int GetDecodePixelWidth()
|
|
|
|
|
{
|
|
|
|
|
var dpi = VisualRoot is TopLevel topLevel
|
|
|
|
|
? topLevel.RenderScaling
|
|
|
|
|
: 1.0;
|
|
|
|
|
|
|
|
|
|
var layoutWidth = Bounds.Width > 0 ? Bounds.Width : 160;
|
2026-05-25 03:02:11 -05:00
|
|
|
|
|
|
|
|
// Add 15% headroom so hover zoom (1.1x) doesn't upscale beyond
|
|
|
|
|
// the decoded resolution.
|
|
|
|
|
var pixelWidth = (int)Math.Ceiling(layoutWidth * dpi * 1.15);
|
2026-05-18 20:07:54 -05:00
|
|
|
|
|
|
|
|
// Round up to the nearest 20px to keep cache keys stable across
|
|
|
|
|
// minor layout shifts (e.g. 142px and 148px both decode at 160px).
|
|
|
|
|
return Math.Max(80, ((pixelWidth + 19) / 20) * 20);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
private void LoadCover(string? source, string? mimeType)
|
2026-04-19 12:55:23 -05:00
|
|
|
{
|
2026-05-24 15:05:29 -05:00
|
|
|
// Skip reload if the source hasn't changed (e.g. recycled with same game)
|
|
|
|
|
if (source == _lastLoadedSource && HasCover)
|
|
|
|
|
return;
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
_loadCts?.Cancel();
|
|
|
|
|
_loadCts?.Dispose();
|
|
|
|
|
_loadCts = null;
|
2026-04-30 23:47:15 -05:00
|
|
|
StopVideo();
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
_isAnimatedCover = IsAnimatedMimeType(mimeType);
|
2026-05-24 15:05:29 -05:00
|
|
|
_lastLoadedSource = source;
|
2026-07-04 00:14:33 -05:00
|
|
|
_animatedSource = _isAnimatedCover ? source : null;
|
2026-04-19 12:55:23 -05:00
|
|
|
|
|
|
|
|
if (string.IsNullOrEmpty(source))
|
|
|
|
|
{
|
|
|
|
|
SetBitmap(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 00:14:33 -05:00
|
|
|
// Route all animated covers (video, APNG, GIF) through LibVLC — but only start
|
|
|
|
|
// streaming when we should actually be animating. Otherwise defer until hover
|
|
|
|
|
// (UpdateAnimationState) and show the title placeholder in the meantime.
|
2026-05-01 01:14:13 -05:00
|
|
|
if (_isAnimatedCover)
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
2026-07-04 00:14:33 -05:00
|
|
|
if (AlwaysAnimate || IsPlayingAnimation)
|
|
|
|
|
LoadAnimatedCover(source);
|
|
|
|
|
else
|
|
|
|
|
SetBitmap(null);
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 20:07:54 -05:00
|
|
|
var cts = new CancellationTokenSource();
|
|
|
|
|
_loadCts = cts;
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
if (Uri.TryCreate(source, UriKind.Absolute, out var uri) &&
|
|
|
|
|
(uri.Scheme == "http" || uri.Scheme == "https"))
|
|
|
|
|
{
|
|
|
|
|
_ = LoadRemoteImageAsync(uri, cts.Token);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-05-18 20:07:54 -05:00
|
|
|
_ = LoadLocalImageAsync(source, cts.Token);
|
2026-04-19 12:55:23 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 20:07:54 -05:00
|
|
|
private async Task LoadLocalImageAsync(string path, CancellationToken ct)
|
2026-04-19 12:55:23 -05:00
|
|
|
{
|
2026-05-18 20:07:54 -05:00
|
|
|
var decodeWidth = GetDecodePixelWidth();
|
|
|
|
|
var cacheKey = $"{path}|{decodeWidth}";
|
|
|
|
|
|
|
|
|
|
// Fast path: bitmap is already cached from a previous scroll position.
|
2026-05-24 15:05:29 -05:00
|
|
|
// No debounce — show it instantly to eliminate flicker on scroll-back.
|
2026-05-18 20:07:54 -05:00
|
|
|
lock (_cacheLock)
|
|
|
|
|
{
|
|
|
|
|
if (_bitmapCache.TryGetValue(cacheKey, out var cached))
|
|
|
|
|
{
|
2026-05-24 15:05:29 -05:00
|
|
|
// Move to end of LRU list (O(1) via node lookup)
|
|
|
|
|
if (_cacheNodes.TryGetValue(cacheKey, out var node))
|
|
|
|
|
{
|
|
|
|
|
_cacheOrder.Remove(node);
|
|
|
|
|
_cacheOrder.AddLast(node);
|
|
|
|
|
}
|
2026-05-18 20:07:54 -05:00
|
|
|
SetBitmap(cached);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Debounce: wait briefly so covers that are scrolled past quickly
|
|
|
|
|
// never start an expensive file-read + decode.
|
2026-05-24 15:05:29 -05:00
|
|
|
await Task.Delay(50, ct);
|
2026-05-18 20:07:54 -05:00
|
|
|
|
|
|
|
|
var bitmap = await Task.Run(() =>
|
|
|
|
|
{
|
|
|
|
|
if (!System.IO.File.Exists(path))
|
|
|
|
|
return null;
|
|
|
|
|
|
2026-05-24 15:05:29 -05:00
|
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
2026-05-25 03:02:11 -05:00
|
|
|
return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
|
2026-05-18 20:07:54 -05:00
|
|
|
}, ct);
|
|
|
|
|
|
|
|
|
|
if (ct.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
bitmap?.Dispose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bitmap != null)
|
2026-06-15 19:31:58 -05:00
|
|
|
bitmap = CacheBitmap(cacheKey, bitmap);
|
2026-05-18 20:07:54 -05:00
|
|
|
|
2026-05-24 15:05:29 -05:00
|
|
|
// Use Background priority so multiple covers completing together
|
|
|
|
|
// coalesce into fewer layout passes instead of each forcing one.
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() => SetBitmap(bitmap), DispatcherPriority.Background);
|
2026-05-18 20:07:54 -05:00
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
if (!ct.IsCancellationRequested)
|
2026-05-24 15:05:29 -05:00
|
|
|
await Dispatcher.UIThread.InvokeAsync(() => SetBitmap(null), DispatcherPriority.Background);
|
2026-05-18 20:07:54 -05:00
|
|
|
}
|
2026-04-19 12:55:23 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task LoadRemoteImageAsync(Uri uri, CancellationToken ct)
|
|
|
|
|
{
|
2026-05-24 15:05:29 -05:00
|
|
|
var decodeWidth = GetDecodePixelWidth();
|
|
|
|
|
var cacheKey = $"{uri.AbsoluteUri}|{decodeWidth}";
|
|
|
|
|
|
|
|
|
|
// Fast path: already cached.
|
|
|
|
|
lock (_cacheLock)
|
|
|
|
|
{
|
|
|
|
|
if (_bitmapCache.TryGetValue(cacheKey, out var cached))
|
|
|
|
|
{
|
|
|
|
|
if (_cacheNodes.TryGetValue(cacheKey, out var node))
|
|
|
|
|
{
|
|
|
|
|
_cacheOrder.Remove(node);
|
|
|
|
|
_cacheOrder.AddLast(node);
|
|
|
|
|
}
|
|
|
|
|
SetBitmap(cached);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
try
|
|
|
|
|
{
|
2026-05-18 20:07:54 -05:00
|
|
|
// Debounce remote loads as well.
|
2026-05-24 15:05:29 -05:00
|
|
|
await Task.Delay(50, ct);
|
2026-05-18 20:07:54 -05:00
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
var data = await _httpClient.GetByteArrayAsync(uri, ct);
|
|
|
|
|
if (ct.IsCancellationRequested) return;
|
|
|
|
|
|
|
|
|
|
using var stream = new MemoryStream(data);
|
2026-05-25 03:02:11 -05:00
|
|
|
var bitmap = Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
|
2026-04-19 12:55:23 -05:00
|
|
|
|
|
|
|
|
if (ct.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
bitmap.Dispose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 19:31:58 -05:00
|
|
|
bitmap = CacheBitmap(cacheKey, bitmap);
|
|
|
|
|
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() => SetBitmap(bitmap), DispatcherPriority.Background);
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
if (!ct.IsCancellationRequested)
|
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() => SetBitmap(null), DispatcherPriority.Background);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Inserts a freshly-decoded bitmap into the shared LRU cache, or returns the
|
|
|
|
|
/// already-cached instance if another <see cref="Cover"/> decoded the same key
|
|
|
|
|
/// concurrently (disposing the duplicate). Evicts least-recently-used entries
|
|
|
|
|
/// until the cache is back under its memory budget, disposing each evicted
|
|
|
|
|
/// bitmap so its native (Skia) memory is freed immediately.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static Bitmap CacheBitmap(string cacheKey, Bitmap bitmap)
|
|
|
|
|
{
|
|
|
|
|
lock (_cacheLock)
|
|
|
|
|
{
|
|
|
|
|
if (_bitmapCache.TryGetValue(cacheKey, out var existing))
|
2026-05-24 15:05:29 -05:00
|
|
|
{
|
2026-06-15 19:31:58 -05:00
|
|
|
bitmap.Dispose();
|
|
|
|
|
bitmap = existing;
|
|
|
|
|
if (_cacheNodes.TryGetValue(cacheKey, out var existingNode))
|
2026-05-24 15:05:29 -05:00
|
|
|
{
|
2026-06-15 19:31:58 -05:00
|
|
|
_cacheOrder.Remove(existingNode);
|
|
|
|
|
_cacheOrder.AddLast(existingNode);
|
2026-05-24 15:05:29 -05:00
|
|
|
}
|
2026-06-15 19:31:58 -05:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_bitmapCache[cacheKey] = bitmap;
|
|
|
|
|
_cacheNodes[cacheKey] = _cacheOrder.AddLast(cacheKey);
|
|
|
|
|
_cacheBytes += BitmapBytes(bitmap);
|
|
|
|
|
}
|
2026-05-24 15:05:29 -05:00
|
|
|
|
2026-06-15 19:31:58 -05:00
|
|
|
// Evict least-recently-used entries until under the byte budget, but
|
|
|
|
|
// always keep the entry we just touched (the tail of the LRU list).
|
|
|
|
|
while (_cacheBytes > MaxCacheBytes && _cacheOrder.Count > 1)
|
|
|
|
|
{
|
|
|
|
|
var oldest = _cacheOrder.First!.Value;
|
|
|
|
|
_cacheOrder.RemoveFirst();
|
|
|
|
|
_cacheNodes.Remove(oldest);
|
|
|
|
|
if (_bitmapCache.Remove(oldest, out var evicted))
|
2026-05-24 15:05:29 -05:00
|
|
|
{
|
2026-06-15 19:31:58 -05:00
|
|
|
_cacheBytes -= BitmapBytes(evicted);
|
|
|
|
|
evicted.Dispose();
|
2026-05-24 15:05:29 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 19:31:58 -05:00
|
|
|
return bitmap;
|
2026-04-19 12:55:23 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 19:31:58 -05:00
|
|
|
private static long BitmapBytes(Bitmap bitmap) =>
|
|
|
|
|
(long)bitmap.PixelSize.Width * bitmap.PixelSize.Height * 4;
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
// ── Animated cover support (video, APNG, GIF via LibVLC) ────────────
|
2026-04-30 23:47:15 -05:00
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
private void LoadAnimatedCover(string source)
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-05-01 01:14:13 -05:00
|
|
|
_receivedFirstFrame = false;
|
2026-05-25 03:02:11 -05:00
|
|
|
var decodeWidth = (uint)GetDecodePixelWidth();
|
|
|
|
|
var maxHeight = (uint)(decodeWidth * 1.5);
|
|
|
|
|
_videoRenderer = new VideoFrameRenderer(maxWidth: decodeWidth, maxHeight: maxHeight);
|
2026-05-01 01:14:13 -05:00
|
|
|
_videoRenderer.FrameReady += OnVideoFrameReady;
|
2026-04-30 23:47:15 -05:00
|
|
|
_videoRenderer.Play(source, muted: true, loop: true);
|
|
|
|
|
|
|
|
|
|
HasCover = true;
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
SetBitmap(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
private void OnVideoFrameReady()
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
|
|
|
|
if (_videoRenderer == null) return;
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
if (!_receivedFirstFrame)
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
2026-05-01 01:14:13 -05:00
|
|
|
_receivedFirstFrame = true;
|
|
|
|
|
|
|
|
|
|
// Hide the Image control — we render directly via Render()
|
|
|
|
|
CoverImage.IsVisible = false;
|
2026-04-30 23:47:15 -05:00
|
|
|
HasCover = true;
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
// Pause immediately if we shouldn't be animating yet
|
|
|
|
|
if (!AlwaysAnimate && !IsPlayingAnimation)
|
|
|
|
|
{
|
|
|
|
|
_videoRenderer.Player?.SetPause(true);
|
|
|
|
|
}
|
2026-04-30 23:47:15 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
// Redraw with the latest video frame
|
|
|
|
|
InvalidateVisual();
|
2026-04-30 23:47:15 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void UpdateAnimationState()
|
|
|
|
|
{
|
2026-07-04 00:14:33 -05:00
|
|
|
if (!_isAnimatedCover) return;
|
|
|
|
|
|
|
|
|
|
var shouldAnimate = AlwaysAnimate || IsPlayingAnimation;
|
2026-04-30 23:47:15 -05:00
|
|
|
|
2026-07-04 00:14:33 -05:00
|
|
|
if (shouldAnimate)
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
2026-07-04 00:14:33 -05:00
|
|
|
// Start streaming on first hover/focus if we deferred it in LoadCover.
|
|
|
|
|
if (_videoRenderer == null)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrEmpty(_animatedSource))
|
|
|
|
|
LoadAnimatedCover(_animatedSource);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_videoRenderer.Player?.SetPause(false);
|
|
|
|
|
}
|
2026-04-30 23:47:15 -05:00
|
|
|
}
|
2026-07-04 00:14:33 -05:00
|
|
|
else if (_videoRenderer != null)
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
2026-07-04 00:14:33 -05:00
|
|
|
// Tear the stream down entirely on un-hover instead of pausing, so idle
|
|
|
|
|
// covers hold no LibVLC/network resources. _isAnimatedCover stays set so a
|
|
|
|
|
// re-hover restarts playback.
|
|
|
|
|
DisposeRenderer();
|
|
|
|
|
SetBitmap(null);
|
|
|
|
|
InvalidateVisual();
|
2026-04-30 23:47:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 00:14:33 -05:00
|
|
|
/// <summary>
|
|
|
|
|
/// Tears down the video renderer without clearing <see cref="_isAnimatedCover"/>,
|
|
|
|
|
/// so the cover can restart on a later hover. Contrast with <see cref="StopVideo"/>,
|
|
|
|
|
/// which fully resets animated state when the source itself changes.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void DisposeRenderer()
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
|
|
|
|
if (_videoRenderer != null)
|
|
|
|
|
{
|
2026-05-01 01:14:13 -05:00
|
|
|
_videoRenderer.FrameReady -= OnVideoFrameReady;
|
2026-04-30 23:47:15 -05:00
|
|
|
_videoRenderer.Dispose();
|
|
|
|
|
_videoRenderer = null;
|
|
|
|
|
}
|
2026-05-01 01:14:13 -05:00
|
|
|
_receivedFirstFrame = false;
|
2026-04-30 23:47:15 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-04 00:14:33 -05:00
|
|
|
private void StopVideo()
|
|
|
|
|
{
|
|
|
|
|
DisposeRenderer();
|
|
|
|
|
_isAnimatedCover = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 23:47:15 -05:00
|
|
|
// ── Rendering ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public override void Render(DrawingContext context)
|
|
|
|
|
{
|
|
|
|
|
base.Render(context);
|
|
|
|
|
|
2026-05-01 01:14:13 -05:00
|
|
|
// For animated covers, render the current video frame
|
|
|
|
|
if (_isAnimatedCover && _videoRenderer?.Bitmap is { } bmp)
|
2026-04-30 23:47:15 -05:00
|
|
|
{
|
|
|
|
|
var srcW = (double)bmp.PixelSize.Width;
|
|
|
|
|
var srcH = (double)bmp.PixelSize.Height;
|
|
|
|
|
var dstW = Bounds.Width;
|
|
|
|
|
var dstH = Bounds.Height;
|
|
|
|
|
|
|
|
|
|
if (srcW > 0 && srcH > 0 && dstW > 0 && dstH > 0)
|
|
|
|
|
{
|
|
|
|
|
// UniformToFill: scale to fill, crop overflow
|
|
|
|
|
var scale = Math.Max(dstW / srcW, dstH / srcH);
|
|
|
|
|
var scaledW = srcW * scale;
|
|
|
|
|
var scaledH = srcH * scale;
|
|
|
|
|
var offsetX = (dstW - scaledW) / 2;
|
|
|
|
|
var offsetY = (dstH - scaledH) / 2;
|
|
|
|
|
|
|
|
|
|
context.DrawImage(
|
|
|
|
|
bmp,
|
|
|
|
|
new Rect(0, 0, srcW, srcH),
|
|
|
|
|
new Rect(offsetX, offsetY, scaledW, scaledH));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 12:55:23 -05:00
|
|
|
private void SetBitmap(Bitmap? bitmap)
|
|
|
|
|
{
|
|
|
|
|
HasCover = bitmap != null;
|
|
|
|
|
|
|
|
|
|
if (CoverImage != null)
|
2026-05-01 01:14:13 -05:00
|
|
|
{
|
|
|
|
|
CoverImage.IsVisible = bitmap != null;
|
2026-04-19 12:55:23 -05:00
|
|
|
CoverImage.Source = bitmap;
|
2026-05-01 01:14:13 -05:00
|
|
|
}
|
2026-04-19 12:55:23 -05:00
|
|
|
}
|
|
|
|
|
}
|