using System;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LANCommander.Launcher.Helpers;
namespace LANCommander.Launcher.Controls;
///
/// Attached properties that asynchronously populate an from a
/// local file path or an http(s) URL, routing through so
/// bitmaps are fetched/decoded off the UI thread and reused across the app.
///
/// Usage: <Image controls:AsyncImage.Source="{Binding HeroPath}" controls:AsyncImage.DecodeWidth="480" />
///
/// Replaces the file-only FilePathToBitmapConverter where images may live on the server.
///
public class AsyncImage : AvaloniaObject
{
public static readonly AttachedProperty SourceProperty =
AvaloniaProperty.RegisterAttached("Source");
public static readonly AttachedProperty DecodeWidthProperty =
AvaloniaProperty.RegisterAttached("DecodeWidth");
public static readonly AttachedProperty DecodeHeightProperty =
AvaloniaProperty.RegisterAttached("DecodeHeight");
// Per-Image cancellation token for the in-flight load, so rapid source changes
// (e.g. carousel container recycling) don't race to set a stale bitmap.
private static readonly AttachedProperty LoadCtsProperty =
AvaloniaProperty.RegisterAttached("LoadCts");
static AsyncImage()
{
SourceProperty.Changed.AddClassHandler((image, _) => Reload(image));
DecodeWidthProperty.Changed.AddClassHandler((image, _) => Reload(image));
DecodeHeightProperty.Changed.AddClassHandler((image, _) => Reload(image));
}
public static string? GetSource(Image image) => image.GetValue(SourceProperty);
public static void SetSource(Image image, string? value) => image.SetValue(SourceProperty, value);
public static int GetDecodeWidth(Image image) => image.GetValue(DecodeWidthProperty);
public static void SetDecodeWidth(Image image, int value) => image.SetValue(DecodeWidthProperty, value);
public static int GetDecodeHeight(Image image) => image.GetValue(DecodeHeightProperty);
public static void SetDecodeHeight(Image image, int value) => image.SetValue(DecodeHeightProperty, value);
private static void Reload(Image image)
{
var previous = image.GetValue(LoadCtsProperty);
previous?.Cancel();
previous?.Dispose();
image.SetValue(LoadCtsProperty, null);
var source = GetSource(image);
if (string.IsNullOrEmpty(source))
{
image.Source = null;
return;
}
var width = GetDecodeWidth(image);
var height = GetDecodeHeight(image);
// Instant path: already decoded, avoid a flash of empty space on scroll-back.
if (RemoteImageCache.TryGet(source, width, height, out var cached))
{
image.Source = cached;
return;
}
image.Source = null;
var cts = new CancellationTokenSource();
image.SetValue(LoadCtsProperty, cts);
LoadAsync(image, source, width, height, cts);
}
private static async void LoadAsync(Image image, string source, int width, int height, CancellationTokenSource cts)
{
try
{
var bitmap = await RemoteImageCache.LoadAsync(source, width, height, cts.Token);
if (bitmap == null || cts.IsCancellationRequested)
return;
// Apply at Background priority so a burst of image completions (the whole
// depot realizes at once — carousels aren't virtualized) yields to scroll
// input and rendering instead of forcing a layout pass ahead of them.
await Dispatcher.UIThread.InvokeAsync(() =>
{
// Only apply if this load is still the current one for this image.
if (!cts.IsCancellationRequested && GetSource(image) == source)
image.Source = bitmap;
}, DispatcherPriority.Background);
}
catch
{
// Network/decoding failures leave the image blank; visibility is driven by the path binding.
}
}
}