Initial demo GUI
This commit is contained in:
parent
aee920a9f1
commit
ec8bf47de5
22
AGENTS.md
22
AGENTS.md
@ -2,24 +2,28 @@
|
||||
|
||||
Note: Keep AGENTS.md updated as project structure, key services, or workflows change.
|
||||
|
||||
This repo is **OF DL** (also known as OF-DL), a C# console app that downloads media from a user's OnlyFans account(s).
|
||||
This repo is **OF DL** (also known as OF-DL), a C# app suite (console + Avalonia desktop GUI) that downloads media
|
||||
from a user's OnlyFans account(s).
|
||||
This document is for AI agents helping developers modify the project. It focuses on architecture, data flow, and the
|
||||
most important change points.
|
||||
|
||||
## Quick Flow
|
||||
|
||||
1. `Program.Main` builds DI, loads `config.conf`, and runs the interactive flow.
|
||||
1. `Program.Main` is the app entrypoint: default launch is GUI; `--cli` switches to CLI mode.
|
||||
2. `StartupService.CheckVersionAsync` checks the latest release tag (`OFDLV*`) from `git.ofdl.tools` when not in DEBUG.
|
||||
3. `StartupService.ValidateEnvironmentAsync` validates OS, FFmpeg, `rules.json`, and Widevine device files.
|
||||
4. `AuthService` loads `auth.json` or opens a browser login (PuppeteerSharp) and persists auth data.
|
||||
5. `ApiService` signs every API request with dynamic rules and the current auth.
|
||||
6. `DownloadOrchestrationService` selects creators, prepares folders/DBs, and calls `DownloadService` per media type.
|
||||
7. `DownloadService` downloads media, handles DRM, and records metadata in SQLite.
|
||||
8. `OF DL.Gui` starts with config validation, then auth validation/browser login, then loads users/lists and provides
|
||||
multi-select download controls.
|
||||
|
||||
## Project Layout
|
||||
|
||||
- `OF DL/Program.cs` orchestrates startup, config/auth loading, and the interactive flow (CLI entrypoint).
|
||||
- `OF DL/Program.cs` is the single entrypoint and routes between GUI (default) and CLI (`--cli`).
|
||||
- `OF DL/CLI/` contains Spectre.Console UI helpers and progress reporting (CLI-only).
|
||||
- `OF DL.Gui/` contains the Avalonia desktop UI (`App`, `MainWindow`, MVVM view models, and GUI event handlers).
|
||||
- `OF DL.Core/Services/` contains application services (API, auth, download, config, DB, startup, logging, filenames).
|
||||
- `OF DL.Core/Models/` holds configuration, auth, API request/response models, downloads/startup results, DTOs,
|
||||
entities, and mapping helpers.
|
||||
@ -86,19 +90,25 @@ most important change points.
|
||||
|
||||
## Execution and Testing
|
||||
|
||||
- .NET SDK: 8.x (`net8.0` for all projects).
|
||||
- .NET SDK: 10.x (`net10.0` for all projects).
|
||||
- Build from the repo root:
|
||||
|
||||
```bash
|
||||
dotnet build OF DL.sln
|
||||
```
|
||||
|
||||
- Run from source (runtime files are read from the current working directory):
|
||||
- Run from source (GUI mode, default):
|
||||
|
||||
```bash
|
||||
dotnet run --project "OF DL/OF DL.csproj"
|
||||
```
|
||||
|
||||
- Run CLI mode:
|
||||
|
||||
```bash
|
||||
dotnet run --project "OF DL/OF DL.csproj" -- --cli
|
||||
```
|
||||
|
||||
- If you want a local `rules.json` fallback, run from `OF DL/` or copy `OF DL/rules.json` into your working directory.
|
||||
- Run tests:
|
||||
|
||||
@ -220,6 +230,8 @@ cookies/user-agent. Output is written to `{filename}_source.mp4`, then moved and
|
||||
## Where to Look First
|
||||
|
||||
- `OF DL/Program.cs` for the execution path and menu flow.
|
||||
- `OF DL.Gui/ViewModels/MainWindowViewModel.cs` for GUI startup flow (config -> auth -> users/lists -> selection).
|
||||
- `OF DL.Gui/Views/MainWindow.axaml` for GUI layout and interaction points.
|
||||
- `OF DL.Core/Services/ApiService.cs` for OF API calls and header signing.
|
||||
- `OF DL.Core/Services/DownloadService.cs` for downloads and DRM handling.
|
||||
- `OF DL.Core/Services/DownloadOrchestrationService.cs` for creator selection and flow control.
|
||||
|
||||
@ -294,6 +294,8 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
|
||||
string cookies = string.Join(" ", mappedCookies.Keys.Where(key => _desiredCookies.Contains(key))
|
||||
.Select(key => $"${key}={mappedCookies[key]};"));
|
||||
|
||||
await browser.CloseAsync();
|
||||
|
||||
return new Auth { Cookie = cookies, UserAgent = userAgent, UserId = userId, XBc = xBc };
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
9
OF DL.Gui/App.axaml
Normal file
9
OF DL.Gui/App.axaml
Normal file
@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fluent="clr-namespace:Avalonia.Themes.Fluent;assembly=Avalonia.Themes.Fluent"
|
||||
RequestedThemeVariant="Light"
|
||||
x:Class="OF_DL.Gui.App">
|
||||
<Application.Styles>
|
||||
<fluent:FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
28
OF DL.Gui/App.axaml.cs
Normal file
28
OF DL.Gui/App.axaml.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OF_DL.Gui.Services;
|
||||
using OF_DL.Gui.ViewModels;
|
||||
using OF_DL.Gui.Views;
|
||||
|
||||
namespace OF_DL.Gui;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
private readonly ServiceProvider _serviceProvider = ServiceCollectionFactory.Create().BuildServiceProvider();
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
MainWindow mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
|
||||
mainWindow.DataContext = _serviceProvider.GetRequiredService<MainWindowViewModel>();
|
||||
desktop.MainWindow = mainWindow;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
28
OF DL.Gui/OF DL.Gui.csproj
Normal file
28
OF DL.Gui/OF DL.Gui.csproj
Normal file
@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>OF_DL.Gui</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OF DL.Core\OF DL.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.0.10"/>
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.10"/>
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.10"/>
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.10"/>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2"/>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
16
OF DL.Gui/Program.cs
Normal file
16
OF DL.Gui/Program.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace OF_DL.Gui;
|
||||
|
||||
public static class GuiLauncher
|
||||
{
|
||||
public static void Run(string[] args)
|
||||
{
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace();
|
||||
}
|
||||
103
OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs
Normal file
103
OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs
Normal file
@ -0,0 +1,103 @@
|
||||
using OF_DL.Models.Downloads;
|
||||
using OF_DL.Services;
|
||||
|
||||
namespace OF_DL.Gui.Services;
|
||||
|
||||
internal sealed class AvaloniaDownloadEventHandler(
|
||||
Action<string> activitySink,
|
||||
Action<string> progressStatusUpdate,
|
||||
Action<string, long, bool> progressStart,
|
||||
Action<long> progressIncrement,
|
||||
Action progressStop,
|
||||
Func<bool> isCancellationRequested) : IDownloadEventHandler
|
||||
{
|
||||
public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
progressStart(statusMessage, 0, false);
|
||||
try
|
||||
{
|
||||
AvaloniaStatusReporter statusReporter = new(progressStatusUpdate, isCancellationRequested);
|
||||
return await work(statusReporter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressStop();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> WithProgressAsync<T>(string description, long maxValue, bool showSize,
|
||||
Func<IProgressReporter, Task<T>> work)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
progressStart(description, maxValue, showSize);
|
||||
try
|
||||
{
|
||||
AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested);
|
||||
return await work(reporter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressStop();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnContentFound(string contentType, int mediaCount, int objectCount)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
progressStatusUpdate($"Found {mediaCount} media from {objectCount} {contentType}.");
|
||||
}
|
||||
|
||||
public void OnNoContentFound(string contentType)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
progressStatusUpdate($"Found 0 {contentType}.");
|
||||
}
|
||||
|
||||
public void OnDownloadComplete(string contentType, DownloadResult result)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
progressStatusUpdate(
|
||||
$"{contentType} complete. Existing: {result.ExistingDownloads}, New: {result.NewDownloads}, Total: {result.TotalCount}.");
|
||||
}
|
||||
|
||||
public void OnUserStarting(string username)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
activitySink($"Starting scrape for {username}.");
|
||||
progressStatusUpdate($"Scraping data for {username}...");
|
||||
}
|
||||
|
||||
public void OnUserComplete(string username, CreatorDownloadResult result)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
activitySink(
|
||||
$"Completed {username}. PaidPosts={result.PaidPostCount}, Posts={result.PostCount}, Archived={result.ArchivedCount}, Streams={result.StreamsCount}, Stories={result.StoriesCount}, Highlights={result.HighlightsCount}, Messages={result.MessagesCount}, PaidMessages={result.PaidMessagesCount}.");
|
||||
}
|
||||
|
||||
public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
activitySink($"Purchased tab complete for {username}. PaidPosts={paidPostCount}, PaidMessages={paidMessagesCount}.");
|
||||
}
|
||||
|
||||
public void OnScrapeComplete(TimeSpan elapsed)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
activitySink($"Scrape completed in {elapsed.TotalMinutes:0.00} minutes.");
|
||||
}
|
||||
|
||||
public void OnMessage(string message)
|
||||
{
|
||||
ThrowIfCancellationRequested();
|
||||
progressStatusUpdate(message);
|
||||
}
|
||||
|
||||
private void ThrowIfCancellationRequested()
|
||||
{
|
||||
if (isCancellationRequested())
|
||||
{
|
||||
throw new OperationCanceledException("Operation canceled by user.");
|
||||
}
|
||||
}
|
||||
}
|
||||
21
OF DL.Gui/Services/AvaloniaProgressReporter.cs
Normal file
21
OF DL.Gui/Services/AvaloniaProgressReporter.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using OF_DL.Services;
|
||||
|
||||
namespace OF_DL.Gui.Services;
|
||||
|
||||
internal sealed class AvaloniaProgressReporter(
|
||||
Action<long> reportAction,
|
||||
Func<bool> isCancellationRequested) : IProgressReporter
|
||||
{
|
||||
public void ReportProgress(long increment)
|
||||
{
|
||||
if (isCancellationRequested())
|
||||
{
|
||||
throw new OperationCanceledException("Operation canceled by user.");
|
||||
}
|
||||
|
||||
if (increment > 0)
|
||||
{
|
||||
reportAction(increment);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
OF DL.Gui/Services/AvaloniaStatusReporter.cs
Normal file
18
OF DL.Gui/Services/AvaloniaStatusReporter.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using OF_DL.Services;
|
||||
|
||||
namespace OF_DL.Gui.Services;
|
||||
|
||||
internal sealed class AvaloniaStatusReporter(
|
||||
Action<string> statusAction,
|
||||
Func<bool> isCancellationRequested) : IStatusReporter
|
||||
{
|
||||
public void ReportStatus(string message)
|
||||
{
|
||||
if (isCancellationRequested())
|
||||
{
|
||||
throw new OperationCanceledException("Operation canceled by user.");
|
||||
}
|
||||
|
||||
statusAction(message);
|
||||
}
|
||||
}
|
||||
84
OF DL.Gui/Services/ConfigValidationService.cs
Normal file
84
OF DL.Gui/Services/ConfigValidationService.cs
Normal file
@ -0,0 +1,84 @@
|
||||
using OF_DL.Models.Config;
|
||||
|
||||
namespace OF_DL.Gui.Services;
|
||||
|
||||
internal static class ConfigValidationService
|
||||
{
|
||||
public static IReadOnlyDictionary<string, string> Validate(Config config)
|
||||
{
|
||||
Dictionary<string, string> errors = new(StringComparer.Ordinal);
|
||||
|
||||
ValidatePath(config.DownloadPath, nameof(Config.DownloadPath), errors, requireExistingFile: false);
|
||||
ValidatePath(config.FFmpegPath, nameof(Config.FFmpegPath), errors, requireExistingFile: true);
|
||||
|
||||
if (config.Timeout.HasValue && config.Timeout.Value <= 0 && config.Timeout.Value != -1)
|
||||
{
|
||||
errors[nameof(Config.Timeout)] = "Timeout must be -1 or greater than 0.";
|
||||
}
|
||||
|
||||
if (config.LimitDownloadRate && config.DownloadLimitInMbPerSec <= 0)
|
||||
{
|
||||
errors[nameof(Config.DownloadLimitInMbPerSec)] =
|
||||
"DownloadLimitInMbPerSec must be greater than 0 when LimitDownloadRate is enabled.";
|
||||
}
|
||||
|
||||
if (config.DownloadOnlySpecificDates && !config.CustomDate.HasValue)
|
||||
{
|
||||
errors[nameof(Config.CustomDate)] = "CustomDate is required when DownloadOnlySpecificDates is enabled.";
|
||||
}
|
||||
|
||||
ValidateFileNameFormat(config.PaidPostFileNameFormat, nameof(Config.PaidPostFileNameFormat), errors);
|
||||
ValidateFileNameFormat(config.PostFileNameFormat, nameof(Config.PostFileNameFormat), errors);
|
||||
ValidateFileNameFormat(config.PaidMessageFileNameFormat, nameof(Config.PaidMessageFileNameFormat), errors);
|
||||
ValidateFileNameFormat(config.MessageFileNameFormat, nameof(Config.MessageFileNameFormat), errors);
|
||||
|
||||
foreach (KeyValuePair<string, CreatorConfig> creatorConfig in config.CreatorConfigs)
|
||||
{
|
||||
ValidateFileNameFormat(creatorConfig.Value.PaidPostFileNameFormat,
|
||||
$"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.PaidPostFileNameFormat", errors);
|
||||
ValidateFileNameFormat(creatorConfig.Value.PostFileNameFormat,
|
||||
$"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.PostFileNameFormat", errors);
|
||||
ValidateFileNameFormat(creatorConfig.Value.PaidMessageFileNameFormat,
|
||||
$"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.PaidMessageFileNameFormat", errors);
|
||||
ValidateFileNameFormat(creatorConfig.Value.MessageFileNameFormat,
|
||||
$"{nameof(Config.CreatorConfigs)}.{creatorConfig.Key}.MessageFileNameFormat", errors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void ValidatePath(string? path, string fieldName, IDictionary<string, string> errors,
|
||||
bool requireExistingFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Any(character => Path.GetInvalidPathChars().Contains(character)))
|
||||
{
|
||||
errors[fieldName] = "Path contains invalid characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireExistingFile && !File.Exists(path))
|
||||
{
|
||||
errors[fieldName] = "Path must point to an existing file.";
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateFileNameFormat(string? format, string fieldName, IDictionary<string, string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasUniqueToken = format.Contains("{mediaId}", StringComparison.OrdinalIgnoreCase) ||
|
||||
format.Contains("{filename}", StringComparison.OrdinalIgnoreCase);
|
||||
if (!hasUniqueToken)
|
||||
{
|
||||
errors[fieldName] = "Format must include {mediaId} or {filename} to avoid file collisions.";
|
||||
}
|
||||
}
|
||||
}
|
||||
29
OF DL.Gui/Services/ServiceCollectionFactory.cs
Normal file
29
OF DL.Gui/Services/ServiceCollectionFactory.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OF_DL.Gui.ViewModels;
|
||||
using OF_DL.Gui.Views;
|
||||
using OF_DL.Services;
|
||||
|
||||
namespace OF_DL.Gui.Services;
|
||||
|
||||
internal static class ServiceCollectionFactory
|
||||
{
|
||||
public static IServiceCollection Create()
|
||||
{
|
||||
IServiceCollection services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton<ILoggingService, LoggingService>();
|
||||
services.AddSingleton<IConfigService, ConfigService>();
|
||||
services.AddSingleton<IAuthService, AuthService>();
|
||||
services.AddSingleton<IApiService, ApiService>();
|
||||
services.AddSingleton<IDbService, DbService>();
|
||||
services.AddSingleton<IDownloadService, DownloadService>();
|
||||
services.AddSingleton<IFileNameService, FileNameService>();
|
||||
services.AddSingleton<IStartupService, StartupService>();
|
||||
services.AddSingleton<IDownloadOrchestrationService, DownloadOrchestrationService>();
|
||||
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
10
OF DL.Gui/ViewModels/AppScreen.cs
Normal file
10
OF DL.Gui/ViewModels/AppScreen.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public enum AppScreen
|
||||
{
|
||||
Loading,
|
||||
Config,
|
||||
Auth,
|
||||
UserSelection,
|
||||
Error
|
||||
}
|
||||
22
OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs
Normal file
22
OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public sealed class ConfigCategoryViewModel : ViewModelBase
|
||||
{
|
||||
public ConfigCategoryViewModel(string categoryName, IEnumerable<ConfigFieldViewModel> fields)
|
||||
{
|
||||
CategoryName = categoryName;
|
||||
foreach (ConfigFieldViewModel field in fields)
|
||||
{
|
||||
Fields.Add(field);
|
||||
}
|
||||
}
|
||||
|
||||
public string CategoryName { get; }
|
||||
|
||||
public bool IsDownloadBehavior =>
|
||||
string.Equals(CategoryName, "Download Behavior", StringComparison.Ordinal);
|
||||
|
||||
public ObservableCollection<ConfigFieldViewModel> Fields { get; } = [];
|
||||
}
|
||||
251
OF DL.Gui/ViewModels/ConfigFieldViewModel.cs
Normal file
251
OF DL.Gui/ViewModels/ConfigFieldViewModel.cs
Normal file
@ -0,0 +1,251 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reflection;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Newtonsoft.Json;
|
||||
using OF_DL.Models.Config;
|
||||
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public partial class ConfigFieldViewModel : ViewModelBase
|
||||
{
|
||||
public ConfigFieldViewModel(PropertyInfo propertyInfo, object? initialValue)
|
||||
{
|
||||
PropertyInfo = propertyInfo;
|
||||
PropertyName = propertyInfo.Name;
|
||||
DisplayName = ToDisplayName(propertyInfo.Name);
|
||||
PropertyType = propertyInfo.PropertyType;
|
||||
|
||||
IsBoolean = PropertyType == typeof(bool);
|
||||
IsEnum = PropertyType.IsEnum;
|
||||
IsDate = PropertyType == typeof(DateTime?);
|
||||
IsNumeric = PropertyType == typeof(int) || PropertyType == typeof(int?);
|
||||
IsMultiline = PropertyType == typeof(Dictionary<string, CreatorConfig>);
|
||||
IsTextInput = !IsBoolean && !IsEnum && !IsDate && !IsNumeric;
|
||||
|
||||
if (IsEnum)
|
||||
{
|
||||
foreach (string enumName in Enum.GetNames(PropertyType))
|
||||
{
|
||||
EnumOptions.Add(enumName);
|
||||
}
|
||||
}
|
||||
|
||||
LoadInitialValue(initialValue);
|
||||
}
|
||||
|
||||
public PropertyInfo PropertyInfo { get; }
|
||||
|
||||
public string PropertyName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public Type PropertyType { get; }
|
||||
|
||||
public bool IsBoolean { get; }
|
||||
|
||||
public bool IsEnum { get; }
|
||||
|
||||
public bool IsDate { get; }
|
||||
|
||||
public bool IsNumeric { get; }
|
||||
|
||||
public bool IsTextInput { get; }
|
||||
|
||||
public bool IsMultiline { get; }
|
||||
|
||||
public double TextBoxMinHeight => IsMultiline ? 150 : 36;
|
||||
|
||||
public ObservableCollection<string> EnumOptions { get; } = [];
|
||||
|
||||
[ObservableProperty] private bool _boolValue;
|
||||
|
||||
[ObservableProperty] private string? _enumValue;
|
||||
|
||||
[ObservableProperty] private DateTimeOffset? _dateValue;
|
||||
|
||||
[ObservableProperty] private decimal? _numericValue;
|
||||
|
||||
[ObservableProperty] private string _textValue = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasError))]
|
||||
private string _errorMessage = string.Empty;
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
|
||||
public bool TryGetTypedValue(out object? value, out string? error)
|
||||
{
|
||||
value = null;
|
||||
error = null;
|
||||
|
||||
if (IsBoolean)
|
||||
{
|
||||
value = BoolValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsEnum)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EnumValue))
|
||||
{
|
||||
error = $"{DisplayName} is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(PropertyType, EnumValue, true, out object? enumResult))
|
||||
{
|
||||
error = $"{DisplayName} must be one of: {string.Join(", ", EnumOptions)}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = enumResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(string))
|
||||
{
|
||||
value = TextValue.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(int))
|
||||
{
|
||||
if (!NumericValue.HasValue)
|
||||
{
|
||||
error = $"{DisplayName} must be a whole number.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (decimal.Truncate(NumericValue.Value) != NumericValue.Value)
|
||||
{
|
||||
error = $"{DisplayName} must be a whole number.";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = (int)NumericValue.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(int?))
|
||||
{
|
||||
if (!NumericValue.HasValue)
|
||||
{
|
||||
value = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decimal.Truncate(NumericValue.Value) != NumericValue.Value)
|
||||
{
|
||||
error = $"{DisplayName} must be a whole number.";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = (int)NumericValue.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(DateTime?))
|
||||
{
|
||||
if (!DateValue.HasValue)
|
||||
{
|
||||
value = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = DateValue.Value.DateTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(Dictionary<string, CreatorConfig>))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TextValue))
|
||||
{
|
||||
value = new Dictionary<string, CreatorConfig>();
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, CreatorConfig>? parsed =
|
||||
JsonConvert.DeserializeObject<Dictionary<string, CreatorConfig>>(TextValue);
|
||||
value = parsed ?? new Dictionary<string, CreatorConfig>();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
error = $"{DisplayName} must be valid JSON.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
error = $"{DisplayName} has an unsupported field type.";
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ClearError()
|
||||
{
|
||||
ErrorMessage = string.Empty;
|
||||
}
|
||||
|
||||
public void SetError(string message)
|
||||
{
|
||||
ErrorMessage = message;
|
||||
}
|
||||
|
||||
private void LoadInitialValue(object? initialValue)
|
||||
{
|
||||
if (IsBoolean)
|
||||
{
|
||||
BoolValue = initialValue is bool boolValue && boolValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEnum)
|
||||
{
|
||||
EnumValue = initialValue?.ToString() ?? EnumOptions.FirstOrDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(Dictionary<string, CreatorConfig>))
|
||||
{
|
||||
Dictionary<string, CreatorConfig> creatorConfigs =
|
||||
initialValue as Dictionary<string, CreatorConfig> ?? new Dictionary<string, CreatorConfig>();
|
||||
TextValue = JsonConvert.SerializeObject(creatorConfigs, Formatting.Indented);
|
||||
return;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(DateTime?))
|
||||
{
|
||||
DateTime? date = initialValue is DateTime dt ? dt : null;
|
||||
DateValue = date.HasValue ? new DateTimeOffset(date.Value) : null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(int))
|
||||
{
|
||||
NumericValue = initialValue is int intValue ? intValue : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PropertyType == typeof(int?))
|
||||
{
|
||||
NumericValue = initialValue is int nullableIntValue ? nullableIntValue : null;
|
||||
return;
|
||||
}
|
||||
|
||||
TextValue = initialValue?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string ToDisplayName(string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(propertyName))
|
||||
{
|
||||
return propertyName;
|
||||
}
|
||||
|
||||
return string.Concat(propertyName.Select((character, index) =>
|
||||
index > 0 && char.IsUpper(character) && !char.IsUpper(propertyName[index - 1])
|
||||
? $" {character}"
|
||||
: character.ToString()));
|
||||
}
|
||||
}
|
||||
1198
OF DL.Gui/ViewModels/MainWindowViewModel.cs
Normal file
1198
OF DL.Gui/ViewModels/MainWindowViewModel.cs
Normal file
File diff suppressed because it is too large
Load Diff
19
OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs
Normal file
19
OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public partial class MultiSelectOptionViewModel : ViewModelBase
|
||||
{
|
||||
public MultiSelectOptionViewModel(string displayName, string propertyName, bool isSelected)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
PropertyName = propertyName;
|
||||
IsSelected = isSelected;
|
||||
}
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string PropertyName { get; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
}
|
||||
12
OF DL.Gui/ViewModels/SelectableUserViewModel.cs
Normal file
12
OF DL.Gui/ViewModels/SelectableUserViewModel.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public partial class SelectableUserViewModel(string username, long userId) : ViewModelBase
|
||||
{
|
||||
public string Username { get; } = username;
|
||||
|
||||
public long UserId { get; } = userId;
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
}
|
||||
7
OF DL.Gui/ViewModels/ViewModelBase.cs
Normal file
7
OF DL.Gui/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace OF_DL.Gui.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
394
OF DL.Gui/Views/MainWindow.axaml
Normal file
394
OF DL.Gui/Views/MainWindow.axaml
Normal file
@ -0,0 +1,394 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:OF_DL.Gui.ViewModels"
|
||||
x:Class="OF_DL.Gui.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Width="1320"
|
||||
Height="860"
|
||||
MinWidth="900"
|
||||
MinHeight="700"
|
||||
Title="OF DL"
|
||||
Background="#EEF3FB"
|
||||
mc:Ignorable="d">
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.surface">
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#DDE5F3" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="#2E6EEA" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.secondary">
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="Foreground" Value="#1F2A44" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="BorderBrush" Value="#CFD9EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Menu Grid.Row="0">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_Logout"
|
||||
IsVisible="{Binding IsAuthenticated}"
|
||||
Command="{Binding LogoutCommand}" />
|
||||
<MenuItem Header="E_xit" Command="{Binding ExitApplicationCommand}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Edit">
|
||||
<MenuItem Header="_Refresh Users" Command="{Binding RefreshUsersCommand}" />
|
||||
<MenuItem Header="Edit _Config" Command="{Binding EditConfigCommand}" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Padding="16"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="#CFD9EB"
|
||||
Background="#DDEAFF">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0"
|
||||
Foreground="#304261"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding AuthenticatedUserDisplay}"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
HorizontalAlignment="Right">
|
||||
<Button IsVisible="{Binding IsDownloading}"
|
||||
Classes="primary"
|
||||
Background="#D84E4E"
|
||||
Command="{Binding StopWorkCommand}"
|
||||
Content="Stop" />
|
||||
<Button Classes="secondary"
|
||||
IsVisible="{Binding IsAuthenticated}"
|
||||
Command="{Binding LogoutCommand}"
|
||||
Content="Logout" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2" Margin="14">
|
||||
<StackPanel IsVisible="{Binding IsLoadingScreen}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12"
|
||||
Width="420">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Text="Loading" />
|
||||
<ProgressBar IsIndeterminate="True" Height="14" />
|
||||
<TextBlock HorizontalAlignment="Center" Text="{Binding LoadingMessage}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer IsVisible="{Binding IsConfigScreen}">
|
||||
<StackPanel Spacing="14" Margin="2,0,4,0">
|
||||
<TextBlock FontSize="22" FontWeight="SemiBold" Text="Configuration" />
|
||||
|
||||
<Grid ColumnDefinitions="3*,5*" Margin="0,0,0,2">
|
||||
<Border Grid.Column="0" Classes="surface" Padding="12" Margin="0,0,10,0">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock FontSize="16" FontWeight="Bold" Foreground="#1F2A44" Text="External" />
|
||||
<TextBlock FontWeight="SemiBold"
|
||||
Foreground="#1F2A44"
|
||||
Text="FFmpeg Path" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding FfmpegPath}"
|
||||
Watermark="Select ffmpeg executable" />
|
||||
<Button Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
Classes="secondary"
|
||||
Content="Browse..."
|
||||
Click="OnBrowseFfmpegPathClick" />
|
||||
</Grid>
|
||||
<TextBlock IsVisible="{Binding HasFfmpegPathError}"
|
||||
Foreground="#FF5A5A"
|
||||
Text="{Binding FfmpegPathError}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Classes="surface" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock FontSize="16"
|
||||
FontWeight="Bold"
|
||||
Foreground="#1F2A44"
|
||||
Text="Download Media Types" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Media Types" />
|
||||
<ItemsControl ItemsSource="{Binding MediaTypeOptions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MultiSelectOptionViewModel">
|
||||
<CheckBox Margin="0,0,14,6"
|
||||
Content="{Binding DisplayName}"
|
||||
IsChecked="{Binding IsSelected}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock IsVisible="{Binding HasMediaTypesError}"
|
||||
Foreground="#FF5A5A"
|
||||
Text="{Binding MediaTypesError}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Sources" />
|
||||
<ItemsControl ItemsSource="{Binding MediaSourceOptions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MultiSelectOptionViewModel">
|
||||
<CheckBox Margin="0,0,14,6"
|
||||
Content="{Binding DisplayName}"
|
||||
IsChecked="{Binding IsSelected}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock IsVisible="{Binding HasMediaSourcesError}"
|
||||
Foreground="#FF5A5A"
|
||||
Text="{Binding MediaSourcesError}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Classes="surface" Padding="12">
|
||||
<ItemsControl ItemsSource="{Binding ConfigCategories}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConfigCategoryViewModel">
|
||||
<Border Classes="surface"
|
||||
Width="600"
|
||||
Margin="0,0,12,12"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock FontSize="16"
|
||||
FontWeight="Bold"
|
||||
Foreground="#1F2A44"
|
||||
Text="{Binding CategoryName}" />
|
||||
<StackPanel IsVisible="{Binding IsDownloadBehavior}"
|
||||
Spacing="4"
|
||||
Margin="0,0,0,10"
|
||||
x:CompileBindings="False">
|
||||
<TextBlock FontWeight="SemiBold"
|
||||
Foreground="#1F2A44"
|
||||
Text="Download Path" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{Binding DataContext.DownloadPath, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Watermark="Select download folder" />
|
||||
<Button Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
Classes="secondary"
|
||||
Content="Browse..."
|
||||
Click="OnBrowseDownloadPathClick" />
|
||||
</Grid>
|
||||
<TextBlock IsVisible="{Binding DataContext.HasDownloadPathError, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Foreground="#FF5A5A"
|
||||
Text="{Binding DataContext.DownloadPathError, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Fields}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConfigFieldViewModel">
|
||||
<Grid Margin="0,0,0,10" ColumnDefinitions="190,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Margin="0,6,10,0"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#1F2A44"
|
||||
Text="{Binding DisplayName}"
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<CheckBox IsVisible="{Binding IsBoolean}"
|
||||
IsChecked="{Binding BoolValue}" />
|
||||
|
||||
<ComboBox IsVisible="{Binding IsEnum}"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding EnumOptions}"
|
||||
SelectedItem="{Binding EnumValue}" />
|
||||
|
||||
<DatePicker IsVisible="{Binding IsDate}"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectedDate="{Binding DateValue}" />
|
||||
|
||||
<NumericUpDown IsVisible="{Binding IsNumeric}"
|
||||
HorizontalAlignment="Stretch"
|
||||
Value="{Binding NumericValue}"
|
||||
Increment="1"
|
||||
FormatString="N0" />
|
||||
|
||||
<TextBox IsVisible="{Binding IsTextInput}"
|
||||
HorizontalAlignment="Stretch"
|
||||
AcceptsReturn="{Binding IsMultiline}"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="{Binding TextBoxMinHeight}"
|
||||
Text="{Binding TextValue}" />
|
||||
|
||||
<TextBlock IsVisible="{Binding HasError}"
|
||||
Foreground="#FF5A5A"
|
||||
Text="{Binding ErrorMessage}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button Content="Save Configuration"
|
||||
Classes="primary"
|
||||
Command="{Binding SaveConfigCommand}" />
|
||||
<Button Content="Cancel"
|
||||
Classes="secondary"
|
||||
Command="{Binding CancelConfigCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel IsVisible="{Binding IsAuthScreen}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Width="640"
|
||||
Spacing="14">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Text="Authentication Required" />
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Text="{Binding AuthScreenMessage}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<Button Content="Login with Browser"
|
||||
Classes="primary"
|
||||
Command="{Binding StartBrowserLoginCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid IsVisible="{Binding IsUserSelectionScreen}" RowDefinitions="Auto,*,Auto" ColumnDefinitions="2*,3*">
|
||||
<Border Grid.Row="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Padding="10"
|
||||
Margin="0,0,0,10"
|
||||
Classes="surface">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Content="Select All" Classes="secondary" Command="{Binding SelectAllUsersCommand}" />
|
||||
<Button Content="Select None" Classes="secondary" Command="{Binding SelectNoUsersCommand}" />
|
||||
<ComboBox Width="280"
|
||||
PlaceholderText="Select a list of users"
|
||||
ItemsSource="{Binding UserLists}"
|
||||
SelectedItem="{Binding SelectedListName}" />
|
||||
<Button Content="Download Purchased Tab"
|
||||
Classes="secondary"
|
||||
Command="{Binding DownloadPurchasedTabCommand}" />
|
||||
<Button Content="Download Selected"
|
||||
Classes="primary"
|
||||
Command="{Binding DownloadSelectedCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="0,0,8,0"
|
||||
Padding="10"
|
||||
Classes="surface">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<StackPanel Grid.Row="0" Spacing="3">
|
||||
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Users" />
|
||||
<TextBlock Foreground="#4A5B78" Text="{Binding SelectedUsersSummary}" />
|
||||
</StackPanel>
|
||||
<ListBox Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding AvailableUsers}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectableUserViewModel">
|
||||
<CheckBox Content="{Binding Username}" IsChecked="{Binding IsSelected}" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
Padding="10"
|
||||
Classes="surface">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TextBlock Grid.Row="0" FontWeight="SemiBold" Foreground="#1F2A44" Text="Activity Log" />
|
||||
<ListBox Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding ActivityLog}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Grid.ColumnSpan="2"
|
||||
Margin="0,10,0,0"
|
||||
Padding="10"
|
||||
Classes="surface"
|
||||
IsVisible="{Binding IsDownloadProgressVisible}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44"
|
||||
Text="{Binding DownloadProgressDescription}" />
|
||||
<ProgressBar IsIndeterminate="{Binding IsDownloadProgressIndeterminate}"
|
||||
Minimum="0"
|
||||
Maximum="{Binding DownloadProgressMaximum}"
|
||||
Value="{Binding DownloadProgressValue}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<StackPanel IsVisible="{Binding IsErrorScreen}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Width="640"
|
||||
Spacing="14">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Text="Startup Error" />
|
||||
<TextBlock TextWrapping="Wrap" Text="{Binding ErrorMessage}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<Button Content="Retry Startup" Classes="primary" Command="{Binding RetryStartupCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
103
OF DL.Gui/Views/MainWindow.axaml.cs
Normal file
103
OF DL.Gui/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,103 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using OF_DL.Gui.ViewModels;
|
||||
|
||||
namespace OF_DL.Gui.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private bool _hasInitialized;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Opened += OnOpened;
|
||||
}
|
||||
|
||||
private async void OnOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (_hasInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hasInitialized = true;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
{
|
||||
await vm.InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnBrowseFfmpegPathClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TopLevel? topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.StorageProvider == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<IStorageFile> selectedFiles = await topLevel.StorageProvider.OpenFilePickerAsync(
|
||||
new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Select FFmpeg executable",
|
||||
AllowMultiple = false
|
||||
});
|
||||
|
||||
IStorageFile? selectedFile = selectedFiles.FirstOrDefault();
|
||||
if (selectedFile == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? localPath = selectedFile.TryGetLocalPath();
|
||||
if (!string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
vm.SetFfmpegPath(localPath);
|
||||
return;
|
||||
}
|
||||
|
||||
vm.SetFfmpegPath(selectedFile.Name);
|
||||
}
|
||||
|
||||
private async void OnBrowseDownloadPathClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TopLevel? topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.StorageProvider == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<IStorageFolder> selectedFolders = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||
new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Select download folder",
|
||||
AllowMultiple = false
|
||||
});
|
||||
|
||||
IStorageFolder? selectedFolder = selectedFolders.FirstOrDefault();
|
||||
if (selectedFolder == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? localPath = selectedFolder.TryGetLocalPath();
|
||||
if (!string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
vm.SetDownloadPath(localPath);
|
||||
return;
|
||||
}
|
||||
|
||||
vm.SetDownloadPath(selectedFolder.Name);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Core", "OF DL.Core\OF
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Tests", "OF DL.Tests\OF DL.Tests.csproj", "{FF5EC4D7-6369-4A78-8C02-E370343E797C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OF DL.Gui", "OF DL.Gui\OF DL.Gui.csproj", "{495749B1-DD15-4637-85AA-49841A86A510}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -27,6 +29,10 @@ Global
|
||||
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FF5EC4D7-6369-4A78-8C02-E370343E797C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{495749B1-DD15-4637-85AA-49841A86A510}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OF DL.Core\OF DL.Core.csproj"/>
|
||||
<ProjectReference Include="..\OF DL.Gui\OF DL.Gui.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OF_DL.CLI;
|
||||
using OF_DL.Gui;
|
||||
using OF_DL.Models;
|
||||
using OF_DL.Enumerations;
|
||||
using OF_DL.Models.Config;
|
||||
@ -14,6 +15,9 @@ namespace OF_DL;
|
||||
|
||||
public class Program(IServiceProvider serviceProvider)
|
||||
{
|
||||
private const string CliFlag = "--cli";
|
||||
private const string NonInteractiveFlag = "--non-interactive";
|
||||
|
||||
private async Task LoadAuthFromBrowser()
|
||||
{
|
||||
IAuthService authService = serviceProvider.GetRequiredService<IAuthService>();
|
||||
@ -65,13 +69,23 @@ public class Program(IServiceProvider serviceProvider)
|
||||
await authService.SaveToFileAsync();
|
||||
}
|
||||
|
||||
[STAThread]
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
bool runCli = await ShouldRunCliModeAsync(args);
|
||||
string[] cleanArgs = args.Where(a => !a.Equals(CliFlag, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
|
||||
if (!runCli)
|
||||
{
|
||||
GuiLauncher.Run(cleanArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.Write(new FigletText("Welcome to OF-DL").Color(Color.Red));
|
||||
AnsiConsole.Markup("Documentation: [link]https://docs.ofdl.tools/[/]\n");
|
||||
AnsiConsole.Markup("Discord server: [link]https://discord.com/invite/6bUW8EJ53j[/]\n\n");
|
||||
|
||||
ServiceCollection services = await ConfigureServices(args);
|
||||
ServiceCollection services = await ConfigureServices(cleanArgs);
|
||||
ServiceProvider serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Get the Program instance and run
|
||||
@ -79,6 +93,34 @@ public class Program(IServiceProvider serviceProvider)
|
||||
await program.RunAsync();
|
||||
}
|
||||
|
||||
private static async Task<bool> ShouldRunCliModeAsync(string[] args)
|
||||
{
|
||||
if (args.Any(a => a.Equals(CliFlag, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.Any(a => a.Equals(NonInteractiveFlag, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ServiceCollection services = new();
|
||||
services.AddSingleton<ILoggingService, LoggingService>();
|
||||
services.AddSingleton<IConfigService, ConfigService>();
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
IConfigService configService = provider.GetRequiredService<IConfigService>();
|
||||
|
||||
bool loaded = await configService.LoadConfigurationAsync(args);
|
||||
if (!loaded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return configService.IsCliNonInteractive || configService.CurrentConfig.NonInteractiveMode;
|
||||
}
|
||||
|
||||
private static async Task<ServiceCollection> ConfigureServices(string[] args)
|
||||
{
|
||||
// Set up dependency injection with LoggingService and ConfigService
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user