LANCommander/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs

689 lines
24 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 local cache (Data.Models.Game)
/// Used when selecting from the library sidebar
/// </summary>
public async void LoadGame(Data.Models.Game game)
{
Id = game.Id;
Title = game.Title ?? "Unknown";
Description = game.Description ?? string.Empty;
ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
ReleaseYear = game.ReleasedOn?.Year > 1 ? game.ReleasedOn.Value.Year.ToString() : "Unknown";
Singleplayer = game.Singleplayer;
// Get media paths from local storage
using var scope = _serviceProvider.CreateScope();
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
CoverPath = GetLocalMediaPath(game.Media, MediaType.Cover, mediaService);
CoverMimeType = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
LogoPath = GetLocalMediaPath(game.Media, MediaType.Logo, mediaService);
BackgroundPath = GetLocalMediaPath(game.Media, MediaType.Background, mediaService);
IconPath = GetLocalMediaPath(game.Media, MediaType.Icon, mediaService);
// 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;
// Media items (screenshots / videos from local cache)
// Collect paths first, add skeletons for screenshots, then lazy-load bitmaps off the UI thread
MediaItems.Clear();
TagsExpanded = false;
var mediaEntries = new List<(string path, int index)>();
if (game.Media != null)
{
foreach (var m in game.Media.Where(m =>
m.Type == MediaType.Screenshot || m.Type == MediaType.Video))
{
var path = mediaService.FileExists(m) ? mediaService.GetImagePath(m) : null;
if (path == null)
continue;
if (m.Type == MediaType.Video)
{
MediaItems.Add(new GameMediaItemViewModel
{
Path = path,
IsVideo = true,
MimeType = string.Empty
});
}
else
{
var index = MediaItems.Count;
MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
mediaEntries.Add((path, index));
}
}
}
OnPropertyChanged(nameof(HasMedia));
// Tools
Tools.Clear();
if (game.Tools != null)
{
foreach (var tool in game.Tools)
Tools.Add(new ToolItemViewModel(tool));
}
OnPropertyChanged(nameof(HasTools));
// Load action bar state
await ActionBar.LoadFromLocalGameAsync(game);
// Load screenshot bitmaps on background thread, replacing skeletons as they complete
if (mediaEntries.Count > 0)
IsLoadingMedia = true;
foreach (var entry in mediaEntries)
{
try
{
var bitmap = await Task.Run(() => new Bitmap(entry.path));
MediaItems[entry.index] = new GameMediaItemViewModel
{
Path = entry.path,
IsVideo = false,
MimeType = string.Empty,
ImageSource = bitmap
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load local screenshot {Path}", entry.path);
}
}
// Remove any skeletons that failed to load
for (var i = MediaItems.Count - 1; i >= 0; i--)
{
if (MediaItems[i].IsSkeleton)
MediaItems.RemoveAt(i);
}
OnPropertyChanged(nameof(HasMedia));
if (mediaEntries.Count > 0)
IsLoadingMedia = false;
}
/// <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);
// Load essential media (cover, logo, background, icon) — needed for page layout
if (game.Media != null && game.Media.Any())
{
try
{
using var scope = _serviceProvider.CreateScope();
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
CoverPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Cover, mediaClient);
CoverMimeType = game.Media.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
LogoPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Logo, mediaClient);
BackgroundPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Background, mediaClient);
IconPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Icon, mediaClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load essential media for game {GameId}", game.Id);
}
// Load screenshots/videos in the background — don't block navigation
_ = LoadCarouselMediaAsync(game);
}
}
/// <summary>
/// Loads screenshots and videos into the media carousel in the background.
/// Replaces skeleton placeholders with real items as they load.
/// </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>();
for (var i = 0; i < carouselMedia.Count; i++)
{
var media = carouselMedia[i];
try
{
var item = new GameMediaItemViewModel
{
IsVideo = media.Type == MediaType.Video,
MimeType = media.MimeType ?? string.Empty
};
if (media.Type == MediaType.Video)
item.Path = mediaClient.GetAbsoluteStreamUrl(media);
else
{
var localPath = mediaClient.GetLocalPath(media);
if (!File.Exists(localPath))
{
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
localPath = fileInfo.FullName;
}
item.Path = localPath;
item.ImageSource = await Task.Run(() => new Bitmap(localPath));
}
// Replace skeleton at the same position, or append if index is out of range
if (i < MediaItems.Count && MediaItems[i].IsSkeleton)
MediaItems[i] = item;
else
MediaItems.Add(item);
OnPropertyChanged(nameof(HasMedia));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load media {MediaId} from server", media.Id);
}
}
// 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;
}
}
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;
}
private string? GetLocalMediaPath(System.Collections.Generic.ICollection<Data.Models.Media>? mediaCollection, MediaType type, MediaService mediaService)
{
var media = mediaCollection?.FirstOrDefault(m => m.Type == type);
if (media == null)
return null;
var path = mediaService.GetImagePath(media);
return mediaService.FileExists(media) ? path : null;
}
private async Task<string?> GetOrDownloadMediaPathAsync(System.Collections.Generic.IEnumerable<SDK.Models.Media> mediaCollection, MediaType type, MediaClient mediaClient)
{
var media = mediaCollection.FirstOrDefault(m => m.Type == type);
if (media == null)
return null;
try
{
var localPath = mediaClient.GetLocalPath(media);
// Check if file exists locally
if (File.Exists(localPath))
return localPath;
// Download the media
_logger.LogDebug("Downloading media {MediaId} of type {Type}", media.Id, type);
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
if (fileInfo.Exists)
return fileInfo.FullName;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get or download media {MediaId}", media.Id);
}
return null;
}
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 localTool = await toolService.GetAsync(tool.Id);
Tools.Add(new ToolItemViewModel(tool, localTool));
}
}
}
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, Data.Models.Tool? localTool)
{
Id = tool.Id;
Name = tool.Name ?? "Unknown Tool";
IsInstalled = localTool?.Installed ?? false;
}
public ToolItemViewModel(Data.Models.Tool tool)
{
Id = tool.Id;
Name = tool.Name ?? "Unknown Tool";
IsInstalled = tool.Installed;
}
}