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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Launcher.Avalonia/Views/DepotBrowseView.axaml.cs b/LANCommander.Launcher.Avalonia/Views/DepotBrowseView.axaml.cs
new file mode 100644
index 00000000..311a85b8
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/Views/DepotBrowseView.axaml.cs
@@ -0,0 +1,32 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LANCommander.Launcher.Avalonia.ViewModels;
+using LANCommander.Launcher.Settings.Enums;
+
+namespace LANCommander.Launcher.Avalonia.Views;
+
+public partial class DepotBrowseView : UserControl
+{
+ public DepotBrowseView()
+ {
+ InitializeComponent();
+ }
+
+ private void GridViewButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is GamesCollectionViewModel vm)
+ vm.SelectedViewType = GameViewType.Grid;
+ }
+
+ private void ListViewButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is GamesCollectionViewModel vm)
+ vm.SelectedViewType = GameViewType.List;
+ }
+
+ private void HorizontalViewButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is GamesCollectionViewModel vm)
+ vm.SelectedViewType = GameViewType.Horizontal;
+ }
+}
diff --git a/LANCommander.Launcher.Avalonia/Views/ShellView.axaml b/LANCommander.Launcher.Avalonia/Views/ShellView.axaml
index a0e9499e..5bf13627 100644
--- a/LANCommander.Launcher.Avalonia/Views/ShellView.axaml
+++ b/LANCommander.Launcher.Avalonia/Views/ShellView.axaml
@@ -20,6 +20,14 @@
+
+
+
+
+
+
+