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` и записывает его целиком - **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
@@ -103,7 +106,8 @@ dotnet test
- `library.db` — SQLite с метаданными; - `library.db` — SQLite с метаданными;
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); - `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
- `settings.json` список папок, перечитывается на лету; - `settings.json` — папки, параметры превью и сканирования, тема, громкость;
перечитывается на лету;
- `logs/` — Serilog, ротация по дням. - `logs/` — Serilog, ротация по дням.
Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
+4
View File
@@ -43,6 +43,10 @@ internal static class AppHost
builder.Services.AddOptions<AppearanceOptions>() builder.Services.AddOptions<AppearanceOptions>()
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName)); .Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
builder.Services.AddOptions<PlaybackOptions>()
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
.ValidateDataAnnotations();
builder.Services.AddPLibInfrastructure(builder.Configuration); builder.Services.AddPLibInfrastructure(builder.Configuration);
builder.Services.AddSingleton<ThumbnailCache>(); builder.Services.AddSingleton<ThumbnailCache>();
+19 -12
View File
@@ -1,12 +1,19 @@
using PLib.Desktop.Settings; using PLib.Desktop.Settings;
namespace PLib.Desktop.Services; namespace PLib.Desktop.Services;
/// <summary> /// <summary>
/// Persists the settings the user can change at runtime. The file it writes is also a /// Persists the settings the user can change at runtime. The file it writes is also a
/// configuration source, so <c>IOptionsMonitor</c> picks changes up without a restart. /// configuration source, so <c>IOptionsMonitor</c> picks changes up without a restart.
/// </summary> /// </summary>
public interface IAppSettingsStore public interface IAppSettingsStore
{ {
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default); /// <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,78 +1,90 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PLib.Application.Library; using Microsoft.Extensions.Options;
using PLib.Desktop.Settings; using PLib.Application.Library;
using PLib.Infrastructure.Storage; using PLib.Desktop.Settings;
using PLib.Infrastructure.Storage;
namespace PLib.Desktop.Services;
namespace PLib.Desktop.Services;
/// <inheritdoc cref="IAppSettingsStore"/>
public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore /// <inheritdoc cref="IAppSettingsStore"/>
{ public sealed class JsonAppSettingsStore(
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; IAppPaths paths,
IOptionsMonitor<LibraryOptions> library,
private readonly SemaphoreSlim _writeLock = new(1, 1); IOptionsMonitor<AppearanceOptions> appearance,
IOptionsMonitor<PlaybackOptions> playback) : IAppSettingsStore
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json"); {
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{ private readonly SemaphoreSlim _writeLock = new(1, 1);
await _writeLock.WaitAsync(cancellationToken);
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
try
{ public AppSettings Current =>
// Merge into whatever is already there: the file is hand-editable and may hold AppSettings.From(library.CurrentValue, appearance.CurrentValue, playback.CurrentValue);
// keys this version of the settings screen knows nothing about.
var root = await ReadRootAsync(cancellationToken); public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{
var library = Section(root, LibraryOptions.SectionName); await _writeLock.WaitAsync(cancellationToken);
library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]);
library["ThumbnailWidth"] = settings.ThumbnailWidth; try
library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio; {
library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency; // Merge into whatever is already there: the file is hand-editable and may hold
library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes; // keys this version of the settings screen knows nothing about.
var root = await ReadRootAsync(cancellationToken);
Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
var library = Section(root, LibraryOptions.SectionName);
// Write through a temp file so an interrupted save cannot corrupt the settings. library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]);
var staging = SettingsFile + ".tmp"; library["ThumbnailWidth"] = settings.ThumbnailWidth;
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken); library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio;
File.Move(staging, SettingsFile, overwrite: true); library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency;
} library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes;
finally
{ Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
_writeLock.Release();
} var playbackSection = Section(root, PlaybackOptions.SectionName);
} playbackSection["Volume"] = Math.Round(settings.Volume, 3);
playbackSection["IsMuted"] = settings.IsMuted;
private static JsonObject Section(JsonObject root, string name)
{ // Write through a temp file so an interrupted save cannot corrupt the settings.
if (root[name] is JsonObject existing) var staging = SettingsFile + ".tmp";
{ await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
return existing; File.Move(staging, SettingsFile, overwrite: true);
} }
finally
var created = new JsonObject(); {
root[name] = created; _writeLock.Release();
return created; }
} }
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken) private static JsonObject Section(JsonObject root, string name)
{ {
if (!File.Exists(SettingsFile)) if (root[name] is JsonObject existing)
{ {
return []; return existing;
} }
try var created = new JsonObject();
{ root[name] = created;
var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken); return created;
return JsonNode.Parse(json) as JsonObject ?? []; }
}
catch (JsonException) private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
{ {
// A hand-edited, broken settings file should not stop the app from saving. if (!File.Exists(SettingsFile))
return []; {
} return [];
} }
}
try
{
var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken);
return JsonNode.Parse(json) as JsonObject ?? [];
}
catch (JsonException)
{
// A hand-edited, broken settings file should not stop the app from saving.
return [];
}
}
}
+52 -43
View File
@@ -1,43 +1,52 @@
using PLib.Application.Library; using PLib.Application.Library;
namespace PLib.Desktop.Settings; namespace PLib.Desktop.Settings;
/// <summary> /// <summary>
/// The subset of configuration the user can change at runtime, as one snapshot. /// The subset of configuration the user can change at runtime, as one snapshot.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Everything is written in a single pass rather than key by key: a settings file that is /// 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. /// only ever replaced whole cannot end up in a state that never existed in the UI.
/// </remarks> /// </remarks>
public sealed record AppSettings public sealed record AppSettings
{ {
public required IReadOnlyList<string> Folders { get; init; } public required IReadOnlyList<string> Folders { get; init; }
public required int ThumbnailWidth { get; init; } public required int ThumbnailWidth { get; init; }
public required double ThumbnailPositionRatio { get; init; } public required double ThumbnailPositionRatio { get; init; }
public required int MaxIndexingConcurrency { get; init; } public required int MaxIndexingConcurrency { get; init; }
public required long MinimumFileSizeInBytes { get; init; } public required long MinimumFileSizeInBytes { get; init; }
public required ThemeMode Theme { get; init; } public required ThemeMode Theme { get; init; }
public static AppSettings From(LibraryOptions library, AppearanceOptions appearance) => new() public required double Volume { get; init; }
{
Folders = [.. library.Folders], public required bool IsMuted { get; init; }
ThumbnailWidth = library.ThumbnailWidth,
ThumbnailPositionRatio = library.ThumbnailPositionRatio, public static AppSettings From(
MaxIndexingConcurrency = library.MaxIndexingConcurrency, LibraryOptions library,
MinimumFileSizeInBytes = library.MinimumFileSizeInBytes, AppearanceOptions appearance,
Theme = appearance.Theme, PlaybackOptions playback) => new()
}; {
Folders = [.. library.Folders],
/// <summary> ThumbnailWidth = library.ThumbnailWidth,
/// True when the difference between the two snapshots means the library has to be ThumbnailPositionRatio = library.ThumbnailPositionRatio,
/// walked again. Cosmetic changes must not trigger a rescan. MaxIndexingConcurrency = library.MaxIndexingConcurrency,
/// </summary> MinimumFileSizeInBytes = library.MinimumFileSizeInBytes,
public bool RequiresRescanComparedTo(AppSettings other) => Theme = appearance.Theme,
!Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) || Volume = playback.Volume,
MinimumFileSizeInBytes != other.MinimumFileSizeInBytes; IsMuted = playback.IsMuted,
} };
/// <summary>
/// True when the difference between the two snapshots means the library has to be
/// walked again. Cosmetic changes must not trigger a rescan.
/// </summary>
public bool RequiresRescanComparedTo(AppSettings other) =>
!Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) ||
MinimumFileSizeInBytes != other.MinimumFileSizeInBytes;
}
@@ -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 IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<LibraryOptions> _options; private readonly IOptionsMonitor<LibraryOptions> _options;
private readonly IAppSettingsStore _settingsStore; private readonly IAppSettingsStore _settingsStore;
private readonly IOptionsMonitor<AppearanceOptions> _appearance;
private readonly IThemeService _theme; private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker; private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell; private readonly ISystemShell _shell;
@@ -66,7 +65,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel( public MainWindowViewModel(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> options, IOptionsMonitor<LibraryOptions> options,
IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore, IAppSettingsStore settingsStore,
IFolderPicker folderPicker, IFolderPicker folderPicker,
ISystemShell shell, ISystemShell shell,
@@ -75,7 +73,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_options = options; _options = options;
_appearance = appearance;
_settingsStore = settingsStore; _settingsStore = settingsStore;
_theme = theme; _theme = theme;
_folderPicker = folderPicker; _folderPicker = folderPicker;
@@ -243,7 +240,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenVideo(VideoCardViewModel card) private void OpenVideo(VideoCardViewModel card)
{ {
OpenedVideo?.Dispose(); 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) private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
@@ -424,13 +421,11 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private async Task ToggleThemeAsync() private async Task ToggleThemeAsync()
{ {
var mode = _theme.Toggle(); 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) => private Task SaveFoldersAsync(IReadOnlyList<string> folders) =>
_settingsStore.SaveAsync(CurrentSettings with { Folders = folders }); _settingsStore.SaveAsync(_settingsStore.Current with { Folders = folders });
/// <summary> /// <summary>
/// A saved settings file is not visible through <c>IOptionsMonitor</c> straight away — /// 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 System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PLib.Application.Library; using PLib.Application.Library;
using PLib.Desktop.Services; using PLib.Desktop.Services;
using PLib.Desktop.Settings; using PLib.Desktop.Settings;
@@ -38,8 +37,6 @@ public sealed partial class SettingsViewModel : ViewModelBase
public SettingsViewModel( public SettingsViewModel(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> library,
IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore, IAppSettingsStore settingsStore,
IFolderPicker folderPicker, IFolderPicker folderPicker,
IThemeService theme, IThemeService theme,
@@ -51,9 +48,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
_theme = theme; _theme = theme;
_logger = logger; _logger = logger;
// CurrentValue, not IOptions.Value: the dialog can be reopened after a save, and a // Read on construction rather than cached anywhere: the panel can be reopened after
// cached snapshot would show the settings the application started with. // a save, and a stale snapshot would show the settings the application started with.
_original = AppSettings.From(library.CurrentValue, appearance.CurrentValue); _original = settingsStore.Current;
Folders = [.. _original.Folders.Select(CreateEntry)]; Folders = [.. _original.Folders.Select(CreateEntry)];
ThumbnailWidth = _original.ThumbnailWidth; ThumbnailWidth = _original.ThumbnailWidth;
@@ -123,7 +120,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
[Reactive] [Reactive]
public partial string? Message { get; set; } 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)], Folders = [.. Folders.Select(entry => entry.Path)],
ThumbnailWidth = ThumbnailWidth, ThumbnailWidth = ThumbnailWidth,
@@ -1,57 +1,126 @@
using PLib.Desktop.Services; using System.Reactive.Concurrency;
using ReactiveUI; using System.Reactive.Linq;
using ReactiveUI.SourceGenerators; using Microsoft.Extensions.Logging;
using RxVoid = ReactiveUI.Primitives.RxVoid; using PLib.Desktop.Services;
using ReactiveUI;
namespace PLib.Desktop.ViewModels; using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
/// <summary>
/// The media page: one video, opened from the grid. It only carries identity and the few namespace PLib.Desktop.ViewModels;
/// commands around the player — transport state belongs to the media control itself, which
/// already exposes position, duration and playback as bindable properties. /// <summary>
/// </summary> /// The media page: one video, opened from the grid. Most of the transport lives on the
public sealed partial class VideoPlayerViewModel : ViewModelBase /// 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.
public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close) /// </summary>
{ public sealed partial class VideoPlayerViewModel : ViewModelBase
Title = card.Title; {
FullPath = card.FullPath; /// <summary>
Source = new Uri(card.FullPath); /// 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.
Subtitle = string.Join( /// </summary>
" · ", private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
new[] { card.QualityText, card.DurationText, card.SizeText }
.Where(part => !string.IsNullOrWhiteSpace(part))); private readonly IAppSettingsStore _settingsStore;
private readonly ILogger _logger;
CloseCommand = ReactiveCommand.Create(close);
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; }); public VideoPlayerViewModel(
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); VideoCardViewModel card,
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); ISystemShell shell,
} IAppSettingsStore settingsStore,
ILogger logger,
public string Title { get; } Action close)
{
public string FullPath { get; } _settingsStore = settingsStore;
_logger = logger;
/// <summary>What the media control plays; a <c>file://</c> URI built from the path.</summary>
public Uri Source { get; } Title = card.Title;
FullPath = card.FullPath;
/// <summary>Quality, duration and size on one line, for the page header.</summary> Source = new Uri(card.FullPath);
public string Subtitle { get; }
Subtitle = string.Join(
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; } " · ",
new[] { card.QualityText, card.DurationText, card.SizeText }
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; } .Where(part => !string.IsNullOrWhiteSpace(part)));
/// <summary> var settings = settingsStore.Current;
/// True while the window is given over to the video. The page hides its own header and Volume = settings.Volume;
/// the window hides its chrome; the transport strip stays, because a native video IsMuted = settings.IsMuted;
/// surface cannot be drawn over and a floating overlay is therefore impossible.
/// </summary> CloseCommand = ReactiveCommand.Create(close);
[Reactive] ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
public partial bool IsFullScreen { get; set; } ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; } RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; } 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 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>
public string Subtitle { get; }
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
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" <controls:VlcVideoView Name="Player"
Source="{Binding Source}" Source="{Binding Source}"
AutoPlay="True" AutoPlay="True"
Volume="0.8" /> Volume="{Binding Volume}"
IsMuted="{Binding IsMuted}" />
<Border Name="ErrorBar" <Border Name="ErrorBar"
HorizontalAlignment="Center" HorizontalAlignment="Center"
@@ -100,16 +101,15 @@
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" /> <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" /> <icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
</Button> </Button>
<Slider Grid.Column="5" <Slider Grid.Column="5"
Name="VolumeSlider"
Width="90" Width="90"
Minimum="0" Minimum="0"
Maximum="1" Maximum="1"
Value="0.8" Value="{Binding Volume}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<Button Grid.Column="6" <Button Grid.Column="6"
@@ -1,6 +1,5 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input; using Avalonia.Input;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@@ -35,14 +34,11 @@ public sealed partial class VideoPlayerView : UserControl
InitializeComponent(); InitializeComponent();
PlayPauseButton.Click += OnPlayPause; PlayPauseButton.Click += OnPlayPause;
MuteButton.Click += OnToggleMute;
// Tunnelled: the Slider's own handlers mark these as handled on the way back up. // Tunnelled: the Slider's own handlers mark these as handled on the way back up.
Seek.AddHandler(PointerPressedEvent, OnScrubStarted, RoutingStrategies.Tunnel); Seek.AddHandler(PointerPressedEvent, OnScrubStarted, RoutingStrategies.Tunnel);
Seek.AddHandler(PointerReleasedEvent, OnScrubFinished, RoutingStrategies.Tunnel); Seek.AddHandler(PointerReleasedEvent, OnScrubFinished, RoutingStrategies.Tunnel);
VolumeSlider.PropertyChanged += OnVolumeChanged;
// Handled on the surrounding panel rather than the video itself: the native window // 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. // is not part of Avalonia's hit-test tree, so the gesture can only arrive here.
VideoArea.DoubleTapped += OnVideoDoubleTapped; VideoArea.DoubleTapped += OnVideoDoubleTapped;
@@ -63,6 +59,12 @@ public sealed partial class VideoPlayerView : UserControl
_subscriptions.Add(viewModel _subscriptions.Add(viewModel
.WhenAnyValue(x => x.IsFullScreen) .WhenAnyValue(x => x.IsFullScreen)
.Subscribe(ApplyFullScreen)); .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) private void OnVideoDoubleTapped(object? sender, TappedEventArgs e)
{ {
if (DataContext is VideoPlayerViewModel viewModel) if (DataContext is VideoPlayerViewModel viewModel)
@@ -135,17 +135,6 @@ public sealed partial class VideoPlayerView : UserControl
Player.Seek(TimeSpan.FromSeconds(Seek.Value)); 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) private void OnPositionChanged(TimeSpan position)
{ {
PositionText.Text = DisplayText.Duration(position); PositionText.Text = DisplayText.Duration(position);
+4
View File
@@ -8,5 +8,9 @@
}, },
"Appearance": { "Appearance": {
"Theme": "Dark" "Theme": "Dark"
},
"Playback": {
"Volume": 0.8,
"IsMuted": false
} }
} }
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Library; using PLib.Application.Library;
using PLib.Desktop.Services; using PLib.Desktop.Services;
using PLib.Desktop.Settings; using PLib.Desktop.Settings;
@@ -28,11 +30,13 @@ public sealed class AppSettingsStoreTests : IDisposable
MaxIndexingConcurrency = 8, MaxIndexingConcurrency = 8,
MinimumFileSizeInBytes = 2_097_152, MinimumFileSizeInBytes = 2_097_152,
Theme = ThemeMode.Light, 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.Folders.ShouldBe(settings.Folders);
library.ThumbnailWidth.ShouldBe(640); library.ThumbnailWidth.ShouldBe(640);
@@ -40,12 +44,14 @@ public sealed class AppSettingsStoreTests : IDisposable
library.MaxIndexingConcurrency.ShouldBe(8); library.MaxIndexingConcurrency.ShouldBe(8);
library.MinimumFileSizeInBytes.ShouldBe(2_097_152); library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
appearance.Theme.ShouldBe(ThemeMode.Light); appearance.Theme.ShouldBe(ThemeMode.Light);
playback.Volume.ShouldBe(0.35);
playback.IsMuted.ShouldBeTrue();
} }
[Fact] [Fact]
public async Task Removing_a_folder_actually_shortens_the_stored_list() 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"] }; var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
await store.SaveAsync(settings, Token); await store.SaveAsync(settings, Token);
@@ -64,7 +70,7 @@ public sealed class AppSettingsStoreTests : IDisposable
"""{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""", """{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
Token); Token);
await new JsonAppSettingsStore(_paths).SaveAsync(Sample, Token); await CreateStore().SaveAsync(Sample, Token);
var configuration = Build(); var configuration = Build();
configuration["Experimental:Flag"].ShouldBe("True"); configuration["Experimental:Flag"].ShouldBe("True");
@@ -93,13 +99,32 @@ public sealed class AppSettingsStoreTests : IDisposable
MaxIndexingConcurrency = 4, MaxIndexingConcurrency = 4,
MinimumFileSizeInBytes = 65_536, MinimumFileSizeInBytes = 65_536,
Theme = ThemeMode.System, 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() private IConfigurationRoot Build() => new ConfigurationBuilder()
.AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false) .AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
.Build(); .Build();
private (LibraryOptions Library, AppearanceOptions Appearance) Reload() private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
{ {
var configuration = Build(); var configuration = Build();
@@ -109,7 +134,10 @@ public sealed class AppSettingsStoreTests : IDisposable
var appearance = new AppearanceOptions(); var appearance = new AppearanceOptions();
configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance); 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 private sealed class TempPaths : IAppPaths, IDisposable
@@ -2,7 +2,6 @@ using System.Reactive.Linq;
using System.Reactive.Threading.Tasks; using System.Reactive.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute; using NSubstitute;
using PLib.Application.Library; using PLib.Application.Library;
using PLib.Desktop.Services; using PLib.Desktop.Services;
@@ -59,6 +58,21 @@ public sealed class SettingsViewModelTests
outcome.RescanRequired.ShouldBeTrue(); 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] [Fact]
public async Task Cancelling_writes_nothing() public async Task Cancelling_writes_nothing()
{ {
@@ -69,18 +83,20 @@ public sealed class SettingsViewModelTests
await viewModel.CancelCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken); await viewModel.CancelCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken);
(await closed).Saved.ShouldBeFalse(); (await closed).Saved.ShouldBeFalse();
_store.ReceivedCalls().ShouldBeEmpty(); _store.ReceivedCalls().ShouldNotContain(call => call.GetMethodInfo().Name == nameof(IAppSettingsStore.SaveAsync));
} }
private AppSettings Written => 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]!; .GetArguments()[0]!;
private async Task<(SettingsViewModel ViewModel, SettingsDialogOutcome Outcome)> SaveAsync( private async Task<(SettingsViewModel ViewModel, SettingsDialogOutcome Outcome)> SaveAsync(
LibraryOptions options, 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(); var closed = viewModel.Closed.FirstAsync().ToTask();
edit?.Invoke(viewModel); edit?.Invoke(viewModel);
@@ -89,19 +105,16 @@ public sealed class SettingsViewModelTests
return (viewModel, await closed); return (viewModel, await closed);
} }
private SettingsViewModel Create(LibraryOptions options) => new( private SettingsViewModel Create(LibraryOptions options, PlaybackOptions? playback = null)
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>>(); _store.Current.Returns(
monitor.CurrentValue.Returns(value); AppSettings.From(options, new AppearanceOptions(), playback ?? new PlaybackOptions()));
return monitor;
return new SettingsViewModel(
Substitute.For<IServiceScopeFactory>(),
_store,
Substitute.For<IFolderPicker>(),
Substitute.For<IThemeService>(),
NullLogger<SettingsViewModel>.Instance);
} }
} }