775 lines
25 KiB
C#
775 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 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.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 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();
|
|
}
|
|
}
|