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
+4
View File
@@ -43,6 +43,10 @@ internal static class AppHost
builder.Services.AddOptions<AppearanceOptions>()
.Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
builder.Services.AddOptions<PlaybackOptions>()
.Bind(builder.Configuration.GetSection(PlaybackOptions.SectionName))
.ValidateDataAnnotations();
builder.Services.AddPLibInfrastructure(builder.Configuration);
builder.Services.AddSingleton<ThumbnailCache>();
+19 -12
View File
@@ -1,12 +1,19 @@
using PLib.Desktop.Settings;
namespace PLib.Desktop.Services;
/// <summary>
/// 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.
/// </summary>
public interface IAppSettingsStore
{
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
}
using PLib.Desktop.Settings;
namespace PLib.Desktop.Services;
/// <summary>
/// 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.
/// </summary>
public interface IAppSettingsStore
{
/// <summary>
/// Everything as it stands right now, read back through configuration. Callers are meant
/// to save <c>Current with { ... }</c>: the file is written whole, so building a snapshot
/// by hand is how a section nobody was thinking about gets wiped.
/// </summary>
AppSettings Current { get; }
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
}
@@ -1,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;
/// <inheritdoc cref="IAppSettingsStore"/>
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<JsonObject> 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;
/// <inheritdoc cref="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 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<JsonObject> 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 [];
}
}
}
+52 -43
View File
@@ -1,43 +1,52 @@
using PLib.Application.Library;
namespace PLib.Desktop.Settings;
/// <summary>
/// The subset of configuration the user can change at runtime, as one snapshot.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed record AppSettings
{
public required IReadOnlyList<string> 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,
};
/// <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;
}
using PLib.Application.Library;
namespace PLib.Desktop.Settings;
/// <summary>
/// The subset of configuration the user can change at runtime, as one snapshot.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed record AppSettings
{
public required IReadOnlyList<string> 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,
};
/// <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 IOptionsMonitor<LibraryOptions> _options;
private readonly IAppSettingsStore _settingsStore;
private readonly IOptionsMonitor<AppearanceOptions> _appearance;
private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell;
@@ -66,7 +65,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> options,
IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
ISystemShell shell,
@@ -75,7 +73,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{
_scopeFactory = scopeFactory;
_options = options;
_appearance = appearance;
_settingsStore = settingsStore;
_theme = theme;
_folderPicker = folderPicker;
@@ -243,7 +240,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenVideo(VideoCardViewModel card)
{
OpenedVideo?.Dispose();
OpenedVideo = new VideoPlayerViewModel(card, _shell, () => OpenedVideo = null);
OpenedVideo = new VideoPlayerViewModel(card, _shell, _settingsStore, _logger, () => OpenedVideo = null);
}
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
@@ -424,13 +421,11 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private async Task ToggleThemeAsync()
{
var mode = _theme.Toggle();
await _settingsStore.SaveAsync(CurrentSettings with { Theme = mode });
await _settingsStore.SaveAsync(_settingsStore.Current with { Theme = mode });
}
private AppSettings CurrentSettings => AppSettings.From(_options.CurrentValue, _appearance.CurrentValue);
private Task SaveFoldersAsync(IReadOnlyList<string> folders) =>
_settingsStore.SaveAsync(CurrentSettings with { Folders = folders });
_settingsStore.SaveAsync(_settingsStore.Current with { Folders = folders });
/// <summary>
/// A saved settings file is not visible through <c>IOptionsMonitor</c> straight away —
@@ -4,7 +4,6 @@ using System.Reactive.Linq;
using System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PLib.Application.Library;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
@@ -38,8 +37,6 @@ public sealed partial class SettingsViewModel : ViewModelBase
public SettingsViewModel(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> library,
IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore,
IFolderPicker folderPicker,
IThemeService theme,
@@ -51,9 +48,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
_theme = theme;
_logger = logger;
// CurrentValue, not IOptions.Value: the dialog can be reopened after a save, and a
// cached snapshot would show the settings the application started with.
_original = AppSettings.From(library.CurrentValue, appearance.CurrentValue);
// Read on construction rather than cached anywhere: the panel can be reopened after
// a save, and a stale snapshot would show the settings the application started with.
_original = settingsStore.Current;
Folders = [.. _original.Folders.Select(CreateEntry)];
ThumbnailWidth = _original.ThumbnailWidth;
@@ -123,7 +120,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
[Reactive]
public partial string? Message { get; set; }
private AppSettings CurrentDraft => new()
// Built from the snapshot the panel opened with, not from scratch: anything this screen
// does not edit — playback volume, for one — has to survive being saved from here.
private AppSettings CurrentDraft => _original with
{
Folders = [.. Folders.Select(entry => entry.Path)],
ThumbnailWidth = ThumbnailWidth,
@@ -1,57 +1,126 @@
using PLib.Desktop.Services;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// The media page: one video, opened from the grid. It only carries identity and the few
/// commands around the player — transport state belongs to the media control itself, which
/// already exposes position, duration and playback as bindable properties.
/// </summary>
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; }
/// <summary>What the media control 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; }
/// <summary>
/// True while the window is given over to the video. The page hides its own header and
/// the window hides its chrome; the transport strip stays, because a native video
/// surface cannot be drawn over and a floating overlay is therefore impossible.
/// </summary>
[Reactive]
public partial bool IsFullScreen { get; set; }
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> 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;
/// <summary>
/// The media page: one video, opened from the grid. Most of the transport lives on the
/// player control itself; what the page owns is the video's identity, the commands around
/// it, and the settings that have to outlive the page.
/// </summary>
public sealed partial class VideoPlayerViewModel : ViewModelBase
{
/// <summary>
/// How long the volume has to sit still before it is written. Dragging the slider
/// produces a value per pixel, and each one would otherwise be a file write.
/// </summary>
private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400);
private readonly IAppSettingsStore _settingsStore;
private readonly ILogger _logger;
public VideoPlayerViewModel(
VideoCardViewModel card,
ISystemShell shell,
IAppSettingsStore settingsStore,
ILogger logger,
Action close)
{
_settingsStore = settingsStore;
_logger = logger;
Title = card.Title;
FullPath = card.FullPath;
Source = new Uri(card.FullPath);
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; }
/// <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"
Source="{Binding Source}"
AutoPlay="True"
Volume="0.8" />
Volume="{Binding Volume}"
IsMuted="{Binding IsMuted}" />
<Border Name="ErrorBar"
HorizontalAlignment="Center"
@@ -100,16 +101,15 @@
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
<Button Grid.Column="4" Name="MuteButton" Classes="transport">
<Button Grid.Column="4" Classes="transport" Command="{Binding ToggleMuteCommand}">
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
</Button>
<Slider Grid.Column="5"
Name="VolumeSlider"
Width="90"
Minimum="0"
Maximum="1"
Value="0.8"
Value="{Binding Volume}"
VerticalAlignment="Center" />
<Button Grid.Column="6"
@@ -1,6 +1,5 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using System.Reactive.Disposables;
using Avalonia.Interactivity;
@@ -35,14 +34,11 @@ public sealed partial class VideoPlayerView : UserControl
InitializeComponent();
PlayPauseButton.Click += OnPlayPause;
MuteButton.Click += OnToggleMute;
// Tunnelled: the Slider's own handlers mark these as handled on the way back up.
Seek.AddHandler(PointerPressedEvent, OnScrubStarted, RoutingStrategies.Tunnel);
Seek.AddHandler(PointerReleasedEvent, OnScrubFinished, RoutingStrategies.Tunnel);
VolumeSlider.PropertyChanged += OnVolumeChanged;
// Handled on the surrounding panel rather than the video itself: the native window
// is not part of Avalonia's hit-test tree, so the gesture can only arrive here.
VideoArea.DoubleTapped += OnVideoDoubleTapped;
@@ -63,6 +59,12 @@ public sealed partial class VideoPlayerView : UserControl
_subscriptions.Add(viewModel
.WhenAnyValue(x => x.IsFullScreen)
.Subscribe(ApplyFullScreen));
// The icon is the one bit of volume state that is neither a bound value nor a
// player property, so it is driven from here rather than through a converter.
_subscriptions.Add(viewModel
.WhenAnyValue(x => x.Volume, x => x.IsMuted, VolumeIconFor)
.Subscribe(kind => MuteIcon.Kind = kind));
}
}
@@ -116,8 +118,6 @@ public sealed partial class VideoPlayerView : UserControl
}
}
private void OnToggleMute(object? sender, RoutedEventArgs e) => Player.IsMuted = !Player.IsMuted;
private void OnVideoDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is VideoPlayerViewModel viewModel)
@@ -135,17 +135,6 @@ public sealed partial class VideoPlayerView : UserControl
Player.Seek(TimeSpan.FromSeconds(Seek.Value));
}
private void OnVolumeChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property != RangeBase.ValueProperty)
{
return;
}
Player.Volume = VolumeSlider.Value;
MuteIcon.Kind = VolumeIconFor(VolumeSlider.Value, Player.IsMuted);
}
private void OnPositionChanged(TimeSpan position)
{
PositionText.Text = DisplayText.Duration(position);
+4
View File
@@ -8,5 +8,9 @@
},
"Appearance": {
"Theme": "Dark"
},
"Playback": {
"Volume": 0.8,
"IsMuted": false
}
}