Enhance playback settings management in PLib video library manager. Introduce PlaybackOptions for volume and mute settings, integrating them into AppSettings and IAppSettingsStore. Update VideoPlayerViewModel to persist playback state and adjust UI bindings in VideoPlayerView for volume control. Revise README.md to document new playback settings functionality.

This commit is contained in:
Leonid Pershin
2026-08-09 06:46:33 +03:00
parent b13d0148df
commit a938a48de9
14 changed files with 399 additions and 251 deletions
+5 -1
View File
@@ -74,6 +74,9 @@ dotnet test
Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю
ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных —
равные по весу читались как два приложения, сшитых по шву.
- **Снимок настроек берётся из одного места.** Файл пишется целиком, поэтому собирать
`AppSettings` вручную — верный способ затереть секцию, о которой не подумал. Все, кто
пишет, начинают с `IAppSettingsStore.Current` и правят его через `with`.
- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
@@ -103,7 +106,8 @@ dotnet test
- `library.db` — SQLite с метаданными;
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
- `settings.json` список папок, перечитывается на лету;
- `settings.json` — папки, параметры превью и сканирования, тема, громкость;
перечитывается на лету;
- `logs/` — Serilog, ротация по дням.
Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
+4
View File
@@ -43,6 +43,10 @@ internal static class AppHost
builder.Services.AddOptions<AppearanceOptions>()
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
builder.Services.AddOptions<PlaybackOptions>()
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
.ValidateDataAnnotations();
builder.Services.AddPLibInfrastructure(builder.Configuration);
builder.Services.AddSingleton<ThumbnailCache>();
@@ -8,5 +8,12 @@ namespace PLib.Desktop.Services;
/// </summary>
public interface IAppSettingsStore
{
/// <summary>
/// Everything as it stands right now, read back through configuration. Callers are meant
/// to save <c>Current with { ... }</c>: the file is written whole, so building a snapshot
/// by hand is how a section nobody was thinking about gets wiped.
/// </summary>
AppSettings Current { get; }
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Options;
using PLib.Application.Library;
using PLib.Desktop.Settings;
using PLib.Infrastructure.Storage;
@@ -7,7 +8,11 @@ using PLib.Infrastructure.Storage;
namespace PLib.Desktop.Services;
/// <inheritdoc cref="IAppSettingsStore"/>
public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
public sealed class JsonAppSettingsStore(
IAppPaths paths,
IOptionsMonitor<LibraryOptions> library,
IOptionsMonitor<AppearanceOptions> appearance,
IOptionsMonitor<PlaybackOptions> playback) : IAppSettingsStore
{
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
@@ -15,6 +20,9 @@ public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
public AppSettings Current =>
AppSettings.From(library.CurrentValue, appearance.CurrentValue, playback.CurrentValue);
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken);
@@ -34,6 +42,10 @@ public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
var playbackSection = Section(root, PlaybackOptions.SectionName);
playbackSection["Volume"] = Math.Round(settings.Volume, 3);
playbackSection["IsMuted"] = settings.IsMuted;
// Write through a temp file so an interrupted save cannot corrupt the settings.
var staging = SettingsFile + ".tmp";
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
+10 -1
View File
@@ -23,7 +23,14 @@ public sealed record AppSettings
public required ThemeMode Theme { get; init; }
public static AppSettings From(LibraryOptions library, AppearanceOptions appearance) => new()
public required double Volume { get; init; }
public required bool IsMuted { get; init; }
public static AppSettings From(
LibraryOptions library,
AppearanceOptions appearance,
PlaybackOptions playback) => new()
{
Folders = [.. library.Folders],
ThumbnailWidth = library.ThumbnailWidth,
@@ -31,6 +38,8 @@ public sealed record AppSettings
MaxIndexingConcurrency = library.MaxIndexingConcurrency,
MinimumFileSizeInBytes = library.MinimumFileSizeInBytes,
Theme = appearance.Theme,
Volume = playback.Volume,
IsMuted = playback.IsMuted,
};
/// <summary>
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace PLib.Desktop.Settings;
/// <summary>Playback preferences that outlive a single media page.</summary>
public sealed class PlaybackOptions
{
public const string SectionName = "Playback";
/// <summary>Volume as a fraction of full scale.</summary>
[Range(0.0, 1.0)]
public double Volume { get; init; } = 0.8;
public bool IsMuted { get; init; }
}
@@ -34,7 +34,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<LibraryOptions> _options;
private readonly IAppSettingsStore _settingsStore;
private readonly IOptionsMonitor<AppearanceOptions> _appearance;
private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell;
@@ -66,7 +65,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> options,
IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
ISystemShell shell,
@@ -75,7 +73,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{
_scopeFactory = scopeFactory;
_options = options;
_appearance = appearance;
_settingsStore = settingsStore;
_theme = theme;
_folderPicker = folderPicker;
@@ -243,7 +240,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenVideo(VideoCardViewModel card)
{
OpenedVideo?.Dispose();
OpenedVideo = new VideoPlayerViewModel(card, _shell, () => OpenedVideo = null);
OpenedVideo = new VideoPlayerViewModel(card, _shell, _settingsStore, _logger, () => OpenedVideo = null);
}
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
@@ -424,13 +421,11 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private async Task ToggleThemeAsync()
{
var mode = _theme.Toggle();
await _settingsStore.SaveAsync(CurrentSettings with { Theme = mode });
await _settingsStore.SaveAsync(_settingsStore.Current with { Theme = mode });
}
private AppSettings CurrentSettings => AppSettings.From(_options.CurrentValue, _appearance.CurrentValue);
private Task SaveFoldersAsync(IReadOnlyList<string> folders) =>
_settingsStore.SaveAsync(CurrentSettings with { Folders = folders });
_settingsStore.SaveAsync(_settingsStore.Current with { Folders = folders });
/// <summary>
/// A saved settings file is not visible through <c>IOptionsMonitor</c> straight away —
@@ -4,7 +4,6 @@ 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;
@@ -38,8 +37,6 @@ public sealed partial class SettingsViewModel : ViewModelBase
public SettingsViewModel(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> library,
IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
IThemeService theme,
@@ -51,9 +48,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
_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);
// Read on construction rather than cached anywhere: the panel can be reopened after
// a save, and a stale snapshot would show the settings the application started with.
_original = settingsStore.Current;
Folders = [.. _original.Folders.Select(CreateEntry)];
ThumbnailWidth = _original.ThumbnailWidth;
@@ -123,7 +120,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
[Reactive]
public partial string? Message { get; set; }
private AppSettings CurrentDraft => new()
// Built from the snapshot the panel opened with, not from scratch: anything this screen
// does not edit — playback volume, for one — has to survive being saved from here.
private AppSettings CurrentDraft => _original with
{
Folders = [.. Folders.Select(entry => entry.Path)],
ThumbnailWidth = ThumbnailWidth,
@@ -1,3 +1,6 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using Microsoft.Extensions.Logging;
using PLib.Desktop.Services;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
@@ -6,14 +9,31 @@ using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// The media page: one video, opened from the grid. It only carries identity and the few
/// commands around the player — transport state belongs to the media control itself, which
/// already exposes position, duration and playback as bindable properties.
/// The media page: one video, opened from the grid. Most of the transport lives on the
/// player control itself; what the page owns is the video's identity, the commands around
/// it, and the settings that have to outlive the page.
/// </summary>
public sealed partial class VideoPlayerViewModel : ViewModelBase
{
public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
/// <summary>
/// How long the volume has to sit still before it is written. Dragging the slider
/// produces a value per pixel, and each one would otherwise be a file write.
/// </summary>
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
private readonly IAppSettingsStore _settingsStore;
private readonly ILogger _logger;
public VideoPlayerViewModel(
VideoCardViewModel card,
ISystemShell shell,
IAppSettingsStore settingsStore,
ILogger logger,
Action close)
{
_settingsStore = settingsStore;
_logger = logger;
Title = card.Title;
FullPath = card.FullPath;
Source = new Uri(card.FullPath);
@@ -23,17 +43,32 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
new[] { card.QualityText, card.DurationText, card.SizeText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
var settings = settingsStore.Current;
Volume = settings.Volume;
IsMuted = settings.IsMuted;
CloseCommand = ReactiveCommand.Create(close);
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
// Skip the values we just restored: they are already what is on disk.
.Skip(1)
.Throttle(SaveDebounce, TaskPoolScheduler.Default)
.DistinctUntilChanged()
.Subscribe(state => Persist(state.volume, state.muted))
.AddTo(Subscriptions);
ObserveCommandFailures();
}
public string Title { get; }
public string FullPath { get; }
/// <summary>What the media control plays; a <c>file://</c> URI built from the path.</summary>
/// <summary>What the player plays; a <c>file://</c> URI built from the path.</summary>
public Uri Source { get; }
/// <summary>Quality, duration and size on one line, for the page header.</summary>
@@ -43,15 +78,49 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
/// <summary>
/// True while the window is given over to the video. The page hides its own header and
/// the window hides its chrome; the transport strip stays, because a native video
/// surface cannot be drawn over and a floating overlay is therefore impossible.
/// </summary>
[Reactive]
public partial bool IsFullScreen { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
/// <summary>
/// True while the window is given over to the video. The page hides its own header and
/// the window hides its chrome.
/// </summary>
[Reactive]
public partial bool IsFullScreen { get; set; }
/// <summary>Volume as a fraction; restored on open and remembered across restarts.</summary>
[Reactive]
public partial double Volume { get; set; }
[Reactive]
public partial bool IsMuted { get; set; }
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
private async Task PersistAsync(double volume, bool isMuted)
{
try
{
await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted });
}
catch (Exception ex)
{
// Losing a volume level is not worth interrupting playback over.
_logger.LogWarning(ex, "Could not save the playback volume");
}
}
private void ObserveCommandFailures() =>
Observable
.Merge(
CloseCommand.ThrownExceptions,
ToggleFullScreenCommand.ThrownExceptions,
ToggleMuteCommand.ThrownExceptions,
OpenExternallyCommand.ThrownExceptions,
RevealCommand.ThrownExceptions)
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
.AddTo(Subscriptions);
}
+4 -4
View File
@@ -65,7 +65,8 @@
<controls:VlcVideoView Name="Player"
Source="{Binding Source}"
AutoPlay="True"
Volume="0.8" />
Volume="{Binding Volume}"
IsMuted="{Binding IsMuted}" />
<Border Name="ErrorBar"
HorizontalAlignment="Center"
@@ -100,16 +101,15 @@
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
<Button Grid.Column="4" Name="MuteButton" Classes="transport">
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
</Button>
<Slider Grid.Column="5"
Name="VolumeSlider"
Width="90"
Minimum="0"
Maximum="1"
Value="0.8"
Value="{Binding Volume}"
VerticalAlignment="Center" />
<Button Grid.Column="6"
@@ -1,6 +1,5 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using System.Reactive.Disposables;
using Avalonia.Interactivity;
@@ -35,14 +34,11 @@ public sealed partial class VideoPlayerView : UserControl
InitializeComponent();
PlayPauseButton.Click += OnPlayPause;
MuteButton.Click += OnToggleMute;
// Tunnelled: the Slider's own handlers mark these as handled on the way back up.
Seek.AddHandler(PointerPressedEvent, OnScrubStarted, RoutingStrategies.Tunnel);
Seek.AddHandler(PointerReleasedEvent, OnScrubFinished, RoutingStrategies.Tunnel);
VolumeSlider.PropertyChanged += OnVolumeChanged;
// Handled on the surrounding panel rather than the video itself: the native window
// is not part of Avalonia's hit-test tree, so the gesture can only arrive here.
VideoArea.DoubleTapped += OnVideoDoubleTapped;
@@ -63,6 +59,12 @@ public sealed partial class VideoPlayerView : UserControl
_subscriptions.Add(viewModel
.WhenAnyValue(x => x.IsFullScreen)
.Subscribe(ApplyFullScreen));
// The icon is the one bit of volume state that is neither a bound value nor a
// player property, so it is driven from here rather than through a converter.
_subscriptions.Add(viewModel
.WhenAnyValue(x => x.Volume, x => x.IsMuted, VolumeIconFor)
.Subscribe(kind => MuteIcon.Kind = kind));
}
}
@@ -116,8 +118,6 @@ public sealed partial class VideoPlayerView : UserControl
}
}
private void OnToggleMute(object? sender, RoutedEventArgs e) => Player.IsMuted = !Player.IsMuted;
private void OnVideoDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is VideoPlayerViewModel viewModel)
@@ -135,17 +135,6 @@ public sealed partial class VideoPlayerView : UserControl
Player.Seek(TimeSpan.FromSeconds(Seek.Value));
}
private void OnVolumeChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property != RangeBase.ValueProperty)
{
return;
}
Player.Volume = VolumeSlider.Value;
MuteIcon.Kind = VolumeIconFor(VolumeSlider.Value, Player.IsMuted);
}
private void OnPositionChanged(TimeSpan position)
{
PositionText.Text = DisplayText.Duration(position);
+4
View File
@@ -8,5 +8,9 @@
},
"Appearance": {
"Theme": "Dark"
},
"Playback": {
"Volume": 0.8,
"IsMuted": false
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Library;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
@@ -28,11 +30,13 @@ public sealed class AppSettingsStoreTests : IDisposable
MaxIndexingConcurrency = 8,
MinimumFileSizeInBytes = 2_097_152,
Theme = ThemeMode.Light,
Volume = 0.35,
IsMuted = true,
};
await new JsonAppSettingsStore(_paths).SaveAsync(settings, Token);
await CreateStore().SaveAsync(settings, Token);
var (library, appearance) = Reload();
var (library, appearance, playback) = Reload();
library.Folders.ShouldBe(settings.Folders);
library.ThumbnailWidth.ShouldBe(640);
@@ -40,12 +44,14 @@ public sealed class AppSettingsStoreTests : IDisposable
library.MaxIndexingConcurrency.ShouldBe(8);
library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
appearance.Theme.ShouldBe(ThemeMode.Light);
playback.Volume.ShouldBe(0.35);
playback.IsMuted.ShouldBeTrue();
}
[Fact]
public async Task Removing_a_folder_actually_shortens_the_stored_list()
{
var store = new JsonAppSettingsStore(_paths);
var store = CreateStore();
var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
await store.SaveAsync(settings, Token);
@@ -64,7 +70,7 @@ public sealed class AppSettingsStoreTests : IDisposable
"""{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
Token);
await new JsonAppSettingsStore(_paths).SaveAsync(Sample, Token);
await CreateStore().SaveAsync(Sample, Token);
var configuration = Build();
configuration["Experimental:Flag"].ShouldBe("True");
@@ -93,13 +99,32 @@ public sealed class AppSettingsStoreTests : IDisposable
MaxIndexingConcurrency = 4,
MinimumFileSizeInBytes = 65_536,
Theme = ThemeMode.System,
Volume = 0.8,
IsMuted = false,
};
/// <summary>
/// The store composes <see cref="AppSettings.Current"/> from configuration, which these
/// tests do not exercise — every case here supplies the snapshot it wants to write.
/// </summary>
private JsonAppSettingsStore CreateStore() => new(
_paths,
Monitor(new LibraryOptions()),
Monitor(new AppearanceOptions()),
Monitor(new PlaybackOptions()));
private static IOptionsMonitor<T> Monitor<T>(T value)
{
var monitor = Substitute.For<IOptionsMonitor<T>>();
monitor.CurrentValue.Returns(value);
return monitor;
}
private IConfigurationRoot Build() => new ConfigurationBuilder()
.AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
.Build();
private (LibraryOptions Library, AppearanceOptions Appearance) Reload()
private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
{
var configuration = Build();
@@ -109,7 +134,10 @@ public sealed class AppSettingsStoreTests : IDisposable
var appearance = new AppearanceOptions();
configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
return (library, appearance);
var playback = new PlaybackOptions();
configuration.GetSection(PlaybackOptions.SectionName).Bind(playback);
return (library, appearance, playback);
}
private sealed class TempPaths : IAppPaths, IDisposable
@@ -2,7 +2,6 @@ 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;
@@ -59,6 +58,21 @@ public sealed class SettingsViewModelTests
outcome.RescanRequired.ShouldBeTrue();
}
[Fact]
public async Task Saving_the_panel_keeps_the_settings_it_does_not_edit()
{
// The panel writes the whole file, so anything outside its own screens — the
// playback volume, for one — has to be carried through untouched.
await SaveAsync(
new LibraryOptions { Folders = [@"C: ideos"] },
viewModel => viewModel.ThumbnailWidth = 720,
new PlaybackOptions { Volume = 0.42, IsMuted = true });
Written.ThumbnailWidth.ShouldBe(720);
Written.Volume.ShouldBe(0.42);
Written.IsMuted.ShouldBeTrue();
}
[Fact]
public async Task Cancelling_writes_nothing()
{
@@ -69,18 +83,20 @@ public sealed class SettingsViewModelTests
await viewModel.CancelCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken);
(await closed).Saved.ShouldBeFalse();
_store.ReceivedCalls().ShouldBeEmpty();
_store.ReceivedCalls().ShouldNotContain(call => call.GetMethodInfo().Name == nameof(IAppSettingsStore.SaveAsync));
}
private AppSettings Written =>
(AppSettings)_store.ReceivedCalls().Single(call => call.GetMethodInfo().Name == nameof(IAppSettingsStore.SaveAsync))
(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)
Action<SettingsViewModel>? edit = null,
PlaybackOptions? playback = null)
{
var viewModel = Create(options);
var viewModel = Create(options, playback);
var closed = viewModel.Closed.FirstAsync().ToTask();
edit?.Invoke(viewModel);
@@ -89,19 +105,16 @@ public sealed class SettingsViewModelTests
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)
private SettingsViewModel Create(LibraryOptions options, PlaybackOptions? playback = null)
{
var monitor = Substitute.For<IOptionsMonitor<T>>();
monitor.CurrentValue.Returns(value);
return monitor;
_store.Current.Returns(
AppSettings.From(options, new AppearanceOptions(), playback ?? new PlaybackOptions()));
return new SettingsViewModel(
Substitute.For<IServiceScopeFactory>(),
_store,
Substitute.For<IFolderPicker>(),
Substitute.For<IThemeService>(),
NullLogger<SettingsViewModel>.Instance);
}
}