using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LANCommander.Launcher.ViewModels.Components; using LANCommander.Launcher.Services; using LANCommander.SDK.Enums; using LANCommander.SDK.Models; using LANCommander.SDK.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LANCommander.Launcher.ViewModels; public partial class DepotViewModel : ViewModelBase { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; // Serializes loads so overlapping invocations (e.g. a depot load still in flight // when LibraryChanged fires on install/uninstall) never mutate the carousels // concurrently, which corrupts the ObservableCollections. private readonly SemaphoreSlim _loadLock = new(1, 1); // ── State ──────────────────────────────────────────────────────────────── [ObservableProperty] private bool _isLoading; [ObservableProperty] private bool _hasError; [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isOfflineMode; // ── Section visibility ──────────────────────────────────────────────────── [ObservableProperty] private bool _hasPopularGames; [ObservableProperty] private bool _hasNewReleases; [ObservableProperty] private bool _hasMultiplayerGames; [ObservableProperty] private bool _hasBacklogGames; [ObservableProperty] private bool _hasBrowseData; [ObservableProperty] private bool _hasBrowseGenres; [ObservableProperty] private bool _hasBrowseTags; [ObservableProperty] private bool _hasBrowseCollections; [ObservableProperty] private bool _hasContent; // ── Carousels ───────────────────────────────────────────────────────────── public ObservableCollection PopularGames { get; } = new(); public ObservableCollection NewReleases { get; } = new(); public ObservableCollection MultiplayerGames { get; } = new(); public ObservableCollection BacklogGames { get; } = new(); // ── Browse data ─────────────────────────────────────────────────────────── public ObservableCollection BrowseGenres { get; } = new(); public ObservableCollection BrowseTags { get; } = new(); public ObservableCollection BrowseCollections { get; } = new(); // ── Search ──────────────────────────────────────────────────────────────── [ObservableProperty] private string _searchText = string.Empty; // ── Events ──────────────────────────────────────────────────────────────── public event EventHandler? GameSelected; public event EventHandler? SearchRequested; public event EventHandler? BrowseByGenreRequested; public event EventHandler? BrowseByTagRequested; public event EventHandler? BrowseByCollectionRequested; public event EventHandler? BrowseAllRequested; // ───────────────────────────────────────────────────────────────────────── public DepotViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _logger = serviceProvider.GetRequiredService>(); } public Task LoadAsync() => LoadInternalAsync(); [RelayCommand] private async Task LoadInternalAsync() { await _loadLock.WaitAsync(); try { await LoadCoreAsync(); } finally { _loadLock.Release(); } } private async Task LoadCoreAsync() { IsLoading = true; HasError = false; PopularGames.Clear(); NewReleases.Clear(); MultiplayerGames.Clear(); BacklogGames.Clear(); BrowseGenres.Clear(); BrowseTags.Clear(); BrowseCollections.Clear(); _logger.LogInformation("Loading depot home (offline: {IsOffline})", IsOfflineMode); try { using var scope = _serviceProvider.CreateScope(); if (IsOfflineMode) { await LoadOfflineAsync(scope); return; } var depotService = scope.ServiceProvider.GetRequiredService(); var libraryService = scope.ServiceProvider.GetRequiredService(); var mediaClient = scope.ServiceProvider.GetRequiredService(); var gameClient = scope.ServiceProvider.GetRequiredService(); var depotItems = await depotService.GetItemsAsync(); // Collect base game list var allGames = new List(); foreach (var item in depotItems ?? []) { if (item.DataItem is DepotGame dg && dg.Type != GameType.Mod && dg.Type != GameType.Expansion) allGames.Add(dg); } // Resolve library membership in one query; build cover URLs (streamed on demand, // never downloaded to disk). var coverCache = new Dictionary(); var coverMimeCache = new Dictionary(); var librarySet = await libraryService.GetLibraryGameIdsAsync(); foreach (var game in allGames) { if (game.Cover != null) { 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 ── var popularCandidates = allGames .OrderByDescending(g => g.CreatedOn) .Take(10) .ToList(); var popularFull = await Task.WhenAll( popularCandidates.Select(g => FetchGameWithMediaAsync(g, librarySet, mediaClient, gameClient))); foreach (var vm in popularFull.Where(v => v != null)) PopularGames.Add(vm!); // ── New Releases: top 20 by ReleasedOn desc ────────────────────────────────────── foreach (var game in allGames.OrderByDescending(g => g.ReleasedOn).Take(20)) NewReleases.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), coverMimeCache.GetValueOrDefault(game.Id), librarySet.Contains(game.Id))); // ── Multiplayer: games with any multiplayer mode ────────────────────────────────── foreach (var game in allGames .Where(g => g.MultiplayerModes?.Any() == true) .OrderBy(g => g.SortTitle ?? g.Title) .Take(20)) MultiplayerGames.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), coverMimeCache.GetValueOrDefault(game.Id), librarySet.Contains(game.Id))); // ── Backlog: library games ──────────────────────────────────────────────────────── foreach (var game in allGames .Where(g => librarySet.Contains(g.Id)) .OrderBy(g => g.SortTitle ?? g.Title) .Take(20)) BacklogGames.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), coverMimeCache.GetValueOrDefault(game.Id), inLibrary: true, showInLibraryBadge: false)); // ── Browse data ─────────────────────────────────────────────────────────────────── var genreGamesMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); var collectionGamesMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); var tagSet = new SortedSet(StringComparer.OrdinalIgnoreCase); foreach (var game in allGames) { if (game.Genres != null) foreach (var g in game.Genres) if (!string.IsNullOrEmpty(g.Name)) { if (!genreGamesMap.TryGetValue(g.Name, out var list)) genreGamesMap[g.Name] = list = new List(); list.Add(game); } if (game.Collections != null) foreach (var c in game.Collections) if (!string.IsNullOrEmpty(c.Name)) { if (!collectionGamesMap.TryGetValue(c.Name, out var list)) collectionGamesMap[c.Name] = list = new List(); list.Add(game); } if (game.Tags != null) foreach (var t in game.Tags) if (!string.IsNullOrEmpty(t.Name)) tagSet.Add(t.Name); } // Reuse hero paths already downloaded for popular games where possible; // otherwise fetch the background for a representative game from that genre. var popularHeroMap = popularFull .Where(v => v != null && !string.IsNullOrEmpty(v!.HeroPath)) .ToDictionary(v => v!.Id, v => v!.HeroPath); var rng = new Random(); var genreHeroTasks = genreGamesMap .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) .Select(async kv => { var popularRep = kv.Value.FirstOrDefault(g => popularHeroMap.ContainsKey(g.Id)); if (popularRep != null) return (Name: kv.Key, HeroPath: popularHeroMap[popularRep.Id]); var rep = kv.Value[rng.Next(kv.Value.Count)]; var heroPath = await FetchGameHeroAsync(rep, mediaClient, gameClient); return (Name: kv.Key, HeroPath: heroPath); }) .ToList(); var genreHeroes = await Task.WhenAll(genreHeroTasks); foreach (var (name, heroPath) in genreHeroes) BrowseGenres.Add(new GenreCarouselButtomViewModel(new Genre { Name = name }, heroPath)); var collectionHeroTasks = collectionGamesMap .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) .Select(async kv => { var popularRep = kv.Value.FirstOrDefault(g => popularHeroMap.ContainsKey(g.Id)); if (popularRep != null) return (Name: kv.Key, HeroPath: popularHeroMap[popularRep.Id]); var rep = kv.Value[rng.Next(kv.Value.Count)]; var heroPath = await FetchGameHeroAsync(rep, mediaClient, gameClient); return (Name: kv.Key, HeroPath: heroPath); }) .ToList(); var collectionHeroes = await Task.WhenAll(collectionHeroTasks); foreach (var (name, heroPath) in collectionHeroes) BrowseCollections.Add(new GenreCarouselButtomViewModel(new Genre { Name = name }, heroPath)); foreach (var name in tagSet) BrowseTags.Add(name); // Update visibility flags HasPopularGames = PopularGames.Count > 0; HasNewReleases = NewReleases.Count > 0; HasMultiplayerGames = MultiplayerGames.Count > 0; HasBacklogGames = BacklogGames.Count > 0; HasBrowseGenres = BrowseGenres.Count > 0; HasBrowseTags = BrowseTags.Count > 0; HasBrowseCollections = BrowseCollections.Count > 0; HasBrowseData = HasBrowseGenres || HasBrowseTags || HasBrowseCollections; HasContent = HasPopularGames || HasNewReleases || HasMultiplayerGames || HasBacklogGames || HasBrowseData; _logger.LogInformation( "Depot home loaded — popular:{P} new:{N} mp:{M} backlog:{B}", PopularGames.Count, NewReleases.Count, MultiplayerGames.Count, BacklogGames.Count); } catch (Exception ex) { _logger.LogError(ex, "Failed to load depot home"); StatusMessage = $"Failed to load: {ex.Message}"; HasError = true; } finally { IsLoading = false; } } private async Task LoadOfflineAsync(IServiceScope scope) { var gameService = scope.ServiceProvider.GetRequiredService(); var mediaService = scope.ServiceProvider.GetRequiredService(); var localGames = await gameService.GetAsync() ?? []; foreach (var game in localGames.OrderBy(g => g.SortTitle ?? g.Title)) { string? GetLocalPath(MediaType type) { var media = game.Media?.FirstOrDefault(m => m.Type == type); return (media != null && mediaService.FileExists(media)) ? mediaService.GetImagePath(media) : null; } var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover); var vm = new GameItemViewModel(game, GetLocalPath(MediaType.Cover), coverMedia?.MimeType, inLibrary: true, showInLibraryBadge: false); vm.HeroPath = GetLocalPath(MediaType.Background); vm.LogoPath = GetLocalPath(MediaType.Logo); PopularGames.Add(vm); BacklogGames.Add(vm); } HasPopularGames = PopularGames.Count > 0; HasBacklogGames = BacklogGames.Count > 0; HasBrowseData = false; HasBrowseGenres = false; HasBrowseTags = false; HasBrowseCollections = false; HasContent = HasPopularGames || HasBacklogGames; } // ── Commands ────────────────────────────────────────────────────────────── [RelayCommand] private async Task ViewGameAsync(GameItemViewModel? item) { if (item == null) return; try { using var scope = _serviceProvider.CreateScope(); if (IsOfflineMode) { var gameService = scope.ServiceProvider.GetRequiredService(); var local = await gameService.GetAsync(item.Id); if (local != null) GameSelected?.Invoke(this, new SDK.Models.Game { Id = local.Id, Title = local.Title ?? "Unknown", SortTitle = local.SortTitle, Description = local.Description, ReleasedOn = local.ReleasedOn ?? DateTime.MinValue }); } else { var gameClient = scope.ServiceProvider.GetRequiredService(); var game = await gameClient.GetAsync(item.Id); if (game != null) GameSelected?.Invoke(this, game); } } catch (Exception ex) { _logger.LogError(ex, "Failed to load game {Id}", item.Id); } } [RelayCommand] private void Search() { if (!string.IsNullOrWhiteSpace(SearchText)) SearchRequested?.Invoke(this, SearchText); } [RelayCommand] private void BrowseByGenre(string name) => BrowseByGenreRequested?.Invoke(this, name); [RelayCommand] private void BrowseByTag(string name) => BrowseByTagRequested?.Invoke(this, name); [RelayCommand] private void BrowseByCollection(string name) => BrowseByCollectionRequested?.Invoke(this, name); [RelayCommand] private void BrowseAll() => BrowseAllRequested?.Invoke(this, EventArgs.Empty); // ── Helpers ─────────────────────────────────────────────────────────────── private async Task FetchGameWithMediaAsync( DepotGame depotGame, HashSet librarySet, MediaClient mediaClient, GameClient gameClient) { try { var game = await gameClient.GetAsync(depotGame.Id); if (game == null) return null; var inLibrary = librarySet.Contains(game.Id); var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover); 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); vm.HeroPath = heroPath; vm.LogoPath = logoPath; return vm; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch full game data for {Id}", depotGame.Id); return new GameItemViewModel(depotGame, null, null, librarySet.Contains(depotGame.Id)); } } private async Task FetchGameHeroAsync(DepotGame depotGame, MediaClient mediaClient, GameClient gameClient) { try { var game = await gameClient.GetAsync(depotGame.Id); return MediaUrl(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient); } catch { return null; } } // 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; if (media.MimeType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true) return mediaClient.GetAbsoluteStreamUrl(media); return mediaClient.GetAbsoluteThumbnailUrl(media); } }