using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.ViewModels.Components;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LANCommander.Launcher.ViewModels;
public partial class GameDetailViewModel : ViewModelBase
{
///
/// In-memory cache of carousel media metadata per game, so skeletons can be shown
/// immediately on repeat visits without waiting for the API.
///
private static readonly ConcurrentDictionary> _mediaCache = new();
private record CachedMediaEntry(MediaType Type, string? MimeType);
private readonly IServiceProvider _serviceProvider;
private readonly INavigationService _navigationService;
private readonly ILogger _logger;
[ObservableProperty]
private Guid _id;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private string? _coverPath;
[ObservableProperty]
private string? _coverMimeType;
[ObservableProperty]
private string? _logoPath;
[ObservableProperty]
private string? _backgroundPath;
[ObservableProperty]
private string? _iconPath;
[ObservableProperty]
private DateTime _releasedOn;
[ObservableProperty]
private string _releaseYear = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
private bool _singleplayer;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(GenreList))]
private string _genres = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DeveloperList))]
private string _developers = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PublisherList))]
private string _publishers = string.Empty;
[ObservableProperty]
private string _platforms = string.Empty;
[ObservableProperty]
private string _multiplayerModes = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TagList))]
[NotifyPropertyChangedFor(nameof(VisibleTagList))]
[NotifyPropertyChangedFor(nameof(HasMoreTags))]
[NotifyPropertyChangedFor(nameof(ExtraTagCount))]
[NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
private string _tags = string.Empty;
// ── Tags expand/collapse ─────────────���────────────────────────────────────
private const int TagsVisibleLimit = 5;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VisibleTagList))]
[NotifyPropertyChangedFor(nameof(HasMoreTags))]
[NotifyPropertyChangedFor(nameof(ExtraTagCount))]
[NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
private bool _tagsExpanded;
public IEnumerable VisibleTagList =>
TagsExpanded ? TagList : TagList.Take(TagsVisibleLimit);
public bool HasMoreTags => TagList.Count() > TagsVisibleLimit;
public int ExtraTagCount => Math.Max(0, TagList.Count() - TagsVisibleLimit);
public string ShowMoreTagsLabel =>
TagsExpanded ? "Show less" : $"+{ExtraTagCount} more";
[RelayCommand]
private void ToggleTagsExpanded() => TagsExpanded = !TagsExpanded;
// ── Screenshots / videos ──────────────────────���────────────────────────���──
public ObservableCollection MediaItems { get; } = new();
public bool HasMedia => MediaItems.Count > 0;
// ── Tools ─────────────────────────────────────────────────────────────────
public ObservableCollection Tools { get; } = new();
public bool HasTools => Tools.Count > 0;
// ── Multiplayer modes ─────────────────────────────────────────────────────
public ObservableCollection MultiplayerModeDetails { get; } = new();
// ── Other ─────────────────────────���───────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(BackLabel))]
private bool _fromLibrary;
public string BackLabel => FromLibrary ? "Back to Library" : "Back to Depot";
// Split list properties for chip rendering
public IEnumerable GenreList => SplitCsv(Genres);
public IEnumerable DeveloperList => SplitCsv(Developers);
public IEnumerable PublisherList => SplitCsv(Publishers);
public IEnumerable TagList => SplitCsv(Tags);
private static IEnumerable SplitCsv(string csv) =>
csv.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
private bool _hasMultiplayer;
public bool HasPlayerInfo => Singleplayer || HasMultiplayer;
[ObservableProperty]
private bool _isLoadingMedia;
private bool _isOfflineMode;
public bool IsOfflineMode
{
get => _isOfflineMode;
set
{
if (SetProperty(ref _isOfflineMode, value))
{
ActionBar.IsOfflineMode = value;
}
}
}
// Action bar component
public GameActionBarViewModel ActionBar { get; }
public event EventHandler? LibraryChanged;
public event EventHandler? InstallRequested;
public event EventHandler? SearchRequested;
public GameDetailViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService>();
_navigationService = serviceProvider.GetRequiredService();
// Create action bar and wire up events
ActionBar = new GameActionBarViewModel(serviceProvider);
ActionBar.LibraryChanged += (_, _) => LibraryChanged?.Invoke(this, EventArgs.Empty);
ActionBar.InstallRequested += (_, _) => InstallRequested?.Invoke(this, EventArgs.Empty);
}
///
/// Refreshes the install status from the database.
/// Called after an installation completes.
///
public async Task RefreshInstallStatusAsync()
{
if (Id == Guid.Empty)
return;
await ActionBar.RefreshAsync();
}
///
/// Load game from local cache (Data.Models.Game)
/// Used when selecting from the library sidebar
///
public async void LoadGame(Data.Models.Game game)
{
Id = game.Id;
Title = game.Title ?? "Unknown";
Description = game.Description ?? string.Empty;
ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
ReleaseYear = game.ReleasedOn?.Year > 1 ? game.ReleasedOn.Value.Year.ToString() : "Unknown";
Singleplayer = game.Singleplayer;
// Get media paths from local storage
using var scope = _serviceProvider.CreateScope();
var mediaService = scope.ServiceProvider.GetRequiredService();
CoverPath = GetLocalMediaPath(game.Media, MediaType.Cover, mediaService);
CoverMimeType = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
LogoPath = GetLocalMediaPath(game.Media, MediaType.Logo, mediaService);
BackgroundPath = GetLocalMediaPath(game.Media, MediaType.Background, mediaService);
IconPath = GetLocalMediaPath(game.Media, MediaType.Icon, mediaService);
// Collections
Genres = game.Genres != null
? string.Join(", ", game.Genres.Select(g => g.Name))
: string.Empty;
Developers = game.Developers != null
? string.Join(", ", game.Developers.Select(d => d.Name))
: string.Empty;
Publishers = game.Publishers != null
? string.Join(", ", game.Publishers.Select(p => p.Name))
: string.Empty;
Platforms = game.Platforms != null
? string.Join(", ", game.Platforms.Select(p => p.Name))
: string.Empty;
Tags = game.Tags != null
? string.Join(", ", game.Tags.Select(t => t.Name))
: string.Empty;
// Multiplayer info
HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
MultiplayerModeDetails.Clear();
if (HasMultiplayer)
{
var modes = game.MultiplayerModes!
.Select(m => m.Type.ToString())
.Distinct();
MultiplayerModes = string.Join(", ", modes);
foreach (var mode in game.MultiplayerModes!)
MultiplayerModeDetails.Add(FormatMultiplayerMode(mode));
}
else
MultiplayerModes = string.Empty;
// Media items (screenshots / videos from local cache)
// Collect paths first, add skeletons for screenshots, then lazy-load bitmaps off the UI thread
MediaItems.Clear();
TagsExpanded = false;
var mediaEntries = new List<(string path, int index)>();
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)
continue;
if (m.Type == MediaType.Video)
{
MediaItems.Add(new GameMediaItemViewModel
{
Path = path,
IsVideo = true,
MimeType = string.Empty
});
}
else
{
var index = MediaItems.Count;
MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
mediaEntries.Add((path, index));
}
}
}
OnPropertyChanged(nameof(HasMedia));
// Tools
Tools.Clear();
if (game.Tools != null)
{
foreach (var tool in game.Tools)
Tools.Add(new ToolItemViewModel(tool));
}
OnPropertyChanged(nameof(HasTools));
// Load action bar state
await ActionBar.LoadFromLocalGameAsync(game);
// Load screenshot bitmaps on background thread, replacing skeletons as they complete
if (mediaEntries.Count > 0)
IsLoadingMedia = true;
foreach (var entry in mediaEntries)
{
try
{
var bitmap = await Task.Run(() => new Bitmap(entry.path));
MediaItems[entry.index] = new GameMediaItemViewModel
{
Path = entry.path,
IsVideo = false,
MimeType = string.Empty,
ImageSource = bitmap
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load local screenshot {Path}", entry.path);
}
}
// Remove any skeletons that failed to load
for (var i = MediaItems.Count - 1; i >= 0; i--)
{
if (MediaItems[i].IsSkeleton)
MediaItems.RemoveAt(i);
}
OnPropertyChanged(nameof(HasMedia));
if (mediaEntries.Count > 0)
IsLoadingMedia = false;
}
///
/// Load game from server API (SDK.Models.Game)
/// Used when selecting from the depot/all games list.
/// Loads essential media (cover, logo, background, icon) and metadata inline,
/// then loads screenshots/videos in the background so the UI is not blocked.
///
public async Task LoadGameAsync(SDK.Models.Game game)
{
Id = game.Id;
Title = game.Title ?? "Unknown";
Description = game.Description ?? string.Empty;
ReleasedOn = game.ReleasedOn;
ReleaseYear = game.ReleasedOn.Year > 1 ? game.ReleasedOn.Year.ToString() : "Unknown";
Singleplayer = game.Singleplayer;
// Reset media paths while loading
CoverPath = null;
CoverMimeType = null;
LogoPath = null;
BackgroundPath = null;
IconPath = null;
// Collections
Genres = game.Genres != null
? string.Join(", ", game.Genres.Select(g => g.Name))
: string.Empty;
Developers = game.Developers != null
? string.Join(", ", game.Developers.Select(d => d.Name))
: string.Empty;
Publishers = game.Publishers != null
? string.Join(", ", game.Publishers.Select(p => p.Name))
: string.Empty;
Platforms = game.Platforms != null
? string.Join(", ", game.Platforms.Select(p => p.Name))
: string.Empty;
Tags = game.Tags != null
? string.Join(", ", game.Tags.Select(t => t.Name))
: string.Empty;
// Multiplayer info
HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
MultiplayerModeDetails.Clear();
if (HasMultiplayer)
{
var modes = game.MultiplayerModes!
.Select(m => m.Type.ToString())
.Distinct();
MultiplayerModes = string.Join(", ", modes);
foreach (var mode in game.MultiplayerModes!)
MultiplayerModeDetails.Add(FormatMultiplayerMode(mode));
}
else
MultiplayerModes = string.Empty;
// Tools
Tools.Clear();
await LoadToolsAsync(game);
OnPropertyChanged(nameof(HasTools));
// Reset media items and tags state while we re-load
MediaItems.Clear();
TagsExpanded = false;
// Determine carousel media entries and populate skeletons immediately.
// Use the game's media list if available, otherwise fall back to cache.
var carouselMediaEntries = game.Media?
.Where(m => m.Type == MediaType.Screenshot || m.Type == MediaType.Video)
.ToList();
if (carouselMediaEntries == null || carouselMediaEntries.Count == 0)
{
// Try cached metadata from a previous visit
if (_mediaCache.TryGetValue(game.Id, out var cached))
foreach (var _ in cached)
MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
}
else
{
// Cache the media metadata for future visits
_mediaCache[game.Id] = carouselMediaEntries
.Select(m => new CachedMediaEntry(m.Type, m.MimeType))
.ToList();
// Add skeleton placeholders so the carousel renders immediately
foreach (var _ in carouselMediaEntries)
MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
}
OnPropertyChanged(nameof(HasMedia));
// Load action bar state
await ActionBar.LoadFromSdkGameAsync(game);
// Load essential media (cover, logo, background, icon) — needed for page layout
if (game.Media != null && game.Media.Any())
{
try
{
using var scope = _serviceProvider.CreateScope();
var mediaClient = scope.ServiceProvider.GetRequiredService();
CoverPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Cover, mediaClient);
CoverMimeType = game.Media.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
LogoPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Logo, mediaClient);
BackgroundPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Background, mediaClient);
IconPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Icon, mediaClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load essential media for game {GameId}", game.Id);
}
// Load screenshots/videos in the background — don't block navigation
_ = LoadCarouselMediaAsync(game);
}
}
///
/// Loads screenshots and videos into the media carousel in the background.
/// Replaces skeleton placeholders with real items as they load.
///
private async Task LoadCarouselMediaAsync(SDK.Models.Game game)
{
if (game.Media == null)
return;
var carouselMedia = game.Media.Where(m =>
m.Type == MediaType.Screenshot || m.Type == MediaType.Video).ToList();
if (!carouselMedia.Any())
return;
IsLoadingMedia = true;
try
{
using var scope = _serviceProvider.CreateScope();
var mediaClient = scope.ServiceProvider.GetRequiredService();
for (var i = 0; i < carouselMedia.Count; i++)
{
var media = carouselMedia[i];
try
{
var item = new GameMediaItemViewModel
{
IsVideo = media.Type == MediaType.Video,
MimeType = media.MimeType ?? string.Empty
};
if (media.Type == MediaType.Video)
item.Path = mediaClient.GetAbsoluteStreamUrl(media);
else
{
var localPath = mediaClient.GetLocalPath(media);
if (!File.Exists(localPath))
{
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
localPath = fileInfo.FullName;
}
item.Path = localPath;
item.ImageSource = await Task.Run(() => new Bitmap(localPath));
}
// Replace skeleton at the same position, or append if index is out of range
if (i < MediaItems.Count && MediaItems[i].IsSkeleton)
MediaItems[i] = item;
else
MediaItems.Add(item);
OnPropertyChanged(nameof(HasMedia));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load media {MediaId} from server", media.Id);
}
}
// Remove any remaining skeletons (e.g. if some items failed to load)
for (var i = MediaItems.Count - 1; i >= 0; i--)
{
if (MediaItems[i].IsSkeleton)
MediaItems.RemoveAt(i);
}
OnPropertyChanged(nameof(HasMedia));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load carousel media for game {GameId}", game.Id);
}
finally
{
IsLoadingMedia = false;
}
}
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 string? GetLocalMediaPath(System.Collections.Generic.ICollection? mediaCollection, MediaType type, MediaService mediaService)
{
var media = mediaCollection?.FirstOrDefault(m => m.Type == type);
if (media == null)
return null;
var path = mediaService.GetImagePath(media);
return mediaService.FileExists(media) ? path : null;
}
private async Task GetOrDownloadMediaPathAsync(System.Collections.Generic.IEnumerable mediaCollection, MediaType type, MediaClient mediaClient)
{
var media = mediaCollection.FirstOrDefault(m => m.Type == type);
if (media == null)
return null;
try
{
var localPath = mediaClient.GetLocalPath(media);
// Check if file exists locally
if (File.Exists(localPath))
return localPath;
// Download the media
_logger.LogDebug("Downloading media {MediaId} of type {Type}", media.Id, type);
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
if (fileInfo.Exists)
return fileInfo.FullName;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get or download media {MediaId}", media.Id);
}
return null;
}
private async Task LoadToolsAsync(SDK.Models.Game game)
{
try
{
using var scope = _serviceProvider.CreateScope();
var gameClient = scope.ServiceProvider.GetRequiredService();
var toolService = scope.ServiceProvider.GetRequiredService();
var tools = game.Tools;
if (tools == null || !tools.Any())
{
tools = await gameClient.GetToolsAsync(game.Id);
}
if (tools != null)
{
foreach (var tool in tools)
{
var localTool = await toolService.GetAsync(tool.Id);
Tools.Add(new ToolItemViewModel(tool, localTool));
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load tools for game {GameId}", game.Id);
}
}
[RelayCommand]
private void GoBack()
{
ActionBar.StopRunningCheck();
_navigationService.GoBack();
}
[RelayCommand]
private void SearchFor(string term)
{
if (!string.IsNullOrWhiteSpace(term))
SearchRequested?.Invoke(this, term);
}
}
///
/// ViewModel for a tool associated with a game.
///
public partial class ToolItemViewModel : ViewModelBase
{
public Guid Id { get; }
public string Name { get; }
public bool IsInstalled { get; }
public ToolItemViewModel(SDK.Models.Tool tool, Data.Models.Tool? localTool)
{
Id = tool.Id;
Name = tool.Name ?? "Unknown Tool";
IsInstalled = localTool?.Installed ?? false;
}
public ToolItemViewModel(Data.Models.Tool tool)
{
Id = tool.Id;
Name = tool.Name ?? "Unknown Tool";
IsInstalled = tool.Installed;
}
}