Refactor settings management in PLib video library manager. Replace dialog-based settings with a single-panel overlay for improved user experience. Update MainWindow and SettingsView to support new settings panel, and adjust ViewModels accordingly. Enhance README.md to reflect these changes.

This commit is contained in:
Leonid Pershin
2026-08-08 12:40:53 +03:00
parent 186dd900e1
commit 835937357e
12 changed files with 441 additions and 216 deletions
+7 -3
View File
@@ -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` и записывает его целиком
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
его не вызывает.
-1
View File
@@ -48,7 +48,6 @@ internal static class AppHost
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>();
@@ -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;
/// <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;
}
}
@@ -104,6 +104,58 @@
<Setter Property="FontSize" Value="12.5" />
</Style>
<!-- ======================== Settings overlay ======================== -->
<!--
The overlay stays in the tree and hides behind opacity rather than IsVisible: a collapsed
element has nothing to animate from, and the slide-in is the whole point. Nothing inside
is focusable while it is closed, because the panel's content is null until it opens.
-->
<Style Selector="Panel.settingsOverlay">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Panel.settingsOverlay.open">
<Setter Property="Opacity" Value="1" />
<Setter Property="IsHitTestVisible" Value="True" />
</Style>
<!-- Click-anywhere-to-dismiss backdrop. A Button gets the click handling for free. -->
<Style Selector="Button.scrim">
<Setter Property="Background" Value="{DynamicResource OverlayBrush}" />
<Setter Property="Focusable" Value="False" />
<Setter Property="Cursor" Value="Arrow" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}" />
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Border.drawer">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Width" Value="470" />
<Setter Property="Background" Value="{DynamicResource PageBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SurfaceBorderBrush}" />
<Setter Property="BorderThickness" Value="1,0,0,0" />
<Setter Property="RenderTransform" Value="translateX(30px)" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.2" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Panel.settingsOverlay.open Border.drawer">
<Setter Property="RenderTransform" Value="none" />
</Style>
<!-- ============================ Badge ============================ -->
<Style Selector="Border.badge">
@@ -35,7 +35,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IOptionsMonitor<LibraryOptions> _options;
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;
@@ -60,13 +59,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ReadOnlyObservableCollection<VideoCardViewModel> _videos;
private readonly ObservableAsPropertyHelper<bool> _isScanning;
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
public MainWindowViewModel(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> options,
IOptionsMonitor<AppearanceOptions> 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<RxVoid, RxVoid> OpenSettingsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> CloseSettingsCommand { get; }
/// <summary>
/// The settings panel while it is on screen, or <c>null</c>. Its presence is what the
/// overlay binds to — settings live in this window rather than a second one.
/// </summary>
[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<SettingsViewModel>();
// 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));
@@ -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("Выберите папку с видео");
+17
View File
@@ -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 @@
<Window.Resources>
<DataTemplate x:Key="SettingsTemplate" DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
<!-- ======================= Video card ======================= -->
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
@@ -234,5 +239,17 @@
</Grid>
</Border>
<!-- ======================= Settings panel ======================= -->
<Panel Grid.Row="0"
Grid.RowSpan="3"
Classes="settingsOverlay"
Classes.open="{Binding IsSettingsOpen}">
<Button Classes="scrim" Command="{Binding CloseSettingsCommand}" />
<Border Classes="drawer">
<ContentControl Content="{Binding SettingsPanel}"
ContentTemplate="{StaticResource SettingsTemplate}" />
</Border>
</Panel>
</Grid>
</Window>
+28 -2
View File
@@ -1,9 +1,35 @@
using ReactiveUI.Avalonia;
using Avalonia.Input;
using Avalonia.Interactivity;
using PLib.Desktop.ViewModels;
using ReactiveUI.Avalonia;
namespace PLib.Desktop.Views;
public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
{
public MainWindow() => InitializeComponent();
public MainWindow()
{
InitializeComponent();
// Handled here rather than as a KeyBinding: a KeyBinding only fires if the event
// bubbles all the way up untouched, and whichever control inside the settings panel
// happens to hold focus may well swallow Escape first. Both strategies are
// registered because a focused control can mark the event handled on the way down.
AddHandler(
KeyDownEvent,
OnPreviewKeyDown,
RoutingStrategies.Tunnel | RoutingStrategies.Bubble,
handledEventsToo: true);
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key is not Key.Escape || DataContext is not MainWindowViewModel { IsSettingsOpen: true } viewModel)
{
return;
}
viewModel.CloseSettingsCommand.Execute().Subscribe();
e.Handled = true;
}
}
+21 -17
View File
@@ -1,18 +1,11 @@
<Window xmlns="https://github.com/avaloniaui"
<UserControl 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">
x:Class="PLib.Desktop.Views.SettingsView"
x:DataType="vm:SettingsViewModel">
<Window.Styles>
<UserControl.Styles>
<!-- One settings block: a titled surface holding a few related controls. -->
<Style Selector="Border.section">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
@@ -33,7 +26,7 @@
<Setter Property="FontSize" Value="11.5" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</Window.Styles>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*,Auto">
@@ -43,8 +36,9 @@
BorderBrush="{DynamicResource SurfaceBorderBrush}"
BorderThickness="0,0,0,1"
Padding="20,14">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Width="32"
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
Width="32"
Height="32"
CornerRadius="9"
Background="{DynamicResource AccentSoftBrush}">
@@ -53,8 +47,18 @@
Height="18"
Foreground="{DynamicResource AccentBrush}" />
</Border>
<TextBlock Classes="sectionTitle" Text="Настройки" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="1"
Classes="sectionTitle"
Margin="10,0,0,0"
Text="Настройки"
VerticalAlignment="Center" />
<Button Grid.Column="2"
Command="{Binding CancelCommand}"
Padding="7"
ToolTip.Tip="Закрыть без сохранения">
<icons:MaterialIcon Kind="Close" Width="15" Height="15" />
</Button>
</Grid>
</Border>
<!-- ======================= Body ======================= -->
@@ -241,4 +245,4 @@
</Border>
</Grid>
</Window>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace PLib.Desktop.Views;
public sealed partial class SettingsView : UserControl
{
public SettingsView() => InitializeComponent();
}
@@ -1,9 +0,0 @@
using PLib.Desktop.ViewModels;
using ReactiveUI.Avalonia;
namespace PLib.Desktop.Views;
public sealed partial class SettingsWindow : ReactiveWindow<SettingsViewModel>
{
public SettingsWindow() => InitializeComponent();
}
@@ -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<IAppSettingsStore>();
[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<SettingsViewModel>? 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<IServiceScopeFactory>(),
Monitor(options),
Monitor(new AppearanceOptions()),
_store,
Substitute.For<IFolderPicker>(),
Substitute.For<IThemeService>(),
NullLogger<SettingsViewModel>.Instance);
private static IOptionsMonitor<T> Monitor<T>(T value)
{
var monitor = Substitute.For<IOptionsMonitor<T>>();
monitor.CurrentValue.Returns(value);
return monitor;
}
}