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}" /> -