Enhance video library management in PLib by introducing folder change tracking and improving video item metadata handling. Update IVideoRepository to include a method for loading video items with labels. Revise VideoPlayerViewModel to manage playback progress and integrate new UI elements for displaying watched status and resume options. Update MainWindowViewModel to observe folder changes for automatic rescanning. Enhance README.md to document these new features and usage instructions.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
using PLib.Domain.Videos;
|
||||
using ReactiveUI;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One tag or collection as shown on the media page. Carries its own remove command so the
|
||||
/// chip template never has to reach up the visual tree.
|
||||
/// </summary>
|
||||
public sealed class LabelViewModel
|
||||
{
|
||||
public LabelViewModel(LibraryLabel label, Action<LabelViewModel> remove)
|
||||
{
|
||||
Id = label.Id;
|
||||
Name = label.Name;
|
||||
Kind = label.Kind;
|
||||
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public LabelKind Kind { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using DynamicData.Kernel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
@@ -34,6 +35,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly ILibraryWatcher _watcher;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly ISystemShell _shell;
|
||||
@@ -68,12 +70,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
IAppSettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
ISystemShell shell,
|
||||
ILibraryWatcher watcher,
|
||||
IThemeService theme,
|
||||
ILogger<MainWindowViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
_settingsStore = settingsStore;
|
||||
_watcher = watcher;
|
||||
_theme = theme;
|
||||
_folderPicker = folderPicker;
|
||||
_shell = shell;
|
||||
@@ -129,6 +133,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
_isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning);
|
||||
|
||||
BuildLibraryView(out _videos, out _isEmpty);
|
||||
ObserveFolderChanges();
|
||||
ObserveCommandFailures();
|
||||
}
|
||||
|
||||
@@ -240,9 +245,25 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
private void OpenVideo(VideoCardViewModel card)
|
||||
{
|
||||
OpenedVideo?.Dispose();
|
||||
OpenedVideo = new VideoPlayerViewModel(card, _shell, _settingsStore, _logger, () => OpenedVideo = null);
|
||||
OpenedVideo = new VideoPlayerViewModel(card, _shell, _scopeFactory, _settingsStore, _logger, () => OpenedVideo = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rescans when the folders change on disk. The watcher has already waited for the
|
||||
/// activity to settle; this only has to make sure a scan is not started on top of one
|
||||
/// that is still running.
|
||||
/// </summary>
|
||||
private void ObserveFolderChanges() =>
|
||||
_watcher.Changed
|
||||
.ObserveOn(_uiScheduler)
|
||||
.Where(_ => !IsScanning && !IsSettingsOpen)
|
||||
.Subscribe(_ =>
|
||||
{
|
||||
_logger.LogInformation("Library folders changed on disk; rescanning");
|
||||
ScanCommand.Execute().Subscribe();
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
|
||||
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(term))
|
||||
@@ -313,6 +334,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
ScanProgress = 0;
|
||||
StatusText = "Поиск файлов…";
|
||||
|
||||
// Re-armed on every scan so a folder added or removed in settings is picked up
|
||||
// without any separate plumbing.
|
||||
_watcher.Watch(folders);
|
||||
|
||||
try
|
||||
{
|
||||
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>One label/value line in the media page's details block.</summary>
|
||||
/// <param name="Label">What the value is, in the user's language.</param>
|
||||
/// <param name="Value">Already formatted; the view only prints it.</param>
|
||||
public sealed record MetadataRow(string Label, string Value);
|
||||
@@ -66,8 +66,35 @@ public sealed partial class VideoCardViewModel : ReactiveObject
|
||||
[Reactive]
|
||||
public partial long RawSizeInBytes { get; set; }
|
||||
|
||||
/// <summary>How far through the video the viewer got, 0..1, for the bar across the poster.</summary>
|
||||
[Reactive]
|
||||
public partial double WatchedFraction { get; set; }
|
||||
|
||||
/// <summary>True once there is progress worth drawing.</summary>
|
||||
[Reactive]
|
||||
public partial bool HasProgress { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsWatched { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string? ResumeText { get; set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
/// <summary>Where playback stopped last time, or <c>null</c> if there is nothing to resume.</summary>
|
||||
public TimeSpan? ResumePosition { get; private set; }
|
||||
|
||||
public int? Width { get; private set; }
|
||||
|
||||
public int? Height { get; private set; }
|
||||
|
||||
public string? VideoCodec { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastPlayedAt { get; private set; }
|
||||
|
||||
public int PlayCount { get; private set; }
|
||||
|
||||
/// <summary>Copies the current state of the entity into the card.</summary>
|
||||
public void Apply(VideoItem item)
|
||||
{
|
||||
@@ -77,9 +104,21 @@ public sealed partial class VideoCardViewModel : ReactiveObject
|
||||
SizeText = DisplayText.FileSize(item.SizeInBytes);
|
||||
QualityText = DisplayText.Quality(item.Width, item.Height);
|
||||
AddedAt = item.AddedAt;
|
||||
Width = item.Width;
|
||||
Height = item.Height;
|
||||
VideoCodec = item.VideoCodec;
|
||||
LastPlayedAt = item.LastPlayedAt;
|
||||
PlayCount = item.PlayCount;
|
||||
RawDuration = item.Duration;
|
||||
RawSizeInBytes = item.SizeInBytes;
|
||||
IsPending = item.ThumbnailPath is null;
|
||||
WatchedFraction = item.WatchedFraction;
|
||||
HasProgress = item.WatchedFraction > 0;
|
||||
IsWatched = item.PlayCount > 0 && item.ResumePosition is null;
|
||||
ResumePosition = item.ResumePosition;
|
||||
ResumeText = item.ResumePosition is { } resume
|
||||
? $"Продолжить с {DisplayText.Duration(resume)}"
|
||||
: null;
|
||||
}
|
||||
|
||||
public bool Matches(string term) =>
|
||||
|
||||
@@ -1,126 +1,306 @@
|
||||
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);
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Reactive.Concurrency;
|
||||
using System.Reactive.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Domain.Videos;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The media page: one video with its player, its details and its labels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Transport state stays on the player control; what the page owns is the video's identity,
|
||||
/// everything shown around the picture, and the settings that outlive the page.
|
||||
/// </remarks>
|
||||
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 IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public VideoPlayerViewModel(
|
||||
VideoCardViewModel card,
|
||||
ISystemShell shell,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IAppSettingsStore settingsStore,
|
||||
ILogger logger,
|
||||
Action close)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsStore = settingsStore;
|
||||
_logger = logger;
|
||||
|
||||
Card = card;
|
||||
VideoId = card.Id;
|
||||
Title = card.Title;
|
||||
FullPath = card.FullPath;
|
||||
Source = new Uri(card.FullPath);
|
||||
ResumeFrom = card.ResumePosition;
|
||||
Details = BuildDetails(card);
|
||||
|
||||
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; });
|
||||
ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; });
|
||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||
|
||||
AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag));
|
||||
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The card this page was opened from; refreshed in place as progress is saved.</summary>
|
||||
public VideoCardViewModel Card { get; }
|
||||
|
||||
public Guid VideoId { get; }
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>Where to start playback, or <c>null</c> to start from the beginning.</summary>
|
||||
public TimeSpan? ResumeFrom { get; }
|
||||
|
||||
public IReadOnlyList<MetadataRow> Details { get; }
|
||||
|
||||
public ObservableCollection<LabelViewModel> Tags { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleMuteCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleDetailsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddTagCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddCollectionCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { 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>The details and labels panel beside the video.</summary>
|
||||
[Reactive]
|
||||
public partial bool AreDetailsVisible { 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; }
|
||||
|
||||
[Reactive]
|
||||
public partial string NewTag { get; set; } = string.Empty;
|
||||
|
||||
[Reactive]
|
||||
public partial string NewCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Records where playback stopped and refreshes the card behind the page, so the grid
|
||||
/// shows the new progress without waiting for a rescan.
|
||||
/// </summary>
|
||||
public async Task SaveProgressAsync(TimeSpan position)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.SaveProgressAsync(VideoId, position);
|
||||
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A lost resume position is not worth surfacing to someone who just closed a video.
|
||||
_logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MetadataRow> BuildDetails(VideoCardViewModel card)
|
||||
{
|
||||
var rows = new List<MetadataRow>
|
||||
{
|
||||
new("Длительность", card.DurationText),
|
||||
new("Размер", card.SizeText),
|
||||
};
|
||||
|
||||
if (card.Width is { } width && card.Height is { } height)
|
||||
{
|
||||
rows.Add(new MetadataRow("Разрешение", $"{width} × {height}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(card.VideoCodec))
|
||||
{
|
||||
rows.Add(new MetadataRow("Кодек", card.VideoCodec));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
|
||||
if (card.LastPlayedAt is { } lastPlayed)
|
||||
{
|
||||
rows.Add(new MetadataRow(
|
||||
"Последний просмотр",
|
||||
lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
if (card.PlayCount > 0)
|
||||
{
|
||||
rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture)));
|
||||
}
|
||||
|
||||
rows.Add(new MetadataRow("Файл", card.FullPath));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task LoadLabelsAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var video = await library.GetVideoWithLabelsAsync(VideoId);
|
||||
|
||||
Tags.Clear();
|
||||
Collections.Clear();
|
||||
|
||||
var labels = video is null
|
||||
? []
|
||||
: video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
|
||||
|
||||
foreach (var label in labels)
|
||||
{
|
||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttachAsync(string name, LabelKind kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.AttachLabelAsync(VideoId, name, kind);
|
||||
|
||||
if (kind == LabelKind.Tag)
|
||||
{
|
||||
NewTag = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
NewCollection = string.Empty;
|
||||
}
|
||||
|
||||
await LoadLabelsAsync();
|
||||
}
|
||||
|
||||
private async Task DetachAsync(LabelViewModel label)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.DetachLabelAsync(VideoId, label.Id);
|
||||
Target(label.Kind).Remove(label);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not remove the label {Name}", label.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) =>
|
||||
kind == LabelKind.Tag ? Tags : Collections;
|
||||
|
||||
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,
|
||||
ToggleDetailsCommand.ThrownExceptions,
|
||||
OpenExternallyCommand.ThrownExceptions,
|
||||
RevealCommand.ThrownExceptions,
|
||||
AddTagCommand.ThrownExceptions,
|
||||
AddCollectionCommand.ThrownExceptions,
|
||||
LoadLabelsCommand.ThrownExceptions)
|
||||
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,29 @@
|
||||
<TextBlock Text="{Binding DurationText}" />
|
||||
</Border>
|
||||
|
||||
<!-- Watched marker, top-right, so it never collides with the quality badge. -->
|
||||
<Border Margin="8"
|
||||
Width="20"
|
||||
Height="20"
|
||||
CornerRadius="10"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Background="{DynamicResource AccentBrush}"
|
||||
IsVisible="{Binding IsWatched}"
|
||||
ToolTip.Tip="Просмотрено">
|
||||
<icons:MaterialIcon Kind="Check" Width="13" Height="13" Foreground="White" />
|
||||
</Border>
|
||||
|
||||
<!-- How far the viewer got, drawn across the bottom of the poster. -->
|
||||
<ProgressBar VerticalAlignment="Bottom"
|
||||
Height="3"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding WatchedFraction}"
|
||||
IsVisible="{Binding HasProgress}"
|
||||
Background="{DynamicResource BadgeBackgroundBrush}"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
|
||||
<Border Classes="playOverlay" Background="{DynamicResource OverlayBrush}">
|
||||
<Border Width="46"
|
||||
Height="46"
|
||||
@@ -91,6 +114,10 @@
|
||||
<TextBlock Classes="cardTitle" Text="{Binding Title}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="cardMeta" Text="{Binding SizeText}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
Text="{Binding ResumeText}"
|
||||
IsVisible="{Binding ResumeText, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -6,6 +6,28 @@
|
||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||
x:DataType="vm:VideoPlayerViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<DataTemplate x:Key="LabelChipTemplate" x:DataType="vm:LabelViewModel">
|
||||
<Border Background="{DynamicResource AccentSoftBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="9,3"
|
||||
Margin="0,0,6,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
<Button Command="{Binding RemoveCommand}"
|
||||
Classes="transport"
|
||||
Padding="2"
|
||||
ToolTip.Tip="Убрать">
|
||||
<icons:MaterialIcon Kind="Close" Width="11" Height="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.transport">
|
||||
<Setter Property="Padding" Value="8" />
|
||||
@@ -45,6 +67,11 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="transport"
|
||||
Command="{Binding ToggleDetailsCommand}"
|
||||
ToolTip.Tip="Сведения и метки">
|
||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding OpenExternallyCommand}"
|
||||
ToolTip.Tip="Открыть во внешнем плеере">
|
||||
@@ -61,7 +88,9 @@
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Video ======================= -->
|
||||
<Panel Grid.Row="1" Name="VideoArea" Background="Black">
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||
|
||||
<Panel Grid.Column="0" Name="VideoArea" Background="Black">
|
||||
<controls:VlcVideoView Name="Player"
|
||||
Source="{Binding Source}"
|
||||
AutoPlay="True"
|
||||
@@ -83,6 +112,73 @@
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Details and labels ======================= -->
|
||||
<Border Grid.Column="1"
|
||||
Width="320"
|
||||
IsVisible="{Binding AreDetailsVisible}"
|
||||
Background="{DynamicResource PanelBackgroundBrush}">
|
||||
<ScrollViewer Padding="16,14">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Сведения" />
|
||||
<ItemsControl ItemsSource="{Binding Details}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataRow">
|
||||
<Grid ColumnDefinitions="130,*" Margin="0,0,0,6">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Label}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Value}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить тег…" Text="{Binding NewTag}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Коллекции" />
|
||||
<ItemsControl ItemsSource="{Binding Collections}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBox PlaceholderText="Добавить в коллекцию…" Text="{Binding NewCollection}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddCollectionCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ======================= Transport ======================= -->
|
||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
|
||||
@@ -29,6 +29,11 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
/// <summary>The window state to come back to when full screen is switched off.</summary>
|
||||
private WindowState _stateBeforeFullScreen = WindowState.Normal;
|
||||
|
||||
private VideoPlayerViewModel? _viewModel;
|
||||
|
||||
/// <summary>Set once playback has been asked to jump to the remembered position.</summary>
|
||||
private bool _resumeApplied;
|
||||
|
||||
public VideoPlayerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -65,6 +70,9 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
_subscriptions.Add(viewModel
|
||||
.WhenAnyValue(x => x.Volume, x => x.IsMuted, VolumeIconFor)
|
||||
.Subscribe(kind => MuteIcon.Kind = kind));
|
||||
|
||||
_viewModel = viewModel;
|
||||
viewModel.LoadLabelsCommand.Execute().Subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +82,14 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
|
||||
_subscriptions.Clear();
|
||||
|
||||
// Where playback got to has to be captured before the player is torn down.
|
||||
if (_viewModel is { } viewModel && Player.Position > TimeSpan.Zero)
|
||||
{
|
||||
_ = viewModel.SaveProgressAsync(Player.Position);
|
||||
}
|
||||
|
||||
_viewModel = null;
|
||||
|
||||
// Closing the page while full screen would otherwise strand the window with no chrome.
|
||||
ApplyFullScreen(false);
|
||||
|
||||
@@ -149,6 +165,14 @@ public sealed partial class VideoPlayerView : UserControl
|
||||
{
|
||||
DurationText.Text = DisplayText.Duration(duration);
|
||||
|
||||
// Seeking is only possible once the length is known, so resuming waits for it —
|
||||
// and happens exactly once, or every later duration report would rewind playback.
|
||||
if (!_resumeApplied && duration > TimeSpan.Zero && _viewModel?.ResumeFrom is { } resume)
|
||||
{
|
||||
_resumeApplied = true;
|
||||
Player.Seek(resume);
|
||||
}
|
||||
|
||||
// A zero maximum would pin the thumb to the left and swallow every seek.
|
||||
Seek.Maximum = duration > TimeSpan.Zero ? duration.TotalSeconds : 1;
|
||||
Seek.IsEnabled = duration > TimeSpan.Zero;
|
||||
|
||||
Reference in New Issue
Block a user