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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,173 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================ Card ============================ -->
|
||||
|
||||
<!--
|
||||
The card is a Button so that keyboard focus, Enter/Space and the pointer all activate it
|
||||
for free. Its template is reduced to a single surface Border because the Semi button
|
||||
chrome would fight the artwork.
|
||||
-->
|
||||
<Style Selector="Button.card">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<!-- Without these the card keeps its desired size inside the grid cell the layout
|
||||
hands it, which leaves the cards small and the grid full of gaps. -->
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="PART_Surface"
|
||||
Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="8,8,8,10">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.025)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pressed">
|
||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:focus-visible /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Play affordance: invisible until the pointer is over the card. -->
|
||||
<Style Selector="Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Text ============================ -->
|
||||
|
||||
<Style Selector="TextBlock.cardTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="MaxLines" Value="2" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.cardMeta">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="17" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.subtle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Badge ============================ -->
|
||||
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="Background" Value="{DynamicResource BadgeBackgroundBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge > TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource BadgeForegroundBrush}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================ Card ============================ -->
|
||||
|
||||
<!--
|
||||
The card is a Button so that keyboard focus, Enter/Space and the pointer all activate it
|
||||
for free. Its template is reduced to a single surface Border because the Semi button
|
||||
chrome would fight the artwork.
|
||||
-->
|
||||
<Style Selector="Button.card">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<!-- Without these the card keeps its desired size inside the grid cell the layout
|
||||
hands it, which leaves the cards small and the grid full of gaps. -->
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="PART_Surface"
|
||||
Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="8,8,8,10">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.025)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pressed">
|
||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:focus-visible /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Play affordance: invisible until the pointer is over the card. -->
|
||||
<Style Selector="Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Text ============================ -->
|
||||
|
||||
<Style Selector="TextBlock.cardTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="MaxLines" Value="2" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.cardMeta">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="17" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.subtle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<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">
|
||||
<Setter Property="Background" Value="{DynamicResource BadgeBackgroundBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge > TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource BadgeForegroundBrush}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
|
||||
@@ -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("Выберите папку с видео");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
using ReactiveUI.Avalonia;
|
||||
using PLib.Desktop.ViewModels;
|
||||
|
||||
namespace PLib.Desktop.Views;
|
||||
|
||||
public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
|
||||
{
|
||||
public MainWindow() => InitializeComponent();
|
||||
}
|
||||
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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<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">
|
||||
<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.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();
|
||||
}
|
||||
Reference in New Issue
Block a user