From a938a48de9f23b0c0834c1c1597168fccebd0fa1 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 9 Aug 2026 06:46:33 +0300 Subject: [PATCH] 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. --- README.md | 6 +- src/PLib.Desktop/AppHost.cs | 4 + .../Services/IAppSettingsStore.cs | 31 +-- .../Services/JsonAppSettingsStore.cs | 168 ++++++++-------- src/PLib.Desktop/Settings/AppSettings.cs | 95 +++++---- src/PLib.Desktop/Settings/PlaybackOptions.cs | 15 ++ .../ViewModels/MainWindowViewModel.cs | 11 +- .../ViewModels/SettingsViewModel.cs | 13 +- .../ViewModels/VideoPlayerViewModel.cs | 183 ++++++++++++------ src/PLib.Desktop/Views/VideoPlayerView.axaml | 8 +- .../Views/VideoPlayerView.axaml.cs | 23 +-- src/PLib.Desktop/appsettings.json | 4 + .../Settings/AppSettingsStoreTests.cs | 40 +++- .../Settings/SettingsViewModelTests.cs | 49 +++-- 14 files changed, 399 insertions(+), 251 deletions(-) create mode 100644 src/PLib.Desktop/Settings/PlaybackOptions.cs diff --git a/README.md b/README.md index be5cee7..b9c73d7 100644 --- a/README.md +++ b/README.md @@ -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`. Когда форма таблицы устоится — заменить на diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs index 0a01e2c..39b59a4 100644 --- a/src/PLib.Desktop/AppHost.cs +++ b/src/PLib.Desktop/AppHost.cs @@ -43,6 +43,10 @@ internal static class AppHost builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName)); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName)) + .ValidateDataAnnotations(); builder.Services.AddPLibInfrastructure(builder.Configuration); builder.Services.AddSingleton(); diff --git a/src/PLib.Desktop/Services/IAppSettingsStore.cs b/src/PLib.Desktop/Services/IAppSettingsStore.cs index 2a32dbf..0a8d1fa 100644 --- a/src/PLib.Desktop/Services/IAppSettingsStore.cs +++ b/src/PLib.Desktop/Services/IAppSettingsStore.cs @@ -1,12 +1,19 @@ -using PLib.Desktop.Settings; - -namespace PLib.Desktop.Services; - -/// -/// Persists the settings the user can change at runtime. The file it writes is also a -/// configuration source, so IOptionsMonitor picks changes up without a restart. -/// -public interface IAppSettingsStore -{ - Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default); -} +using PLib.Desktop.Settings; + +namespace PLib.Desktop.Services; + +/// +/// Persists the settings the user can change at runtime. The file it writes is also a +/// configuration source, so IOptionsMonitor picks changes up without a restart. +/// +public interface IAppSettingsStore +{ + /// + /// Everything as it stands right now, read back through configuration. Callers are meant + /// to save Current with { ... }: the file is written whole, so building a snapshot + /// by hand is how a section nobody was thinking about gets wiped. + /// + AppSettings Current { get; } + + Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Desktop/Services/JsonAppSettingsStore.cs b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs index 8292bbd..d7c19f5 100644 --- a/src/PLib.Desktop/Services/JsonAppSettingsStore.cs +++ b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs @@ -1,78 +1,90 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using PLib.Application.Library; -using PLib.Desktop.Settings; -using PLib.Infrastructure.Storage; - -namespace PLib.Desktop.Services; - -/// -public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore -{ - private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; - - private readonly SemaphoreSlim _writeLock = new(1, 1); - - private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json"); - - public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default) - { - await _writeLock.WaitAsync(cancellationToken); - - try - { - // Merge into whatever is already there: the file is hand-editable and may hold - // keys this version of the settings screen knows nothing about. - var root = await ReadRootAsync(cancellationToken); - - var library = Section(root, LibraryOptions.SectionName); - library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]); - library["ThumbnailWidth"] = settings.ThumbnailWidth; - library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio; - library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency; - library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes; - - Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString(); - - // 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); - File.Move(staging, SettingsFile, overwrite: true); - } - finally - { - _writeLock.Release(); - } - } - - private static JsonObject Section(JsonObject root, string name) - { - if (root[name] is JsonObject existing) - { - return existing; - } - - var created = new JsonObject(); - root[name] = created; - return created; - } - - private async Task ReadRootAsync(CancellationToken cancellationToken) - { - if (!File.Exists(SettingsFile)) - { - 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 []; - } - } -} +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; + +namespace PLib.Desktop.Services; + +/// +public sealed class JsonAppSettingsStore( + IAppPaths paths, + IOptionsMonitor library, + IOptionsMonitor appearance, + IOptionsMonitor playback) : IAppSettingsStore +{ + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + + private readonly SemaphoreSlim _writeLock = new(1, 1); + + 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); + + try + { + // Merge into whatever is already there: the file is hand-editable and may hold + // keys this version of the settings screen knows nothing about. + var root = await ReadRootAsync(cancellationToken); + + var library = Section(root, LibraryOptions.SectionName); + library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]); + library["ThumbnailWidth"] = settings.ThumbnailWidth; + library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio; + library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency; + library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes; + + 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); + File.Move(staging, SettingsFile, overwrite: true); + } + finally + { + _writeLock.Release(); + } + } + + private static JsonObject Section(JsonObject root, string name) + { + if (root[name] is JsonObject existing) + { + return existing; + } + + var created = new JsonObject(); + root[name] = created; + return created; + } + + private async Task ReadRootAsync(CancellationToken cancellationToken) + { + if (!File.Exists(SettingsFile)) + { + 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 []; + } + } +} diff --git a/src/PLib.Desktop/Settings/AppSettings.cs b/src/PLib.Desktop/Settings/AppSettings.cs index 41de518..8309c11 100644 --- a/src/PLib.Desktop/Settings/AppSettings.cs +++ b/src/PLib.Desktop/Settings/AppSettings.cs @@ -1,43 +1,52 @@ -using PLib.Application.Library; - -namespace PLib.Desktop.Settings; - -/// -/// The subset of configuration the user can change at runtime, as one snapshot. -/// -/// -/// 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. -/// -public sealed record AppSettings -{ - public required IReadOnlyList Folders { get; init; } - - public required int ThumbnailWidth { get; init; } - - public required double ThumbnailPositionRatio { get; init; } - - public required int MaxIndexingConcurrency { get; init; } - - public required long MinimumFileSizeInBytes { get; init; } - - public required ThemeMode Theme { get; init; } - - public static AppSettings From(LibraryOptions library, AppearanceOptions appearance) => new() - { - Folders = [.. library.Folders], - ThumbnailWidth = library.ThumbnailWidth, - ThumbnailPositionRatio = library.ThumbnailPositionRatio, - MaxIndexingConcurrency = library.MaxIndexingConcurrency, - MinimumFileSizeInBytes = library.MinimumFileSizeInBytes, - Theme = appearance.Theme, - }; - - /// - /// True when the difference between the two snapshots means the library has to be - /// walked again. Cosmetic changes must not trigger a rescan. - /// - public bool RequiresRescanComparedTo(AppSettings other) => - !Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) || - MinimumFileSizeInBytes != other.MinimumFileSizeInBytes; -} +using PLib.Application.Library; + +namespace PLib.Desktop.Settings; + +/// +/// The subset of configuration the user can change at runtime, as one snapshot. +/// +/// +/// 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. +/// +public sealed record AppSettings +{ + public required IReadOnlyList Folders { get; init; } + + public required int ThumbnailWidth { get; init; } + + public required double ThumbnailPositionRatio { get; init; } + + public required int MaxIndexingConcurrency { get; init; } + + public required long MinimumFileSizeInBytes { get; init; } + + public required ThemeMode Theme { get; init; } + + 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, + ThumbnailPositionRatio = library.ThumbnailPositionRatio, + MaxIndexingConcurrency = library.MaxIndexingConcurrency, + MinimumFileSizeInBytes = library.MinimumFileSizeInBytes, + Theme = appearance.Theme, + Volume = playback.Volume, + IsMuted = playback.IsMuted, + }; + + /// + /// True when the difference between the two snapshots means the library has to be + /// walked again. Cosmetic changes must not trigger a rescan. + /// + public bool RequiresRescanComparedTo(AppSettings other) => + !Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) || + MinimumFileSizeInBytes != other.MinimumFileSizeInBytes; +} diff --git a/src/PLib.Desktop/Settings/PlaybackOptions.cs b/src/PLib.Desktop/Settings/PlaybackOptions.cs new file mode 100644 index 0000000..4992eb6 --- /dev/null +++ b/src/PLib.Desktop/Settings/PlaybackOptions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace PLib.Desktop.Settings; + +/// Playback preferences that outlive a single media page. +public sealed class PlaybackOptions +{ + public const string SectionName = "Playback"; + + /// Volume as a fraction of full scale. + [Range(0.0, 1.0)] + public double Volume { get; init; } = 0.8; + + public bool IsMuted { get; init; } +} diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index 4e4dd48..5f0cfc1 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -34,7 +34,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly IServiceScopeFactory _scopeFactory; private readonly IOptionsMonitor _options; private readonly IAppSettingsStore _settingsStore; - private readonly IOptionsMonitor _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 options, - IOptionsMonitor 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 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 folders) => - _settingsStore.SaveAsync(CurrentSettings with { Folders = folders }); + _settingsStore.SaveAsync(_settingsStore.Current with { Folders = folders }); /// /// A saved settings file is not visible through IOptionsMonitor straight away — diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs index b48bf59..cd2498d 100644 --- a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs +++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs @@ -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 library, - IOptionsMonitor 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, diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs index 7626c46..1744407 100644 --- a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs @@ -1,57 +1,126 @@ -using PLib.Desktop.Services; -using ReactiveUI; -using ReactiveUI.SourceGenerators; -using RxVoid = ReactiveUI.Primitives.RxVoid; - -namespace PLib.Desktop.ViewModels; - -/// -/// 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. -/// -public sealed partial class VideoPlayerViewModel : ViewModelBase -{ - public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close) - { - Title = card.Title; - FullPath = card.FullPath; - Source = new Uri(card.FullPath); - - Subtitle = string.Join( - " · ", - new[] { card.QualityText, card.DurationText, card.SizeText } - .Where(part => !string.IsNullOrWhiteSpace(part))); - - CloseCommand = ReactiveCommand.Create(close); - ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; }); - OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); - RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); - } - - public string Title { get; } - - public string FullPath { get; } - - /// What the media control plays; a file:// URI built from the path. - public Uri Source { get; } - - /// Quality, duration and size on one line, for the page header. - public string Subtitle { get; } - - public ReactiveCommand CloseCommand { get; } - - public ReactiveCommand ToggleFullScreenCommand { get; } - - /// - /// 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. - /// - [Reactive] - public partial bool IsFullScreen { get; set; } - - public ReactiveCommand OpenExternallyCommand { get; } - - public ReactiveCommand RevealCommand { get; } -} +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using Microsoft.Extensions.Logging; +using PLib.Desktop.Services; +using ReactiveUI; +using ReactiveUI.SourceGenerators; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// +/// 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. +/// +public sealed partial class VideoPlayerViewModel : ViewModelBase +{ + /// + /// 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. + /// + 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); + + Subtitle = string.Join( + " · ", + 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; } + + /// What the player plays; a file:// URI built from the path. + public Uri Source { get; } + + /// Quality, duration and size on one line, for the page header. + public string Subtitle { get; } + + public ReactiveCommand CloseCommand { get; } + + public ReactiveCommand ToggleFullScreenCommand { get; } + + public ReactiveCommand ToggleMuteCommand { get; } + + public ReactiveCommand OpenExternallyCommand { get; } + + public ReactiveCommand RevealCommand { get; } + + /// + /// True while the window is given over to the video. The page hides its own header and + /// the window hides its chrome. + /// + [Reactive] + public partial bool IsFullScreen { get; set; } + + /// Volume as a fraction; restored on open and remembered across restarts. + [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); +} diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml index a70ce4f..6a8c964 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml @@ -65,7 +65,8 @@ + Volume="{Binding Volume}" + IsMuted="{Binding IsMuted}" /> -