This commit is contained in:
erri120 2022-01-21 14:41:37 +01:00
parent 8782aeca94
commit 92a2195ccf
No known key found for this signature in database
GPG Key ID: 7FA9556C936B847C
30 changed files with 548 additions and 449 deletions

View File

@ -6,18 +6,9 @@ insert_final_newline = true
# C# and Razor files # C# and Razor files
[*.{cs,razor}] [*.{cs,razor}]
# CS8602: Dereference of a possibly null reference.
# CS8618: Non-nullable member is uninitialized.
# Reason: The compiler/IDE doesn't quite understand Dependency Injection yet.
dotnet_diagnostic.CS8602.severity = none
dotnet_diagnostic.CS8618.severity = none
# RZ10012: Markup element with unexpected name. # RZ10012: Markup element with unexpected name.
# Reason: The component namespace is added to the global _Imports.razor file. # Reason: The component namespace is added to the global _Imports.razor file.
dotnet_diagnostic.RZ10012.severity = none dotnet_diagnostic.RZ10012.severity = none
dotnet_sort_system_directives_first = true
[*.scss] [*.scss]
indent_size = 2 indent_size = 2

View File

@ -14,16 +14,17 @@ namespace Wabbajack.App.Blazor;
public partial class App public partial class App
{ {
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IHost _host;
public App() public App()
{ {
_host = Host.CreateDefaultBuilder(Array.Empty<string>()) _serviceProvider = Host.CreateDefaultBuilder(Array.Empty<string>())
.ConfigureLogging(c => { c.ClearProviders(); }) .ConfigureLogging(loggingBuilder =>
.ConfigureServices((host, services) => { ConfigureServices(services); }) {
.Build(); loggingBuilder.ClearProviders();
})
_serviceProvider = _host.Services; .ConfigureServices(services => ConfigureServices(services))
.Build()
.Services;
} }
private static IServiceCollection ConfigureServices(IServiceCollection services) private static IServiceCollection ConfigureServices(IServiceCollection services)
@ -33,24 +34,18 @@ public partial class App
services.AddAllSingleton<ILoggerProvider, LoggerProvider>(); services.AddAllSingleton<ILoggerProvider, LoggerProvider>();
services.AddTransient<MainWindow>(); services.AddTransient<MainWindow>();
services.AddSingleton<SystemParametersConstructor>(); services.AddSingleton<SystemParametersConstructor>();
services.AddSingleton<GlobalState>(); services.AddSingleton(typeof(IStateContainer), typeof(StateContainer));
return services; return services;
} }
private void OnStartup(object sender, StartupEventArgs e) private void OnStartup(object sender, StartupEventArgs e)
{ {
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>(); var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
mainWindow!.Show(); mainWindow.Show();
} }
private void OnExit(object sender, ExitEventArgs e) private void OnExit(object sender, ExitEventArgs e)
{ {
Current.Shutdown(); Current.Shutdown();
// using (_host)
// {
// _host.StopAsync();
// }
//
// base.OnExit(e);
} }
} }

View File

@ -1,28 +0,0 @@
@using Wabbajack.DTOs
@namespace Wabbajack.App.Blazor.Components
<div class="item">
<div class="display">
<img src="@Metadata.Links.ImageUri" loading="lazy" class="image" alt="@Metadata.Title">
<div class="interaction">
@ChildContent
</div>
</div>
<div class="info">
<div class="title">@Metadata.Title</div>
<div class="author">@Metadata.Author</div>
<div class="description">@Metadata.Description</div>
</div>
<div class="tags"></div>
</div>
@code {
[Parameter]
public ModlistMetadata Metadata { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
}

View File

@ -1,82 +0,0 @@
@import "../Shared/Globals.scss";
$display-height: 225px;
$hover-icon-size: 75px;
.item {
width: 400px;
height: 450px;
overflow: hidden;
margin: 0.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(7px);
-webkit-backdrop-filter: blur(7px);
border: 1px solid rgba(255, 255, 255, 0.31);
&:hover .display .image {
filter: blur(2px) brightness(70%);
}
&:hover .display .interaction {
opacity: 1;
}
.display {
position: relative;
height: $display-height;
display: flex;
justify-content: center;
align-items: center;
.image {
position: absolute;
width: 100%;
height: 100%;
object-fit: contain;
transition: all 250ms ease-in-out;
}
.interaction {
position: absolute;
opacity: 0;
transition: all 250ms ease-in-out;
::deep img {
width: $hover-icon-size;
height: $hover-icon-size;
margin: 0;
transition: all 150ms ease-in-out;
}
}
}
.info {
padding-bottom: 1rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
.title {
color: white;
font-weight: 100;
font-size: 2rem;
line-height: 2.5rem;
margin: 0;
}
.author {
color: lightgray;
font-size: 1rem;
}
.description {
color: grey;
font-size: 0.9rem;
}
}
.tags {
border-radius: 0.5rem;
}
}

View File

@ -4,6 +4,7 @@
@* TODO: [Low] Replace logo with SVG? *@ @* TODO: [Low] Replace logo with SVG? *@
<img class="logo" src="images/Logo_Dark_Transparent.png" alt="Wabbajack Logo"> <img class="logo" src="images/Logo_Dark_Transparent.png" alt="Wabbajack Logo">
<div class="socials"> <div class="socials">
@* TODO: wrap social icons in a button and make it clickable *@
<img src="images/icons/patreon.svg" alt=""> <img src="images/icons/patreon.svg" alt="">
<img src="images/icons/github.svg" alt=""> <img src="images/icons/github.svg" alt="">
<img src="images/icons/discord.svg" alt=""> <img src="images/icons/discord.svg" alt="">

View File

@ -1,42 +1,36 @@
@using Wabbajack.App.Blazor.Pages @using Wabbajack.App.Blazor.Pages
@using Wabbajack.App.Blazor.Shared @using Wabbajack.App.Blazor.Shared
@using Wabbajack.App.Blazor.State @using Wabbajack.App.Blazor.State
@inject NavigationManager _navigationManager
@inject IStateContainer _stateContainer
@namespace Wabbajack.App.Blazor.Components @namespace Wabbajack.App.Blazor.Components
@* TODO: [Low] Clean this up a bit. *@
<header id="top-bar"> <header id="top-bar">
<nav class="@(GlobalState.NavigationAllowed ? "" : "disallow")"> <nav class="@(_stateContainer.NavigationAllowed ? "" : "disallow")">
<ul> <ul>
<li> @foreach (var (name, route) in Pages)
<div class='item @CurrentPage("")' @onclick='() => Navigate("")'>Play</div> {
</li> <li>
<li> <div class="item @CurrentPage(route)" @onclick="@(() => Navigate(route))">@name</div>
<div class='item @CurrentPage("Gallery")' @onclick='() => Navigate("Gallery")'>Gallery</div> </li>
</li> }
<li>
<div class='item @CurrentPage("Install")' @onclick='() => Navigate("Install")'>Install</div>
</li>
<li>
<div class='item @CurrentPage("Create")' @onclick='() => Navigate("Create")'>Create</div>
</li>
</ul> </ul>
</nav> </nav>
<div class="settings"> <div class="settings">
<InteractionIcon Icon="images/icons/adjust.svg" Label="Settings" Size="100%" OnClick="@(() => Navigate("Settings"))"/> <InteractionIcon Icon="images/icons/adjust.svg" Label="Settings" Size="100%" OnClick="@(() => Navigate(Settings.Route))"/>
</div> </div>
</header> </header>
@code { @code {
[Inject] private static readonly Dictionary<string, string> Pages = new()
NavigationManager _navigationManager { get; set; } {
{"Play", Play.Route},
[Inject] {"Gallery", Gallery.Route},
GlobalState GlobalState { get; set; } {"Install", Install.Route},
{"Create", Create.Route}
[CascadingParameter] };
protected MainLayout _mainLayout { get; set; }
private void Navigate(string page) private void Navigate(string page)
{ {
@ -45,14 +39,14 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
_navigationManager.LocationChanged += (o, args) => StateHasChanged(); // TODO(erri120): update this
GlobalState.OnNavigationStateChange += StateHasChanged; // _navigationManager.LocationChanged += (_, _) => StateHasChanged();
// _globalState.OnNavigationStateChange += StateHasChanged;
} }
private string CurrentPage(string page) private string CurrentPage(string page)
{ {
string relativePath = _navigationManager.ToBaseRelativePath(_navigationManager.Uri).ToLower(); var relativePath = _navigationManager.ToBaseRelativePath(_navigationManager.Uri);
return page.ToLower() == relativePath ? "active" : ""; return page.Equals(relativePath, StringComparison.OrdinalIgnoreCase) ? "active" : string.Empty;
} }
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Wabbajack.App.Blazor.Models; using Wabbajack.App.Blazor.Models;
using Wabbajack.App.Blazor.State; using Wabbajack.App.Blazor.State;
@ -11,20 +12,20 @@ namespace Wabbajack.App.Blazor;
public partial class MainWindow public partial class MainWindow
{ {
private readonly ILogger<MainWindow> _logger; private readonly ILogger<MainWindow> _logger;
private readonly LoggerProvider _loggerProvider; private readonly LoggerProvider _loggerProvider;
private readonly SystemParametersConstructor _systemParams; private readonly SystemParametersConstructor _systemParams;
private readonly GlobalState _globalState; private readonly IStateContainer _stateContainer;
public MainWindow(ILogger<MainWindow> logger, IServiceProvider serviceProvider, LoggerProvider loggerProvider, public MainWindow(ILogger<MainWindow> logger, IServiceProvider serviceProvider, LoggerProvider loggerProvider,
SystemParametersConstructor systemParams, GlobalState globalState) SystemParametersConstructor systemParams, IStateContainer stateContainer)
{ {
_logger = logger; _logger = logger;
_loggerProvider = loggerProvider; _loggerProvider = loggerProvider;
_systemParams = systemParams; _systemParams = systemParams;
_globalState = globalState; _stateContainer = stateContainer;
_globalState.OnTaskBarStateChange += state => _stateContainer.TaskBarStateObservable.Subscribe(state =>
{ {
Dispatcher.InvokeAsync(() => Dispatcher.InvokeAsync(() =>
{ {
@ -32,7 +33,7 @@ public partial class MainWindow
TaskBarItem.ProgressState = state.State; TaskBarItem.ProgressState = state.State;
TaskBarItem.ProgressValue = state.ProgressValue; TaskBarItem.ProgressValue = state.ProgressValue;
}); });
}; });
InitializeComponent(); InitializeComponent();
BlazorWebView.Services = serviceProvider; BlazorWebView.Services = serviceProvider;

View File

@ -71,7 +71,7 @@ public class LoggerProvider : ILoggerProvider
private void LogToFile(ILogMessage logMessage) private void LogToFile(ILogMessage logMessage)
{ {
string? line = $"[{logMessage.TimeStamp - _startupTime}] {logMessage.LongMessage}"; var line = $"[{logMessage.TimeStamp - _startupTime}] {logMessage.LongMessage}";
lock (_logStream) lock (_logStream)
{ {
_logStream.Write(line); _logStream.Write(line);

View File

@ -1,52 +1,50 @@
@page "/Configure" @page "/configure"
@using Wabbajack.App.Blazor.State @using Wabbajack.App.Blazor.State
@namespace Wabbajack.App.Blazor.Pages @namespace Wabbajack.App.Blazor.Pages
<div id="content"> <div id="content">
<div class="install-background"> <div class="install-background">
<img src="@Image" alt=""> <img id="background-image" src="" alt=""/>
</div> </div>
<div class="list"> <div class="list">
@* TODO: [High] Find a cleaner way to show/hide components based on state. *@ @if (Modlist is not null)
@* TODO: [Low] Split each "side" into their own components? *@ {
<div class="left-side"> <div class="left-side">
@if (!string.IsNullOrEmpty(ModList.Name)) @if (InstallState != InstallState.Installing)
{
if (InstallState != GlobalState.InstallStateEnum.Installing)
{ {
<InfoBlock Title="@ModList.Name" Subtitle="@ModList.Author" Comment="@ModList.Version.ToString()" Description="@ModList.Description"/> <InfoBlock Title="@Modlist.Name" Subtitle="@Modlist.Author" Comment="@Modlist.Version.ToString()" Description="@Modlist.Description"/>
} }
else if (InstallState == GlobalState.InstallStateEnum.Installing) else
{ {
<InfoBlock Supertitle="Installing..." Title="@ModList.Name" Subtitle="@StatusText"/> <InfoBlock Supertitle="Installing..." Title="@Modlist.Name" Subtitle="@StatusText"/>
// TODO: [Low] Step logging. // TODO: [Low] Step logging
} }
} </div>
</div> <div class="right-side">
<div class="right-side"> @* TODO: whatever this is *@
@if (!string.IsNullOrEmpty(Image)) @* @if (!string.IsNullOrEmpty(Image)) *@
{ @* { *@
if (InstallState != GlobalState.InstallStateEnum.Installing) @* if (InstallState != GlobalState.InstallStateEnum.Installing) *@
{ @* { *@
<InfoImage Image="@Image"/> @* <InfoImage Image="@Image"/> *@
} @* } *@
else if (InstallState == GlobalState.InstallStateEnum.Installing) @* else if (InstallState == GlobalState.InstallStateEnum.Installing) *@
{ @* { *@
// TODO: [Low] Implement featured mod slideshow. @* // TODO: [Low] Implement featured mod slideshow. *@
<InfoImage Image="@Image" Title="Some Mod Title" Subtitle="Author and others" Description="This mod adds something cool but I'm not going to tell you anything."/> @* <InfoImage Image="@Image" Title="Some Mod Title" Subtitle="Author and others" Description="This mod adds something cool but I'm not going to tell you anything."/> *@
} @* } *@
} @* } *@
</div> </div>
}
</div> </div>
@if (InstallState == GlobalState.InstallStateEnum.Installing) @if (InstallState == InstallState.Installing)
{ {
<div class="logger-container"> <div class="logger-container">
<VirtualLogger Messages="_loggerProvider.Messages"/> <VirtualLogger Messages="LoggerProvider.Messages"/>
</div> </div>
} }
@if (InstallState != GlobalState.InstallStateEnum.Installing) else
{ {
<div class="settings"> <div class="settings">
<div class="locations"> <div class="locations">
@ -57,9 +55,9 @@
<span>Download Location</span> <span>Download Location</span>
</div> </div>
<div class="paths"> <div class="paths">
<span class="modlist-file">@ModListPath</span> <span class="modlist-file">@ModlistPath.ToString()</span>
<span class="install-location" @onclick="SelectInstallFolder">@InstallPath</span> <span class="install-location" @onclick="SelectInstallFolder">@InstallPath.ToString()</span>
<span class="download-location" @onclick="SelectDownloadFolder">@DownloadPath</span> <span class="download-location" @onclick="SelectDownloadFolder">@DownloadPath.ToString()</span>
</div> </div>
</div> </div>
@ -75,3 +73,7 @@
</div> </div>
} }
</div> </div>
@code {
public const string Route = "/configure";
}

View File

@ -1,6 +1,4 @@
using System; using System;
using System.Diagnostics;
using System.IO;
using System.Threading; using System.Threading;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Wabbajack.DTOs; using Wabbajack.DTOs;
@ -12,6 +10,8 @@ using Wabbajack.Downloaders.GameFile;
using Wabbajack.Hashing.xxHash64; using Wabbajack.Hashing.xxHash64;
using Wabbajack.Services.OSIntegrated; using Wabbajack.Services.OSIntegrated;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Wabbajack.App.Blazor.Models; using Wabbajack.App.Blazor.Models;
using Wabbajack.App.Blazor.State; using Wabbajack.App.Blazor.State;
@ -19,71 +19,74 @@ namespace Wabbajack.App.Blazor.Pages;
public partial class Configure public partial class Configure
{ {
[Inject] private NavigationManager NavigationManager { get; set; } [Inject] private ILogger<Configure> Logger { get; set; } = default!;
[Inject] private GlobalState GlobalState { get; set; } [Inject] private IStateContainer StateContainer { get; set; } = default!;
[Inject] private DTOSerializer _dtos { get; set; } [Inject] private DTOSerializer DTOs { get; set; } = default!;
[Inject] private IServiceProvider _serviceProvider { get; set; } [Inject] private IServiceProvider ServiceProvider { get; set; } = default!;
[Inject] private SystemParametersConstructor _parametersConstructor { get; set; } [Inject] private SystemParametersConstructor ParametersConstructor { get; set; } = default!;
[Inject] private IGameLocator _gameLocator { get; set; } [Inject] private IGameLocator GameLocator { get; set; } = default!;
[Inject] private SettingsManager _settingsManager { get; set; } [Inject] private SettingsManager SettingsManager { get; set; } = default!;
[Inject] private LoggerProvider _loggerProvider { get; set; } [Inject] private LoggerProvider LoggerProvider { get; set; } = default!;
[Inject] private JSRuntime JSRuntime { get; set; } = default!;
private string Image { get; set; } private ModList? Modlist => StateContainer.Modlist;
private ModList ModList { get; set; } = new(); // Init a new modlist so we can listen for changes in Blazor components.
private AbsolutePath ModListPath { get; set; } private AbsolutePath ModlistPath => StateContainer.ModlistPath;
private AbsolutePath InstallPath { get; set; } private AbsolutePath InstallPath { get; set; }
private AbsolutePath DownloadPath { get; set; } private AbsolutePath DownloadPath { get; set; }
private string StatusText { get; set; } private string StatusText { get; set; } = string.Empty;
public GlobalState.InstallStateEnum InstallState { get; set; } private InstallState InstallState => StateContainer.InstallState;
private LoggerProvider.ILogMessage CurrentLog { get; set; } // private LoggerProvider.ILogMessage CurrentLog { get; set; }
private const string InstallSettingsPrefix = "install-settings-"; private const string InstallSettingsPrefix = "install-settings-";
private bool _shouldRender;
protected override bool ShouldRender() => _shouldRender;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// var Location = KnownFolders.EntryPoint.Combine("downloaded_mod_lists", machineURL).WithExtension(Ext.Wabbajack); // var Location = KnownFolders.EntryPoint.Combine("downloaded_mod_lists", machineURL).WithExtension(Ext.Wabbajack);
GlobalState.OnInstallStateChange += () => InstallState = GlobalState.InstallState;
await CheckValidInstallPath(); await CheckValidInstallPath();
await base.OnInitializedAsync(); _shouldRender = true;
} }
private async Task CheckValidInstallPath() private async Task CheckValidInstallPath()
{ {
if (GlobalState.ModListPath == AbsolutePath.Empty) return; if (ModlistPath == AbsolutePath.Empty) return;
ModListPath = GlobalState.ModListPath; var modlist = await StandardInstaller.LoadFromFile(DTOs, ModlistPath);
ModList = await StandardInstaller.LoadFromFile(_dtos, ModListPath); StateContainer.Modlist = modlist;
GlobalState.ModList = ModList;
string hex = (await ModListPath.ToString().Hash()).ToHex(); var hex = (await ModlistPath.ToString().Hash()).ToHex();
var prevSettings = await _settingsManager.Load<SavedInstallSettings>(InstallSettingsPrefix + hex); var prevSettings = await SettingsManager.Load<SavedInstallSettings>(InstallSettingsPrefix + hex);
if (prevSettings.ModListLocation == ModListPath) if (prevSettings.ModlistLocation == ModlistPath)
{ {
ModListPath = prevSettings.ModListLocation; StateContainer.ModlistPath = prevSettings.ModlistLocation;
InstallPath = prevSettings.InstallLocation; InstallPath = prevSettings.InstallLocation;
DownloadPath = prevSettings.DownloadLoadction; DownloadPath = prevSettings.DownloadLocation;
//ModlistMetadata = metadata ?? prevSettings.Metadata; //ModlistMetadata = metadata ?? prevSettings.Metadata;
} }
Stream image = await StandardInstaller.ModListImageStream(ModListPath); // see https://docs.microsoft.com/en-us/aspnet/core/blazor/images?view=aspnetcore-6.0#streaming-examples
await using var reader = new MemoryStream(); var imageStream = await StandardInstaller.ModListImageStream(ModlistPath);
await image.CopyToAsync(reader); var dotnetImageStream = new DotNetStreamReference(imageStream);
Image = $"data:image/png;base64,{Convert.ToBase64String(reader.ToArray())}"; // setImageUsingStreaming accepts the img id and the data stream
await JSRuntime.InvokeVoidAsync("setImageUsingStreaming", "background-image", dotnetImageStream);
} }
private async void SelectInstallFolder() private async void SelectInstallFolder()
{ {
try try
{ {
AbsolutePath? thing = await Dialog.ShowDialogNonBlocking(true); var installPath = await Dialog.ShowDialogNonBlocking(true);
if (thing != null) InstallPath = (AbsolutePath)thing; if (installPath is not null) InstallPath = (AbsolutePath)installPath;
StateHasChanged();
} }
catch (Exception ex) catch (Exception e)
{ {
Debug.Print(ex.Message); Logger.LogError(e, "Exception selecting install folder");
} }
} }
@ -91,68 +94,68 @@ public partial class Configure
{ {
try try
{ {
AbsolutePath? thing = await Dialog.ShowDialogNonBlocking(true); var downloadPath = await Dialog.ShowDialogNonBlocking(true);
if (thing != null) DownloadPath = (AbsolutePath)thing; if (downloadPath is not null) DownloadPath = (AbsolutePath)downloadPath;
StateHasChanged();
} }
catch (Exception ex) catch (Exception e)
{ {
Debug.Print(ex.Message); Logger.LogError(e, "Exception selecting download folder");
} }
} }
private async Task Install() private async Task Install()
{ {
GlobalState.InstallState = GlobalState.InstallStateEnum.Installing; if (Modlist is null) return;
await Task.Run(BeginInstall);
StateContainer.InstallState = InstallState.Installing;
await Task.Run(() => BeginInstall(Modlist));
} }
private async Task BeginInstall() private async Task BeginInstall(ModList modlist)
{ {
string postfix = (await ModListPath.ToString().Hash()).ToHex(); var postfix = (await ModlistPath.ToString().Hash()).ToHex();
await _settingsManager.Save(InstallSettingsPrefix + postfix, new SavedInstallSettings await SettingsManager.Save(InstallSettingsPrefix + postfix, new SavedInstallSettings
{ {
ModListLocation = ModListPath, ModlistLocation = ModlistPath,
InstallLocation = InstallPath, InstallLocation = InstallPath,
DownloadLoadction = DownloadPath DownloadLocation = DownloadPath
}); });
try try
{ {
var installer = StandardInstaller.Create(_serviceProvider, new InstallerConfiguration var installer = StandardInstaller.Create(ServiceProvider, new InstallerConfiguration
{ {
Game = ModList.GameType, Game = modlist.GameType,
Downloads = DownloadPath, Downloads = DownloadPath,
Install = InstallPath, Install = InstallPath,
ModList = ModList, ModList = modlist,
ModlistArchive = ModListPath, ModlistArchive = ModlistPath,
SystemParameters = _parametersConstructor.Create(), SystemParameters = ParametersConstructor.Create(),
GameFolder = _gameLocator.GameLocation(ModList.GameType) GameFolder = GameLocator.GameLocation(modlist.GameType)
}); });
installer.OnStatusUpdate = update => installer.OnStatusUpdate = update =>
{ {
if (StatusText != update.StatusText) var (statusText, _, _) = update;
{ if (StatusText == statusText) return;
StatusText = update.StatusText; StatusText = statusText;
InvokeAsync(StateHasChanged);
}
}; };
await installer.Begin(CancellationToken.None); await installer.Begin(CancellationToken.None);
StateContainer.InstallState = InstallState.Success;
} }
catch (Exception ex) catch (Exception e)
{ {
Debug.Print(ex.Message); Logger.LogError(e, "Exception installing Modlist");
StateContainer.InstallState = InstallState.Failure;
} }
} }
} }
internal class SavedInstallSettings internal class SavedInstallSettings
{ {
public AbsolutePath ModListLocation { get; set; } public AbsolutePath ModlistLocation { get; set; } = AbsolutePath.Empty;
public AbsolutePath InstallLocation { get; set; } public AbsolutePath InstallLocation { get; set; } = AbsolutePath.Empty;
public AbsolutePath DownloadLoadction { get; set; } public AbsolutePath DownloadLocation { get; set; } = AbsolutePath.Empty;
public ModlistMetadata Metadata { get; set; } // public ModlistMetadata Metadata { get; set; }
} }

View File

@ -1,4 +1,4 @@
@page "/Create" @page "/create"
@namespace Wabbajack.App.Blazor.Pages @namespace Wabbajack.App.Blazor.Pages
<div id="content"> <div id="content">
@ -6,3 +6,8 @@
</div> </div>
</div> </div>
@code
{
public const string Route = "/create";
}

View File

@ -1,24 +1,51 @@
@page "/Gallery" @page "/gallery"
@using Wabbajack.DTOs
@using Wabbajack.RateLimiter @using Wabbajack.RateLimiter
@using System.Globalization
@namespace Wabbajack.App.Blazor.Pages @namespace Wabbajack.App.Blazor.Pages
<div id="content"> <div id="content">
@foreach (ModlistMetadata modlist in _listItems) @if (_errorLoadingModlists)
{ {
<ModlistItem Metadata=@modlist> @* TODO: error *@
<InteractionIcon Icon="images/icons/install.svg" Label="Install" Size="75px" OnClick="@(() => OnClickDownload(modlist))"/>
<InteractionIcon Icon="images/icons/info.svg" Label="Information" Size="75px" OnClick="@(() => OnClickInformation(modlist))"/>
</ModlistItem>
} }
@if (DownloadProgress != Percent.Zero) else if (!Modlists.Any())
{ {
<BottomBar Image="@DownloadingMetaData.Links.ImageUri" Title="Downloading..." Subtitle="@DownloadingMetaData.Title"> @* TODO: loading *@
}
else
{
@foreach (var modlist in Modlists)
{
<div @key="modlist.Title" class="item">
<div class="display">
<img src="@modlist.Links.ImageUri" loading="lazy" class="image" alt="@modlist.Title">
<div class="interaction">
<InteractionIcon Icon="images/icons/install.svg" Label="Install" Size="75px" OnClick="@(() => OnClickDownload(modlist))"/>
<InteractionIcon Icon="images/icons/info.svg" Label="Information" Size="75px" OnClick="@(() => OnClickInformation(modlist))"/>
</div>
</div>
<div class="info">
<div class="title">@modlist.Title</div>
<div class="author">@modlist.Author</div>
<div class="description">@modlist.Description</div>
</div>
<div class="tags"></div>
</div>
}
}
@if (_downloadProgress != Percent.Zero && _downloadingMetaData is not null)
{
<BottomBar Image="@_downloadingMetaData.Links.ImageUri" Title="Downloading..." Subtitle="@_downloadingMetaData.Title">
<div style="height:1.5rem;"> <div style="height:1.5rem;">
<ProgressBar Percentage="@DownloadProgress" Text="@DownloadProgress.Value.ToString()"/> <ProgressBar Percentage="@_downloadProgress" Text="@_downloadProgress.Value.ToString(CultureInfo.InvariantCulture)"/>
</div> </div>
</BottomBar> </BottomBar>
} }
</div> </div>
@code {
public const string Route = "/gallery";
}

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Shell; using System.Windows.Shell;
@ -20,33 +21,35 @@ namespace Wabbajack.App.Blazor.Pages;
public partial class Gallery public partial class Gallery
{ {
[Inject] private GlobalState GlobalState { get; set; } [Inject] private ILogger<Gallery> Logger { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } [Inject] private IStateContainer StateContainer { get; set; } = default!;
[Inject] private ILogger<Gallery> _logger { get; set; } [Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private Client _client { get; set; } [Inject] private ModListDownloadMaintainer Maintainer { get; set; } = default!;
[Inject] private ModListDownloadMaintainer _maintainer { get; set; }
public Percent DownloadProgress { get; set; } = Percent.Zero; private Percent _downloadProgress = Percent.Zero;
public ModlistMetadata DownloadingMetaData { get; set; } = new ModlistMetadata(); private ModlistMetadata? _downloadingMetaData;
private List<ModlistMetadata> _listItems { get; set; } = new(); private IEnumerable<ModlistMetadata> Modlists => StateContainer.Modlists;
private bool _errorLoadingModlists;
private bool _shouldRender;
protected override bool ShouldRender() => _shouldRender;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try if (!StateContainer.Modlists.Any())
{ {
_logger.LogInformation("Getting modlists..."); var res = await StateContainer.LoadModlistMetadata();
ModlistMetadata[] modLists = await _client.LoadLists(); if (!res)
_listItems.AddRange(modLists.ToList()); {
StateHasChanged(); _errorLoadingModlists = true;
} _shouldRender = true;
catch (Exception ex) return;
{ }
//TODO: [Critical] Figure out why an exception is thrown on first navigation.
_logger.LogError(ex, "Error while loading lists");
} }
await base.OnInitializedAsync(); _shouldRender = true;
} }
private async void OnClickDownload(ModlistMetadata metadata) private async void OnClickDownload(ModlistMetadata metadata)
@ -62,38 +65,42 @@ public partial class Gallery
private async Task Download(ModlistMetadata metadata) private async Task Download(ModlistMetadata metadata)
{ {
GlobalState.NavigationAllowed = false; StateContainer.NavigationAllowed = false;
DownloadingMetaData = metadata; _downloadingMetaData = metadata;
await using Timer timer = new(_ => InvokeAsync(StateHasChanged));
timer.Change(TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(250));
try try
{ {
(IObservable<Percent> progress, Task task) = _maintainer.DownloadModlist(metadata); var (progress, task) = Maintainer.DownloadModlist(metadata);
GlobalState.SetTaskBarState(TaskbarItemProgressState.Indeterminate,$"Downloading {metadata.Title}"); var dispose = progress
.DistinctUntilChanged(p => p.Value)
var dispose = progress.Subscribe(p => .Throttle(TimeSpan.FromMilliseconds(100))
.Subscribe(p =>
{ {
DownloadProgress = p; _downloadProgress = p;
GlobalState.SetTaskBarState(TaskbarItemProgressState.Indeterminate,$"Downloading {metadata.Title}", p.Value); StateContainer.TaskBarState = new TaskBarState
}); {
Description = $"Downloading {metadata.Title}",
State = TaskbarItemProgressState.Indeterminate,
ProgressValue = p.Value
};
}, () => { StateContainer.TaskBarState = new TaskBarState(); });
await task; await task;
//await _wjClient.SendMetric("downloading", Metadata.Title);
dispose.Dispose(); dispose.Dispose();
GlobalState.SetTaskBarState();
var path = KnownFolders.EntryPoint.Combine("downloaded_mod_lists", metadata.Links.MachineURL).WithExtension(Ext.Wabbajack);
AbsolutePath path = KnownFolders.EntryPoint.Combine("downloaded_mod_lists", metadata.Links.MachineURL).WithExtension(Ext.Wabbajack); StateContainer.ModlistPath = path;
GlobalState.ModListPath = path; NavigationManager.NavigateTo(Configure.Route);
NavigationManager.NavigateTo("/Configure");
} }
catch (Exception e) catch (Exception e)
{ {
Debug.Print(e.Message); Logger.LogError(e, "Exception downloading Modlist {Name}", metadata.Title);
}
finally
{
StateContainer.TaskBarState = new TaskBarState();
StateContainer.NavigationAllowed = true;
} }
await timer.DisposeAsync();
GlobalState.NavigationAllowed = true;
} }
} }

View File

@ -1,6 +1,89 @@
#content { @import "../Shared/Globals.scss";
#content {
width: 100%; width: 100%;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-evenly; justify-content: space-evenly;
} }
$display-height: 225px;
$hover-icon-size: 75px;
.item {
width: 400px;
height: 450px;
overflow: hidden;
margin: 0.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(7px);
-webkit-backdrop-filter: blur(7px);
border: 1px solid rgba(255, 255, 255, 0.31);
&:hover .display .image {
filter: blur(2px) brightness(70%);
}
&:hover .display .interaction {
opacity: 1;
}
.display {
position: relative;
height: $display-height;
display: flex;
justify-content: center;
align-items: center;
.image {
position: absolute;
width: 100%;
height: 100%;
object-fit: contain;
transition: all 250ms ease-in-out;
}
.interaction {
position: absolute;
opacity: 0;
transition: all 250ms ease-in-out;
::deep img {
width: $hover-icon-size;
height: $hover-icon-size;
margin: 0;
transition: all 150ms ease-in-out;
}
}
}
.info {
padding-bottom: 1rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
.title {
color: white;
font-weight: 100;
font-size: 2rem;
line-height: 2.5rem;
margin: 0;
}
.author {
color: lightgray;
font-size: 1rem;
}
.description {
color: grey;
font-size: 0.9rem;
}
}
.tags {
border-radius: 0.5rem;
}
}

View File

@ -1,4 +1,4 @@
@page "/Install" @page "/install"
@namespace Wabbajack.App.Blazor.Pages @namespace Wabbajack.App.Blazor.Pages
@ -24,3 +24,7 @@
</div> </div>
</div> </div>
</div> </div>
@code {
public const string Route = "/install";
}

View File

@ -8,8 +8,8 @@ namespace Wabbajack.App.Blazor.Pages;
public partial class Install public partial class Install
{ {
[Inject] private NavigationManager NavigationManager { get; set; } [Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private GlobalState GlobalState { get; set; } [Inject] private IStateContainer StateContainer { get; set; } = default!;
private void SelectFile() private void SelectFile()
{ {
@ -18,10 +18,10 @@ public partial class Install
dialog.Multiselect = false; dialog.Multiselect = false;
dialog.Filters.Add(new CommonFileDialogFilter("Wabbajack File", "*" + Ext.Wabbajack)); dialog.Filters.Add(new CommonFileDialogFilter("Wabbajack File", "*" + Ext.Wabbajack));
if (dialog.ShowDialog() != CommonFileDialogResult.Ok) return; if (dialog.ShowDialog() != CommonFileDialogResult.Ok) return;
GlobalState.ModListPath = dialog.FileName.ToAbsolutePath(); StateContainer.ModlistPath = dialog.FileName.ToAbsolutePath();
} }
NavigationManager.NavigateTo("/Configure"); NavigationManager.NavigateTo(Configure.Route);
} }
private void VerifyFile(AbsolutePath path) { } private void VerifyFile(AbsolutePath path) { }

View File

@ -21,3 +21,7 @@
// } // }
// } // }
} }
@code {
public const string Route = "/";
}

View File

@ -1,4 +1,4 @@
@page "/Settings" @page "/settings"
@namespace Wabbajack.App.Blazor.Pages @namespace Wabbajack.App.Blazor.Pages
<div id="content"> <div id="content">
@ -6,3 +6,7 @@
</div> </div>
</div> </div>
@code {
public const string Route = "/settings";
}

View File

@ -13,7 +13,7 @@ public partial class Settings
{ {
try try
{ {
ResourceSettingsManager.ResourceSetting resource = await _resourceSettingsManager.GetSettings("Downloads"); var resource = await _resourceSettingsManager.GetSettings("Downloads");
StateHasChanged(); StateHasChanged();
} }
catch (Exception ex) { } catch (Exception ex) { }

View File

@ -1,90 +0,0 @@
using System;
using System.Windows.Shell;
using Wabbajack.DTOs;
using Wabbajack.Paths;
namespace Wabbajack.App.Blazor.State;
public class GlobalState
{
#region Navigation Allowed
private bool _navigationAllowed = true;
public event Action OnNavigationStateChange;
public bool NavigationAllowed
{
get => _navigationAllowed;
set
{
_navigationAllowed = value;
OnNavigationStateChange?.Invoke();
}
}
#endregion
#region Install
private InstallStateEnum _installState;
private AbsolutePath _modListPath;
private ModList _modList;
public event Action OnModListPathChange;
public event Action OnModListChange;
public event Action OnInstallStateChange;
public event Action<TaskBarState> OnTaskBarStateChange;
public void SetTaskBarState(TaskbarItemProgressState state = TaskbarItemProgressState.None, string description="", double progress = 0)
{
OnTaskBarStateChange?.Invoke(new TaskBarState
{
State = state,
ProgressValue = progress,
Description = description
});
}
public AbsolutePath ModListPath
{
get => _modListPath;
set
{
_modListPath = value;
OnModListPathChange?.Invoke();
}
}
public ModList ModList
{
get => _modList;
set
{
_modList = value;
OnModListChange?.Invoke();
}
}
public InstallStateEnum InstallState
{
get => _installState;
set
{
_installState = value;
OnInstallStateChange?.Invoke();
}
}
public enum InstallStateEnum
{
Waiting,
Configuration,
Installing,
Success,
Failure
}
#endregion
}

View File

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wabbajack.DTOs;
using Wabbajack.Paths;
namespace Wabbajack.App.Blazor.State;
public interface IStateContainer
{
IEnumerable<ModlistMetadata> Modlists { get; }
Task<bool> LoadModlistMetadata();
IObservable<bool> NavigationAllowedObservable { get; }
bool NavigationAllowed { get; set; }
IObservable<AbsolutePath> ModlistPathObservable { get; }
AbsolutePath ModlistPath { get; set; }
IObservable<ModList?> ModlistObservable { get; }
ModList? Modlist { get; set; }
IObservable<InstallState> InstallStateObservable { get; }
InstallState InstallState { get; set; }
IObservable<TaskBarState> TaskBarStateObservable { get; }
TaskBarState TaskBarState { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace Wabbajack.App.Blazor.State;
public enum InstallState
{
Waiting,
Configuration,
Installing,
Success,
Failure
}

View File

@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Wabbajack.Common;
using Wabbajack.DTOs;
using Wabbajack.Networking.WabbajackClientApi;
using Wabbajack.Paths;
namespace Wabbajack.App.Blazor.State;
public class StateContainer : IStateContainer
{
private readonly ILogger<StateContainer> _logger;
private readonly Client _client;
public StateContainer(ILogger<StateContainer> logger, Client client)
{
_logger = logger;
_client = client;
}
private ModlistMetadata[] _modlists = Array.Empty<ModlistMetadata>();
public IEnumerable<ModlistMetadata> Modlists => _modlists;
public async Task<bool> LoadModlistMetadata()
{
try
{
var lists = await _client.LoadLists();
_modlists = lists;
return _modlists.Any();
}
catch (Exception e)
{
_logger.LogError(e, "Exception loading Modlists");
return false;
}
}
private readonly CustomObservable<bool> _navigationAllowedObservable = new(true);
public IObservable<bool> NavigationAllowedObservable => _navigationAllowedObservable;
public bool NavigationAllowed
{
get => _navigationAllowedObservable.Value;
set => _navigationAllowedObservable.Value = value;
}
private readonly CustomObservable<AbsolutePath> _modlistPathObservable = new(AbsolutePath.Empty);
public IObservable<AbsolutePath> ModlistPathObservable => _modlistPathObservable;
public AbsolutePath ModlistPath
{
get => _modlistPathObservable.Value;
set => _modlistPathObservable.Value = value;
}
private readonly CustomObservable<ModList?> _modlistObservable = new(null);
public IObservable<ModList?> ModlistObservable => _modlistObservable;
public ModList? Modlist
{
get => _modlistObservable.Value;
set => _modlistObservable.Value = value;
}
private readonly CustomObservable<InstallState> _installStateObservable = new(InstallState.Waiting);
public IObservable<InstallState> InstallStateObservable => _installStateObservable;
public InstallState InstallState
{
get => _installStateObservable.Value;
set => _installStateObservable.Value = value;
}
private readonly CustomObservable<TaskBarState> _taskBarStateObservable = new(new TaskBarState());
public IObservable<TaskBarState> TaskBarStateObservable => _taskBarStateObservable;
public TaskBarState TaskBarState
{
get => _taskBarStateObservable.Value;
set => _taskBarStateObservable.Value = value;
}
}

View File

@ -4,7 +4,7 @@ namespace Wabbajack.App.Blazor.State;
public class TaskBarState public class TaskBarState
{ {
public string Description { get; set; } public string Description { get; set; } = string.Empty;
public double ProgressValue { get; set; } public double ProgressValue { get; set; }
public TaskbarItemProgressState State { get; set; } public TaskbarItemProgressState State { get; set; } = TaskbarItemProgressState.None;
} }

View File

@ -20,7 +20,7 @@ public static class Dialog
var dialog = new CommonOpenFileDialog(); var dialog = new CommonOpenFileDialog();
dialog.IsFolderPicker = isFolderPicker; dialog.IsFolderPicker = isFolderPicker;
dialog.Multiselect = false; dialog.Multiselect = false;
CommonFileDialogResult result = dialog.ShowDialog(newWindow); var result = dialog.ShowDialog(newWindow);
return result == CommonFileDialogResult.Ok ? dialog.FileName : null; return result == CommonFileDialogResult.Ok ? dialog.FileName : null;
}, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()) }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext())
.ContinueWith(result => result.Result?.ToAbsolutePath()) .ContinueWith(result => result.Result?.ToAbsolutePath())

View File

@ -40,14 +40,14 @@ public class SystemParametersConstructor
SetProcessDPIAware(); SetProcessDPIAware();
unsafe unsafe
{ {
List<(int Width, int Height, bool IsPrimary)>? col = new List<(int Width, int Height, bool IsPrimary)>(); var col = new List<(int Width, int Height, bool IsPrimary)>();
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
delegate(IntPtr hMonitor, IntPtr hdcMonitor, RECT* lprcMonitor, void* dwData) delegate(IntPtr hMonitor, IntPtr hdcMonitor, RECT* lprcMonitor, void* dwData)
{ {
var mi = new MONITORINFOEX(); var mi = new MONITORINFOEX();
mi.cbSize = Marshal.SizeOf(mi); mi.cbSize = Marshal.SizeOf(mi);
bool success = GetMonitorInfo(hMonitor, (MONITORINFO*)&mi); var success = GetMonitorInfo(hMonitor, (MONITORINFO*)&mi);
if (success) if (success)
col.Add((mi.Monitor.right - mi.Monitor.left, mi.Monitor.bottom - mi.Monitor.top, col.Add((mi.Monitor.right - mi.Monitor.left, mi.Monitor.bottom - mi.Monitor.top,
mi.Flags == MONITORINFO_Flags.MONITORINFOF_PRIMARY)); mi.Flags == MONITORINFO_Flags.MONITORINFOF_PRIMARY));
@ -60,17 +60,17 @@ public class SystemParametersConstructor
public SystemParameters Create() public SystemParameters Create()
{ {
(int width, int height, _) = GetDisplays().First(d => d.IsPrimary); (var width, var height, _) = GetDisplays().First(d => d.IsPrimary);
/*using var f = new SharpDX.DXGI.Factory1(); /*using var f = new SharpDX.DXGI.Factory1();
var video_memory = f.Adapters1.Select(a => var video_memory = f.Adapters1.Select(a =>
Math.Max(a.Description.DedicatedSystemMemory, (long)a.Description.DedicatedVideoMemory)).Max();*/ Math.Max(a.Description.DedicatedSystemMemory, (long)a.Description.DedicatedVideoMemory)).Max();*/
ulong dxgiMemory = 0UL; var dxgiMemory = 0UL;
unsafe unsafe
{ {
using DXGI? api = DXGI.GetApi(); using var api = DXGI.GetApi();
IDXGIFactory1* factory1 = default; IDXGIFactory1* factory1 = default;
@ -79,15 +79,15 @@ public class SystemParametersConstructor
//https://docs.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-createdxgifactory1 //https://docs.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-createdxgifactory1
SilkMarshal.ThrowHResult(api.CreateDXGIFactory1(SilkMarshal.GuidPtrOf<IDXGIFactory1>(), (void**)&factory1)); SilkMarshal.ThrowHResult(api.CreateDXGIFactory1(SilkMarshal.GuidPtrOf<IDXGIFactory1>(), (void**)&factory1));
uint i = 0u; var i = 0u;
while (true) while (true)
{ {
IDXGIAdapter1* adapter1 = default; IDXGIAdapter1* adapter1 = default;
//https://docs.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgifactory1-enumadapters1 //https://docs.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgifactory1-enumadapters1
int res = factory1->EnumAdapters1(i, &adapter1); var res = factory1->EnumAdapters1(i, &adapter1);
Exception? exception = Marshal.GetExceptionForHR(res); var exception = Marshal.GetExceptionForHR(res);
if (exception != null) break; if (exception != null) break;
AdapterDesc1 adapterDesc = default; AdapterDesc1 adapterDesc = default;
@ -95,10 +95,10 @@ public class SystemParametersConstructor
//https://docs.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgiadapter1-getdesc1 //https://docs.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgiadapter1-getdesc1
SilkMarshal.ThrowHResult(adapter1->GetDesc1(&adapterDesc)); SilkMarshal.ThrowHResult(adapter1->GetDesc1(&adapterDesc));
ulong systemMemory = (ulong)adapterDesc.DedicatedSystemMemory; var systemMemory = (ulong)adapterDesc.DedicatedSystemMemory;
ulong videoMemory = (ulong)adapterDesc.DedicatedVideoMemory; var videoMemory = (ulong)adapterDesc.DedicatedVideoMemory;
ulong maxMemory = Math.Max(systemMemory, videoMemory); var maxMemory = Math.Max(systemMemory, videoMemory);
if (maxMemory > dxgiMemory) if (maxMemory > dxgiMemory)
dxgiMemory = maxMemory; dxgiMemory = maxMemory;
@ -117,7 +117,7 @@ public class SystemParametersConstructor
} }
} }
MEMORYSTATUSEX? memory = GetMemoryStatus(); var memory = GetMemoryStatus();
var p = new SystemParameters var p = new SystemParameters
{ {
ScreenWidth = width, ScreenWidth = width,

View File

@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.1" />
<PackageReference Include="PInvoke.User32" Version="0.7.104" /> <PackageReference Include="PInvoke.User32" Version="0.7.104" />
<PackageReference Include="Silk.NET.DXGI" Version="2.12.0" /> <PackageReference Include="Silk.NET.DXGI" Version="2.12.0" />
<PackageReference Include="System.Reactive" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@ -32,6 +32,5 @@
<div id="app"></div> <div id="app"></div>
<script src="_framework/blazor.webview.js"></script> <script src="_framework/blazor.webview.js"></script>
<script src="_content/Fluxor.Blazor.Web/scripts/index.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Reactive;
namespace Wabbajack.Common;
public class CustomObservable<T> : ObservableBase<T>
{
private readonly List<IObserver<T>> _observers = new();
private T _value;
public T Value
{
get => _value;
set
{
if (EqualityComparer<T>.Default.Equals(value, _value)) return;
_value = value;
foreach (var observer in _observers)
{
observer.OnNext(value);
}
}
}
public CustomObservable(T value)
{
_value = value;
}
protected override IDisposable SubscribeCore(IObserver<T> observer)
{
if (!_observers.Contains(observer))
{
_observers.Add(observer);
observer.OnNext(Value);
}
return new Unsubscriber<T>(_observers, observer);
}
}
internal sealed class Unsubscriber<T> : IDisposable
{
private readonly List<IObserver<T>> _observers;
private readonly IObserver<T> _observer;
public Unsubscriber(List<IObserver<T>> observers, IObserver<T> observer)
{
_observers = observers;
_observer = observer;
}
public void Dispose()
{
if (_observers.Contains(_observer)) _observers.Remove(_observer);
}
}