LANCommander/LANCommander.Launcher.Avalonia/ViewModels/DownloadQueueViewModel.cs

409 lines
13 KiB
C#

using System;
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Avalonia.Services;
using LANCommander.Launcher.Models;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LANCommander.Launcher.Avalonia.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 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? BackRequested;
public DownloadQueueViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<DownloadQueueViewModel>>();
_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.OnInstallComplete += OnInstallComplete;
_installService.OnInstallFail += OnInstallFail;
RefreshQueue();
}
private Task OnQueueChanged()
{
Dispatcher.UIThread.Post(async () => await RefreshQueueAsync());
return Task.CompletedTask;
}
private Task OnProgress(InstallProgress progress)
{
_taskbarProgressService.SetProgress(progress.Progress);
Dispatcher.UIThread.Post(() =>
{
CurrentStatus = GetDisplayName(progress.Status);
CurrentProgress = progress.Progress;
CurrentTransferSpeed = progress.TransferSpeed;
// Format progress text
var bytesDownloaded = FormatBytes(progress.BytesTransferred);
var totalBytes = FormatBytes(progress.TotalBytes);
CurrentProgressText = $"{bytesDownloaded} / {totalBytes} ({progress.Progress:P0})";
// Format transfer speed
TransferSpeedText = $"{FormatBytes(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);
TimeRemainingText = ts.TotalHours >= 1
? $"{(int)ts.TotalHours}h {ts.Minutes}m remaining"
: ts.TotalMinutes >= 1
? $"{ts.Minutes}m {ts.Seconds}s remaining"
: $"{ts.Seconds}s remaining";
}
else
{
TimeRemainingText = string.Empty;
}
// Update the matching queue item
var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id);
if (item != null)
{
item.Status = progress.Status;
item.Progress = progress.Progress;
item.TransferSpeed = progress.TransferSpeed;
item.BytesDownloaded = progress.BytesTransferred;
item.TotalBytes = 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 async Task OnInstallComplete(Data.Models.Game game)
{
_logger.LogInformation("Install complete for game {GameTitle}", game.Title);
_taskbarProgressService.ClearProgress();
// 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);
Dispatcher.UIThread.Post(() =>
{
RefreshQueue();
InstallCompleted?.Invoke(this, game.Id);
});
}
private Task OnInstallFail(Data.Models.Game game)
{
_logger.LogError("Install failed for game {GameTitle}", game.Title);
_taskbarProgressService.ClearProgress();
_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;
QueueItems.Clear();
foreach (var item in _installService.Queue)
{
var vm = new InstallQueueItemViewModel(item);
// Resolve icon path from the media database
if (vm.IconId != Guid.Empty)
{
try
{
using var scope = _serviceProvider.CreateScope();
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
if (await mediaService.FileExists(vm.IconId))
{
vm.IconPath = await mediaService.GetImagePath(vm.IconId);
vm.HasIcon = !string.IsNullOrEmpty(vm.IconPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to resolve icon for queue item {Title}", vm.Title);
}
}
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;
}
}
[RelayCommand]
private void Back() => BackRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
public void ToggleExpanded()
{
IsExpanded = !IsExpanded;
}
[RelayCommand]
public void Show()
{
IsExpanded = true;
}
[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);
}
private static string GetDisplayName(InstallStatus status)
{
var member = typeof(InstallStatus).GetField(status.ToString());
var display = member?.GetCustomAttribute<DisplayAttribute>();
return display?.Name ?? status.ToString();
}
private static string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
int order = 0;
double size = bytes;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size /= 1024;
}
return $"{size:0.##} {sizes[order]}";
}
}
/// <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? _iconPath;
[ObservableProperty]
private bool _hasIcon;
[ObservableProperty]
private bool _isUpdate;
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 string ProgressText => $"{FormatBytes(BytesDownloaded)} / {FormatBytes(TotalBytes)}";
public string SpeedText => $"{FormatBytes(TransferSpeed)}/s";
public InstallQueueItemViewModel() { }
public InstallQueueItemViewModel(IInstallQueueItem item)
{
Id = item.Id;
Title = item.Title;
Status = item.Status;
Progress = item.Progress;
TransferSpeed = (long)item.TransferSpeed;
BytesDownloaded = item.BytesDownloaded;
TotalBytes = item.TotalBytes;
CoverId = item.CoverId;
IconId = item.IconId;
IsUpdate = item.IsUpdate;
}
private static string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
int order = 0;
double size = bytes;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size /= 1024;
}
return $"{size:0.##} {sizes[order]}";
}
}