LANCommander/LANCommander.Launcher/ViewModels/DownloadQueueViewModel.cs
2026-07-02 20:34:22 -05:00

791 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Threading;
using ByteSizeLib;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Helpers;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.Models;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Models;
using LANCommander.SDK.Services;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SkiaSharp;
namespace LANCommander.Launcher.ViewModels;
/// <summary>
/// ViewModel for the download/install queue panel
/// </summary>
public partial class DownloadQueueViewModel : ViewModelBase
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<DownloadQueueViewModel> _logger;
private readonly INavigationService _navigationService;
private readonly NotificationService _notificationService;
private readonly TaskbarProgressService _taskbarProgressService;
private InstallService? _installService;
[ObservableProperty]
private bool _isExpanded;
[ObservableProperty]
private ObservableCollection<InstallQueueItemViewModel> _queueItems = new();
[ObservableProperty]
private InstallQueueItemViewModel? _currentItem;
[ObservableProperty]
private string _currentStatus = string.Empty;
[ObservableProperty]
private float _currentProgress;
[ObservableProperty]
private string _currentProgressText = string.Empty;
[ObservableProperty]
private long _currentTransferSpeed;
[ObservableProperty]
private string _transferSpeedText = string.Empty;
[ObservableProperty]
private string _timeRemainingText = string.Empty;
[ObservableProperty]
private bool _hasActiveDownload;
[ObservableProperty]
private bool _hasQueuedItems;
[ObservableProperty]
private bool _hasCompletedItems;
[ObservableProperty]
private bool _hasItems;
[ObservableProperty]
private bool _hasPendingItems;
[ObservableProperty]
private int _activeCount;
public event EventHandler<Guid>? InstallCompleted;
public event EventHandler<Guid>? ToolInstallCompleted;
public DownloadQueueViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<DownloadQueueViewModel>>();
_navigationService = serviceProvider.GetRequiredService<INavigationService>();
_notificationService = serviceProvider.GetRequiredService<NotificationService>();
_taskbarProgressService = serviceProvider.GetRequiredService<TaskbarProgressService>();
}
public void Initialize()
{
// InstallService is a singleton — resolve directly from the root provider,
// not through a child scope that would be disposed immediately.
_installService = _serviceProvider.GetRequiredService<InstallService>();
_installService.OnQueueChanged += OnQueueChanged;
_installService.OnProgress += OnProgress;
_installService.OnTaskProgressUpdate += OnTaskProgressUpdate;
_installService.OnInstallComplete += OnInstallComplete;
_installService.OnToolInstallComplete += OnToolInstallComplete;
_installService.OnInstallQueueComplete += OnInstallQueueComplete;
_installService.OnInstallFail += OnInstallFail;
RefreshQueue();
}
private Task OnQueueChanged()
{
Dispatcher.UIThread.Post(async () => await RefreshQueueAsync());
return Task.CompletedTask;
}
private Task OnTaskProgressUpdate(InstallTaskProgress taskProgress)
{
Dispatcher.UIThread.Post(() =>
{
var item = QueueItems.FirstOrDefault(i => i.Id == taskProgress.QueueItemId);
if (item == null)
return;
var task = item.Tasks.FirstOrDefault(t => t.Id == taskProgress.TaskId);
if (task == null)
return;
task.Status = taskProgress.TaskStatus;
task.Progress = taskProgress.Progress;
task.BytesTransferred = taskProgress.BytesTransferred;
task.TotalBytes = taskProgress.TotalBytes;
task.TransferSpeed = taskProgress.TransferSpeed;
task.ErrorMessage = taskProgress.ErrorMessage;
item.CurrentTask = task;
// Update current status from task
if (item == CurrentItem)
CurrentStatus = taskProgress.TaskTitle;
});
return Task.CompletedTask;
}
private Task OnProgress(InstallProgress progress)
{
Dispatcher.UIThread.Post(() =>
{
var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id);
// Progress can arrive out-of-band when nothing is queued (e.g. a game-launch
// sub-operation). Treat the event as a real install only if it maps to a queue
// item, or if some item is already actively installing (covers addon/expansion
// sub-installs, whose progress carries the addon — not the base queue item).
// Otherwise ignore it so it can't drive the footer or taskbar.
if (item == null && !QueueItems.Any(i => i.IsActive))
{
_taskbarProgressService.ClearProgress();
return;
}
_taskbarProgressService.Report(progress);
CurrentStatus = GetDisplayName(progress.Status);
CurrentProgress = progress.Progress;
CurrentTransferSpeed = progress.TransferSpeed;
// Format progress text
var bytesDownloaded = ByteSize.FromBytes(progress.BytesTransferred);
var totalBytes = ByteSize.FromBytes(progress.TotalBytes);
CurrentProgressText = $"{bytesDownloaded} / {totalBytes} ({progress.Progress:P0})";
// Format transfer speed
TransferSpeedText = $"{ByteSize.FromBytes(progress.TransferSpeed)}/s";
// Format time remaining
var bytesRemaining = progress.TotalBytes - progress.BytesTransferred;
if (progress.TransferSpeed > 0 && bytesRemaining > 0)
{
var seconds = (double)bytesRemaining / progress.TransferSpeed;
var ts = TimeSpan.FromSeconds(seconds);
string remaining;
if (ts.TotalHours >= 1)
remaining = $"{(int)ts.TotalHours}h {ts.Minutes}m";
else if (ts.TotalMinutes >= 1)
remaining = $"{ts.Minutes}m {ts.Seconds}s";
else
remaining = $"{ts.Seconds}s";
TimeRemainingText = Localize("TimeRemaining", remaining);
}
else
{
TimeRemainingText = string.Empty;
}
// Update the matching queue item. May be null for a sub-install (e.g. an addon)
// whose progress carries the addon rather than the active base queue item; the
// footer/taskbar above still reflect it.
if (item != null)
{
item.UpdateProgress(progress.Status, progress.Progress, progress.TransferSpeed, progress.BytesTransferred, progress.TotalBytes);
// Ensure footer visibility and CurrentItem are up-to-date
// without waiting for the next OnQueueChanged cycle
if (!HasActiveDownload)
HasActiveDownload = true;
CurrentItem ??= item;
}
});
return Task.CompletedTask;
}
private Task OnInstallComplete(Data.Models.Game game)
{
_logger.LogInformation("Install complete for game {GameTitle}", game.Title);
_taskbarProgressService.ClearProgress();
Dispatcher.UIThread.Post(() =>
{
RefreshQueue();
InstallCompleted?.Invoke(this, game.Id);
});
return Task.CompletedTask;
}
private Task OnToolInstallComplete(Data.Models.Game game)
{
_logger.LogInformation("Tool install complete for game {GameTitle}", game.Title);
Dispatcher.UIThread.Post(() =>
{
RefreshQueue();
ToolInstallCompleted?.Invoke(this, game.Id);
});
return Task.CompletedTask;
}
private async Task OnInstallQueueComplete(Data.Models.Game game)
{
_logger.LogInformation("Install batch complete for game {GameTitle}", game.Title);
// Resolve icon and grid art paths for the notification
string? iconPath = null;
string? gridPath = null;
try
{
using var scope = _serviceProvider.CreateScope();
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
var icon = await mediaService.FirstOrDefaultAsync(m => m.GameId == game.Id && m.Type == MediaType.Icon);
if (icon != null && mediaService.FileExists(icon))
iconPath = mediaService.GetImagePath(icon);
var grid = await mediaService.FirstOrDefaultAsync(m => m.GameId == game.Id && m.Type == MediaType.Grid);
if (grid != null && mediaService.FileExists(grid))
gridPath = mediaService.GetImagePath(grid);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to resolve media for notification");
}
_notificationService.NotifyInstallComplete(game.Title ?? "Game", iconPath, gridPath, game.Id);
}
private Task OnInstallFail(Data.Models.Game game)
{
_logger.LogError("Install failed for game {GameTitle}", game.Title);
_taskbarProgressService.SetError();
_notificationService.NotifyInstallFailed(game.Title ?? "Game", game.Id);
Dispatcher.UIThread.Post(RefreshQueue);
return Task.CompletedTask;
}
private void RefreshQueue() => _ = RefreshQueueAsync();
private async Task RefreshQueueAsync()
{
if (_installService == null)
return;
var sourceIds = _installService.Queue.Select(i => i.Id).ToHashSet();
// Remove items no longer in the service queue
for (int i = QueueItems.Count - 1; i >= 0; i--)
{
if (!sourceIds.Contains(QueueItems[i].Id))
QueueItems.RemoveAt(i);
}
// Add or update items
for (int i = 0; i < _installService.Queue.Count; i++)
{
var source = _installService.Queue[i];
var existing = QueueItems.FirstOrDefault(vm => vm.Id == source.Id);
if (existing != null)
{
// Update in place — preserves IsExpanded
existing.UpdateFrom(source);
// Ensure correct position
var currentIndex = QueueItems.IndexOf(existing);
if (currentIndex != i && i < QueueItems.Count)
QueueItems.Move(currentIndex, i);
}
else
{
var vm = new InstallQueueItemViewModel(source);
await ResolveCoverArt(vm);
await ResolveIcon(vm);
if (i < QueueItems.Count)
QueueItems.Insert(i, vm);
else
QueueItems.Add(vm);
}
}
// Update state flags
HasActiveDownload = QueueItems.Any(i => i.IsActive);
HasQueuedItems = QueueItems.Any(i => i.Status == InstallStatus.Queued);
HasCompletedItems = QueueItems.Any(i => i.Status == InstallStatus.Complete);
HasItems = QueueItems.Any();
ActiveCount = QueueItems.Count(i => i.IsActive || i.Status == InstallStatus.Queued);
HasPendingItems = ActiveCount > 0;
CurrentItem = QueueItems.FirstOrDefault(i => i.IsActive);
// Auto-expand when there's an active download
if (HasActiveDownload && !IsExpanded)
IsExpanded = true;
}
private async Task ResolveCoverArt(InstallQueueItemViewModel vm)
{
if (vm.CoverMedia == null)
{
_logger.LogInformation("[InstallQueue] ResolveCoverArt: CoverMedia is null for {Title} (CoverId={CoverId})", vm.Title, vm.CoverId);
return;
}
try
{
var mediaClient = _serviceProvider.GetRequiredService<MediaClient>();
var localPath = mediaClient.GetLocalPath(vm.CoverMedia);
_logger.LogInformation("[InstallQueue] ResolveCoverArt: {Title} — mediaId={MediaId}, fileId={FileId}, localPath={Path}, exists={Exists}",
vm.Title, vm.CoverMedia.Id, vm.CoverMedia.FileId, localPath, File.Exists(localPath));
if (!File.Exists(localPath))
{
var dir = Path.GetDirectoryName(localPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var file = await mediaClient.DownloadAsync(vm.CoverMedia, localPath);
_logger.LogInformation("[InstallQueue] ResolveCoverArt: Downloaded {Title} cover to {Path}, exists={Exists}",
vm.Title, file.FullName, file.Exists);
if (file.Exists)
localPath = file.FullName;
else
return;
}
vm.CoverPath = localPath;
vm.HasCover = true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[InstallQueue] ResolveCoverArt: Failed for {Title}", vm.Title);
}
}
private async Task ResolveIcon(InstallQueueItemViewModel vm)
{
if (vm.IconMedia == null)
return;
try
{
var mediaClient = _serviceProvider.GetRequiredService<MediaClient>();
var localPath = mediaClient.GetLocalPath(vm.IconMedia);
if (!File.Exists(localPath))
{
var dir = Path.GetDirectoryName(localPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var file = await mediaClient.DownloadAsync(vm.IconMedia, localPath);
if (file.Exists)
localPath = file.FullName;
else
return;
}
vm.IconPath = localPath;
vm.HasIcon = true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[InstallQueue] ResolveIcon: Failed for {Title}", vm.Title);
}
}
[RelayCommand]
private void Back() => _navigationService.GoBack();
[RelayCommand]
public void ToggleExpanded()
{
IsExpanded = !IsExpanded;
}
[RelayCommand]
public void Show()
{
IsExpanded = true;
}
[RelayCommand]
private void ToggleItemExpanded(InstallQueueItemViewModel? item)
{
if (item == null || !item.HasTasks) return;
item.IsExpanded = !item.IsExpanded;
}
[RelayCommand]
private async Task CancelAsync(InstallQueueItemViewModel? item)
{
if (item == null || _installService == null) return;
_logger.LogInformation("Canceling install for {Title}", item.Title);
await _installService.CancelInstallAsync(item.Id);
}
[RelayCommand]
private void ClearCompleted()
{
if (_installService == null) return;
foreach (var item in QueueItems.Where(i => i.IsCompleted || i.IsFailed).ToList())
_installService.Remove(item.Id);
}
[RelayCommand]
private void Remove(InstallQueueItemViewModel? item)
{
if (item == null || _installService == null) return;
_logger.LogInformation("Removing {Title} from queue", item.Title);
_installService.Remove(item.Id);
}
[RelayCommand]
private async Task ViewInLibraryAsync(InstallQueueItemViewModel? item)
{
if (item == null) return;
var shell = _serviceProvider.GetRequiredService<MainWindowViewModel>().ShellViewModel;
await shell.NavigateToGameByIdAsync(item.Id);
}
[RelayCommand]
private async Task PlayAsync(InstallQueueItemViewModel? item)
{
if (item == null) return;
var shell = _serviceProvider.GetRequiredService<MainWindowViewModel>().ShellViewModel;
await shell.NavigateToGameByIdAsync(item.Id);
await shell.GameDetailViewModel.ActionBar.PlayCommand.ExecuteAsync(null);
}
private static string GetDisplayName(InstallStatus status)
{
var member = typeof(InstallStatus).GetField(status.ToString());
var display = member?.GetCustomAttribute<DisplayAttribute>();
return display?.Name ?? status.ToString();
}
}
/// <summary>
/// ViewModel for an individual queue item
/// </summary>
public partial class InstallQueueItemViewModel : ViewModelBase
{
[ObservableProperty]
private Guid _id;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private InstallStatus _status;
[ObservableProperty]
private float _progress;
[ObservableProperty]
private long _transferSpeed;
[ObservableProperty]
private long _bytesDownloaded;
[ObservableProperty]
private long _totalBytes;
[ObservableProperty]
private Guid _coverId;
[ObservableProperty]
private Guid _iconId;
[ObservableProperty]
private string? _coverPath;
[ObservableProperty]
private bool _hasCover;
[ObservableProperty]
private string? _iconPath;
[ObservableProperty]
private bool _hasIcon;
[ObservableProperty]
private bool _isUpdate;
[ObservableProperty]
private ObservableCollection<InstallTaskItemViewModel> _tasks = new();
[ObservableProperty]
private InstallTaskItemViewModel? _currentTask;
[ObservableProperty]
private bool _hasTasks;
[ObservableProperty]
private bool _isExpanded;
[ObservableProperty]
private string _progressText = string.Empty;
[ObservableProperty]
private string _speedText = string.Empty;
[ObservableProperty]
private string _percentText = string.Empty;
[ObservableProperty]
private string _statusText = string.Empty;
[ObservableProperty]
private string? _completedOnText;
/// <summary>
/// The SDK Media object for the cover — used by ResolveCoverArt to get the local file path.
/// </summary>
public Media? CoverMedia { get; set; }
/// <summary>
/// The SDK Media object for the icon — used by ResolveIcon to get the local file path.
/// </summary>
public Media? IconMedia { get; set; }
private const int MaxSpeedSamples = 60;
private readonly ObservableCollection<double> _speedValues = new(new double[MaxSpeedSamples]);
[ObservableProperty]
private ISeries[] _speedSeries = Array.Empty<ISeries>();
[ObservableProperty]
private Axis[] _speedXAxes = { new Axis { IsVisible = false } };
[ObservableProperty]
private Axis[] _speedYAxes = { new Axis {
IsVisible = true,
MinLimit = 0,
LabelsPaint = new SolidColorPaint(SKColors.Gray),
TextSize = 10,
SeparatorsPaint = null,
Labeler = v => $"{ByteSize.FromBytes(v).MegaBytes:0.#}",
} };
private void InitSpeedChart()
{
SpeedSeries = new ISeries[]
{
new LineSeries<double>
{
Values = _speedValues,
GeometrySize = 0,
Stroke = new SolidColorPaint(SKColors.DodgerBlue, 2),
Fill = new SolidColorPaint(SKColors.DodgerBlue.WithAlpha(40)),
LineSmoothness = 0.3,
AnimationsSpeed = TimeSpan.FromMilliseconds(150),
YToolTipLabelFormatter = point => $"{ByteSize.FromBytes(point.Coordinate.PrimaryValue).MegaBytes:0.##} MB/s",
}
};
}
private void RecordSpeed(long bytesPerSecond)
{
_speedValues.Add(bytesPerSecond);
while (_speedValues.Count > MaxSpeedSamples)
_speedValues.RemoveAt(0);
}
public bool IsActive => Status != InstallStatus.Queued &&
Status != InstallStatus.Complete &&
Status != InstallStatus.Failed &&
Status != InstallStatus.Canceled;
public bool IsQueued => Status == InstallStatus.Queued;
public bool IsCompleted => Status == InstallStatus.Complete;
public bool IsFailed => Status == InstallStatus.Failed;
public bool HasSpeedData => true;
public string SpeedChartLabel => IsActive
? Localize("TransferSpeed")
: Localize("TransferSpeedHistory");
public InstallQueueItemViewModel()
{
InitSpeedChart();
}
public InstallQueueItemViewModel(IInstallQueueItem item)
{
InitSpeedChart();
Id = item.Id;
Title = item.Title;
CoverId = item.CoverId;
IconId = item.IconId;
IsUpdate = item.IsUpdate;
// Resolve cover and icon media from the underlying game/redist model
if (item is InstallQueueGame gameItem)
{
CoverMedia = gameItem.Game?.Media?.FirstOrDefault(m => m.Type == MediaType.Cover);
IconMedia = gameItem.Game?.Media?.FirstOrDefault(m => m.Type == MediaType.Icon);
}
if (item.Tasks != null && item.Tasks.Count > 0)
{
var ordered = item.Tasks.OrderBy(t => t.Order).ToList();
for (int i = 0; i < ordered.Count; i++)
{
var vm = new InstallTaskItemViewModel(ordered[i])
{
IsFirst = i == 0,
IsLast = i == ordered.Count - 1,
};
Tasks.Add(vm);
}
HasTasks = true;
}
ApplyMetrics(item);
}
/// <summary>
/// Updates mutable state from the service model without recreating the VM.
/// Preserves IsExpanded and Tasks collection identity.
/// </summary>
public void UpdateFrom(IInstallQueueItem item)
{
Title = item.Title;
ApplyMetrics(item);
}
/// <summary>
/// Updates progress from the live InstallProgress event.
/// Recalculates formatted text properties.
/// </summary>
public void UpdateProgress(InstallStatus status, float progress, long transferSpeed, long bytesTransferred, long totalBytes)
{
Status = status;
Progress = progress;
TransferSpeed = transferSpeed;
BytesDownloaded = bytesTransferred;
TotalBytes = totalBytes;
ProgressText = totalBytes > 0
? $"{ByteSize.FromBytes(bytesTransferred)} / {ByteSize.FromBytes(totalBytes)}"
: string.Empty;
SpeedText = transferSpeed > 0 ? $"{ByteSize.FromBytes(transferSpeed)}/s" : string.Empty;
PercentText = totalBytes > 0 ? $"{progress:P0}" : string.Empty;
StatusText = GetDisplayName(status);
RecordSpeed(transferSpeed);
OnPropertyChanged(nameof(IsActive));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsCompleted));
OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(SpeedChartLabel));
}
private void ApplyMetrics(IInstallQueueItem item)
{
Status = item.Status;
Progress = item.Progress;
TransferSpeed = (long)item.TransferSpeed;
BytesDownloaded = item.BytesDownloaded;
TotalBytes = item.TotalBytes;
if (Status == InstallStatus.Complete)
{
ProgressText = Localize("InstallStatusComplete");
SpeedText = string.Empty;
PercentText = string.Empty;
StatusText = Localize("InstallStatusComplete");
CompletedOnText = item.CompletedOn?.ToString("g");
}
else if (Status == InstallStatus.Failed)
{
ProgressText = Localize("InstallStatusFailed");
SpeedText = string.Empty;
PercentText = string.Empty;
StatusText = Localize("InstallStatusFailed");
}
else if (Status == InstallStatus.Queued)
{
ProgressText = Localize("InstallStatusQueued");
SpeedText = string.Empty;
PercentText = string.Empty;
StatusText = Localize("InstallStatusQueued");
}
else
{
ProgressText = TotalBytes > 0
? $"{ByteSize.FromBytes(BytesDownloaded)} / {ByteSize.FromBytes(TotalBytes)}"
: string.Empty;
SpeedText = TransferSpeed > 0 ? $"{ByteSize.FromBytes(TransferSpeed)}/s" : string.Empty;
PercentText = TotalBytes > 0 ? $"{Progress:P0}" : string.Empty;
StatusText = GetDisplayName(Status);
}
OnPropertyChanged(nameof(IsActive));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsCompleted));
OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(SpeedChartLabel));
}
private static string GetDisplayName(InstallStatus status)
{
var member = typeof(InstallStatus).GetField(status.ToString());
var display = member?.GetCustomAttribute<DisplayAttribute>();
return display?.Name ?? status.ToString();
}
}