LANCommander/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs

466 lines
17 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.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Avalonia.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.Avalonia.ViewModels;
public partial class GameDetailViewModel : ViewModelBase
{
private readonly IServiceProvider _serviceProvider;
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? _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;
// ── 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? BackRequested;
public event EventHandler? LibraryChanged;
public event EventHandler? InstallRequested;
public event EventHandler<string>? SearchRequested;
public GameDetailViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<GameDetailViewModel>>();
// 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);
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)
MediaItems.Clear();
TagsExpanded = false;
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)
MediaItems.Add(new GameMediaItemViewModel
{
Path = path,
IsVideo = m.Type == MediaType.Video,
MimeType = string.Empty
});
}
}
OnPropertyChanged(nameof(HasMedia));
// Load action bar state
await ActionBar.LoadFromLocalGameAsync(game);
}
/// <summary>
/// Load game from server API (SDK.Models.Game)
/// Used when selecting from the depot/all games list
/// </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;
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;
}
// Reset media items and tags state while we re-load
MediaItems.Clear();
TagsExpanded = false;
OnPropertyChanged(nameof(HasMedia));
// Load action bar state
await ActionBar.LoadFromSdkGameAsync(game);
// Load media asynchronously
if (game.Media != null && game.Media.Any())
{
IsLoadingMedia = true;
try
{
using var scope = _serviceProvider.CreateScope();
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
CoverPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Cover, mediaClient);
LogoPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Logo, mediaClient);
BackgroundPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Background, mediaClient);
IconPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Icon, mediaClient);
// Screenshots and videos
foreach (var media in game.Media.Where(m =>
m.Type == MediaType.Screenshot || m.Type == MediaType.Video))
{
var path = await GetOrDownloadSingleMediaAsync(media, mediaClient);
if (path != null)
MediaItems.Add(new GameMediaItemViewModel
{
Path = path,
IsVideo = media.Type == MediaType.Video,
MimeType = media.MimeType ?? string.Empty
});
}
OnPropertyChanged(nameof(HasMedia));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load 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 async Task<string?> GetOrDownloadSingleMediaAsync(SDK.Models.Media media, MediaClient mediaClient)
{
try
{
var localPath = mediaClient.GetLocalPath(media);
if (File.Exists(localPath)) return localPath;
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
return fileInfo.Exists ? fileInfo.FullName : null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download media {MediaId}", media.Id);
return null;
}
}
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;
}
[RelayCommand]
private void GoBack()
{
ActionBar.StopRunningCheck();
BackRequested?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private void SearchFor(string term)
{
if (!string.IsNullOrWhiteSpace(term))
SearchRequested?.Invoke(this, term);
}
}