Avoid downloading depot images to disk, improve UI jank

Fixes #427
This commit is contained in:
Pat Hartl 2026-07-04 00:14:33 -05:00
parent 862f266275
commit b034736822
7 changed files with 379 additions and 66 deletions

View file

@ -174,6 +174,16 @@ namespace LANCommander.Launcher.Services
.AnyAsync(g => g.Id == gameId && g.Libraries.Any(l => l.UserId == userId));
}
public async Task<HashSet<Guid>> GetLibraryGameIdsAsync()
{
var userId = AuthenticationService.GetUserId();
var ids = await Context.Games
.Where(g => g.Libraries.Any(l => l.UserId == userId))
.Select(g => g.Id)
.ToListAsync();
return ids.ToHashSet();
}
public async Task<IEnumerable<ListItem>> GetItemsAsync()
{
Items.Clear();

View file

@ -0,0 +1,109 @@
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;
/// <summary>
/// Attached properties that asynchronously populate an <see cref="Image.Source"/> from a
/// local file path or an http(s) URL, routing through <see cref="RemoteImageCache"/> so
/// bitmaps are fetched/decoded off the UI thread and reused across the app.
///
/// Usage: <c>&lt;Image controls:AsyncImage.Source="{Binding HeroPath}" controls:AsyncImage.DecodeWidth="480" /&gt;</c>
///
/// Replaces the file-only <c>FilePathToBitmapConverter</c> where images may live on the server.
/// </summary>
public class AsyncImage : AvaloniaObject
{
public static readonly AttachedProperty<string?> SourceProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, string?>("Source");
public static readonly AttachedProperty<int> DecodeWidthProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, int>("DecodeWidth");
public static readonly AttachedProperty<int> DecodeHeightProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, int>("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<CancellationTokenSource?> LoadCtsProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, CancellationTokenSource?>("LoadCts");
static AsyncImage()
{
SourceProperty.Changed.AddClassHandler<Image>((image, _) => Reload(image));
DecodeWidthProperty.Changed.AddClassHandler<Image>((image, _) => Reload(image));
DecodeHeightProperty.Changed.AddClassHandler<Image>((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.
}
}
}

View file

@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
namespace LANCommander.Launcher.Helpers;
/// <summary>
/// Shared, RAM-only image cache for images loaded from either a local file path
/// or an http(s) URL. Decoded bitmaps are held in a byte-bounded LRU so images
/// that scroll back into view (or are revisited while navigating) render instantly
/// without re-fetching or re-decoding.
///
/// Bitmaps returned by <see cref="LoadAsync"/> / <see cref="TryGet"/> are owned by
/// the cache and must NOT be disposed by callers — eviction disposes them once they
/// fall out of the budget.
/// </summary>
public static class RemoteImageCache
{
private static readonly HttpClient _httpClient = new();
private static readonly Dictionary<string, Bitmap> _cache = new();
private static readonly LinkedList<string> _order = new();
private static readonly Dictionary<string, LinkedListNode<string>> _nodes = new();
private static readonly object _lock = new();
private static long _cacheBytes;
private const long MaxCacheBytes = 128L * 1024 * 1024;
/// <summary>
/// Synchronous fast path: returns a cached bitmap for the given source/decode size
/// if one is present, refreshing its position in the LRU. Never touches disk or network.
/// </summary>
public static bool TryGet(string source, int decodeWidth, int decodeHeight, out Bitmap? bitmap)
{
var key = BuildKey(source, decodeWidth, decodeHeight);
lock (_lock)
{
if (_cache.TryGetValue(key, out bitmap))
{
Touch(key);
return true;
}
}
bitmap = null;
return false;
}
/// <summary>
/// Returns a decoded bitmap for <paramref name="source"/> (a local file path or an
/// http(s) URL), fetching and decoding off the calling thread when not already cached.
/// Pass <paramref name="decodeWidth"/> to downscale by width, else <paramref name="decodeHeight"/>
/// to downscale by height, else both zero to decode at full resolution.
/// </summary>
public static async Task<Bitmap?> LoadAsync(string source, int decodeWidth, int decodeHeight, CancellationToken ct)
{
if (string.IsNullOrEmpty(source))
return null;
if (TryGet(source, decodeWidth, decodeHeight, out var cached))
return cached;
byte[]? data = null;
if (IsHttp(source))
{
data = await _httpClient.GetByteArrayAsync(source, ct);
if (ct.IsCancellationRequested)
return null;
}
var bitmap = await Task.Run(() => Decode(source, data, decodeWidth, decodeHeight), ct);
if (bitmap == null)
return null;
if (ct.IsCancellationRequested)
{
bitmap.Dispose();
return null;
}
return Insert(BuildKey(source, decodeWidth, decodeHeight), bitmap);
}
private static Bitmap? Decode(string source, byte[]? data, int decodeWidth, int decodeHeight)
{
Stream stream;
if (data != null)
{
stream = new MemoryStream(data);
}
else
{
if (!File.Exists(source))
return null;
stream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read);
}
using (stream)
{
if (decodeWidth > 0)
return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
if (decodeHeight > 0)
return Bitmap.DecodeToHeight(stream, decodeHeight, BitmapInterpolationMode.HighQuality);
return new Bitmap(stream);
}
}
/// <summary>
/// Inserts a freshly-decoded bitmap, or returns the already-cached instance if another
/// caller decoded the same key concurrently (disposing the duplicate). Evicts
/// least-recently-used entries until back under the memory budget.
/// </summary>
private static Bitmap Insert(string key, Bitmap bitmap)
{
lock (_lock)
{
if (_cache.TryGetValue(key, out var existing))
{
bitmap.Dispose();
Touch(key);
return existing;
}
_cache[key] = bitmap;
_nodes[key] = _order.AddLast(key);
_cacheBytes += BitmapBytes(bitmap);
// Evict LRU entries until under budget, always keeping the one just added.
while (_cacheBytes > MaxCacheBytes && _order.Count > 1)
{
var oldest = _order.First!.Value;
_order.RemoveFirst();
_nodes.Remove(oldest);
if (_cache.Remove(oldest, out var evicted))
{
_cacheBytes -= BitmapBytes(evicted);
evicted.Dispose();
}
}
return bitmap;
}
}
private static void Touch(string key)
{
if (_nodes.TryGetValue(key, out var node))
{
_order.Remove(node);
_order.AddLast(node);
}
}
private static string BuildKey(string source, int decodeWidth, int decodeHeight) =>
decodeWidth > 0 ? $"{source}|w{decodeWidth}"
: decodeHeight > 0 ? $"{source}|h{decodeHeight}"
: $"{source}|full";
private static bool IsHttp(string source) =>
source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
private static long BitmapBytes(Bitmap bitmap) =>
(long)bitmap.PixelSize.Width * bitmap.PixelSize.Height * 4;
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -140,25 +139,20 @@ public partial class DepotViewModel : ViewModelBase
allGames.Add(dg);
}
// Parallel: download covers + resolve library membership
// Resolve library membership in one query; build cover URLs (streamed on demand,
// never downloaded to disk).
var coverCache = new Dictionary<Guid, string?>();
var coverMimeCache = new Dictionary<Guid, string?>();
var librarySet = new HashSet<Guid>();
var librarySet = await libraryService.GetLibraryGameIdsAsync();
await Task.Run(async () =>
foreach (var game in allGames)
{
foreach (var game in allGames)
if (game.Cover != null)
{
if (await libraryService.IsInLibraryAsync(game.Id))
librarySet.Add(game.Id);
if (game.Cover != null)
{
coverCache[game.Id] = await GetOrDownloadMediaAsync(game.Cover, mediaClient);
coverMimeCache[game.Id] = game.Cover.MimeType;
}
coverCache[game.Id] = MediaUrl(game.Cover, mediaClient);
coverMimeCache[game.Id] = game.Cover.MimeType;
}
});
}
// ── Popular games: newest 10 (by CreatedOn desc), fetch full data for hero+logo ──
@ -426,9 +420,9 @@ public partial class DepotViewModel : ViewModelBase
var inLibrary = librarySet.Contains(game.Id);
var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover);
var coverPath = await GetOrDownloadMediaAsync(coverMedia, mediaClient);
var heroPath = await GetOrDownloadMediaAsync(game.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
var logoPath = await GetOrDownloadMediaAsync(game.Media?.FirstOrDefault(m => m.Type == MediaType.Logo), mediaClient);
var coverPath = MediaUrl(coverMedia, mediaClient);
var heroPath = MediaUrl(game.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
var logoPath = MediaUrl(game.Media?.FirstOrDefault(m => m.Type == MediaType.Logo), mediaClient);
var vm = new GameItemViewModel(depotGame, coverPath, coverMedia?.MimeType, inLibrary);
@ -449,8 +443,8 @@ public partial class DepotViewModel : ViewModelBase
try
{
var game = await gameClient.GetAsync(depotGame.Id);
return await GetOrDownloadMediaAsync(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
return MediaUrl(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
}
catch
{
@ -458,24 +452,17 @@ public partial class DepotViewModel : ViewModelBase
}
}
private static async Task<string?> GetOrDownloadMediaAsync(Media? media, MediaClient mediaClient)
// Depot media is streamed from the server on demand (see RemoteImageCache / AsyncImage),
// never persisted to disk. Still images use the server-resized thumbnail; animated
// (video) covers use the range-capable stream endpoint.
private static string? MediaUrl(Media? media, MediaClient mediaClient)
{
if (media == null)
return null;
try
{
var localPath = mediaClient.GetLocalPath(media);
if (File.Exists(localPath))
return localPath;
var file = await mediaClient.DownloadAsync(media, localPath);
return file.Exists ? file.FullName : null;
}
catch
{
return null;
}
if (media.MimeType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true)
return mediaClient.GetAbsoluteStreamUrl(media);
return mediaClient.GetAbsoluteThumbnailUrl(media);
}
}

View file

@ -59,6 +59,9 @@ public partial class Cover : UserControl
private bool _isAnimatedCover;
private bool _receivedFirstFrame;
private string? _lastLoadedSource;
// 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;
public string? Source
{
@ -210,6 +213,7 @@ public partial class Cover : UserControl
_isAnimatedCover = IsAnimatedMimeType(mimeType);
_lastLoadedSource = source;
_animatedSource = _isAnimatedCover ? source : null;
if (string.IsNullOrEmpty(source))
{
@ -217,10 +221,16 @@ public partial class Cover : UserControl
return;
}
// Route all animated covers (video, APNG, GIF) through LibVLC
// 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.
if (_isAnimatedCover)
{
LoadAnimatedCover(source);
if (AlwaysAnimate || IsPlayingAnimation)
LoadAnimatedCover(source);
else
SetBitmap(null);
return;
}
@ -438,19 +448,40 @@ public partial class Cover : UserControl
private void UpdateAnimationState()
{
if (!_isAnimatedCover || _videoRenderer?.Player == null) return;
if (!_isAnimatedCover) return;
if (AlwaysAnimate || IsPlayingAnimation)
var shouldAnimate = AlwaysAnimate || IsPlayingAnimation;
if (shouldAnimate)
{
_videoRenderer.Player.SetPause(false);
// 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);
}
}
else
else if (_videoRenderer != null)
{
_videoRenderer.Player.SetPause(true);
// 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();
}
}
private void StopVideo()
/// <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()
{
if (_videoRenderer != null)
{
@ -458,10 +489,15 @@ public partial class Cover : UserControl
_videoRenderer.Dispose();
_videoRenderer = null;
}
_isAnimatedCover = false;
_receivedFirstFrame = false;
}
private void StopVideo()
{
DisposeRenderer();
_isAnimatedCover = false;
}
// ── Rendering ────────────────────────────────────────────────────────
public override void Render(DrawingContext context)

View file

@ -1,16 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vmComponents="using:LANCommander.Launcher.ViewModels.Components"
xmlns:converters="using:LANCommander.Launcher.Converters"
xmlns:controls="using:LANCommander.Launcher.Controls"
xmlns:local="using:LANCommander.Launcher.Views.Components"
x:Class="LANCommander.Launcher.Views.Components.GenreCarouselButton"
x:Class="LANCommander.Launcher.Views.Components.GenreCarouselButton"
x:DataType="vmComponents:GenreCarouselButtomViewModel"
ClipToBounds="False">
<UserControl.Resources>
<converters:FilePathToBitmapConverter x:Key="FilePathToBitmapConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button.GenreCarouselButton">
<Setter Property="Padding" Value="0" />
@ -53,7 +49,8 @@
</Border>
<!-- Hero background image -->
<Image Source="{Binding BackgroundPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=h150}"
<Image controls:AsyncImage.Source="{Binding BackgroundPath}"
controls:AsyncImage.DecodeHeight="150"
Stretch="UniformToFill"
IsVisible="{Binding HasBackground}" />

View file

@ -1,16 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vmComponents="using:LANCommander.Launcher.ViewModels.Components"
xmlns:converters="using:LANCommander.Launcher.Converters"
xmlns:controls="using:LANCommander.Launcher.Controls"
xmlns:local="using:LANCommander.Launcher.Views.Components"
x:Class="LANCommander.Launcher.Views.Components.HeroCard"
x:DataType="vmComponents:GameItemViewModel"
ClipToBounds="False">
<UserControl.Resources>
<converters:FilePathToBitmapConverter x:Key="FilePathToBitmapConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button.HeroCard">
<Setter Property="Padding" Value="0" />
@ -41,18 +37,20 @@
CommandParameter="{Binding $parent[local:HeroCard].CommandParameter}">
<Panel>
<!-- Background: hero image -->
<Image Source="{Binding HeroPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding HeroPath}"
controls:AsyncImage.DecodeWidth="480"
Stretch="UniformToFill"
IsVisible="{Binding HeroPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Fallback: cover blurred as background when no hero -->
<!-- Fallback background when no hero: approximate a blur by decoding the
cover tiny (32px) and letting the GPU upscale it smoothly. This is a
static texture, unlike a live BlurEffect which re-renders every scroll
frame and was the main source of depot scroll jank. -->
<Panel IsVisible="{Binding HeroPath, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Image Source="{Binding CoverPath, Converter={StaticResource FilePathToBitmapConverter}}"
Stretch="UniformToFill">
<Image.Effect>
<BlurEffect Radius="20" />
</Image.Effect>
</Image>
<Image controls:AsyncImage.Source="{Binding CoverPath}"
controls:AsyncImage.DecodeWidth="32"
RenderOptions.BitmapInterpolationMode="HighQuality"
Stretch="UniformToFill" />
<Border Background="#55000000" />
</Panel>
@ -67,7 +65,7 @@
</Border>
<!-- Logo (bottom-left) -->
<Image Source="{Binding LogoPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding LogoPath}"
HorizontalAlignment="Left" VerticalAlignment="Bottom"
MaxWidth="190" MaxHeight="72"
Margin="14,0,0,14"