Separate views for library vs depot

This commit is contained in:
Pat Hartl 2026-04-07 23:12:22 -05:00
parent 0ab6ee5262
commit caee4ca4e0
9 changed files with 800 additions and 75 deletions

View file

@ -0,0 +1,9 @@
namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
/// <summary>Represents a single screenshot or video in the game detail media carousel.</summary>
public class GameMediaItemViewModel
{
public string Path { get; set; } = string.Empty;
public bool IsVideo { get; set; }
public string MimeType { get; set; } = string.Empty;
}

View file

@ -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;
/// <summary>Which dimension (if any) of the initial navigation is locked and cannot be cleared.</summary>
public enum LockedFilterKind { None, Genre, Tag, Collection }
public partial class DepotBrowseViewModel : GamesCollectionViewModel
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<DepotBrowseViewModel> _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<ILogger<DepotBrowseViewModel>>();
}
/// <summary>
/// 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.
/// </summary>
public void Initialize(
IEnumerable<GameItemViewModel> 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);
/// <summary>
/// Clears user-added filters while preserving the locked initial filter.
/// Bound to the clear (✕) button in the view instead of the base ClearFiltersCommand.
/// </summary>
[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<GameService>();
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<GameClient>();
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);
}
}
}

View file

@ -0,0 +1,17 @@
using System;
namespace LANCommander.Launcher.Avalonia.ViewModels;
/// <summary>
/// Depot-specific game detail view model. Extends <see cref="GameDetailViewModel"/>
/// 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.
/// </summary>
public partial class DepotGameDetailViewModel : GameDetailViewModel
{
public DepotGameDetailViewModel(IServiceProvider serviceProvider)
: base(serviceProvider)
{
FromLibrary = false;
}
}

View file

@ -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 ─────────────<E29480><E29480><EFBFBD>────────────────────────────────────
private const int TagsVisibleLimit = 5;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VisibleTagList))]
[NotifyPropertyChangedFor(nameof(HasMoreTags))]
[NotifyPropertyChangedFor(nameof(ExtraTagCount))]
[NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
private bool _tagsExpanded;
public IEnumerable<string> VisibleTagList =>
TagsExpanded ? TagList : TagList.Take(TagsVisibleLimit);
public bool HasMoreTags => TagList.Count() > TagsVisibleLimit;
public int ExtraTagCount => Math.Max(0, TagList.Count() - TagsVisibleLimit);
public string ShowMoreTagsLabel =>
TagsExpanded ? "Show less" : $"+{ExtraTagCount} more";
[RelayCommand]
private void ToggleTagsExpanded() => TagsExpanded = !TagsExpanded;
// ── Screenshots / videos ──────────────────────<E29480><E29480><EFBFBD>────────────────────────<E29480><E29480><EFBFBD>──
public ObservableCollection<GameMediaItemViewModel> MediaItems { get; } = new();
public bool HasMedia => MediaItems.Count > 0;
// ── Multiplayer modes ─────────────────────────────────────────────────────
public ObservableCollection<string> MultiplayerModeDetails { get; } = new();
// ── Other ─────────────────────────<E29480><E29480><EFBFBD>───────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(BackLabel))]
private bool _fromLibrary;
public string BackLabel => FromLibrary ? "← Back to Library" : "← Back to Depot";
public string BackLabel => FromLibrary ? "Back to Library" : "Back to Depot";
// Split list properties for chip rendering
public IEnumerable<string> GenreList => SplitCsv(Genres);
public IEnumerable<string> DeveloperList => SplitCsv(Developers);
public IEnumerable<string> PublisherList => SplitCsv(Publishers);
public IEnumerable<string> TagList => SplitCsv(Tags);
public IEnumerable<string> GenreList => SplitCsv(Genres);
public IEnumerable<string> DeveloperList => SplitCsv(Developers);
public IEnumerable<string> PublisherList => SplitCsv(Publishers);
public IEnumerable<string> TagList => SplitCsv(Tags);
private static IEnumerable<string> SplitCsv(string csv) =>
csv.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0);
@ -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<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);
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<string?> GetOrDownloadSingleMediaAsync(SDK.Models.Media media, MediaClient mediaClient)
{
try
{
var localPath = mediaClient.GetLocalPath(media);
if (File.Exists(localPath)) return localPath;
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
return fileInfo.Exists ? fileInfo.FullName : null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download media {MediaId}", media.Id);
return null;
}
}
private string? GetLocalMediaPath(System.Collections.Generic.ICollection<Data.Models.Media>? mediaCollection, MediaType type, MediaService mediaService)
{
var media = mediaCollection?.FirstOrDefault(m => m.Type == type);

View file

@ -33,6 +33,9 @@ public partial class GamesListViewModel : GamesCollectionViewModel
public override Task LoadGamesAsync() => LoadGamesInternalAsync();
/// <summary>Returns a snapshot of all loaded games for use by depot browse views.</summary>
public IEnumerable<GameItemViewModel> GetAllGames() => _allGames;
[RelayCommand]
private async Task LoadGamesInternalAsync()
{

View file

@ -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;
}
/// <summary>Game selected from the depot context (DepotView or DepotBrowseView).</summary>
private void OnDepotGameSelected(object? sender, SDK.Models.Game game)
{
_depotReturnView = sender is DepotBrowseViewModel ? DepotBrowseViewModel : DepotViewModel;
ContentView = DepotGameDetailViewModel;
_ = DepotGameDetailViewModel.LoadGameAsync(game);
}
/// <summary>Game selected from the library context.</summary>
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();
}
/// <summary>Navigate to the full games list while staying in depot context.</summary>
private void NavigateToGamesListFromDepot()
private void OnBackFromDepotBrowse(object? sender, EventArgs e)
{
IsDepotActive = true;
_depotReturnView = DepotViewModel;
ContentView = GamesListViewModel;
_depotReturnView = null;
ContentView = DepotViewModel;
}
/// <summary>Initialize and navigate to the depot-only browse grid with an optional pre-filter.</summary>
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();

View file

@ -0,0 +1,252 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
xmlns:enums="using:LANCommander.Launcher.Settings.Enums"
xmlns:local="using:LANCommander.Launcher.Avalonia.Views"
x:Class="LANCommander.Launcher.Avalonia.Views.DepotBrowseView"
x:DataType="vm:DepotBrowseViewModel">
<UserControl.Styles>
<Style Selector="Button.active">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}" />
</Style>
<!-- Locked filter chip: looks like an active tag but non-interactive (no hover change) -->
<Style Selector="Border.locked-chip">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="8,4" />
</Style>
</UserControl.Styles>
<DockPanel>
<!-- ── Filter Bar ──────────────────────────────────────────────────── -->
<StackPanel DockPanel.Dock="Bottom"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
<!-- Advanced panel — expands upward above the quick row -->
<Border IsVisible="{Binding IsAdvancedFilterOpen}"
Padding="12,10,12,4"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<WrapPanel Orientation="Horizontal" ItemWidth="180">
<!-- Sort -->
<StackPanel Spacing="4" Margin="0,0,12,8">
<TextBlock Text="Sort" FontSize="11" Opacity="0.6" />
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0"
SelectedItem="{Binding SelectedSortBy}"
HorizontalAlignment="Stretch">
<ComboBox.Items>
<enums:SortBy>Title</enums:SortBy>
<enums:SortBy>DateReleased</enums:SortBy>
</ComboBox.Items>
</ComboBox>
<Button Grid.Column="1"
Command="{Binding ToggleSortDirectionCommand}"
Padding="8,6" Margin="4,0,0,0">
<Panel>
<Icon Type="Regular" Value="ArrowUp" Width="12" Height="12" IsVisible="{Binding SortAscending}" />
<Icon Type="Regular" Value="ArrowDown" Width="12" Height="12" IsVisible="{Binding !SortAscending}" />
</Panel>
</Button>
</Grid>
</StackPanel>
<!-- Group by (hidden when collection is locked — grouping is implicit) -->
<StackPanel Spacing="4" Margin="0,0,12,8"
IsVisible="{Binding !IsCollectionLocked}">
<TextBlock Text="Group By" FontSize="11" Opacity="0.6" />
<ComboBox ItemsSource="{Binding AvailableGroupByOptions}"
SelectedItem="{Binding SelectedGroupBy}"
HorizontalAlignment="Stretch" />
</StackPanel>
<!-- Genre — hidden when genre is locked -->
<StackPanel Spacing="4" Margin="0,0,12,8"
IsVisible="{Binding !IsGenreLocked}">
<TextBlock Text="Genre" FontSize="11" Opacity="0.6" />
<ComboBox ItemsSource="{Binding AvailableGenres}"
SelectedItem="{Binding SelectedGenre}"
PlaceholderText="Any"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Tags — hidden when tag is locked -->
<StackPanel Spacing="4" Margin="0,0,12,8"
IsVisible="{Binding !IsTagLocked}">
<TextBlock Text="Tag" FontSize="11" Opacity="0.6" />
<ComboBox ItemsSource="{Binding AvailableTags}"
SelectedItem="{Binding SelectedTag}"
PlaceholderText="Any"
HorizontalAlignment="Stretch" />
</StackPanel>
<!-- Developer -->
<StackPanel Spacing="4" Margin="0,0,12,8">
<TextBlock Text="Developer" FontSize="11" Opacity="0.6" />
<ComboBox ItemsSource="{Binding AvailableDevelopers}"
SelectedItem="{Binding SelectedDeveloper}"
PlaceholderText="Any"
HorizontalAlignment="Stretch" />
</StackPanel>
<!-- Publisher -->
<StackPanel Spacing="4" Margin="0,0,12,8">
<TextBlock Text="Publisher" FontSize="11" Opacity="0.6" />
<ComboBox ItemsSource="{Binding AvailablePublishers}"
SelectedItem="{Binding SelectedPublisher}"
PlaceholderText="Any"
HorizontalAlignment="Stretch" />
</StackPanel>
<!-- Multiplayer -->
<StackPanel Spacing="4" Margin="0,0,12,8">
<TextBlock Text="Multiplayer" FontSize="11" Opacity="0.6" />
<ComboBox ItemsSource="{x:Static vm:GamesCollectionViewModel.AvailableMultiplayerTypes}"
SelectedItem="{Binding SelectedMultiplayerType}"
PlaceholderText="Any"
HorizontalAlignment="Stretch" />
</StackPanel>
<!-- In Library (depot only) -->
<StackPanel Spacing="4" Margin="0,0,12,8"
IsVisible="{Binding ShowInLibraryFilter}">
<TextBlock Text="Ownership" FontSize="11" Opacity="0.6" />
<ToggleButton IsChecked="{Binding ShowInLibraryOnly}"
Content="In Library Only"
HorizontalAlignment="Stretch" />
</StackPanel>
</WrapPanel>
</Border>
<!-- Quick row -->
<Border Padding="12,8">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
<!-- Back to Depot button (always visible on solid bar background) -->
<Button Grid.Column="0"
Command="{Binding GoBackCommand}"
Classes="Text"
Padding="8,6"
Margin="0,0,8,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Icon Type="Regular" Value="ArrowLeft" Width="13" Height="13" VerticalAlignment="Center" />
<TextBlock Text="Depot" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- Locked filter chip — shown when navigated from genre/tag/collection -->
<Border Grid.Column="1"
Classes="locked-chip"
Margin="0,0,8,0"
IsVisible="{Binding HasLockedFilter}"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding LockedFilterLabel}"
FontSize="11"
Opacity="0.7"
VerticalAlignment="Center" />
<TextBlock Text="{Binding LockedFilterValue}"
FontSize="12"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Icon Type="Regular" Value="Lock" Width="10" Height="10"
Opacity="0.7" VerticalAlignment="Center" />
</StackPanel>
</Border>
<TextBox Grid.Column="2"
Watermark="Search games..."
Text="{Binding SearchText}"
Margin="0,0,8,0" />
<!-- View type toggle -->
<Border Grid.Column="3"
CornerRadius="2"
Margin="0,0,8,0"
Padding="4"
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
<StackPanel Orientation="Horizontal">
<Button Background="Transparent" BorderThickness="0" Padding="8,6"
ToolTip.Tip="Grid view"
Classes.active="{Binding IsGridView}"
Click="GridViewButton_Click">
<Icon Type="Regular" Value="SquaresFour" Width="14" Height="14" />
</Button>
<Button Background="Transparent" BorderThickness="0" Padding="8,6"
ToolTip.Tip="List view"
Classes.active="{Binding IsListView}"
Click="ListViewButton_Click">
<Icon Type="Regular" Value="Rows" Width="14" Height="14" />
</Button>
<Button Background="Transparent" BorderThickness="0" Padding="8,6"
ToolTip.Tip="Horizontal view"
Classes.active="{Binding IsHorizontalView}"
Click="HorizontalViewButton_Click">
<Icon Type="Regular" Value="ArrowsHorizontal" Width="14" Height="14" />
</Button>
</StackPanel>
</Border>
<!-- Advanced toggle -->
<ToggleButton Grid.Column="4"
Classes="Text"
IsChecked="{Binding IsAdvancedFilterOpen}"
Padding="8,6"
Margin="0,0,8,0"
ToolTip.Tip="Advanced filters">
<Icon Type="Regular" Value="FunnelSimple" Width="14" Height="14" />
</ToggleButton>
<!-- Clear button — uses ClearAdditionalFiltersCommand to preserve locked filter -->
<Button Grid.Column="5"
Command="{Binding ClearAdditionalFiltersCommand}"
Content="✕"
Classes="Text Error"
Padding="8,6" />
</Grid>
</Border>
</StackPanel>
<!-- ── Loading ─────────────────────────────────────────────────────── -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding IsLoading}"
Padding="16,54,16,10"
HorizontalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<ProgressBar IsIndeterminate="True" Width="120" Height="3" />
<TextBlock Text="Loading..." VerticalAlignment="Center" Opacity="0.6" FontSize="13" />
</StackPanel>
</Border>
<!-- ── Error ──────────────────────────────────────────────────────── -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding HasError}"
Padding="12"
Margin="16,44,16,0"
CornerRadius="0">
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Foreground="IndianRed" />
</Border>
<!-- ── Sub-views ──────────────────────────────────────────────────── -->
<Grid>
<local:GamesGridView IsVisible="{Binding IsGridViewFlat}" />
<local:GamesRowView IsVisible="{Binding IsListViewFlat}" />
<local:GamesShelfView IsVisible="{Binding IsHorizontalViewFlat}" />
<local:GamesGroupedView IsVisible="{Binding IsGrouped}" />
</Grid>
</DockPanel>
</UserControl>

View file

@ -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;
}
}

View file

@ -20,6 +20,14 @@
<DataTemplate DataType="vm:DepotViewModel">
<views:DepotView />
</DataTemplate>
<DataTemplate DataType="vm:DepotBrowseViewModel">
<views:DepotBrowseView />
</DataTemplate>
<!-- DepotGameDetailViewModel must appear before GameDetailViewModel
so the more-specific type is matched first. -->
<DataTemplate DataType="vm:DepotGameDetailViewModel">
<views:GameDetailView />
</DataTemplate>
<DataTemplate DataType="vm:GamesListViewModel">
<views:GamesListView />
</DataTemplate>