1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Shared UI - Added HttpClientUtilities offering progressable stream content

Submission wizard - Added upload progress
Submission wizard - Close on finish
This commit is contained in:
Robert 2023-08-14 20:10:05 +02:00
parent ad4da3032d
commit a1fd8d5099
10 changed files with 249 additions and 16 deletions

View File

@ -0,0 +1,119 @@
// Heavily based on:
// SkyClip
// - ProgressableStreamContent.cs
// --------------------------------------------------------------------
// Author: Jeff Hansen <jeff@jeffijoe.com>
// Copyright (C) Jeff Hansen 2015. All rights reserved.
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Artemis.UI.Shared.Utilities;
/// <summary>
/// Provides HTTP content based on a stream with support for IProgress.
/// </summary>
public class ProgressableStreamContent : StreamContent
{
private const int DEFAULT_BUFFER_SIZE = 4096;
private readonly int _bufferSize;
private readonly IProgress<StreamProgress> _progress;
private readonly Stream _streamToWrite;
private bool _contentConsumed;
/// <summary>
/// Initializes a new instance of the <see cref="ProgressableStreamContent" /> class.
/// </summary>
/// <param name="streamToWrite">The stream to write.</param>
/// <param name="progress">The downloader.</param>
public ProgressableStreamContent(Stream streamToWrite, IProgress<StreamProgress> progress) : this(streamToWrite, DEFAULT_BUFFER_SIZE, progress)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ProgressableStreamContent" /> class.
/// </summary>
/// <param name="streamToWrite">The stream to write.</param>
/// <param name="bufferSize">The buffer size.</param>
/// <param name="progress">The downloader.</param>
public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress<StreamProgress> progress) : base(streamToWrite, bufferSize)
{
if (bufferSize <= 0)
throw new ArgumentOutOfRangeException(nameof(bufferSize));
_streamToWrite = streamToWrite;
_bufferSize = bufferSize;
_progress = progress;
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
_streamToWrite.Dispose();
base.Dispose(disposing);
}
/// <inheritdoc />
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
{
await SerializeToStreamAsync(stream, context, CancellationToken.None);
}
/// <inheritdoc />
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
{
PrepareContent();
byte[] buffer = new byte[_bufferSize];
long size = _streamToWrite.Length;
int uploaded = 0;
await using (_streamToWrite)
{
while (!cancellationToken.IsCancellationRequested)
{
int length = await _streamToWrite.ReadAsync(buffer, cancellationToken);
if (length <= 0)
break;
uploaded += length;
_progress.Report(new StreamProgress(uploaded, size));
await stream.WriteAsync(buffer, 0, length, cancellationToken);
}
}
}
/// <inheritdoc />
protected override bool TryComputeLength(out long length)
{
length = _streamToWrite.Length;
return true;
}
/// <summary>
/// Prepares the content.
/// </summary>
/// <exception cref="System.InvalidOperationException">The stream has already been read.</exception>
private void PrepareContent()
{
if (_contentConsumed)
{
// If the content needs to be written to a target stream a 2nd time, then the stream must support
// seeking (e.g. a FileStream), otherwise the stream can't be copied a second time to a target
// stream (e.g. a NetworkStream).
if (_streamToWrite.CanSeek)
_streamToWrite.Position = 0;
else
throw new InvalidOperationException("The stream has already been read.");
}
_contentConsumed = true;
}
}

View File

@ -0,0 +1,57 @@
// Heavily based on:
// SkyClip
// - UploadProgress.cs
// --------------------------------------------------------------------
// Author: Jeff Hansen <jeff@jeffijoe.com>
// Copyright (C) Jeff Hansen 2015. All rights reserved.
namespace Artemis.UI.Shared.Utilities;
/// <summary>
/// The upload progress.
/// </summary>
public class StreamProgress
{
/// <summary>
/// Initializes a new instance of the <see cref="StreamProgress" /> class.
/// </summary>
/// <param name="bytesTransfered">
/// The bytes transfered.
/// </param>
/// <param name="totalBytes">
/// The total bytes.
/// </param>
public StreamProgress(long bytesTransfered, long? totalBytes)
{
BytesTransfered = bytesTransfered;
TotalBytes = totalBytes;
if (totalBytes.HasValue)
ProgressPercentage = (int) ((float) bytesTransfered / totalBytes.Value * 100);
}
/// <summary>
/// Returns a <see cref="System.String" /> that represents this instance.
/// </summary>
/// <returns>
/// A <see cref="System.String" /> that represents this instance.
/// </returns>
public override string ToString()
{
return string.Format("{0}% ({1} / {2})", ProgressPercentage, BytesTransfered, TotalBytes);
}
/// <summary>
/// Gets the bytes transfered.
/// </summary>
public long BytesTransfered { get; }
/// <summary>
/// Gets the progress percentage.
/// </summary>
public int ProgressPercentage { get; }
/// <summary>
/// Gets the total bytes.
/// </summary>
public long? TotalBytes { get; }
}

View File

@ -26,7 +26,7 @@ public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewMode
private async Task ExecuteAddSubmission(CancellationToken arg) private async Task ExecuteAddSubmission(CancellationToken arg)
{ {
await _windowService.ShowDialogAsync<SubmissionWizardViewModel>(); await _windowService.ShowDialogAsync<SubmissionWizardViewModel, bool>();
} }
public EntryType? EntryType => null; public EntryType? EntryType => null;

View File

@ -21,6 +21,12 @@
<TextBlock TextAlignment="Center" TextWrapping="Wrap"> <TextBlock TextAlignment="Center" TextWrapping="Wrap">
Wooo, the final step, that was pretty easy, right!? Wooo, the final step, that was pretty easy, right!?
</TextBlock> </TextBlock>
<ProgressBar Margin="0 15 0 0"
Width="380"
IsVisible="{CompiledBinding !Finished}"
IsIndeterminate="{CompiledBinding ProgressIndeterminate}"
Value="{CompiledBinding ProgressPercentage}"></ProgressBar>
<StackPanel IsVisible="{CompiledBinding Finished}" Margin="0 100 0 0"> <StackPanel IsVisible="{CompiledBinding Finished}" Margin="0 100 0 0">
<Lottie Path="/Assets/Animations/success.json" RepeatCount="1" Width="300" Height="300"></Lottie> <Lottie Path="/Assets/Animations/success.json" RepeatCount="1" Width="300" Height="300"></Lottie>

View File

@ -10,6 +10,9 @@ using ReactiveUI;
using StrawberryShake; using StrawberryShake;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using Artemis.Core; using Artemis.Core;
using Artemis.UI.Shared.Routing;
using System;
using Artemis.UI.Shared.Utilities;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
@ -18,19 +21,33 @@ public class UploadStepViewModel : SubmissionViewModel
private readonly IWorkshopClient _workshopClient; private readonly IWorkshopClient _workshopClient;
private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory;
private readonly IWindowService _windowService; private readonly IWindowService _windowService;
private readonly IRouter _router;
private readonly Progress<StreamProgress> _progress = new();
private readonly ObservableAsPropertyHelper<int> _progressPercentage;
private readonly ObservableAsPropertyHelper<bool> _progressIndeterminate;
private bool _finished; private bool _finished;
private Guid? _entryId;
/// <inheritdoc /> /// <inheritdoc />
public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService) public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router)
{ {
_workshopClient = workshopClient; _workshopClient = workshopClient;
_entryUploadHandlerFactory = entryUploadHandlerFactory; _entryUploadHandlerFactory = entryUploadHandlerFactory;
_windowService = windowService; _windowService = windowService;
_router = router;
ShowGoBack = false; ShowGoBack = false;
ContinueText = "Finish"; ContinueText = "Finish";
Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); Continue = ReactiveCommand.CreateFromTask(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished));
_progressPercentage = Observable.FromEventPattern<StreamProgress>(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x)
.Select(e => e.EventArgs.ProgressPercentage)
.ToProperty(this, vm => vm.ProgressPercentage);
_progressIndeterminate = Observable.FromEventPattern<StreamProgress>(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x)
.Select(e => e.EventArgs.ProgressPercentage == 0)
.ToProperty(this, vm => vm.ProgressIndeterminate);
this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d)); this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d));
} }
@ -40,6 +57,9 @@ public class UploadStepViewModel : SubmissionViewModel
/// <inheritdoc /> /// <inheritdoc />
public override ReactiveCommand<Unit, Unit> GoBack { get; } = null!; public override ReactiveCommand<Unit, Unit> GoBack { get; } = null!;
public int ProgressPercentage => _progressPercentage.Value;
public bool ProgressIndeterminate => _progressIndeterminate.Value;
public bool Finished public bool Finished
{ {
get => _finished; get => _finished;
@ -72,7 +92,7 @@ public class UploadStepViewModel : SubmissionViewModel
try try
{ {
IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType); IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType);
EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, cancellationToken); EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, _progress, cancellationToken);
if (!uploadResult.IsSuccess) if (!uploadResult.IsSuccess)
{ {
string? message = uploadResult.Message; string? message = uploadResult.Message;
@ -85,6 +105,7 @@ public class UploadStepViewModel : SubmissionViewModel
return; return;
} }
_entryId = entryId;
Finished = true; Finished = true;
} }
catch (Exception e) catch (Exception e)
@ -94,8 +115,25 @@ public class UploadStepViewModel : SubmissionViewModel
} }
} }
private void ExecuteContinue() private async Task ExecuteContinue()
{ {
throw new NotImplementedException(); if (_entryId == null)
return;
State.Finish();
switch (State.EntryType)
{
case EntryType.Layout:
await _router.Navigate($"workshop/layouts/{_entryId.Value}");
break;
case EntryType.Plugin:
await _router.Navigate($"workshop/plugins/{_entryId.Value}");
break;
case EntryType.Profile:
await _router.Navigate($"workshop/profiles/{_entryId.Value}");
break;
default:
throw new ArgumentOutOfRangeException();
}
} }
} }

View File

@ -44,4 +44,14 @@ public class SubmissionWizardState
_windowService.ShowExceptionDialog("Wizard screen failed to activate", e); _windowService.ShowExceptionDialog("Wizard screen failed to activate", e);
} }
} }
public void Finish()
{
_wizardViewModel.Close(true);
}
public void Cancel()
{
_wizardViewModel.Close(false);
}
} }

View File

@ -25,6 +25,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Artemis.Core\Artemis.Core.csproj" /> <ProjectReference Include="..\Artemis.Core\Artemis.Core.csproj" />
<ProjectReference Include="..\Artemis.UI.Shared\Artemis.UI.Shared.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,7 +1,8 @@
 using Artemis.UI.Shared.Utilities;
namespace Artemis.WebClient.Workshop.UploadHandlers; namespace Artemis.WebClient.Workshop.UploadHandlers;
public interface IEntryUploadHandler public interface IEntryUploadHandler
{ {
Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken); Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, Progress<StreamProgress> progress, CancellationToken cancellationToken);
} }

View File

@ -1,11 +1,11 @@
using RGB.NET.Layout; using Artemis.UI.Shared.Utilities;
namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations;
public class LayoutEntryUploadHandler : IEntryUploadHandler public class LayoutEntryUploadHandler : IEntryUploadHandler
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, Progress<StreamProgress> progress, CancellationToken cancellationToken)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }

View File

@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Text; using System.Text;
using Artemis.Core; using Artemis.Core;
using Artemis.Core.Services; using Artemis.Core.Services;
using Artemis.UI.Shared.Utilities;
using Artemis.Web.Workshop.Entities; using Artemis.Web.Workshop.Entities;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -20,7 +21,7 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, Progress<StreamProgress> progress, CancellationToken cancellationToken)
{ {
if (file is not ProfileConfiguration profileConfiguration) if (file is not ProfileConfiguration profileConfiguration)
throw new InvalidOperationException("Can only create releases for profile configurations"); throw new InvalidOperationException("Can only create releases for profile configurations");
@ -42,19 +43,19 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler
// Submit the archive // Submit the archive
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
// Construct the request // Construct the request
archiveStream.Seek(0, SeekOrigin.Begin); archiveStream.Seek(0, SeekOrigin.Begin);
MultipartFormDataContent content = new(); MultipartFormDataContent content = new();
StreamContent streamContent = new(archiveStream); ProgressableStreamContent streamContent = new(archiveStream, progress);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
content.Add(streamContent, "file", "file.zip"); content.Add(streamContent, "file", "file.zip");
// Submit // Submit
HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken); HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}");
Release? release = JsonConvert.DeserializeObject<Release>(await response.Content.ReadAsStringAsync(cancellationToken)); Release? release = JsonConvert.DeserializeObject<Release>(await response.Content.ReadAsStringAsync(cancellationToken));
return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response"); return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response");
} }