diff --git a/README.md b/README.md index 3a46865..b4d86fd 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ - Метаданные (длительность, разрешение, кодек) через ffprobe. - Постеры кадром из видео через ffmpeg, с кэшем на диске. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. -- Окно настроек: папки библиотеки (с удалением), параметры превью и сканирования, тема, - очистка кэша превью. Всё пишется в `settings.json` и подхватывается без перезапуска. +- Настройки — выдвижной панелью в том же окне: папки библиотеки (с удалением), параметры + превью и сканирования, тема, очистка кэша. Всё пишется в `settings.json` и подхватывается + без перезапуска. - Светлая, тёмная и системная темы; выбор запоминается. - Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. @@ -62,7 +63,10 @@ dotnet test декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. - **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт `IServiceScopeFactory` и создаёт scope на каждый вызов. -- **Настройки — рабочая копия.** Диалог правит снимок `AppSettings` и записывает его целиком +- **Одно окно.** Настройки — панель поверх сетки, а не второе окно: библиотека остаётся + видна, в alt-tab ничего не добавляется, и приложение остаётся переносимым на + `ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует. +- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра его не вызывает. diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs index 4211cf8..0a01e2c 100644 --- a/src/PLib.Desktop/AppHost.cs +++ b/src/PLib.Desktop/AppHost.cs @@ -48,7 +48,6 @@ internal static class AppHost builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/PLib.Desktop/Services/DialogService.cs b/src/PLib.Desktop/Services/DialogService.cs deleted file mode 100644 index 9e51b83..0000000 --- a/src/PLib.Desktop/Services/DialogService.cs +++ /dev/null @@ -1,42 +0,0 @@ -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/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml index 9b111a4..f293078 100644 --- a/src/PLib.Desktop/Themes/LibraryStyles.axaml +++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml @@ -1,121 +1,173 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index 3a33cb2..a1c58d8 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -35,7 +35,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly IOptionsMonitor _options; private readonly IAppSettingsStore _settingsStore; private readonly IOptionsMonitor _appearance; - private readonly IDialogService _dialogs; private readonly IThemeService _theme; private readonly IFolderPicker _folderPicker; private readonly ISystemShell _shell; @@ -60,13 +59,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly ReadOnlyObservableCollection _videos; private readonly ObservableAsPropertyHelper _isScanning; private readonly ObservableAsPropertyHelper _isEmpty; + private readonly ObservableAsPropertyHelper _isSettingsOpen; public MainWindowViewModel( IServiceScopeFactory scopeFactory, IOptionsMonitor options, IOptionsMonitor appearance, IAppSettingsStore settingsStore, - IDialogService dialogs, IFolderPicker folderPicker, ISystemShell shell, IThemeService theme, @@ -76,7 +75,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase _options = options; _appearance = appearance; _settingsStore = settingsStore; - _dialogs = dialogs; _theme = theme; _folderPicker = folderPicker; _shell = shell; @@ -87,7 +85,19 @@ public sealed partial class MainWindowViewModel : ViewModelBase InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync); AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync); ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync); - OpenSettingsCommand = ReactiveCommand.CreateFromTask(OpenSettingsAsync); + + _isSettingsOpen = this + .WhenAnyValue(x => x.SettingsPanel) + .Select(panel => panel is not null) + .ToProperty(this, x => x.IsSettingsOpen); + + OpenSettingsCommand = ReactiveCommand.CreateFromTask( + OpenSettingsAsync, + this.WhenAnyValue(x => x.IsSettingsOpen).Select(open => !open)); + + CloseSettingsCommand = ReactiveCommand.Create( + () => { SettingsPanel?.CancelCommand.Execute().Subscribe(); }, + this.WhenAnyValue(x => x.IsSettingsOpen)); // Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling // simply unsubscribes it, which cancels the token Observable.StartAsync handed out. @@ -128,6 +138,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase public ReactiveCommand OpenSettingsCommand { get; } + public ReactiveCommand CloseSettingsCommand { get; } + + /// + /// The settings panel while it is on screen, or null. Its presence is what the + /// overlay binds to — settings live in this window rather than a second one. + /// + [Reactive] + public partial SettingsViewModel? SettingsPanel { get; set; } + + public bool IsSettingsOpen => _isSettingsOpen.Value; + [Reactive] public partial string SearchText { get; set; } @@ -204,7 +225,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase CancelScanCommand.ThrownExceptions, AddFolderCommand.ThrownExceptions, ToggleThemeCommand.ThrownExceptions, - OpenSettingsCommand.ThrownExceptions) + OpenSettingsCommand.ThrownExceptions, + CloseSettingsCommand.ThrownExceptions) .Subscribe(ex => { _logger.LogError(ex, "A command failed"); @@ -313,14 +335,35 @@ public sealed partial class MainWindowViewModel : ViewModelBase private async Task OpenSettingsAsync() { - var outcome = await _dialogs.ShowSettingsAsync(); + // A scope per opening: the panel edits a working copy, so a cancelled edit must not + // survive into the next time it is opened, and disposing the scope disposes it. + await using var scope = _scopeFactory.CreateAsyncScope(); + var panel = scope.ServiceProvider.GetRequiredService(); + + // Fill in the cache size before the panel appears, so the number never pops in late. + await panel.RefreshCacheSizeCommand.Execute().FirstAsync(); + + SettingsPanel = panel; + + SettingsDialogOutcome outcome; + + try + { + outcome = await panel.Closed.FirstAsync(); + } + finally + { + // Close first: a rescan started below would otherwise run behind a panel the + // user has already dismissed. + SettingsPanel = null; + } if (outcome is not { Saved: true, Settings: { } saved }) { return; } - // The dialog wrote the file; wait for the configuration to catch up before anything + // The panel 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)); diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs index a085db6..b48bf59 100644 --- a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs +++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs @@ -57,9 +57,9 @@ public sealed partial class SettingsViewModel : ViewModelBase Folders = [.. _original.Folders.Select(CreateEntry)]; ThumbnailWidth = _original.ThumbnailWidth; - ThumbnailPositionPercent = Math.Round(_original.ThumbnailPositionRatio * 100); + ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio); MaxIndexingConcurrency = _original.MaxIndexingConcurrency; - MinimumFileSizeMegabytes = Math.Round(_original.MinimumFileSizeInBytes / BytesPerMegabyte, 2); + MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes); SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme); AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync); @@ -127,12 +127,28 @@ public sealed partial class SettingsViewModel : ViewModelBase { Folders = [.. Folders.Select(entry => entry.Path)], ThumbnailWidth = ThumbnailWidth, - ThumbnailPositionRatio = Math.Round(ThumbnailPositionPercent / 100, 4), + + // Both of these are shown in a friendlier unit than they are stored in, and that + // conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915. + // An untouched field therefore keeps the original value verbatim — otherwise merely + // opening the panel and pressing Save would rewrite settings and force a rescan. + ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio) + ? _original.ThumbnailPositionRatio + : Math.Round(ThumbnailPositionPercent / 100, 4), + MaxIndexingConcurrency = MaxIndexingConcurrency, - MinimumFileSizeInBytes = (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte), + + MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes) + ? _original.MinimumFileSizeInBytes + : (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte), + Theme = SelectedTheme.Mode, }; + private static double ToPercent(double ratio) => Math.Round(ratio * 100); + + private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2); + private async Task AddFolderAsync() { var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео"); diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml index 0c66494..5a47ed0 100644 --- a/src/PLib.Desktop/Views/MainWindow.axaml +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:PLib.Desktop.Controls" xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:views="clr-namespace:PLib.Desktop.Views" xmlns:vm="clr-namespace:PLib.Desktop.ViewModels" x:Class="PLib.Desktop.Views.MainWindow" x:DataType="vm:MainWindowViewModel" @@ -15,6 +16,10 @@ + + + + + @@ -241,4 +245,4 @@ - + diff --git a/src/PLib.Desktop/Views/SettingsView.axaml.cs b/src/PLib.Desktop/Views/SettingsView.axaml.cs new file mode 100644 index 0000000..029db64 --- /dev/null +++ b/src/PLib.Desktop/Views/SettingsView.axaml.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls; + +namespace PLib.Desktop.Views; + +public sealed partial class SettingsView : UserControl +{ + public SettingsView() => InitializeComponent(); +} diff --git a/src/PLib.Desktop/Views/SettingsWindow.axaml.cs b/src/PLib.Desktop/Views/SettingsWindow.axaml.cs deleted file mode 100644 index 33890ed..0000000 --- a/src/PLib.Desktop/Views/SettingsWindow.axaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -using PLib.Desktop.ViewModels; -using ReactiveUI.Avalonia; - -namespace PLib.Desktop.Views; - -public sealed partial class SettingsWindow : ReactiveWindow -{ - public SettingsWindow() => InitializeComponent(); -} diff --git a/tests/PLib.Tests/Settings/SettingsViewModelTests.cs b/tests/PLib.Tests/Settings/SettingsViewModelTests.cs new file mode 100644 index 0000000..b28045e --- /dev/null +++ b/tests/PLib.Tests/Settings/SettingsViewModelTests.cs @@ -0,0 +1,107 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using PLib.Application.Library; +using PLib.Desktop.Services; +using PLib.Desktop.Settings; +using PLib.Desktop.ViewModels; +using Shouldly; + +namespace PLib.Tests.Settings; + +public sealed class SettingsViewModelTests +{ + private readonly IAppSettingsStore _store = Substitute.For(); + + [Fact] + public async Task Opening_and_saving_without_touching_anything_changes_nothing() + { + // Both of these are displayed in a lossier unit than they are stored in: 65 536 bytes + // shows as 0,06 MB. Converting the untouched field back used to yield 62 915, which + // silently rewrote the setting and forced a full rescan on every visit to the panel. + var options = new LibraryOptions + { + Folders = [@"C:\videos"], + MinimumFileSizeInBytes = 65_536, + ThumbnailPositionRatio = 0.15, + }; + + var (viewModel, outcome) = await SaveAsync(options); + + outcome.RescanRequired.ShouldBeFalse(); + Written.MinimumFileSizeInBytes.ShouldBe(65_536); + Written.ThumbnailPositionRatio.ShouldBe(0.15); + viewModel.HasFolders.ShouldBeTrue(); + } + + [Fact] + public async Task Editing_the_minimum_size_does_write_the_new_value() + { + var (_, outcome) = await SaveAsync( + new LibraryOptions { Folders = [@"C:\videos"], MinimumFileSizeInBytes = 65_536 }, + viewModel => viewModel.MinimumFileSizeMegabytes = 4); + + Written.MinimumFileSizeInBytes.ShouldBe(4 * 1024 * 1024); + outcome.RescanRequired.ShouldBeTrue(); + } + + [Fact] + public async Task Removing_a_folder_is_carried_into_the_saved_settings() + { + var (_, outcome) = await SaveAsync( + new LibraryOptions { Folders = [@"C:\a", @"C:\b"] }, + viewModel => viewModel.Folders[0].RemoveCommand.Execute().Subscribe()); + + Written.Folders.ShouldBe([@"C:\b"]); + outcome.RescanRequired.ShouldBeTrue(); + } + + [Fact] + public async Task Cancelling_writes_nothing() + { + var viewModel = Create(new LibraryOptions { Folders = [@"C:\videos"] }); + var closed = viewModel.Closed.FirstAsync().ToTask(); + + viewModel.MinimumFileSizeMegabytes = 999; + await viewModel.CancelCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken); + + (await closed).Saved.ShouldBeFalse(); + _store.ReceivedCalls().ShouldBeEmpty(); + } + + private AppSettings Written => + (AppSettings)_store.ReceivedCalls().Single(call => call.GetMethodInfo().Name == nameof(IAppSettingsStore.SaveAsync)) + .GetArguments()[0]!; + + private async Task<(SettingsViewModel ViewModel, SettingsDialogOutcome Outcome)> SaveAsync( + LibraryOptions options, + Action? edit = null) + { + var viewModel = Create(options); + var closed = viewModel.Closed.FirstAsync().ToTask(); + + edit?.Invoke(viewModel); + await viewModel.SaveCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken); + + return (viewModel, await closed); + } + + private SettingsViewModel Create(LibraryOptions options) => new( + Substitute.For(), + Monitor(options), + Monitor(new AppearanceOptions()), + _store, + Substitute.For(), + Substitute.For(), + NullLogger.Instance); + + private static IOptionsMonitor Monitor(T value) + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(value); + return monitor; + } +}