2026-06-06 05:49:24 -05:00
|
|
|
|
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
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// In-memory cache of carousel media metadata per game, so skeletons can be shown
|
|
|
|
|
|
/// immediately on repeat visits without waiting for the API.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static readonly ConcurrentDictionary<Guid, List<CachedMediaEntry>> _mediaCache = new();
|
|
|
|
|
|
|
|
|
|
|
|
private record CachedMediaEntry(MediaType Type, string? MimeType);
|
|
|
|
|
|
|
|
|
|
|
|
private readonly IServiceProvider _serviceProvider;
|
|
|
|
|
|
private readonly INavigationService _navigationService;
|
|
|
|
|
|
private readonly ILogger<GameDetailViewModel> _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 ─────────────<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;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Tools ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
public ObservableCollection<ToolItemViewModel> Tools { get; } = new();
|
|
|
|
|
|
|
|
|
|
|
|
public bool HasTools => Tools.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";
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
|
|
private static IEnumerable<string> 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<string>? SearchRequested;
|
|
|
|
|
|
|
|
|
|
|
|
public GameDetailViewModel(IServiceProvider serviceProvider)
|
|
|
|
|
|
{
|
|
|
|
|
|
_serviceProvider = serviceProvider;
|
|
|
|
|
|
_logger = serviceProvider.GetRequiredService<ILogger<GameDetailViewModel>>();
|
|
|
|
|
|
_navigationService = serviceProvider.GetRequiredService<INavigationService>();
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Refreshes the install status from the database.
|
|
|
|
|
|
/// Called after an installation completes.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public async Task RefreshInstallStatusAsync()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (Id == Guid.Empty)
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
await ActionBar.RefreshAsync();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 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.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
if (game.Media != null && game.Media.Any())
|
|
|
|
|
|
{
|
2026-06-14 01:58:52 -05:00
|
|
|
|
// Start loading screenshots/videos in the background immediately so videos
|
|
|
|
|
|
// begin streaming without waiting on the essential media downloads below.
|
|
|
|
|
|
_ = LoadCarouselMediaAsync(game);
|
|
|
|
|
|
|
|
|
|
|
|
// Load essential media (cover, logo, background, icon) — needed for page layout
|
2026-06-06 05:49:24 -05:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
|
|
|
|
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
|
|
|
|
|
|
|
2026-07-04 03:33:13 -05:00
|
|
|
|
CoverPath = ResolveEssentialMediaPath(game.Media, MediaType.Cover, mediaClient);
|
2026-06-06 05:49:24 -05:00
|
|
|
|
CoverMimeType = game.Media.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
|
2026-07-04 03:33:13 -05:00
|
|
|
|
LogoPath = ResolveEssentialMediaPath(game.Media, MediaType.Logo, mediaClient);
|
|
|
|
|
|
BackgroundPath = ResolveEssentialMediaPath(game.Media, MediaType.Background, mediaClient);
|
|
|
|
|
|
IconPath = ResolveEssentialMediaPath(game.Media, MediaType.Icon, mediaClient);
|
2026-06-06 05:49:24 -05:00
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogError(ex, "Failed to load essential media for game {GameId}", game.Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Loads screenshots and videos into the media carousel in the background.
|
2026-06-14 01:58:52 -05:00
|
|
|
|
/// Videos are set up immediately so they begin streaming right away; screenshots
|
|
|
|
|
|
/// are downloaded/decoded concurrently and pop in as each one finishes, so no item
|
|
|
|
|
|
/// blocks the others.
|
2026-06-06 05:49:24 -05:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
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<MediaClient>();
|
|
|
|
|
|
|
2026-06-14 01:58:52 -05:00
|
|
|
|
var screenshotTasks = new List<Task>();
|
|
|
|
|
|
|
2026-06-06 05:49:24 -05:00
|
|
|
|
for (var i = 0; i < carouselMedia.Count; i++)
|
|
|
|
|
|
{
|
2026-06-14 01:58:52 -05:00
|
|
|
|
var index = i;
|
2026-06-06 05:49:24 -05:00
|
|
|
|
var media = carouselMedia[i];
|
|
|
|
|
|
|
2026-06-14 01:58:52 -05:00
|
|
|
|
if (media.Type == MediaType.Video)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Videos only need a stream URL — set them immediately so they don't
|
|
|
|
|
|
// wait behind screenshot downloads.
|
|
|
|
|
|
ReplaceMediaItem(index, new GameMediaItemViewModel
|
2026-06-06 05:49:24 -05:00
|
|
|
|
{
|
2026-06-14 01:58:52 -05:00
|
|
|
|
IsVideo = true,
|
|
|
|
|
|
MimeType = media.MimeType ?? string.Empty,
|
|
|
|
|
|
Path = mediaClient.GetAbsoluteStreamUrl(media)
|
|
|
|
|
|
});
|
2026-06-06 05:49:24 -05:00
|
|
|
|
}
|
2026-06-14 01:58:52 -05:00
|
|
|
|
else
|
2026-06-06 05:49:24 -05:00
|
|
|
|
{
|
2026-06-14 01:58:52 -05:00
|
|
|
|
screenshotTasks.Add(LoadScreenshotAsync(index, media, mediaClient));
|
2026-06-06 05:49:24 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 01:58:52 -05:00
|
|
|
|
await Task.WhenAll(screenshotTasks);
|
|
|
|
|
|
|
2026-06-06 05:49:24 -05:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 01:58:52 -05:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Downloads (if needed) and decodes a single screenshot, then swaps it into the
|
|
|
|
|
|
/// carousel in place of its skeleton. Runs concurrently with other screenshots.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private async Task LoadScreenshotAsync(int index, SDK.Models.Media media, MediaClient mediaClient)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var localPath = mediaClient.GetLocalPath(media);
|
|
|
|
|
|
|
|
|
|
|
|
if (!File.Exists(localPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
var fileInfo = await mediaClient.DownloadAsync(media, localPath);
|
|
|
|
|
|
localPath = fileInfo.FullName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 19:31:58 -05:00
|
|
|
|
// Decode downscaled: the carousel slot is 384 logical px, so ~2x covers
|
|
|
|
|
|
// HiDPI/UniformToFill without holding the full-resolution source in memory.
|
|
|
|
|
|
// The lightbox loads the full-res image from Path when it needs it.
|
|
|
|
|
|
var bitmap = await Task.Run(() =>
|
|
|
|
|
|
{
|
|
|
|
|
|
using var stream = File.OpenRead(localPath);
|
|
|
|
|
|
return Bitmap.DecodeToWidth(stream, 768, BitmapInterpolationMode.HighQuality);
|
|
|
|
|
|
});
|
2026-06-14 01:58:52 -05:00
|
|
|
|
|
|
|
|
|
|
ReplaceMediaItem(index, new GameMediaItemViewModel
|
|
|
|
|
|
{
|
|
|
|
|
|
IsVideo = false,
|
|
|
|
|
|
MimeType = media.MimeType ?? string.Empty,
|
|
|
|
|
|
Path = localPath,
|
|
|
|
|
|
ImageSource = bitmap
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogError(ex, "Failed to load media {MediaId} from server", media.Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Replaces the skeleton at <paramref name="index"/> with the loaded item, or appends
|
|
|
|
|
|
/// it if the index no longer points at a skeleton. Always invoked on the UI thread via
|
|
|
|
|
|
/// awaited continuations, so collection access is serialized.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private void ReplaceMediaItem(int index, GameMediaItemViewModel item)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (index < MediaItems.Count && MediaItems[index].IsSkeleton)
|
|
|
|
|
|
MediaItems[index] = item;
|
|
|
|
|
|
else
|
|
|
|
|
|
MediaItems.Add(item);
|
|
|
|
|
|
|
|
|
|
|
|
OnPropertyChanged(nameof(HasMedia));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 05:49:24 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 03:33:13 -05:00
|
|
|
|
// Resolves an essential asset (cover/logo/background/icon) to a local path when the import has
|
|
|
|
|
|
// already cached it, otherwise a server stream URL so it renders instantly without blocking on a
|
|
|
|
|
|
// download. The background import still caches these to disk for offline use.
|
|
|
|
|
|
private static string? ResolveEssentialMediaPath(System.Collections.Generic.IEnumerable<SDK.Models.Media> mediaCollection, MediaType type, MediaClient mediaClient)
|
|
|
|
|
|
=> MediaSourceResolver.Resolve(mediaCollection.FirstOrDefault(m => m.Type == type), mediaClient);
|
2026-06-06 05:49:24 -05:00
|
|
|
|
|
|
|
|
|
|
private async Task LoadToolsAsync(SDK.Models.Game game)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
|
|
|
|
var gameClient = scope.ServiceProvider.GetRequiredService<GameClient>();
|
|
|
|
|
|
var toolService = scope.ServiceProvider.GetRequiredService<LANCommander.Launcher.Services.ToolService>();
|
|
|
|
|
|
|
|
|
|
|
|
var tools = game.Tools;
|
|
|
|
|
|
|
|
|
|
|
|
if (tools == null || !tools.Any())
|
|
|
|
|
|
{
|
|
|
|
|
|
tools = await gameClient.GetToolsAsync(game.Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tools != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var tool in tools)
|
|
|
|
|
|
{
|
2026-06-27 11:51:55 -05:00
|
|
|
|
var isInstalled = await toolService.IsToolInstalledForGameAsync(game.Id, tool.Id);
|
|
|
|
|
|
Tools.Add(new ToolItemViewModel(tool, isInstalled));
|
2026-06-06 05:49:24 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// ViewModel for a tool associated with a game.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public partial class ToolItemViewModel : ViewModelBase
|
|
|
|
|
|
{
|
|
|
|
|
|
public Guid Id { get; }
|
|
|
|
|
|
public string Name { get; }
|
|
|
|
|
|
public bool IsInstalled { get; }
|
|
|
|
|
|
|
2026-06-27 11:51:55 -05:00
|
|
|
|
public ToolItemViewModel(SDK.Models.Tool tool, bool isInstalled)
|
2026-06-06 05:49:24 -05:00
|
|
|
|
{
|
|
|
|
|
|
Id = tool.Id;
|
|
|
|
|
|
Name = tool.Name ?? "Unknown Tool";
|
2026-06-27 11:51:55 -05:00
|
|
|
|
IsInstalled = isInstalled;
|
2026-06-06 05:49:24 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|