LANCommander/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs
Pat Hartl 38db011de8 Show library before it's fully imported
Loads the user's library from the /Library/Games endpoint and displays it immediately. This route returns a full data set for the user's games. Images are rendered directly from the server. The import still runs in the background, preserving any offline functionality.
2026-07-04 03:33:13 -05:00

543 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.ViewModels.Components;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LANCommander.Launcher.ViewModels;
public partial class GameDetailViewModel : ViewModelBase
{
/// <summary>
/// In-memory cache of carousel media metadata per game, so skeletons can be shown
/// immediately on repeat visits without waiting for the API.
/// </summary>
private static readonly ConcurrentDictionary<Guid, List<CachedMediaEntry>> _mediaCache = new();
private record CachedMediaEntry(MediaType Type, string? MimeType);
private readonly IServiceProvider _serviceProvider;
private readonly INavigationService _navigationService;
private readonly ILogger<GameDetailViewModel> _logger;
[ObservableProperty]
private Guid _id;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private string? _coverPath;
[ObservableProperty]
private string? _coverMimeType;
[ObservableProperty]
private string? _logoPath;
[ObservableProperty]
private string? _backgroundPath;
[ObservableProperty]
private string? _iconPath;
[ObservableProperty]
private DateTime _releasedOn;
[ObservableProperty]
private string _releaseYear = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
private bool _singleplayer;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(GenreList))]
private string _genres = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DeveloperList))]
private string _developers = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PublisherList))]
private string _publishers = string.Empty;
[ObservableProperty]
private string _platforms = string.Empty;
[ObservableProperty]
private string _multiplayerModes = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TagList))]
[NotifyPropertyChangedFor(nameof(VisibleTagList))]
[NotifyPropertyChangedFor(nameof(HasMoreTags))]
[NotifyPropertyChangedFor(nameof(ExtraTagCount))]
[NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
private string _tags = string.Empty;
// ── Tags expand/collapse ─────────────<E29480><E29480><EFBFBD>────────────────────────────────────
private const int TagsVisibleLimit = 5;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VisibleTagList))]
[NotifyPropertyChangedFor(nameof(HasMoreTags))]
[NotifyPropertyChangedFor(nameof(ExtraTagCount))]
[NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
private bool _tagsExpanded;
public IEnumerable<string> VisibleTagList =>
TagsExpanded ? TagList : TagList.Take(TagsVisibleLimit);
public bool HasMoreTags => TagList.Count() > TagsVisibleLimit;
public int ExtraTagCount => Math.Max(0, TagList.Count() - TagsVisibleLimit);
public string ShowMoreTagsLabel =>
TagsExpanded ? "Show less" : $"+{ExtraTagCount} more";
[RelayCommand]
private void ToggleTagsExpanded() => TagsExpanded = !TagsExpanded;
// ── Screenshots / videos ──────────────────────<E29480><E29480><EFBFBD>────────────────────────<E29480><E29480><EFBFBD>──
public ObservableCollection<GameMediaItemViewModel> MediaItems { get; } = new();
public bool HasMedia => MediaItems.Count > 0;
// ── Tools ─────────────────────────────────────────────────────────────────
public ObservableCollection<ToolItemViewModel> Tools { get; } = new();
public bool HasTools => Tools.Count > 0;
// ── Multiplayer modes ─────────────────────────────────────────────────────
public ObservableCollection<string> MultiplayerModeDetails { get; } = new();
// ── Other ─────────────────────────<E29480><E29480><EFBFBD>───────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(BackLabel))]
private bool _fromLibrary;
public string BackLabel => FromLibrary ? "Back to Library" : "Back to Depot";
// Split list properties for chip rendering
public IEnumerable<string> GenreList => SplitCsv(Genres);
public IEnumerable<string> DeveloperList => SplitCsv(Developers);
public IEnumerable<string> PublisherList => SplitCsv(Publishers);
public IEnumerable<string> TagList => SplitCsv(Tags);
private static IEnumerable<string> SplitCsv(string csv) =>
csv.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
private bool _hasMultiplayer;
public bool HasPlayerInfo => Singleplayer || HasMultiplayer;
[ObservableProperty]
private bool _isLoadingMedia;
private bool _isOfflineMode;
public bool IsOfflineMode
{
get => _isOfflineMode;
set
{
if (SetProperty(ref _isOfflineMode, value))
{
ActionBar.IsOfflineMode = value;
}
}
}
// Action bar component
public GameActionBarViewModel ActionBar { get; }
public event EventHandler? LibraryChanged;
public event EventHandler? InstallRequested;
public event EventHandler<string>? SearchRequested;
public GameDetailViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<GameDetailViewModel>>();
_navigationService = serviceProvider.GetRequiredService<INavigationService>();
// Create action bar and wire up events
ActionBar = new GameActionBarViewModel(serviceProvider);
ActionBar.LibraryChanged += (_, _) => LibraryChanged?.Invoke(this, EventArgs.Empty);
ActionBar.InstallRequested += (_, _) => InstallRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Refreshes the install status from the database.
/// Called after an installation completes.
/// </summary>
public async Task RefreshInstallStatusAsync()
{
if (Id == Guid.Empty)
return;
await ActionBar.RefreshAsync();
}
/// <summary>
/// Load game from server API (SDK.Models.Game)
/// Used when selecting from the depot/all games list.
/// Loads essential media (cover, logo, background, icon) and metadata inline,
/// then loads screenshots/videos in the background so the UI is not blocked.
/// </summary>
public async Task LoadGameAsync(SDK.Models.Game game)
{
Id = game.Id;
Title = game.Title ?? "Unknown";
Description = game.Description ?? string.Empty;
ReleasedOn = game.ReleasedOn;
ReleaseYear = game.ReleasedOn.Year > 1 ? game.ReleasedOn.Year.ToString() : "Unknown";
Singleplayer = game.Singleplayer;
// Reset media paths while loading
CoverPath = null;
CoverMimeType = null;
LogoPath = null;
BackgroundPath = null;
IconPath = null;
// Collections
Genres = game.Genres != null
? string.Join(", ", game.Genres.Select(g => g.Name))
: string.Empty;
Developers = game.Developers != null
? string.Join(", ", game.Developers.Select(d => d.Name))
: string.Empty;
Publishers = game.Publishers != null
? string.Join(", ", game.Publishers.Select(p => p.Name))
: string.Empty;
Platforms = game.Platforms != null
? string.Join(", ", game.Platforms.Select(p => p.Name))
: string.Empty;
Tags = game.Tags != null
? string.Join(", ", game.Tags.Select(t => t.Name))
: string.Empty;
// Multiplayer info
HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
MultiplayerModeDetails.Clear();
if (HasMultiplayer)
{
var modes = game.MultiplayerModes!
.Select(m => m.Type.ToString())
.Distinct();
MultiplayerModes = string.Join(", ", modes);
foreach (var mode in game.MultiplayerModes!)
MultiplayerModeDetails.Add(FormatMultiplayerMode(mode));
}
else
MultiplayerModes = string.Empty;
// Tools
Tools.Clear();
await LoadToolsAsync(game);
OnPropertyChanged(nameof(HasTools));
// Reset media items and tags state while we re-load
MediaItems.Clear();
TagsExpanded = false;
// Determine carousel media entries and populate skeletons immediately.
// Use the game's media list if available, otherwise fall back to cache.
var carouselMediaEntries = game.Media?
.Where(m => m.Type == MediaType.Screenshot || m.Type == MediaType.Video)
.ToList();
if (carouselMediaEntries == null || carouselMediaEntries.Count == 0)
{
// Try cached metadata from a previous visit
if (_mediaCache.TryGetValue(game.Id, out var cached))
foreach (var _ in cached)
MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
}
else
{
// Cache the media metadata for future visits
_mediaCache[game.Id] = carouselMediaEntries
.Select(m => new CachedMediaEntry(m.Type, m.MimeType))
.ToList();
// Add skeleton placeholders so the carousel renders immediately
foreach (var _ in carouselMediaEntries)
MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
}
OnPropertyChanged(nameof(HasMedia));
// Load action bar state
await ActionBar.LoadFromSdkGameAsync(game);
if (game.Media != null && game.Media.Any())
{
// Start loading screenshots/videos in the background immediately so videos
// begin streaming without waiting on the essential media downloads below.
_ = LoadCarouselMediaAsync(game);
// Load essential media (cover, logo, background, icon) — needed for page layout
try
{
using var scope = _serviceProvider.CreateScope();
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
CoverPath = ResolveEssentialMediaPath(game.Media, MediaType.Cover, mediaClient);
CoverMimeType = game.Media.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
LogoPath = ResolveEssentialMediaPath(game.Media, MediaType.Logo, mediaClient);
BackgroundPath = ResolveEssentialMediaPath(game.Media, MediaType.Background, mediaClient);
IconPath = ResolveEssentialMediaPath(game.Media, MediaType.Icon, mediaClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load essential media for game {GameId}", game.Id);
}
}
}
/// <summary>
/// Loads screenshots and videos into the media carousel in the background.
/// Videos are set up immediately so they begin streaming right away; screenshots
/// are downloaded/decoded concurrently and pop in as each one finishes, so no item
/// blocks the others.
/// </summary>
private async Task LoadCarouselMediaAsync(SDK.Models.Game game)
{
if (game.Media == null)
return;
var carouselMedia = game.Media.Where(m =>
m.Type == MediaType.Screenshot || m.Type == MediaType.Video).ToList();
if (!carouselMedia.Any())
return;
IsLoadingMedia = true;
try
{
using var scope = _serviceProvider.CreateScope();
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
var screenshotTasks = new List<Task>();
for (var i = 0; i < carouselMedia.Count; i++)
{
var index = i;
var media = carouselMedia[i];
if (media.Type == MediaType.Video)
{
// Videos only need a stream URL — set them immediately so they don't
// wait behind screenshot downloads.
ReplaceMediaItem(index, new GameMediaItemViewModel
{
IsVideo = true,
MimeType = media.MimeType ?? string.Empty,
Path = mediaClient.GetAbsoluteStreamUrl(media)
});
}
else
{
screenshotTasks.Add(LoadScreenshotAsync(index, media, mediaClient));
}
}
await Task.WhenAll(screenshotTasks);
// Remove any remaining skeletons (e.g. if some items failed to load)
for (var i = MediaItems.Count - 1; i >= 0; i--)
{
if (MediaItems[i].IsSkeleton)
MediaItems.RemoveAt(i);
}
OnPropertyChanged(nameof(HasMedia));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load carousel media for game {GameId}", game.Id);
}
finally
{
IsLoadingMedia = false;
}
}
/// <summary>
/// Downloads (if needed) and decodes a single screenshot, then swaps it into the
/// carousel in place of its skeleton. Runs concurrently with other screenshots.
/// </summary>
private async Task LoadScreenshotAsync(int index, SDK.Models.Media media, MediaClient mediaClient)
{
try
{
var localPath = mediaClient.GetLocalPath(media);
if (!File.Exists(localPath))
{
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
localPath = fileInfo.FullName;
}
// Decode downscaled: the carousel slot is 384 logical px, so ~2x covers
// HiDPI/UniformToFill without holding the full-resolution source in memory.
// The lightbox loads the full-res image from Path when it needs it.
var bitmap = await Task.Run(() =>
{
using var stream = File.OpenRead(localPath);
return Bitmap.DecodeToWidth(stream, 768, BitmapInterpolationMode.HighQuality);
});
ReplaceMediaItem(index, new GameMediaItemViewModel
{
IsVideo = false,
MimeType = media.MimeType ?? string.Empty,
Path = localPath,
ImageSource = bitmap
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load media {MediaId} from server", media.Id);
}
}
/// <summary>
/// Replaces the skeleton at <paramref name="index"/> with the loaded item, or appends
/// it if the index no longer points at a skeleton. Always invoked on the UI thread via
/// awaited continuations, so collection access is serialized.
/// </summary>
private void ReplaceMediaItem(int index, GameMediaItemViewModel item)
{
if (index < MediaItems.Count && MediaItems[index].IsSkeleton)
MediaItems[index] = item;
else
MediaItems.Add(item);
OnPropertyChanged(nameof(HasMedia));
}
private static string FormatMultiplayerMode(Data.Models.MultiplayerMode mode) =>
FormatMultiplayerMode(mode.Type, mode.MinPlayers, mode.MaxPlayers);
private static string FormatMultiplayerMode(SDK.Models.MultiplayerMode mode) =>
FormatMultiplayerMode(mode.Type, mode.MinPlayers, mode.MaxPlayers);
private static string FormatMultiplayerMode(SDK.Enums.MultiplayerType type, int minPlayers, int maxPlayers)
{
var typeLabel = type switch
{
SDK.Enums.MultiplayerType.Local => "Local Multiplayer",
SDK.Enums.MultiplayerType.LAN => "LAN Multiplayer",
SDK.Enums.MultiplayerType.Online => "Online Multiplayer",
_ => type.ToString()
};
if (maxPlayers > 0)
{
var range = minPlayers > 1 && minPlayers < maxPlayers
? $"{minPlayers}{maxPlayers} players"
: $"Up to {maxPlayers} players";
return $"{typeLabel} · {range}";
}
return typeLabel;
}
// Resolves an essential asset (cover/logo/background/icon) to a local path when the import has
// already cached it, otherwise a server stream URL so it renders instantly without blocking on a
// download. The background import still caches these to disk for offline use.
private static string? ResolveEssentialMediaPath(System.Collections.Generic.IEnumerable<SDK.Models.Media> mediaCollection, MediaType type, MediaClient mediaClient)
=> MediaSourceResolver.Resolve(mediaCollection.FirstOrDefault(m => m.Type == type), mediaClient);
private async Task LoadToolsAsync(SDK.Models.Game game)
{
try
{
using var scope = _serviceProvider.CreateScope();
var gameClient = scope.ServiceProvider.GetRequiredService<GameClient>();
var toolService = scope.ServiceProvider.GetRequiredService<LANCommander.Launcher.Services.ToolService>();
var tools = game.Tools;
if (tools == null || !tools.Any())
{
tools = await gameClient.GetToolsAsync(game.Id);
}
if (tools != null)
{
foreach (var tool in tools)
{
var isInstalled = await toolService.IsToolInstalledForGameAsync(game.Id, tool.Id);
Tools.Add(new ToolItemViewModel(tool, isInstalled));
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load tools for game {GameId}", game.Id);
}
}
[RelayCommand]
private void GoBack()
{
ActionBar.StopRunningCheck();
_navigationService.GoBack();
}
[RelayCommand]
private void SearchFor(string term)
{
if (!string.IsNullOrWhiteSpace(term))
SearchRequested?.Invoke(this, term);
}
}
/// <summary>
/// ViewModel for a tool associated with a game.
/// </summary>
public partial class ToolItemViewModel : ViewModelBase
{
public Guid Id { get; }
public string Name { get; }
public bool IsInstalled { get; }
public ToolItemViewModel(SDK.Models.Tool tool, bool isInstalled)
{
Id = tool.Id;
Name = tool.Name ?? "Unknown Tool";
IsInstalled = isInstalled;
}
}