Skip to content

Commit 73fd8c1

Browse files
authored
Merge pull request #192 from pingzing/add-downloads-page
Add downloads panel
2 parents 3094ef1 + 1631dce commit 73fd8c1

File tree

14 files changed

+1098
-245
lines changed

14 files changed

+1098
-245
lines changed

Stardrop/App.axaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ private async void OnUrlsOpen(object? sender, UrlOpenedEventArgs e, MainWindow m
5151
{
5252
foreach (string? url in e.Urls.Where(u => String.IsNullOrEmpty(u) is false))
5353
{
54-
await mainWindow.ProcessNXMLink(Nexus.GetKey(), new NXM() { Link = url, Timestamp = DateTime.Now });
54+
await mainWindow.ProcessNXMLink(new NXM() { Link = url, Timestamp = DateTime.Now });
5555
}
5656
}
5757

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Avalonia.Data.Converters;
2+
using System;
3+
using System.Globalization;
4+
5+
namespace Stardrop.Converters
6+
{
7+
public class EnumEqualsConverter : IValueConverter
8+
{
9+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
10+
{
11+
if (value?.GetType()?.IsEnum is not true)
12+
{
13+
throw new ArgumentOutOfRangeException(nameof(value), "Value must be a non-null enum.");
14+
}
15+
if (parameter?.GetType()?.IsEnum is not true)
16+
{
17+
throw new ArgumentOutOfRangeException(nameof(parameter), "Parameter must be a non-null enum.");
18+
}
19+
20+
return Enum.Equals(value, parameter);
21+
}
22+
23+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
24+
{
25+
throw new NotImplementedException();
26+
}
27+
}
28+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System;
2+
using System.Threading;
3+
4+
namespace Stardrop.Models.Data
5+
{
6+
internal record ModDownloadStartedEventArgs(Uri Uri, string Name, long? Size, CancellationTokenSource DownloadCancellationSource);
7+
internal record ModDownloadProgressEventArgs(Uri Uri, long TotalBytes);
8+
internal record ModDownloadCompletedEventArgs(Uri Uri);
9+
internal record ModDownloadFailedEventArgs(Uri Uri);
10+
}

Stardrop/Stardrop.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
<Compile Update="Views\WarningWindow.axaml.cs">
6262
<DependentUpon>WarningWindow.axaml</DependentUpon>
6363
</Compile>
64+
<Compile Update="Views\DownloadPanel.axaml.cs">
65+
<DependentUpon>DownloadPanel.axaml</DependentUpon>
66+
</Compile>
6467
</ItemGroup>
6568
<ItemGroup>
6669
<Content Update="Themes\Light.xaml">

Stardrop/Utilities/External/Nexus.cs renamed to Stardrop/Utilities/External/NexusClient.cs

Lines changed: 164 additions & 134 deletions
Large diffs are not rendered by default.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Stardrop.Utilities.External
2+
{
3+
public enum DownloadResultKind
4+
{
5+
Failed,
6+
UserCanceled,
7+
Success
8+
}
9+
10+
public record struct NexusDownloadResult(DownloadResultKind ResultKind, string? DownloadedModFilePath);
11+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using Avalonia.Controls;
2+
using DynamicData;
3+
using DynamicData.Aggregation;
4+
using DynamicData.Alias;
5+
using DynamicData.Binding;
6+
using ReactiveUI;
7+
using Stardrop.Models.Data;
8+
using Stardrop.Utilities.External;
9+
using System;
10+
using System.Collections.ObjectModel;
11+
using System.Linq;
12+
13+
namespace Stardrop.ViewModels
14+
{
15+
public class DownloadPanelViewModel : ViewModelBase
16+
{
17+
private ObservableCollection<ModDownloadViewModel> _downloads = new();
18+
public ObservableCollection<ModDownloadViewModel> Downloads { get => _downloads; set => this.RaiseAndSetIfChanged(ref _downloads, value); }
19+
20+
public IObservable<int> InProgressDownloads { get; init; }
21+
22+
public DownloadPanelViewModel(NexusClient? nexusClient)
23+
{
24+
Nexus.ClientChanged += NexusClientChanged;
25+
if (nexusClient is not null)
26+
{
27+
RegisterEventHandlers(nexusClient);
28+
}
29+
30+
// Count failed and canceled downloads toward this value, because those still need to be
31+
// handled in some way by the user
32+
InProgressDownloads = Downloads
33+
.ToObservableChangeSet(t => t.ModUri)
34+
.AutoRefresh(x => x.DownloadStatus, scheduler: RxApp.MainThreadScheduler)
35+
.Filter(x => x.DownloadStatus != ModDownloadStatus.Successful)
36+
.Count();
37+
}
38+
39+
private void NexusClientChanged(NexusClient? oldClient, NexusClient? newClient)
40+
{
41+
if (oldClient is not null)
42+
{
43+
// Cancel all downloads and clear the dictionary, so we don't have zombie downloads from an old client lingering
44+
foreach (var download in Downloads)
45+
{
46+
// Trigger the cancel command, and ignore any return values (as it has none)
47+
download.CancelCommand.Execute().Subscribe();
48+
}
49+
ClearEventHandlers(oldClient);
50+
Downloads.Clear();
51+
}
52+
if (newClient is not null)
53+
{
54+
RegisterEventHandlers(newClient);
55+
}
56+
}
57+
58+
private void RegisterEventHandlers(NexusClient nexusClient)
59+
{
60+
nexusClient.DownloadStarted += DownloadStarted;
61+
nexusClient.DownloadProgressChanged += DownloadProgressChanged;
62+
nexusClient.DownloadCompleted += DownloadCompleted;
63+
nexusClient.DownloadFailed += DownloadFailed;
64+
}
65+
66+
private void ClearEventHandlers(NexusClient nexusClient)
67+
{
68+
nexusClient.DownloadStarted -= DownloadStarted;
69+
nexusClient.DownloadProgressChanged -= DownloadProgressChanged;
70+
nexusClient.DownloadCompleted -= DownloadCompleted;
71+
nexusClient.DownloadFailed -= DownloadFailed;
72+
}
73+
74+
private void DownloadStarted(object? sender, ModDownloadStartedEventArgs e)
75+
{
76+
var existingDownload = Downloads.FirstOrDefault(x => x.ModUri == e.Uri);
77+
if (existingDownload is not null)
78+
{
79+
// If the user is trying to download the same file twice, it's *probably* because they
80+
// want to retry a failed download.
81+
// But just in case, check to see if the existing download is still in-progress. If it is, do nothing.
82+
// We don't want to stop a user's 95% download because they accidentally hit the "download again please" button!
83+
if (existingDownload.DownloadStatus == ModDownloadStatus.NotStarted
84+
|| existingDownload.DownloadStatus == ModDownloadStatus.InProgress)
85+
{
86+
return;
87+
}
88+
89+
// If it does exist, and isn't in a progress state, they're probably trying to redownload a failed download.
90+
// Since we use the URI as our unique ID, we shouldn't have two items with the same URI in the list,
91+
// so clear out the old one.
92+
Downloads.Remove(existingDownload);
93+
}
94+
95+
var downloadVM = new ModDownloadViewModel(e.Uri, e.Name, e.Size, e.DownloadCancellationSource);
96+
downloadVM.RemovalRequested += DownloadRemovalRequested;
97+
Downloads.Add(downloadVM);
98+
}
99+
100+
private void DownloadProgressChanged(object? sender, ModDownloadProgressEventArgs e)
101+
{
102+
var download = Downloads.SingleOrDefault(x => x.ModUri == e.Uri);
103+
if (download is not null)
104+
{
105+
download.DownloadStatus = ModDownloadStatus.InProgress;
106+
download.DownloadedBytes = e.TotalBytes;
107+
}
108+
}
109+
110+
private void DownloadCompleted(object? sender, ModDownloadCompletedEventArgs e)
111+
{
112+
var download = Downloads.SingleOrDefault(x => x.ModUri == e.Uri);
113+
if (download is not null)
114+
{
115+
download.DownloadStatus = ModDownloadStatus.Successful;
116+
}
117+
}
118+
119+
private void DownloadFailed(object? sender, ModDownloadFailedEventArgs e)
120+
{
121+
var download = Downloads.SingleOrDefault(x => x.ModUri == e.Uri);
122+
if (download is not null)
123+
{
124+
download.DownloadStatus = ModDownloadStatus.Failed;
125+
}
126+
}
127+
128+
private void DownloadRemovalRequested(object? sender, EventArgs _)
129+
{
130+
if (sender is not ModDownloadViewModel downloadVM)
131+
{
132+
return;
133+
}
134+
135+
downloadVM.RemovalRequested -= DownloadRemovalRequested;
136+
Downloads.Remove(downloadVM);
137+
}
138+
139+
// Designer-only constructor
140+
public DownloadPanelViewModel()
141+
{
142+
if (!Design.IsDesignMode)
143+
{
144+
throw new Exception("This constructor should only be called in design mode.");
145+
}
146+
147+
var inProgressDownload = new ModDownloadViewModel(
148+
new Uri("https://www.fakeurl.com/testMod"),
149+
"Fake Test Mod Download",
150+
1024 * 1024,
151+
new()
152+
);
153+
inProgressDownload.DownloadStatus = ModDownloadStatus.InProgress;
154+
inProgressDownload.DownloadedBytes = inProgressDownload.SizeBytes!.Value / 2;
155+
Downloads.Add(inProgressDownload);
156+
157+
var succeededDownload = new ModDownloadViewModel(
158+
new Uri("https://www.fakeSuccess.com"),
159+
"Fake Succeeded Download",
160+
1234,
161+
new()
162+
);
163+
succeededDownload.DownloadStatus = ModDownloadStatus.Successful;
164+
succeededDownload.DownloadedBytes = 1234;
165+
Downloads.Add(succeededDownload);
166+
167+
var failedDownload = new ModDownloadViewModel(
168+
new Uri("https://www.differentFakeUrl.com"),
169+
"Failed Fake Download",
170+
1024 * 1024 * 1024,
171+
new()
172+
);
173+
failedDownload.DownloadedBytes = failedDownload.SizeBytes!.Value / 3;
174+
failedDownload.DownloadStatus = ModDownloadStatus.Failed;
175+
Downloads.Add(failedDownload);
176+
177+
var cancelledDownload = new ModDownloadViewModel(
178+
new Uri("https://www.cancelledFake.com"),
179+
"Cancelled Fake Download",
180+
1024 * 1024 * 5,
181+
new()
182+
);
183+
cancelledDownload.DownloadedBytes = cancelledDownload.SizeBytes!.Value / 4;
184+
cancelledDownload.DownloadStatus = ModDownloadStatus.Canceled;
185+
Downloads.Add(cancelledDownload);
186+
187+
var indeterminateInProgressDownload = new ModDownloadViewModel(
188+
new Uri("https://www.inProgressMystery.com"),
189+
"In Progress Download of Unknown Size",
190+
null,
191+
new()
192+
);
193+
indeterminateInProgressDownload.DownloadedBytes = 1024 * 1024 * 2;
194+
indeterminateInProgressDownload.DownloadStatus = ModDownloadStatus.InProgress;
195+
Downloads.Add(indeterminateInProgressDownload);
196+
}
197+
}
198+
}

Stardrop/ViewModels/MainWindowViewModel.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public class MainWindowViewModel : ViewModelBase
5858
public List<string> ColumnFilter { get { return _columnFilter; } set { _columnFilter = value; UpdateFilter(); } }
5959
private string _updateStatusText = Program.translation.Get("ui.main_window.button.update_status.generic");
6060
public string UpdateStatusText { get { return _updateStatusText; } set { this.RaiseAndSetIfChanged(ref _updateStatusText, value); } }
61+
private string _downloadsButtonText;
62+
public string DownloadsButtonText { get => _downloadsButtonText; set => this.RaiseAndSetIfChanged(ref _downloadsButtonText, value); }
6163
private int _modsWithCachedUpdates;
6264
public int ModsWithCachedUpdates { get { return _modsWithCachedUpdates; } set { this.RaiseAndSetIfChanged(ref _modsWithCachedUpdates, value); } }
6365
public string Version { get; set; }
@@ -533,14 +535,14 @@ internal List<Config> GetPendingConfigUpdates(Profile profile, bool excludeMissi
533535
return pendingConfigUpdates;
534536
}
535537

536-
internal async void UpdateEndorsements(string? apiKey)
538+
internal async void UpdateEndorsements()
537539
{
538-
if (String.IsNullOrEmpty(apiKey))
540+
if (Nexus.Client is null)
539541
{
540542
return;
541543
}
542544

543-
var endorsements = await Nexus.GetEndorsements(apiKey);
545+
var endorsements = await Nexus.Client.GetEndorsements();
544546
foreach (var mod in Mods.Where(m => m.HasUpdateKeys() && endorsements.Any(e => e.Id == m.NexusModId)))
545547
{
546548
mod.IsEndorsed = endorsements.First(e => e.Id == mod.NexusModId).IsEndorsed();

0 commit comments

Comments
 (0)