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 { /// /// In-memory cache of carousel media metadata per game, so skeletons can be shown /// immediately on repeat visits without waiting for the API. /// private static readonly ConcurrentDictionary> _mediaCache = new(); private record CachedMediaEntry(MediaType Type, string? MimeType); private readonly IServiceProvider _serviceProvider; private readonly INavigationService _navigationService; private readonly ILogger _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 ─────────────���──────────────────────────────────── private const int TagsVisibleLimit = 5; [ObservableProperty] [NotifyPropertyChangedFor(nameof(VisibleTagList))] [NotifyPropertyChangedFor(nameof(HasMoreTags))] [NotifyPropertyChangedFor(nameof(ExtraTagCount))] [NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))] private bool _tagsExpanded; public IEnumerable 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 ──────────────────────���────────────────────────���── public ObservableCollection MediaItems { get; } = new(); public bool HasMedia => MediaItems.Count > 0; // ── Tools ───────────────────────────────────────────────────────────────── public ObservableCollection Tools { get; } = new(); public bool HasTools => Tools.Count > 0; // ── Multiplayer modes ───────────────────────────────────────────────────── public ObservableCollection MultiplayerModeDetails { get; } = new(); // ── Other ─────────────────────────���─────────────────────────────────────── [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 GenreList => SplitCsv(Genres); public IEnumerable DeveloperList => SplitCsv(Developers); public IEnumerable PublisherList => SplitCsv(Publishers); public IEnumerable TagList => SplitCsv(Tags); private static IEnumerable 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? SearchRequested; public GameDetailViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _logger = serviceProvider.GetRequiredService>(); _navigationService = serviceProvider.GetRequiredService(); // 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); } /// /// Refreshes the install status from the database. /// Called after an installation completes. /// public async Task RefreshInstallStatusAsync() { if (Id == Guid.Empty) return; await ActionBar.RefreshAsync(); } /// /// 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. /// 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(); 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); } } } /// /// 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. /// 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(); var screenshotTasks = new List(); 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; } } /// /// Downloads (if needed) and decodes a single screenshot, then swaps it into the /// carousel in place of its skeleton. Runs concurrently with other screenshots. /// 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); } } /// /// Replaces the skeleton at 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. /// 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; } private async Task GetOrDownloadMediaPathAsync(System.Collections.Generic.IEnumerable 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(); var toolService = scope.ServiceProvider.GetRequiredService(); 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); } } /// /// ViewModel for a tool associated with a game. /// 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; } }