Implement theme management and settings dialog in PLib video library manager. Add theme service for light/dark/system themes, integrate settings dialog for user preferences, and update app configuration to include appearance options. Enhance thumbnail caching with new methods for cache size and clearing. Update README.md to reflect new features and settings.
This commit is contained in:
@@ -4,8 +4,11 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Desktop.Controls;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Desktop.Views;
|
||||
|
||||
@@ -27,6 +30,11 @@ public sealed class App : Avalonia.Application
|
||||
|
||||
AsyncImage.Loader = _host.Services.GetRequiredService<ThumbnailCache>();
|
||||
|
||||
// The theme lives in configuration, so restore the user's choice before the
|
||||
// first window is shown and avoid a visible flash of the wrong variant.
|
||||
_host.Services.GetRequiredService<IThemeService>().Apply(
|
||||
_host.Services.GetRequiredService<IOptionsMonitor<AppearanceOptions>>().CurrentValue.Theme);
|
||||
|
||||
var viewModel = _host.Services.GetRequiredService<MainWindowViewModel>();
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
desktop.Exit += OnExit;
|
||||
|
||||
+78
-69
@@ -1,69 +1,78 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Infrastructure;
|
||||
using PLib.Infrastructure.Storage;
|
||||
using Serilog;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root. Everything the application is made of is wired up here and nowhere else.
|
||||
/// </summary>
|
||||
internal static class AppHost
|
||||
{
|
||||
public static IHost Create(string[] args)
|
||||
{
|
||||
// The paths are needed to locate the user settings file, which is itself a
|
||||
// configuration source — so they are built before the container exists and then
|
||||
// handed to it as an instance.
|
||||
var paths = new AppPaths();
|
||||
|
||||
// A desktop app is launched from arbitrary working directories, so the content root
|
||||
// has to be the folder the executable lives in rather than Environment.CurrentDirectory.
|
||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory,
|
||||
});
|
||||
|
||||
builder.Configuration.AddJsonFile(
|
||||
Path.Combine(paths.DataDirectory, "settings.json"),
|
||||
optional: true,
|
||||
reloadOnChange: true);
|
||||
|
||||
ConfigureLogging(builder, paths);
|
||||
|
||||
builder.Services.AddSingleton<IAppPaths>(paths);
|
||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<ILibrarySettingsStore, JsonLibrarySettingsStore>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog(Log.Logger, dispose: true);
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Infrastructure;
|
||||
using PLib.Infrastructure.Storage;
|
||||
using Serilog;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root. Everything the application is made of is wired up here and nowhere else.
|
||||
/// </summary>
|
||||
internal static class AppHost
|
||||
{
|
||||
public static IHost Create(string[] args)
|
||||
{
|
||||
// The paths are needed to locate the user settings file, which is itself a
|
||||
// configuration source — so they are built before the container exists and then
|
||||
// handed to it as an instance.
|
||||
var paths = new AppPaths();
|
||||
|
||||
// A desktop app is launched from arbitrary working directories, so the content root
|
||||
// has to be the folder the executable lives in rather than Environment.CurrentDirectory.
|
||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory,
|
||||
});
|
||||
|
||||
builder.Configuration.AddJsonFile(
|
||||
Path.Combine(paths.DataDirectory, "settings.json"),
|
||||
optional: true,
|
||||
reloadOnChange: true);
|
||||
|
||||
ConfigureLogging(builder, paths);
|
||||
|
||||
builder.Services.AddSingleton<IAppPaths>(paths);
|
||||
|
||||
builder.Services.AddOptions<AppearanceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
|
||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
|
||||
builder.Services.AddSingleton<IDialogService, DialogService>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
// Scoped, not singleton: the settings dialog edits a working copy, so each opening
|
||||
// gets its own model and a cancelled edit never leaks into the next one.
|
||||
builder.Services.AddScoped<SettingsViewModel>();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog(Log.Logger, dispose: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Desktop.Views;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Opens the application's dialogs, so view models never touch window types.</summary>
|
||||
public interface IDialogService
|
||||
{
|
||||
Task<SettingsDialogOutcome> ShowSettingsAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IDialogService"/>
|
||||
public sealed class DialogService(IServiceScopeFactory scopeFactory) : IDialogService
|
||||
{
|
||||
public async Task<SettingsDialogOutcome> ShowSettingsAsync()
|
||||
{
|
||||
if (Avalonia.Application.Current?.ApplicationLifetime
|
||||
is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
{
|
||||
return SettingsDialogOutcome.Cancelled;
|
||||
}
|
||||
|
||||
// A scope per dialog: the container would otherwise keep every settings view model
|
||||
// it ever built alive until shutdown, and disposing the scope disposes the model.
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var viewModel = scope.ServiceProvider.GetRequiredService<SettingsViewModel>();
|
||||
|
||||
var window = new SettingsWindow { DataContext = viewModel };
|
||||
|
||||
// The view model decides when it is done; the window only carries the answer out.
|
||||
using var subscription = viewModel.Closed.Subscribe(outcome => window.Close(outcome));
|
||||
|
||||
// Show a real number in the cache section rather than an empty label.
|
||||
await viewModel.RefreshCacheSizeCommand.Execute().FirstAsync();
|
||||
|
||||
// Closing through the title bar yields null, which counts as cancelling.
|
||||
return await window.ShowDialog<SettingsDialogOutcome?>(owner) ?? SettingsDialogOutcome.Cancelled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using PLib.Desktop.Settings;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the settings the user can change at runtime. The file it writes is also a
|
||||
/// configuration source, so <c>IOptionsMonitor</c> picks changes up without a restart.
|
||||
/// </summary>
|
||||
public interface IAppSettingsStore
|
||||
{
|
||||
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the parts of <see cref="Application.Library.LibraryOptions"/> the user can change
|
||||
/// at runtime. Writes land in a JSON file that is also a configuration source, so
|
||||
/// <c>IOptionsMonitor</c> picks the change up without a restart.
|
||||
/// </summary>
|
||||
public interface ILibrarySettingsStore
|
||||
{
|
||||
Task SaveFoldersAsync(IReadOnlyList<string> folders, CancellationToken cancellationToken = default);
|
||||
}
|
||||
+25
-11
@@ -1,12 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Settings;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="ILibrarySettingsStore"/>
|
||||
public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
|
||||
/// <inheritdoc cref="IAppSettingsStore"/>
|
||||
public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
@@ -14,23 +15,24 @@ public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettings
|
||||
|
||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||
|
||||
public async Task SaveFoldersAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken = default)
|
||||
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Merge into whatever is already there: the file is hand-editable and may hold
|
||||
// keys this version of the settings screen knows nothing about.
|
||||
var root = await ReadRootAsync(cancellationToken);
|
||||
|
||||
if (root[LibraryOptions.SectionName] is not JsonObject section)
|
||||
{
|
||||
section = [];
|
||||
root[LibraryOptions.SectionName] = section;
|
||||
}
|
||||
var library = Section(root, LibraryOptions.SectionName);
|
||||
library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]);
|
||||
library["ThumbnailWidth"] = settings.ThumbnailWidth;
|
||||
library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio;
|
||||
library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency;
|
||||
library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes;
|
||||
|
||||
section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
|
||||
Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
|
||||
|
||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||
var staging = SettingsFile + ".tmp";
|
||||
@@ -43,6 +45,18 @@ public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettings
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject Section(JsonObject root, string name)
|
||||
{
|
||||
if (root[name] is JsonObject existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new JsonObject();
|
||||
root[name] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(SettingsFile))
|
||||
@@ -1,28 +1,48 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using PLib.Desktop.Settings;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Switches the application between the light and dark variants.</summary>
|
||||
/// <summary>Applies the light/dark/system choice to the running application.</summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
void Toggle();
|
||||
/// <summary>The mode currently in effect.</summary>
|
||||
ThemeMode Current { get; }
|
||||
|
||||
void Apply(ThemeMode mode);
|
||||
|
||||
/// <summary>Flips between light and dark, resolving "system" to whatever is on screen.</summary>
|
||||
ThemeMode Toggle();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IThemeService"/>
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public void Toggle()
|
||||
{
|
||||
if (Avalonia.Application.Current is not { } application)
|
||||
{
|
||||
return;
|
||||
}
|
||||
public ThemeMode Current { get; private set; } = ThemeMode.System;
|
||||
|
||||
// ActualThemeVariant resolves "follow the system" to whatever is on screen right now,
|
||||
// which is what the user is actually toggling away from.
|
||||
application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark
|
||||
? ThemeVariant.Light
|
||||
: ThemeVariant.Dark;
|
||||
public void Apply(ThemeMode mode)
|
||||
{
|
||||
Current = mode;
|
||||
|
||||
if (Avalonia.Application.Current is { } application)
|
||||
{
|
||||
application.RequestedThemeVariant = mode switch
|
||||
{
|
||||
ThemeMode.Light => ThemeVariant.Light,
|
||||
ThemeMode.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ThemeMode Toggle()
|
||||
{
|
||||
// ActualThemeVariant resolves "follow the system" to what the user is actually
|
||||
// looking at, which is what they mean by "the other one".
|
||||
var isDark = Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
|
||||
var next = isDark ? ThemeMode.Light : ThemeMode.Dark;
|
||||
|
||||
Apply(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using PLib.Application.Library;
|
||||
|
||||
namespace PLib.Desktop.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// The subset of configuration the user can change at runtime, as one snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything is written in a single pass rather than key by key: a settings file that is
|
||||
/// only ever replaced whole cannot end up in a state that never existed in the UI.
|
||||
/// </remarks>
|
||||
public sealed record AppSettings
|
||||
{
|
||||
public required IReadOnlyList<string> Folders { get; init; }
|
||||
|
||||
public required int ThumbnailWidth { get; init; }
|
||||
|
||||
public required double ThumbnailPositionRatio { get; init; }
|
||||
|
||||
public required int MaxIndexingConcurrency { get; init; }
|
||||
|
||||
public required long MinimumFileSizeInBytes { get; init; }
|
||||
|
||||
public required ThemeMode Theme { get; init; }
|
||||
|
||||
public static AppSettings From(LibraryOptions library, AppearanceOptions appearance) => new()
|
||||
{
|
||||
Folders = [.. library.Folders],
|
||||
ThumbnailWidth = library.ThumbnailWidth,
|
||||
ThumbnailPositionRatio = library.ThumbnailPositionRatio,
|
||||
MaxIndexingConcurrency = library.MaxIndexingConcurrency,
|
||||
MinimumFileSizeInBytes = library.MinimumFileSizeInBytes,
|
||||
Theme = appearance.Theme,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True when the difference between the two snapshots means the library has to be
|
||||
/// walked again. Cosmetic changes must not trigger a rescan.
|
||||
/// </summary>
|
||||
public bool RequiresRescanComparedTo(AppSettings other) =>
|
||||
!Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) ||
|
||||
MinimumFileSizeInBytes != other.MinimumFileSizeInBytes;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace PLib.Desktop.Settings;
|
||||
|
||||
public enum ThemeMode
|
||||
{
|
||||
/// <summary>Follow whatever the operating system is set to.</summary>
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// <summary>Look-and-feel settings; purely a concern of the desktop shell.</summary>
|
||||
public sealed class AppearanceOptions
|
||||
{
|
||||
public const string SectionName = "Appearance";
|
||||
|
||||
public ThemeMode Theme { get; init; } = ThemeMode.Dark;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using ReactiveUI;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One library folder in the settings list. It carries its own remove command so the row
|
||||
/// template never has to reach up the visual tree for the parent view model.
|
||||
/// </summary>
|
||||
public sealed class FolderEntryViewModel
|
||||
{
|
||||
public FolderEntryViewModel(string path, Action<FolderEntryViewModel> remove)
|
||||
{
|
||||
Path = path;
|
||||
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||
}
|
||||
@@ -4,12 +4,14 @@ using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Avalonia.Threading;
|
||||
using DynamicData;
|
||||
using DynamicData.Aggregation;
|
||||
using DynamicData.Kernel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
// Type alias, not a namespace import: pulling in ReactiveUI.Primitives would put a second
|
||||
@@ -31,7 +33,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||
private readonly ILibrarySettingsStore _settingsStore;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly IOptionsMonitor<AppearanceOptions> _appearance;
|
||||
private readonly IDialogService _dialogs;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly ISystemShell _shell;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
@@ -59,7 +64,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
public MainWindowViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<LibraryOptions> options,
|
||||
ILibrarySettingsStore settingsStore,
|
||||
IOptionsMonitor<AppearanceOptions> appearance,
|
||||
IAppSettingsStore settingsStore,
|
||||
IDialogService dialogs,
|
||||
IFolderPicker folderPicker,
|
||||
ISystemShell shell,
|
||||
IThemeService theme,
|
||||
@@ -67,7 +74,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
_appearance = appearance;
|
||||
_settingsStore = settingsStore;
|
||||
_dialogs = dialogs;
|
||||
_theme = theme;
|
||||
_folderPicker = folderPicker;
|
||||
_shell = shell;
|
||||
_logger = logger;
|
||||
@@ -76,7 +86,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
ToggleThemeCommand = ReactiveCommand.Create(theme.Toggle);
|
||||
ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync);
|
||||
OpenSettingsCommand = ReactiveCommand.CreateFromTask(OpenSettingsAsync);
|
||||
|
||||
// Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling
|
||||
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
|
||||
@@ -115,6 +126,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleThemeCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenSettingsCommand { get; }
|
||||
|
||||
[Reactive]
|
||||
public partial string SearchText { get; set; }
|
||||
|
||||
@@ -150,16 +163,21 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
.WhenAnyValue(x => x.SelectedSort)
|
||||
.Select(option => option.Comparer);
|
||||
|
||||
isEmpty = _library
|
||||
var bound = _library
|
||||
.Connect()
|
||||
// Cards mutate in place while indexing runs; without this their position and
|
||||
// visibility would be frozen at whatever they were when first inserted.
|
||||
.AutoRefresh(propertyChangeThrottle: RefreshBuffer, scheduler: TaskPoolScheduler.Default)
|
||||
.Filter(filterChanged)
|
||||
.ObserveOn(_uiScheduler)
|
||||
.SortAndBind(out videos, comparerChanged)
|
||||
.Count()
|
||||
.CombineLatest(this.WhenAnyValue(x => x.IsScanning), (count, scanning) => count == 0 && !scanning)
|
||||
.SortAndBind(out videos, comparerChanged);
|
||||
|
||||
// Called as a static on purpose. Written as an extension method, System.Reactive's
|
||||
// Count() also matches and wins — and that one only emits when the source completes,
|
||||
// which a change set never does, so the flag would silently stay false forever.
|
||||
isEmpty = CountEx
|
||||
.IsEmpty(bound)
|
||||
.CombineLatest(this.WhenAnyValue(x => x.IsScanning), (empty, scanning) => empty && !scanning)
|
||||
.ToProperty(this, x => x.IsEmpty);
|
||||
}
|
||||
|
||||
@@ -185,7 +203,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
ScanCommand.ThrownExceptions,
|
||||
CancelScanCommand.ThrownExceptions,
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
ToggleThemeCommand.ThrownExceptions)
|
||||
ToggleThemeCommand.ThrownExceptions,
|
||||
OpenSettingsCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A command failed");
|
||||
@@ -226,12 +245,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
var folders = _options.CurrentValue.Folders.ToArray();
|
||||
|
||||
if (folders.Length == 0)
|
||||
{
|
||||
StatusText = "Не выбрано ни одной папки";
|
||||
return;
|
||||
}
|
||||
|
||||
IsProgressIndeterminate = true;
|
||||
ScanProgress = 0;
|
||||
StatusText = "Поиск файлов…";
|
||||
@@ -261,6 +274,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
finally
|
||||
{
|
||||
IsProgressIndeterminate = false;
|
||||
|
||||
if (folders.Length == 0)
|
||||
{
|
||||
// The reconciliation still had to run — it is what empties the grid after the
|
||||
// last folder is removed — but "nothing found" would be the wrong explanation.
|
||||
StatusText = "Не выбрано ни одной папки";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,22 +301,59 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
folders.Add(folder);
|
||||
await _settingsStore.SaveFoldersAsync(folders);
|
||||
await SaveFoldersAsync(folders);
|
||||
|
||||
// IOptionsMonitor reloads from the file asynchronously; wait for it so the scan below
|
||||
// sees the folder we just added instead of racing the file watcher.
|
||||
await WaitForFolderAsync(folder);
|
||||
await WaitForConfigurationAsync(
|
||||
() => _options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance));
|
||||
|
||||
this.RaisePropertyChanged(nameof(HasFolders));
|
||||
|
||||
await ScanCommand.Execute();
|
||||
}
|
||||
|
||||
private async Task WaitForFolderAsync(string folder)
|
||||
private async Task OpenSettingsAsync()
|
||||
{
|
||||
var outcome = await _dialogs.ShowSettingsAsync();
|
||||
|
||||
if (outcome is not { Saved: true, Settings: { } saved })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The dialog wrote the file; wait for the configuration to catch up before anything
|
||||
// reads it back, otherwise the rescan below would use the previous folder list.
|
||||
await WaitForConfigurationAsync(
|
||||
() => _options.CurrentValue.Folders.SequenceEqual(saved.Folders, LibraryPathComparer.Instance));
|
||||
|
||||
this.RaisePropertyChanged(nameof(HasFolders));
|
||||
|
||||
if (outcome.RescanRequired)
|
||||
{
|
||||
await ScanCommand.Execute();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleThemeAsync()
|
||||
{
|
||||
var mode = _theme.Toggle();
|
||||
await _settingsStore.SaveAsync(CurrentSettings with { Theme = mode });
|
||||
}
|
||||
|
||||
private AppSettings CurrentSettings => AppSettings.From(_options.CurrentValue, _appearance.CurrentValue);
|
||||
|
||||
private Task SaveFoldersAsync(IReadOnlyList<string> folders) =>
|
||||
_settingsStore.SaveAsync(CurrentSettings with { Folders = folders });
|
||||
|
||||
/// <summary>
|
||||
/// A saved settings file is not visible through <c>IOptionsMonitor</c> straight away —
|
||||
/// the reload runs off a file watcher. Give it a moment before reading the value back,
|
||||
/// otherwise a rescan would still be looking at the previous configuration.
|
||||
/// </summary>
|
||||
private async Task WaitForConfigurationAsync(Func<bool> isUpToDate)
|
||||
{
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
if (isUpToDate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -304,7 +361,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder);
|
||||
_logger.LogWarning("Configuration did not reload in time after saving settings");
|
||||
}
|
||||
|
||||
private void Handle(LibraryScanEvent scanEvent)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Edits a working copy of the settings and only writes it back when the user confirms, so
|
||||
/// cancelling leaves both the file and the running configuration untouched.
|
||||
/// </summary>
|
||||
public sealed partial class SettingsViewModel : ViewModelBase
|
||||
{
|
||||
private const double BytesPerMegabyte = 1024 * 1024;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ILogger<SettingsViewModel> _logger;
|
||||
|
||||
/// <summary>The state the dialog opened with; the baseline every change is compared to.</summary>
|
||||
private readonly AppSettings _original;
|
||||
|
||||
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
||||
|
||||
/// <summary>Set once the cache has been wiped, which always forces a rescan on close.</summary>
|
||||
private bool _thumbnailsWereReset;
|
||||
|
||||
public SettingsViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<LibraryOptions> library,
|
||||
IOptionsMonitor<AppearanceOptions> appearance,
|
||||
IAppSettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
IThemeService theme,
|
||||
ILogger<SettingsViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsStore = settingsStore;
|
||||
_folderPicker = folderPicker;
|
||||
_theme = theme;
|
||||
_logger = logger;
|
||||
|
||||
// CurrentValue, not IOptions.Value: the dialog can be reopened after a save, and a
|
||||
// cached snapshot would show the settings the application started with.
|
||||
_original = AppSettings.From(library.CurrentValue, appearance.CurrentValue);
|
||||
|
||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||
ThumbnailWidth = _original.ThumbnailWidth;
|
||||
ThumbnailPositionPercent = Math.Round(_original.ThumbnailPositionRatio * 100);
|
||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||
MinimumFileSizeMegabytes = Math.Round(_original.MinimumFileSizeInBytes / BytesPerMegabyte, 2);
|
||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
RefreshCacheSizeCommand = ReactiveCommand.CreateFromTask(RefreshCacheSizeAsync);
|
||||
ClearCacheCommand = ReactiveCommand.CreateFromTask(ClearCacheAsync);
|
||||
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
|
||||
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
|
||||
|
||||
// A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
|
||||
// is re-raised whenever the list changes.
|
||||
Folders.CollectionChanged += OnFoldersChanged;
|
||||
Disposable
|
||||
.Create(() => Folders.CollectionChanged -= OnFoldersChanged)
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
/// <summary>Fires once, when the dialog should close.</summary>
|
||||
public IObservable<SettingsDialogOutcome> Closed => _closed;
|
||||
|
||||
public ObservableCollection<FolderEntryViewModel> Folders { get; }
|
||||
|
||||
public bool HasFolders => Folders.Count > 0;
|
||||
|
||||
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RefreshCacheSizeCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ClearCacheCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
|
||||
|
||||
/// <summary>Width of generated poster frames; height follows the source aspect ratio.</summary>
|
||||
[Reactive]
|
||||
public partial int ThumbnailWidth { get; set; }
|
||||
|
||||
/// <summary>How far into the video the poster frame is taken, in percent.</summary>
|
||||
[Reactive]
|
||||
public partial double ThumbnailPositionPercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial int MaxIndexingConcurrency { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial double MinimumFileSizeMegabytes { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial ThemeOption SelectedTheme { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string CacheSizeText { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string? Message { get; set; }
|
||||
|
||||
private AppSettings CurrentDraft => new()
|
||||
{
|
||||
Folders = [.. Folders.Select(entry => entry.Path)],
|
||||
ThumbnailWidth = ThumbnailWidth,
|
||||
ThumbnailPositionRatio = Math.Round(ThumbnailPositionPercent / 100, 4),
|
||||
MaxIndexingConcurrency = MaxIndexingConcurrency,
|
||||
MinimumFileSizeInBytes = (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||
Theme = SelectedTheme.Mode,
|
||||
};
|
||||
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Folders.Add(CreateEntry(folder));
|
||||
}
|
||||
|
||||
private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
|
||||
|
||||
private FolderEntryViewModel CreateEntry(string path) =>
|
||||
new(path, entry => Folders.Remove(entry));
|
||||
|
||||
private async Task RefreshCacheSizeAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var bytes = await library.GetThumbnailCacheSizeAsync();
|
||||
CacheSizeText = DisplayText.FileSize(bytes);
|
||||
}
|
||||
|
||||
private async Task ClearCacheAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.ResetThumbnailsAsync();
|
||||
await RefreshCacheSizeAsync();
|
||||
|
||||
// The frames are gone from disk and from the library, so the grid has to be
|
||||
// rebuilt regardless of what else the user changes before closing.
|
||||
_thumbnailsWereReset = true;
|
||||
Message = "Кэш очищен — превью соберутся заново при следующем сканировании";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
var draft = CurrentDraft;
|
||||
|
||||
await _settingsStore.SaveAsync(draft);
|
||||
|
||||
// The theme is not read from configuration again while the app runs, so apply it here.
|
||||
_theme.Apply(draft.Theme);
|
||||
|
||||
var rescan = _thumbnailsWereReset || draft.RequiresRescanComparedTo(_original);
|
||||
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
||||
}
|
||||
|
||||
private void ObserveCommandFailures() =>
|
||||
Observable
|
||||
.Merge(
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
RefreshCacheSizeCommand.ThrownExceptions,
|
||||
ClearCacheCommand.ThrownExceptions,
|
||||
SaveCommand.ThrownExceptions,
|
||||
CancelCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A settings command failed");
|
||||
Message = "Не удалось выполнить действие — подробности в журнале";
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
/// <summary>What the settings dialog left behind once it closed.</summary>
|
||||
/// <param name="Saved">False when the user cancelled or closed the window.</param>
|
||||
/// <param name="RescanRequired">True when the change affects what the library contains.</param>
|
||||
/// <param name="Settings">The snapshot that was written, so the caller can wait for it to load.</param>
|
||||
public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
|
||||
{
|
||||
public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
|
||||
}
|
||||
|
||||
public sealed record ThemeOption(ThemeMode Mode, string Label)
|
||||
{
|
||||
public static IReadOnlyList<ThemeOption> All { get; } =
|
||||
[
|
||||
new(ThemeMode.System, "Как в системе"),
|
||||
new(ThemeMode.Light, "Светлая"),
|
||||
new(ThemeMode.Dark, "Тёмная"),
|
||||
];
|
||||
}
|
||||
@@ -145,6 +145,10 @@
|
||||
<icons:MaterialIcon Kind="ThemeLightDark" Width="17" Height="17" />
|
||||
</Button>
|
||||
|
||||
<Button Command="{Binding OpenSettingsCommand}" ToolTip.Tip="Настройки">
|
||||
<icons:MaterialIcon Kind="CogOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="Primary" Command="{Binding AddFolderCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<icons:MaterialIcon Kind="FolderPlusOutline" Width="17" Height="17" />
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.SettingsWindow"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Title="Настройки"
|
||||
Width="620"
|
||||
Height="700"
|
||||
MinWidth="520"
|
||||
MinHeight="520"
|
||||
Background="{DynamicResource PageBackgroundBrush}"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.Styles>
|
||||
<!-- One settings block: a titled surface holding a few related controls. -->
|
||||
<Style Selector="Border.section">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SurfaceBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.hint">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ======================= Header ======================= -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="20,14">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Width="32"
|
||||
Height="32"
|
||||
CornerRadius="9"
|
||||
Background="{DynamicResource AccentSoftBrush}">
|
||||
<icons:MaterialIcon Kind="CogOutline"
|
||||
Width="18"
|
||||
Height="18"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
</Border>
|
||||
<TextBlock Classes="sectionTitle" Text="Настройки" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Body ======================= -->
|
||||
<ScrollViewer Grid.Row="1" Padding="20,18">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<!-- Folders -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Папки библиотеки" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Folders}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:FolderEntryViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10,6"
|
||||
Margin="0,0,0,6">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="9">
|
||||
<icons:MaterialIcon Grid.Column="0"
|
||||
Kind="FolderOutline"
|
||||
Width="16"
|
||||
Height="16"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Path}"
|
||||
ToolTip.Tip="{Binding Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
FontSize="12.5" />
|
||||
<Button Grid.Column="2"
|
||||
Command="{Binding RemoveCommand}"
|
||||
Padding="6"
|
||||
ToolTip.Tip="Убрать из библиотеки">
|
||||
<icons:MaterialIcon Kind="Close" Width="14" Height="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="hint"
|
||||
IsVisible="{Binding !HasFolders}"
|
||||
Text="Пока не добавлено ни одной папки." />
|
||||
|
||||
<Button HorizontalAlignment="Left" Command="{Binding AddFolderCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<icons:MaterialIcon Kind="FolderPlusOutline" Width="16" Height="16" />
|
||||
<TextBlock Text="Добавить папку" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Thumbnails -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Превью" />
|
||||
|
||||
<Grid ColumnDefinitions="200,*" RowDefinitions="Auto,Auto" RowSpacing="12">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="Ширина кадра, px" />
|
||||
<NumericUpDown Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Minimum="160"
|
||||
Maximum="1920"
|
||||
Increment="40"
|
||||
FormatString="0"
|
||||
Value="{Binding ThumbnailWidth}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="Кадр на отметке, %" />
|
||||
<Grid Grid.Row="1" Grid.Column="1" ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<Slider Grid.Column="0"
|
||||
Minimum="0"
|
||||
Maximum="90"
|
||||
TickFrequency="5"
|
||||
IsSnapToTickEnabled="True"
|
||||
Value="{Binding ThumbnailPositionPercent}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="label"
|
||||
MinWidth="36"
|
||||
TextAlignment="Right"
|
||||
Text="{Binding ThumbnailPositionPercent, StringFormat='{}{0:0} %'}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint"
|
||||
Text="Самый первый кадр почти всегда чёрный или с логотипом, поэтому кадр берётся чуть дальше от начала. Изменения применятся к новым превью — для пересборки старых очистите кэш." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Scanning -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Сканирование" />
|
||||
|
||||
<Grid ColumnDefinitions="200,*" RowDefinitions="Auto,Auto" RowSpacing="12">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="Файлов одновременно" />
|
||||
<NumericUpDown Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Minimum="1"
|
||||
Maximum="32"
|
||||
Increment="1"
|
||||
FormatString="0"
|
||||
Value="{Binding MaxIndexingConcurrency}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="Пропускать файлы меньше, МБ" />
|
||||
<NumericUpDown Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Minimum="0"
|
||||
Maximum="1024"
|
||||
Increment="0.5"
|
||||
FormatString="0.##"
|
||||
Value="{Binding MinimumFileSizeMegabytes}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Appearance -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Внешний вид" />
|
||||
|
||||
<Grid ColumnDefinitions="200,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="Тема" />
|
||||
<ComboBox Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding ThemeOptions}"
|
||||
SelectedItem="{Binding SelectedTheme}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ThemeOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Cache -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Кэш превью" />
|
||||
|
||||
<Grid ColumnDefinitions="200,*,Auto" ColumnSpacing="12">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="Занято на диске" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="label"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
Text="{Binding CacheSizeText}" />
|
||||
<Button Grid.Column="2"
|
||||
Command="{Binding ClearCacheCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Content="Очистить" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint"
|
||||
Text="Все кадры удаляются с диска, а библиотека забывает пути к ним. После закрытия настроек превью соберутся заново." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ======================= Footer ======================= -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="20,12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="14">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="hint"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding Message}" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Отмена" Command="{Binding CancelCommand}" />
|
||||
<Button Classes="Primary" Content="Сохранить" Command="{Binding SaveCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,9 @@
|
||||
using PLib.Desktop.ViewModels;
|
||||
using ReactiveUI.Avalonia;
|
||||
|
||||
namespace PLib.Desktop.Views;
|
||||
|
||||
public sealed partial class SettingsWindow : ReactiveWindow<SettingsViewModel>
|
||||
{
|
||||
public SettingsWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"Library": {
|
||||
"Folders": [],
|
||||
"ThumbnailWidth": 480,
|
||||
"ThumbnailPositionRatio": 0.15,
|
||||
"MaxIndexingConcurrency": 4,
|
||||
"MinimumFileSizeInBytes": 65536
|
||||
}
|
||||
}
|
||||
{
|
||||
"Library": {
|
||||
"Folders": [],
|
||||
"ThumbnailWidth": 480,
|
||||
"ThumbnailPositionRatio": 0.15,
|
||||
"MaxIndexingConcurrency": 4,
|
||||
"MinimumFileSizeInBytes": 65536
|
||||
},
|
||||
"Appearance": {
|
||||
"Theme": "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user