parent
862f266275
commit
b034736822
7 changed files with 379 additions and 66 deletions
|
|
@ -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();
|
||||
|
|
|
|||
109
LANCommander.Launcher/Controls/AsyncImage.cs
Normal file
109
LANCommander.Launcher/Controls/AsyncImage.cs
Normal 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><Image controls:AsyncImage.Source="{Binding HeroPath}" controls:AsyncImage.DecodeWidth="480" /></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.
|
||||
}
|
||||
}
|
||||
}
|
||||
176
LANCommander.Launcher/Helpers/RemoteImageCache.cs
Normal file
176
LANCommander.Launcher/Helpers/RemoteImageCache.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue