diff --git a/LANCommander.Launcher.Avalonia/ViewModels/Components/GameMediaItemViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameMediaItemViewModel.cs new file mode 100644 index 00000000..6a5dbb43 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameMediaItemViewModel.cs @@ -0,0 +1,9 @@ +namespace LANCommander.Launcher.Avalonia.ViewModels.Components; + +/// Represents a single screenshot or video in the game detail media carousel. +public class GameMediaItemViewModel +{ + public string Path { get; set; } = string.Empty; + public bool IsVideo { get; set; } + public string MimeType { get; set; } = string.Empty; +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/DepotBrowseViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/DepotBrowseViewModel.cs new file mode 100644 index 00000000..8871adfa --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/DepotBrowseViewModel.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +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.Launcher.Settings.Enums; +using LANCommander.SDK.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +/// Which dimension (if any) of the initial navigation is locked and cannot be cleared. +public enum LockedFilterKind { None, Genre, Tag, Collection } + +public partial class DepotBrowseViewModel : GamesCollectionViewModel +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + // ── Locked filter ───────────────────────────────────────────────────────── + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasLockedFilter))] + [NotifyPropertyChangedFor(nameof(LockedFilterLabel))] + [NotifyPropertyChangedFor(nameof(IsGenreLocked))] + [NotifyPropertyChangedFor(nameof(IsTagLocked))] + [NotifyPropertyChangedFor(nameof(IsCollectionLocked))] + private LockedFilterKind _lockedFilterKind = LockedFilterKind.None; + + [ObservableProperty] + private string _lockedFilterValue = string.Empty; + + public bool HasLockedFilter => LockedFilterKind != LockedFilterKind.None; + public bool IsGenreLocked => LockedFilterKind == LockedFilterKind.Genre; + public bool IsTagLocked => LockedFilterKind == LockedFilterKind.Tag; + public bool IsCollectionLocked => LockedFilterKind == LockedFilterKind.Collection; + + public string LockedFilterLabel => LockedFilterKind switch + { + LockedFilterKind.Genre => "Genre", + LockedFilterKind.Tag => "Tag", + LockedFilterKind.Collection => "Collection", + _ => string.Empty + }; + + // ── Title ───────────────────────────────────────────────────────────────── + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewTitle))] + private string _browseTitle = "All Games"; + + public override string ViewTitle => BrowseTitle; + public override bool ShowInLibraryFilter => true; + + // ── Events ──────────────────────────────────────────────────────────────── + + public event EventHandler? BackToDepotRequested; + + // ───────────────────────────────────────────────────────────────────────── + + public DepotBrowseViewModel(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _logger = serviceProvider.GetRequiredService>(); + } + + /// + /// Populate from a snapshot of already-loaded games and optionally pre-filter. + /// Passing a genre/tag/collection locks that filter so it cannot be cleared. + /// + public void Initialize( + IEnumerable allGames, + string? preFilterGenre = null, + string? preFilterTag = null, + string? preFilterCollection = null, + string? preFilterSearch = null) + { + _allGames.Clear(); + _allGames.AddRange(allGames); + + PopulateGenres(); + PopulateTags(); + PopulateDevelopers(); + PopulatePublishers(); + + // Determine locked filter BEFORE resetting values + LockedFilterKind = !string.IsNullOrEmpty(preFilterGenre) ? LockedFilterKind.Genre + : !string.IsNullOrEmpty(preFilterTag) ? LockedFilterKind.Tag + : !string.IsNullOrEmpty(preFilterCollection) ? LockedFilterKind.Collection + : LockedFilterKind.None; + + LockedFilterValue = preFilterGenre ?? preFilterTag ?? preFilterCollection ?? string.Empty; + + // Reset all filters (won't trigger ApplyFilters when values haven't changed) + SearchText = string.Empty; + SelectedGenre = null; + SelectedTag = null; + SelectedDeveloper = null; + SelectedPublisher = null; + SelectedMultiplayerType = null; + ShowInLibraryOnly = false; + SelectedSortBy = SortBy.Title; + SortAscending = true; + SelectedGroupBy = GroupBy.None; + + // Apply pre-filter + if (!string.IsNullOrEmpty(preFilterGenre)) + { + var genre = AvailableGenres.FirstOrDefault(g => + string.Equals(g.Name, preFilterGenre, StringComparison.OrdinalIgnoreCase)); + if (genre != null) + SelectedGenre = genre; + } + + if (!string.IsNullOrEmpty(preFilterTag)) + { + SelectedTag = AvailableTags.FirstOrDefault(t => + string.Equals(t, preFilterTag, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(preFilterCollection)) + SelectedGroupBy = GroupBy.Collection; + + if (!string.IsNullOrEmpty(preFilterSearch)) + SearchText = preFilterSearch; + + BrowseTitle = !string.IsNullOrEmpty(preFilterGenre) ? preFilterGenre + : !string.IsNullOrEmpty(preFilterTag) ? preFilterTag + : !string.IsNullOrEmpty(preFilterCollection) ? preFilterCollection + : !string.IsNullOrEmpty(preFilterSearch) ? $"Search: {preFilterSearch}" + : "All Games"; + + ApplyFilters(); + } + + // Data comes from Initialize(); no network loading needed. + public override Task LoadGamesAsync() => Task.CompletedTask; + + [RelayCommand] + private void GoBack() => BackToDepotRequested?.Invoke(this, EventArgs.Empty); + + /// + /// Clears user-added filters while preserving the locked initial filter. + /// Bound to the clear (✕) button in the view instead of the base ClearFiltersCommand. + /// + [RelayCommand] + private void ClearAdditionalFilters() + { + // Only reset search if it's not the locked dimension (search is never locked, but be explicit) + SearchText = string.Empty; + + if (!IsGenreLocked) SelectedGenre = null; + if (!IsTagLocked) SelectedTag = null; + if (!IsCollectionLocked) SelectedGroupBy = GroupBy.None; + + SelectedDeveloper = null; + SelectedPublisher = null; + SelectedMultiplayerType = null; + ShowInLibraryOnly = false; + SelectedSortBy = SortBy.Title; + SortAscending = true; + } + + protected override async Task ViewGameDetailsAsync(GameItemViewModel? gameItem) + { + if (gameItem == null) return; + _logger.LogDebug("Viewing game from depot browse: {GameId}", gameItem.Id); + + try + { + using var scope = _serviceProvider.CreateScope(); + + if (IsOfflineMode) + { + var gameService = scope.ServiceProvider.GetRequiredService(); + var localGame = await gameService.GetAsync(gameItem.Id); + if (localGame != null) + RaiseGameSelected(new SDK.Models.Game + { + Id = localGame.Id, + Title = localGame.Title ?? "Unknown", + SortTitle = localGame.SortTitle, + Description = localGame.Description, + ReleasedOn = localGame.ReleasedOn ?? DateTime.MinValue + }); + } + else + { + var gameClient = scope.ServiceProvider.GetRequiredService(); + var game = await gameClient.GetAsync(gameItem.Id); + if (game != null) RaiseGameSelected(game); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch game {GameId}", gameItem.Id); + } + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/DepotGameDetailViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/DepotGameDetailViewModel.cs new file mode 100644 index 00000000..a2d155d3 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/DepotGameDetailViewModel.cs @@ -0,0 +1,17 @@ +using System; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +/// +/// Depot-specific game detail view model. Extends +/// so the ShellView DataTemplate can route to a distinct depot detail view. +/// FromLibrary is always false since this is only used in the depot context. +/// +public partial class DepotGameDetailViewModel : GameDetailViewModel +{ + public DepotGameDetailViewModel(IServiceProvider serviceProvider) + : base(serviceProvider) + { + FromLibrary = false; + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs index 8aacf9ae..fc16fd4e 100644 --- a/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs +++ b/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -69,19 +70,57 @@ public partial class GameDetailViewModel : ViewModelBase [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; + + // ── 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"; + 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); + 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); @@ -179,18 +218,41 @@ public partial class GameDetailViewModel : ViewModelBase // 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); } @@ -237,18 +299,26 @@ public partial class GameDetailViewModel : ViewModelBase // 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); @@ -261,10 +331,25 @@ public partial class GameDetailViewModel : ViewModelBase using var scope = _serviceProvider.CreateScope(); var mediaClient = scope.ServiceProvider.GetRequiredService(); - 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); + 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) { @@ -277,6 +362,49 @@ public partial class GameDetailViewModel : ViewModelBase } } + 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 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? mediaCollection, MediaType type, MediaService mediaService) { var media = mediaCollection?.FirstOrDefault(m => m.Type == type); diff --git a/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs index 845e7e5e..85d40845 100644 --- a/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs +++ b/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs @@ -33,6 +33,9 @@ public partial class GamesListViewModel : GamesCollectionViewModel public override Task LoadGamesAsync() => LoadGamesInternalAsync(); + /// Returns a snapshot of all loaded games for use by depot browse views. + public IEnumerable GetAllGames() => _allGames; + [RelayCommand] private async Task LoadGamesInternalAsync() { diff --git a/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs index 00992139..e213eec7 100644 --- a/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs +++ b/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs @@ -35,11 +35,13 @@ public partial class ShellViewModel : ViewModelBase public string ContentViewTitle => ContentView switch { - DepotViewModel _ => "Depot", - GamesListViewModel _ => "Depot", - LibraryViewModel _ => "My Library", - GameDetailViewModel gd => !string.IsNullOrEmpty(gd.Title) ? gd.Title : string.Empty, - SettingsViewModel _ => "Settings", + DepotViewModel _ => "Depot", + DepotBrowseViewModel db => db.BrowseTitle, + GamesListViewModel _ => "Depot", + LibraryViewModel _ => "My Library", + DepotGameDetailViewModel gd => !string.IsNullOrEmpty(gd.Title) ? gd.Title : string.Empty, + GameDetailViewModel gd => !string.IsNullOrEmpty(gd.Title) ? gd.Title : string.Empty, + SettingsViewModel _ => "Settings", DownloadQueueViewModel _ => "Downloads", _ => string.Empty }; @@ -54,6 +56,11 @@ public partial class ShellViewModel : ViewModelBase oldDetail.PropertyChanged -= OnGameDetailPropertyChanged; if (newValue is GameDetailViewModel newDetail) newDetail.PropertyChanged += OnGameDetailPropertyChanged; + + if (oldValue is DepotBrowseViewModel oldBrowse) + oldBrowse.PropertyChanged -= OnDepotBrowsePropertyChanged; + if (newValue is DepotBrowseViewModel newBrowse) + newBrowse.PropertyChanged += OnDepotBrowsePropertyChanged; } private void OnGameDetailPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -62,6 +69,12 @@ public partial class ShellViewModel : ViewModelBase OnPropertyChanged(nameof(ContentViewTitle)); } + private void OnDepotBrowsePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(DepotBrowseViewModel.BrowseTitle)) + OnPropertyChanged(nameof(ContentViewTitle)); + } + [ObservableProperty] private bool _isCheckingConnection; @@ -73,17 +86,29 @@ public partial class ShellViewModel : ViewModelBase public bool CanGoOnline => IsOfflineMode && !IsCheckingConnection; // Child view models - public DepotViewModel DepotViewModel { get; private set; } = null!; - public GamesListViewModel GamesListViewModel { get; private set; } = null!; - public LibraryViewModel LibraryViewModel { get; private set; } = null!; - public GameDetailViewModel GameDetailViewModel { get; private set; } = null!; - public DownloadQueueViewModel DownloadQueue { get; private set; } = null!; - public SettingsViewModel SettingsViewModel { get; private set; } = null!; - public ProfileViewModel Profile { get; private set; } = null!; + public DepotViewModel DepotViewModel { get; private set; } = null!; + public DepotBrowseViewModel DepotBrowseViewModel { get; private set; } = null!; + public DepotGameDetailViewModel DepotGameDetailViewModel { get; private set; } = null!; + public GamesListViewModel GamesListViewModel { get; private set; } = null!; + public LibraryViewModel LibraryViewModel { get; private set; } = null!; + public GameDetailViewModel GameDetailViewModel { get; private set; } = null!; + public DownloadQueueViewModel DownloadQueue { get; private set; } = null!; + public SettingsViewModel SettingsViewModel { get; private set; } = null!; + public ProfileViewModel Profile { get; private set; } = null!; + public ChatWindowViewModel Chat { get; private set; } = null!; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnreadChat))] + private int _chatUnreadCount; + + public bool HasUnreadChat => ChatUnreadCount > 0; // Tracks previous view within the depot context for back navigation private ViewModelBase? _depotReturnView; + // Tracks the most recent depot browse filter so the view can be refreshed after library changes + private (string? Genre, string? Tag, string? Collection, string? Search) _lastDepotBrowseFilter; + public event EventHandler? LogoutRequested; public ShellViewModel(IServiceProvider serviceProvider) @@ -105,28 +130,49 @@ public partial class ShellViewModel : ViewModelBase { _logger.LogInformation("ShellViewModel initializing... (Offline: {IsOffline})", IsOfflineMode); - DepotViewModel = new DepotViewModel(_serviceProvider); - GamesListViewModel = new GamesListViewModel(_serviceProvider); - LibraryViewModel = new LibraryViewModel(_serviceProvider); - GameDetailViewModel = new GameDetailViewModel(_serviceProvider); - DownloadQueue = new DownloadQueueViewModel(_serviceProvider); - SettingsViewModel = new SettingsViewModel(_serviceProvider); + DepotViewModel = new DepotViewModel(_serviceProvider); + DepotBrowseViewModel = new DepotBrowseViewModel(_serviceProvider); + DepotGameDetailViewModel = new DepotGameDetailViewModel(_serviceProvider); + GamesListViewModel = new GamesListViewModel(_serviceProvider); + LibraryViewModel = new LibraryViewModel(_serviceProvider); + GameDetailViewModel = new GameDetailViewModel(_serviceProvider); + DownloadQueue = new DownloadQueueViewModel(_serviceProvider); + SettingsViewModel = new SettingsViewModel(_serviceProvider); // Profile is already created in the constructor; reuse it here - DepotViewModel.IsOfflineMode = IsOfflineMode; - GamesListViewModel.IsOfflineMode = IsOfflineMode; - LibraryViewModel.IsOfflineMode = IsOfflineMode; - GameDetailViewModel.IsOfflineMode = IsOfflineMode; + Chat = new ChatWindowViewModel(_serviceProvider); + await Chat.InitializeAsync(); + Chat.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ChatWindowViewModel.TotalUnreadCount)) + ChatUnreadCount = Chat.TotalUnreadCount; + }; - DepotViewModel.GameSelected += OnGameSelected; - DepotViewModel.SearchRequested += OnSearchRequested; - DepotViewModel.BrowseByGenreRequested += OnDepotBrowseByGenre; - DepotViewModel.BrowseByTagRequested += OnDepotBrowseByTag; + DepotViewModel.IsOfflineMode = IsOfflineMode; + DepotBrowseViewModel.IsOfflineMode = IsOfflineMode; + DepotGameDetailViewModel.IsOfflineMode = IsOfflineMode; + GamesListViewModel.IsOfflineMode = IsOfflineMode; + LibraryViewModel.IsOfflineMode = IsOfflineMode; + GameDetailViewModel.IsOfflineMode = IsOfflineMode; + + DepotViewModel.GameSelected += OnDepotGameSelected; + DepotViewModel.SearchRequested += OnSearchRequested; + DepotViewModel.BrowseByGenreRequested += OnDepotBrowseByGenre; + DepotViewModel.BrowseByTagRequested += OnDepotBrowseByTag; DepotViewModel.BrowseByCollectionRequested += OnDepotBrowseByCollection; - DepotViewModel.BrowseAllRequested += OnDepotBrowseAll; + DepotViewModel.BrowseAllRequested += OnDepotBrowseAll; + + DepotBrowseViewModel.GameSelected += OnDepotGameSelected; + DepotBrowseViewModel.BackToDepotRequested += OnBackFromDepotBrowse; GamesListViewModel.GameSelected += OnGameSelected; LibraryViewModel.GameSelected += OnGameSelected; + + DepotGameDetailViewModel.BackRequested += OnBackFromGameDetail; + DepotGameDetailViewModel.LibraryChanged += OnLibraryChanged; + DepotGameDetailViewModel.InstallRequested += OnInstallRequested; + DepotGameDetailViewModel.SearchRequested += OnSearchRequested; + GameDetailViewModel.BackRequested += OnBackFromGameDetail; GameDetailViewModel.LibraryChanged += OnLibraryChanged; GameDetailViewModel.InstallRequested += OnInstallRequested; @@ -207,10 +253,12 @@ public partial class ShellViewModel : ViewModelBase if (connectionClient.IsConnected()) { IsOfflineMode = false; - DepotViewModel.IsOfflineMode = false; - GamesListViewModel.IsOfflineMode = false; - LibraryViewModel.IsOfflineMode = false; - GameDetailViewModel.IsOfflineMode = false; + DepotViewModel.IsOfflineMode = false; + DepotBrowseViewModel.IsOfflineMode = false; + DepotGameDetailViewModel.IsOfflineMode = false; + GamesListViewModel.IsOfflineMode = false; + LibraryViewModel.IsOfflineMode = false; + GameDetailViewModel.IsOfflineMode = false; await ImportAndLoadAsync(); return; @@ -235,10 +283,12 @@ public partial class ShellViewModel : ViewModelBase if (IsOfflineMode) return; IsOfflineMode = true; - DepotViewModel.IsOfflineMode = true; - GamesListViewModel.IsOfflineMode = true; - LibraryViewModel.IsOfflineMode = true; - GameDetailViewModel.IsOfflineMode = true; + DepotViewModel.IsOfflineMode = true; + DepotBrowseViewModel.IsOfflineMode = true; + DepotGameDetailViewModel.IsOfflineMode = true; + GamesListViewModel.IsOfflineMode = true; + LibraryViewModel.IsOfflineMode = true; + GameDetailViewModel.IsOfflineMode = true; } [RelayCommand] @@ -268,6 +318,15 @@ public partial class ShellViewModel : ViewModelBase ContentView = LibraryViewModel; } + /// Game selected from the depot context (DepotView or DepotBrowseView). + private void OnDepotGameSelected(object? sender, SDK.Models.Game game) + { + _depotReturnView = sender is DepotBrowseViewModel ? DepotBrowseViewModel : DepotViewModel; + ContentView = DepotGameDetailViewModel; + _ = DepotGameDetailViewModel.LoadGameAsync(game); + } + + /// Game selected from the library context. private void OnGameSelected(object? sender, SDK.Models.Game game) { GameDetailViewModel.FromLibrary = !IsDepotActive; @@ -322,17 +381,9 @@ public partial class ShellViewModel : ViewModelBase .FirstOrDefault(g => string.Equals(g.Name, term, StringComparison.OrdinalIgnoreCase)); if (matchedGenre != null) - { - GamesListViewModel.SearchText = string.Empty; - GamesListViewModel.SelectedGenre = matchedGenre; - } + NavigateToDepotBrowse(genre: matchedGenre.Name); else - { - GamesListViewModel.SelectedGenre = null; - GamesListViewModel.SearchText = term; - } - - NavigateToGamesListFromDepot(); + NavigateToDepotBrowse(search: term); } else { @@ -356,47 +407,44 @@ public partial class ShellViewModel : ViewModelBase private void OnDepotBrowseByGenre(object? sender, string genreName) { - GamesListViewModel.ClearFiltersCommand.Execute(null); - var genre = GamesListViewModel.AvailableGenres - .FirstOrDefault(g => string.Equals(g.Name, genreName, StringComparison.OrdinalIgnoreCase)); - if (genre != null) - GamesListViewModel.SelectedGenre = genre; - NavigateToGamesListFromDepot(); + NavigateToDepotBrowse(genre: genreName); } private void OnDepotBrowseByTag(object? sender, string tagName) { - GamesListViewModel.ClearFiltersCommand.Execute(null); - GamesListViewModel.SelectedTag = GamesListViewModel.AvailableTags - .FirstOrDefault(t => string.Equals(t, tagName, StringComparison.OrdinalIgnoreCase)); - NavigateToGamesListFromDepot(); + NavigateToDepotBrowse(tag: tagName); } private void OnDepotBrowseByCollection(object? sender, string collectionName) { - GamesListViewModel.ClearFiltersCommand.Execute(null); - // Group by collection so the user sees games sorted into that collection - GamesListViewModel.SelectedGroupBy = LANCommander.Launcher.Settings.Enums.GroupBy.Collection; - NavigateToGamesListFromDepot(); + NavigateToDepotBrowse(collection: collectionName); } private void OnDepotBrowseAll(object? sender, EventArgs e) { - GamesListViewModel.ClearFiltersCommand.Execute(null); - NavigateToGamesListFromDepot(); + NavigateToDepotBrowse(); } - /// Navigate to the full games list while staying in depot context. - private void NavigateToGamesListFromDepot() + private void OnBackFromDepotBrowse(object? sender, EventArgs e) { IsDepotActive = true; - _depotReturnView = DepotViewModel; - ContentView = GamesListViewModel; + _depotReturnView = null; + ContentView = DepotViewModel; + } + + /// Initialize and navigate to the depot-only browse grid with an optional pre-filter. + private void NavigateToDepotBrowse(string? genre = null, string? tag = null, string? collection = null, string? search = null) + { + _lastDepotBrowseFilter = (genre, tag, collection, search); + DepotBrowseViewModel.Initialize(GamesListViewModel.GetAllGames(), genre, tag, collection, search); + IsDepotActive = true; + _depotReturnView = DepotBrowseViewModel; + ContentView = DepotBrowseViewModel; } private void OnBackFromGameDetail(object? sender, EventArgs e) { - if (IsDepotActive) + if (sender is DepotGameDetailViewModel || IsDepotActive) ContentView = _depotReturnView ?? DepotViewModel; else ShowLibrary(); @@ -407,6 +455,14 @@ public partial class ShellViewModel : ViewModelBase await LibraryViewModel.LoadGamesAsync(); await GamesListViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); + // Re-initialize the browse view with fresh data so "in library" badges update + if (ContentView == DepotBrowseViewModel) + DepotBrowseViewModel.Initialize( + GamesListViewModel.GetAllGames(), + _lastDepotBrowseFilter.Genre, + _lastDepotBrowseFilter.Tag, + _lastDepotBrowseFilter.Collection, + _lastDepotBrowseFilter.Search); } private void OnInstallRequested(object? sender, EventArgs e) => DownloadQueue.Show(); @@ -417,6 +473,9 @@ public partial class ShellViewModel : ViewModelBase await GamesListViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); + if (DepotGameDetailViewModel.Id == gameId) + await DepotGameDetailViewModel.RefreshInstallStatusAsync(); + if (GameDetailViewModel.Id == gameId) await GameDetailViewModel.RefreshInstallStatusAsync(); } @@ -428,6 +487,20 @@ public partial class ShellViewModel : ViewModelBase ContentView = SettingsViewModel; } + [RelayCommand] + private async Task OpenChatAsync() + { + if (Chat == null) return; + + // Lazy-load threads on first open + await Chat.LoadThreadsAsync(); + + // Raise event so the view layer can show the window + OpenChatRequested?.Invoke(this, EventArgs.Empty); + } + + public event EventHandler? OpenChatRequested; + private void OnBackFromSettings(object? sender, EventArgs e) { if (IsDepotActive) ShowDepot(); diff --git a/LANCommander.Launcher.Avalonia/Views/DepotBrowseView.axaml b/LANCommander.Launcher.Avalonia/Views/DepotBrowseView.axaml new file mode 100644 index 00000000..037c276f --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/DepotBrowseView.axaml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + Title + DateReleased + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +