diff --git a/LANCommander.Launcher.Services/LibraryService.cs b/LANCommander.Launcher.Services/LibraryService.cs index fdcfdbc1..a25196ef 100644 --- a/LANCommander.Launcher.Services/LibraryService.cs +++ b/LANCommander.Launcher.Services/LibraryService.cs @@ -174,6 +174,16 @@ namespace LANCommander.Launcher.Services .AnyAsync(g => g.Id == gameId && g.Libraries.Any(l => l.UserId == userId)); } + public async Task> 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> GetItemsAsync() { Items.Clear(); diff --git a/LANCommander.Launcher/Controls/AsyncImage.cs b/LANCommander.Launcher/Controls/AsyncImage.cs new file mode 100644 index 00000000..afa8be8c --- /dev/null +++ b/LANCommander.Launcher/Controls/AsyncImage.cs @@ -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; + +/// +/// 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. + } + } +} diff --git a/LANCommander.Launcher/Helpers/RemoteImageCache.cs b/LANCommander.Launcher/Helpers/RemoteImageCache.cs new file mode 100644 index 00000000..bd44d5fb --- /dev/null +++ b/LANCommander.Launcher/Helpers/RemoteImageCache.cs @@ -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; + +/// +/// 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 / are owned by +/// the cache and must NOT be disposed by callers — eviction disposes them once they +/// fall out of the budget. +/// +public static class RemoteImageCache +{ + private static readonly HttpClient _httpClient = new(); + + private static readonly Dictionary _cache = new(); + private static readonly LinkedList _order = new(); + private static readonly Dictionary> _nodes = new(); + private static readonly object _lock = new(); + private static long _cacheBytes; + private const long MaxCacheBytes = 128L * 1024 * 1024; + + /// + /// 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. + /// + 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; + } + + /// + /// Returns a decoded bitmap for (a local file path or an + /// http(s) URL), fetching and decoding off the calling thread when not already cached. + /// Pass to downscale by width, else + /// to downscale by height, else both zero to decode at full resolution. + /// + public static async Task 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); + } + } + + /// + /// 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. + /// + 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; +} diff --git a/LANCommander.Launcher/ViewModels/DepotViewModel.cs b/LANCommander.Launcher/ViewModels/DepotViewModel.cs index ea2a1b7a..8948620b 100644 --- a/LANCommander.Launcher/ViewModels/DepotViewModel.cs +++ b/LANCommander.Launcher/ViewModels/DepotViewModel.cs @@ -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(); var coverMimeCache = new Dictionary(); - var librarySet = new HashSet(); + 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 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); } } diff --git a/LANCommander.Launcher/Views/Components/Cover.axaml.cs b/LANCommander.Launcher/Views/Components/Cover.axaml.cs index 4190cab2..30ba966a 100644 --- a/LANCommander.Launcher/Views/Components/Cover.axaml.cs +++ b/LANCommander.Launcher/Views/Components/Cover.axaml.cs @@ -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() + /// + /// Tears down the video renderer without clearing , + /// so the cover can restart on a later hover. Contrast with , + /// which fully resets animated state when the source itself changes. + /// + 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) diff --git a/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml b/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml index 38e131cf..35273283 100644 --- a/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml +++ b/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml @@ -1,16 +1,12 @@ - - - - - +