diff --git a/Directory.Packages.props b/Directory.Packages.props index a6f6b11..ed107ea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -46,6 +46,7 @@ + diff --git a/README.md b/README.md index d99b750..3a46865 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ - Метаданные (длительность, разрешение, кодек) через ffprobe. - Постеры кадром из видео через ffmpeg, с кэшем на диске. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. -- Светлая и тёмная темы. +- Окно настроек: папки библиотеки (с удалением), параметры превью и сканирования, тема, + очистка кэша превью. Всё пишется в `settings.json` и подхватывается без перезапуска. +- Светлая, тёмная и системная темы; выбор запоминается. - Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. ## Требования @@ -60,6 +62,10 @@ dotnet test декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. - **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт `IServiceScopeFactory` и создаёт scope на каждый вызов. +- **Настройки — рабочая копия.** Диалог правит снимок `AppSettings` и записывает его целиком + только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается + только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра + его не вызывает. - **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного diff --git a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs index 6f81755..ba3c5f2 100644 --- a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs +++ b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs @@ -1,31 +1,37 @@ -namespace PLib.Application.Abstractions; - -/// Produces (and caches on disk) a poster frame for a video file. -public interface IThumbnailGenerator -{ - /// - /// Returns the absolute path of the poster frame for , - /// generating it if it is not cached yet. Returns null when no frame could be - /// extracted; a missing thumbnail is a normal outcome, not an error. - /// - Task GetOrCreateAsync( - string videoPath, - TimeSpan? duration, - CancellationToken cancellationToken = default); - - /// - /// True when a previously generated poster frame is still present in the cache. The - /// cache directory is ordinary user-writable storage, so a path the library remembers - /// is not proof that the file behind it still exists. - /// - bool IsAvailable(string? thumbnailPath); - - /// - /// Deletes cached frames that no library item points at any more, and returns how many - /// files were removed. Call only after a complete scan: anything not in - /// is treated as garbage. - /// - Task PurgeUnusedAsync( - IReadOnlyCollection inUsePaths, - CancellationToken cancellationToken = default); -} +namespace PLib.Application.Abstractions; + +/// Produces (and caches on disk) a poster frame for a video file. +public interface IThumbnailGenerator +{ + /// + /// Returns the absolute path of the poster frame for , + /// generating it if it is not cached yet. Returns null when no frame could be + /// extracted; a missing thumbnail is a normal outcome, not an error. + /// + Task GetOrCreateAsync( + string videoPath, + TimeSpan? duration, + CancellationToken cancellationToken = default); + + /// + /// True when a previously generated poster frame is still present in the cache. The + /// cache directory is ordinary user-writable storage, so a path the library remembers + /// is not proof that the file behind it still exists. + /// + bool IsAvailable(string? thumbnailPath); + + /// + /// Deletes cached frames that no library item points at any more, and returns how many + /// files were removed. Call only after a complete scan: anything not in + /// is treated as garbage. + /// + Task PurgeUnusedAsync( + IReadOnlyCollection inUsePaths, + CancellationToken cancellationToken = default); + + /// How much disk the cache currently occupies, in bytes. + Task GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default); + + /// Empties the cache completely. Returns how many files were removed. + Task ClearAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs index 6b3ed4c..242b6b2 100644 --- a/src/PLib.Application/Library/ILibraryService.cs +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -1,18 +1,27 @@ -using PLib.Domain.Videos; - -namespace PLib.Application.Library; - -/// Use cases the UI needs in order to show and refresh the video library. -public interface ILibraryService -{ - /// Everything currently stored in the library, newest first. - Task> GetLibraryAsync(CancellationToken cancellationToken = default); - - /// - /// Reconciles the library with the configured folders and then fills in metadata and - /// poster frames for anything that is missing them, streaming progress as it goes. - /// - IAsyncEnumerable ScanAsync( - IReadOnlyList folders, - CancellationToken cancellationToken = default); -} +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// Use cases the UI needs in order to show and refresh the video library. +public interface ILibraryService +{ + /// Everything currently stored in the library, newest first. + Task> GetLibraryAsync(CancellationToken cancellationToken = default); + + /// + /// Reconciles the library with the configured folders and then fills in metadata and + /// poster frames for anything that is missing them, streaming progress as it goes. + /// + IAsyncEnumerable ScanAsync( + IReadOnlyList folders, + CancellationToken cancellationToken = default); + + /// Disk space currently taken by cached poster frames, in bytes. + Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default); + + /// + /// Throws every poster frame away and forgets the paths, so the next scan renders them + /// from scratch. Useful after changing the thumbnail width or capture position. + /// + Task ResetThumbnailsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index 88d32ca..2a821d7 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -27,6 +27,27 @@ public sealed class LibraryService( return [.. items.OrderByDescending(x => x.AddedAt)]; } + public Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) => + thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); + + public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default) + { + var items = await repository.GetAllAsync(cancellationToken); + + foreach (var item in items) + { + item.DetachThumbnail(); + } + + // Forget the paths before deleting the files. Interrupted the other way round, the + // library would point at frames that no longer exist — recoverable, but only after + // a full scan notices. This order leaves at worst some orphans, which the purge eats. + await repository.SaveChangesAsync(cancellationToken); + + var removed = await thumbnailGenerator.ClearAsync(cancellationToken); + logger.LogInformation("Cleared {Count} cached poster frames on request", removed); + } + public async IAsyncEnumerable ScanAsync( IReadOnlyList folders, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/src/PLib.Desktop/App.axaml.cs b/src/PLib.Desktop/App.axaml.cs index 4bb411b..6c1f9a0 100644 --- a/src/PLib.Desktop/App.axaml.cs +++ b/src/PLib.Desktop/App.axaml.cs @@ -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(); + // 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().Apply( + _host.Services.GetRequiredService>().CurrentValue.Theme); + var viewModel = _host.Services.GetRequiredService(); desktop.MainWindow = new MainWindow { DataContext = viewModel }; desktop.Exit += OnExit; diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs index b871cf7..4211cf8 100644 --- a/src/PLib.Desktop/AppHost.cs +++ b/src/PLib.Desktop/AppHost.cs @@ -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; - -/// -/// Composition root. Everything the application is made of is wired up here and nowhere else. -/// -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(paths); - builder.Services.AddPLibInfrastructure(builder.Configuration); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - 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; + +/// +/// Composition root. Everything the application is made of is wired up here and nowhere else. +/// +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(paths); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName)); + builder.Services.AddPLibInfrastructure(builder.Configuration); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // 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(); + + 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); + } +} diff --git a/src/PLib.Desktop/Services/DialogService.cs b/src/PLib.Desktop/Services/DialogService.cs new file mode 100644 index 0000000..9e51b83 --- /dev/null +++ b/src/PLib.Desktop/Services/DialogService.cs @@ -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; + +/// Opens the application's dialogs, so view models never touch window types. +public interface IDialogService +{ + Task ShowSettingsAsync(); +} + +/// +public sealed class DialogService(IServiceScopeFactory scopeFactory) : IDialogService +{ + public async Task 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(); + + 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(owner) ?? SettingsDialogOutcome.Cancelled; + } +} diff --git a/src/PLib.Desktop/Services/IAppSettingsStore.cs b/src/PLib.Desktop/Services/IAppSettingsStore.cs new file mode 100644 index 0000000..2a32dbf --- /dev/null +++ b/src/PLib.Desktop/Services/IAppSettingsStore.cs @@ -0,0 +1,12 @@ +using PLib.Desktop.Settings; + +namespace PLib.Desktop.Services; + +/// +/// Persists the settings the user can change at runtime. The file it writes is also a +/// configuration source, so IOptionsMonitor picks changes up without a restart. +/// +public interface IAppSettingsStore +{ + Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Desktop/Services/ILibrarySettingsStore.cs b/src/PLib.Desktop/Services/ILibrarySettingsStore.cs deleted file mode 100644 index fb82dc4..0000000 --- a/src/PLib.Desktop/Services/ILibrarySettingsStore.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace PLib.Desktop.Services; - -/// -/// Persists the parts of the user can change -/// at runtime. Writes land in a JSON file that is also a configuration source, so -/// IOptionsMonitor picks the change up without a restart. -/// -public interface ILibrarySettingsStore -{ - Task SaveFoldersAsync(IReadOnlyList folders, CancellationToken cancellationToken = default); -} diff --git a/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs similarity index 53% rename from src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs rename to src/PLib.Desktop/Services/JsonAppSettingsStore.cs index a3c5dad..8292bbd 100644 --- a/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs +++ b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs @@ -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; -/// -public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore +/// +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 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 ReadRootAsync(CancellationToken cancellationToken) { if (!File.Exists(SettingsFile)) diff --git a/src/PLib.Desktop/Services/ThemeService.cs b/src/PLib.Desktop/Services/ThemeService.cs index a66945d..699cbfd 100644 --- a/src/PLib.Desktop/Services/ThemeService.cs +++ b/src/PLib.Desktop/Services/ThemeService.cs @@ -1,28 +1,48 @@ -using Avalonia; using Avalonia.Styling; +using PLib.Desktop.Settings; namespace PLib.Desktop.Services; -/// Switches the application between the light and dark variants. +/// Applies the light/dark/system choice to the running application. public interface IThemeService { - void Toggle(); + /// The mode currently in effect. + ThemeMode Current { get; } + + void Apply(ThemeMode mode); + + /// Flips between light and dark, resolving "system" to whatever is on screen. + ThemeMode Toggle(); } /// 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; } } diff --git a/src/PLib.Desktop/Settings/AppSettings.cs b/src/PLib.Desktop/Settings/AppSettings.cs new file mode 100644 index 0000000..41de518 --- /dev/null +++ b/src/PLib.Desktop/Settings/AppSettings.cs @@ -0,0 +1,43 @@ +using PLib.Application.Library; + +namespace PLib.Desktop.Settings; + +/// +/// The subset of configuration the user can change at runtime, as one snapshot. +/// +/// +/// 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. +/// +public sealed record AppSettings +{ + public required IReadOnlyList 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, + }; + + /// + /// True when the difference between the two snapshots means the library has to be + /// walked again. Cosmetic changes must not trigger a rescan. + /// + public bool RequiresRescanComparedTo(AppSettings other) => + !Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) || + MinimumFileSizeInBytes != other.MinimumFileSizeInBytes; +} diff --git a/src/PLib.Desktop/Settings/AppearanceOptions.cs b/src/PLib.Desktop/Settings/AppearanceOptions.cs new file mode 100644 index 0000000..7b1a43f --- /dev/null +++ b/src/PLib.Desktop/Settings/AppearanceOptions.cs @@ -0,0 +1,17 @@ +namespace PLib.Desktop.Settings; + +public enum ThemeMode +{ + /// Follow whatever the operating system is set to. + System, + Light, + Dark, +} + +/// Look-and-feel settings; purely a concern of the desktop shell. +public sealed class AppearanceOptions +{ + public const string SectionName = "Appearance"; + + public ThemeMode Theme { get; init; } = ThemeMode.Dark; +} diff --git a/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs b/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs new file mode 100644 index 0000000..f1a052b --- /dev/null +++ b/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs @@ -0,0 +1,21 @@ +using ReactiveUI; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// +/// 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. +/// +public sealed class FolderEntryViewModel +{ + public FolderEntryViewModel(string path, Action remove) + { + Path = path; + RemoveCommand = ReactiveCommand.Create(() => remove(this)); + } + + public string Path { get; } + + public ReactiveCommand RemoveCommand { get; } +} diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index 7179823..3a33cb2 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -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 _options; - private readonly ILibrarySettingsStore _settingsStore; + private readonly IAppSettingsStore _settingsStore; + private readonly IOptionsMonitor _appearance; + private readonly IDialogService _dialogs; + private readonly IThemeService _theme; private readonly IFolderPicker _folderPicker; private readonly ISystemShell _shell; private readonly ILogger _logger; @@ -59,7 +64,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase public MainWindowViewModel( IServiceScopeFactory scopeFactory, IOptionsMonitor options, - ILibrarySettingsStore settingsStore, + IOptionsMonitor 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 ToggleThemeCommand { get; } + public ReactiveCommand 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 folders) => + _settingsStore.SaveAsync(CurrentSettings with { Folders = folders }); + + /// + /// A saved settings file is not visible through IOptionsMonitor 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. + /// + private async Task WaitForConfigurationAsync(Func 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) diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..a085db6 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs @@ -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; + +/// +/// 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. +/// +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 _logger; + + /// The state the dialog opened with; the baseline every change is compared to. + private readonly AppSettings _original; + + private readonly Subject _closed = new(); + + /// Set once the cache has been wiped, which always forces a rescan on close. + private bool _thumbnailsWereReset; + + public SettingsViewModel( + IServiceScopeFactory scopeFactory, + IOptionsMonitor library, + IOptionsMonitor appearance, + IAppSettingsStore settingsStore, + IFolderPicker folderPicker, + IThemeService theme, + ILogger 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(); + } + + /// Fires once, when the dialog should close. + public IObservable Closed => _closed; + + public ObservableCollection Folders { get; } + + public bool HasFolders => Folders.Count > 0; + + public IReadOnlyList ThemeOptions { get; } = ThemeOption.All; + + public ReactiveCommand AddFolderCommand { get; } + + public ReactiveCommand RefreshCacheSizeCommand { get; } + + public ReactiveCommand ClearCacheCommand { get; } + + public ReactiveCommand SaveCommand { get; } + + public ReactiveCommand CancelCommand { get; } + + /// Width of generated poster frames; height follows the source aspect ratio. + [Reactive] + public partial int ThumbnailWidth { get; set; } + + /// How far into the video the poster frame is taken, in percent. + [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(); + + 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(); + + 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); +} + +/// What the settings dialog left behind once it closed. +/// False when the user cancelled or closed the window. +/// True when the change affects what the library contains. +/// The snapshot that was written, so the caller can wait for it to load. +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 All { get; } = + [ + new(ThemeMode.System, "Как в системе"), + new(ThemeMode.Light, "Светлая"), + new(ThemeMode.Dark, "Тёмная"), + ]; +} diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml index 7bef7fc..0c66494 100644 --- a/src/PLib.Desktop/Views/MainWindow.axaml +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -145,6 +145,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +