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:
@@ -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`. Когда форма таблицы устоится — заменить на
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -8,5 +8,12 @@ namespace PLib.Desktop.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAppSettingsStore
|
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);
|
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Infrastructure.Storage;
|
||||||
@@ -7,7 +8,11 @@ using PLib.Infrastructure.Storage;
|
|||||||
namespace PLib.Desktop.Services;
|
namespace PLib.Desktop.Services;
|
||||||
|
|
||||||
/// <inheritdoc cref="IAppSettingsStore"/>
|
/// <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 };
|
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");
|
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)
|
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _writeLock.WaitAsync(cancellationToken);
|
await _writeLock.WaitAsync(cancellationToken);
|
||||||
@@ -34,6 +42,10 @@ public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
|
|||||||
|
|
||||||
Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
|
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.
|
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||||
var staging = SettingsFile + ".tmp";
|
var staging = SettingsFile + ".tmp";
|
||||||
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ public sealed record AppSettings
|
|||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
|
public required bool IsMuted { get; init; }
|
||||||
|
|
||||||
|
public static AppSettings From(
|
||||||
|
LibraryOptions library,
|
||||||
|
AppearanceOptions appearance,
|
||||||
|
PlaybackOptions playback) => new()
|
||||||
{
|
{
|
||||||
Folders = [.. library.Folders],
|
Folders = [.. library.Folders],
|
||||||
ThumbnailWidth = library.ThumbnailWidth,
|
ThumbnailWidth = library.ThumbnailWidth,
|
||||||
@@ -31,6 +38,8 @@ public sealed record AppSettings
|
|||||||
MaxIndexingConcurrency = library.MaxIndexingConcurrency,
|
MaxIndexingConcurrency = library.MaxIndexingConcurrency,
|
||||||
MinimumFileSizeInBytes = library.MinimumFileSizeInBytes,
|
MinimumFileSizeInBytes = library.MinimumFileSizeInBytes,
|
||||||
Theme = appearance.Theme,
|
Theme = appearance.Theme,
|
||||||
|
Volume = playback.Volume,
|
||||||
|
IsMuted = playback.IsMuted,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <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 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,3 +1,6 @@
|
|||||||
|
using System.Reactive.Concurrency;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using ReactiveUI.SourceGenerators;
|
using ReactiveUI.SourceGenerators;
|
||||||
@@ -6,14 +9,31 @@ using RxVoid = ReactiveUI.Primitives.RxVoid;
|
|||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The media page: one video, opened from the grid. It only carries identity and the few
|
/// The media page: one video, opened from the grid. Most of the transport lives on the
|
||||||
/// commands around the player — transport state belongs to the media control itself, which
|
/// player control itself; what the page owns is the video's identity, the commands around
|
||||||
/// already exposes position, duration and playback as bindable properties.
|
/// it, and the settings that have to outlive the page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
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;
|
Title = card.Title;
|
||||||
FullPath = card.FullPath;
|
FullPath = card.FullPath;
|
||||||
Source = new Uri(card.FullPath);
|
Source = new Uri(card.FullPath);
|
||||||
@@ -23,17 +43,32 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
new[] { card.QualityText, card.DurationText, card.SizeText }
|
new[] { card.QualityText, card.DurationText, card.SizeText }
|
||||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||||
|
|
||||||
|
var settings = settingsStore.Current;
|
||||||
|
Volume = settings.Volume;
|
||||||
|
IsMuted = settings.IsMuted;
|
||||||
|
|
||||||
CloseCommand = ReactiveCommand.Create(close);
|
CloseCommand = ReactiveCommand.Create(close);
|
||||||
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
||||||
|
ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; });
|
||||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(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 Title { get; }
|
||||||
|
|
||||||
public string FullPath { 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; }
|
public Uri Source { get; }
|
||||||
|
|
||||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
/// <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; }
|
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||||
|
|
||||||
/// <summary>
|
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { 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.
|
|
||||||
/// </summary>
|
|
||||||
[Reactive]
|
|
||||||
public partial bool IsFullScreen { get; set; }
|
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user