From 09d2a553a8e6e336d67a2e742279c7998fea702b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 8 Aug 2026 14:46:40 +0300 Subject: [PATCH] Add media playback functionality to PLib video library manager. Integrate MediaPlayer.Controls and VideoLAN.LibVLC for enhanced video playback capabilities. Update MainWindow and ViewModels to manage video player state and commands, allowing users to play videos within the application. Enhance README.md to document new player features and requirements. --- Directory.Packages.props | 4 + README.md | 16 ++- src/PLib.Desktop/PLib.Desktop.csproj | 2 + .../ViewModels/MainWindowViewModel.cs | 37 ++++- .../ViewModels/VideoCardViewModel.cs | 9 +- .../ViewModels/VideoPlayerViewModel.cs | 45 +++++++ src/PLib.Desktop/Views/MainWindow.axaml | 16 +++ src/PLib.Desktop/Views/MainWindow.axaml.cs | 18 ++- src/PLib.Desktop/Views/VideoPlayerView.axaml | 115 ++++++++++++++++ .../Views/VideoPlayerView.axaml.cs | 126 ++++++++++++++++++ 10 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs create mode 100644 src/PLib.Desktop/Views/VideoPlayerView.axaml create mode 100644 src/PLib.Desktop/Views/VideoPlayerView.axaml.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ed107ea..7ce3110 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,10 @@ + + + diff --git a/README.md b/README.md index 0cecf8e..b106283 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,16 @@ библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется в `settings.json` и подхватывается без перезапуска. - Светлая, тёмная и системная темы; выбор запоминается. -- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. +- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, + перемотка, громкость, кнопка «назад». Внешний плеер и «показать в папке» остались + в контекстном меню карточки. ## Требования - .NET 10 SDK -- `ffmpeg` и `ffprobe` в `PATH` +- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных) + +Нативный LibVLC приезжает пакетом и в системе не нужен. ## Запуск @@ -74,6 +78,14 @@ dotnet test только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра его не вызывает. +- **Плеер — `GpuMediaPlayer` из `MediaPlayer.Controls`.** Он наследует `OpenGlControlBase`, + то есть рисует внутрь композиции Avalonia, а не в нативное дочернее окно: контролы можно + класть поверх видео, чего дал бы не всякий плеер. Транспорт (позиция, длительность, + play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы; + дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её. + Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева — и декодер останавливается. +- **Нативный LibVLC подключён намеренно.** Без него бэкенд откатывается на Media Foundation, + который не открывает MKV, AVI и WebM — то есть половину того, что сканер кладёт в библиотеку. - **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного diff --git a/src/PLib.Desktop/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj index 4a7ec07..262592b 100644 --- a/src/PLib.Desktop/PLib.Desktop.csproj +++ b/src/PLib.Desktop/PLib.Desktop.csproj @@ -23,6 +23,8 @@ + + all diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index a1c58d8..48fe908 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -60,6 +60,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly ObservableAsPropertyHelper _isScanning; private readonly ObservableAsPropertyHelper _isEmpty; private readonly ObservableAsPropertyHelper _isSettingsOpen; + private readonly ObservableAsPropertyHelper _isPlayerOpen; public MainWindowViewModel( IServiceScopeFactory scopeFactory, @@ -99,6 +100,15 @@ public sealed partial class MainWindowViewModel : ViewModelBase () => { SettingsPanel?.CancelCommand.Execute().Subscribe(); }, this.WhenAnyValue(x => x.IsSettingsOpen)); + _isPlayerOpen = this + .WhenAnyValue(x => x.OpenedVideo) + .Select(player => player is not null) + .ToProperty(this, x => x.IsPlayerOpen); + + ClosePlayerCommand = ReactiveCommand.Create( + () => { OpenedVideo = null; }, + this.WhenAnyValue(x => x.IsPlayerOpen)); + // Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling // simply unsubscribes it, which cancels the token Observable.StartAsync handed out. ScanCommand = ReactiveCommand.CreateFromObservable( @@ -140,6 +150,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase public ReactiveCommand CloseSettingsCommand { get; } + public ReactiveCommand ClosePlayerCommand { get; } + + /// + /// The media page while it is open, or null. Setting it to null tears the player + /// view out of the visual tree, which is what stops playback and releases the decoder. + /// + [Reactive] + public partial VideoPlayerViewModel? OpenedVideo { get; set; } + + public bool IsPlayerOpen => _isPlayerOpen.Value; + /// /// The settings panel while it is on screen, or null. Its presence is what the /// overlay binds to — settings live in this window rather than a second one. @@ -202,6 +223,15 @@ public sealed partial class MainWindowViewModel : ViewModelBase .ToProperty(this, x => x.IsEmpty); } + private VideoCardViewModel CreateCard(Domain.Videos.VideoItem item) => + new(item, _shell, OpenVideo); + + private void OpenVideo(VideoCardViewModel card) + { + OpenedVideo?.Dispose(); + OpenedVideo = new VideoPlayerViewModel(card, _shell, () => OpenedVideo = null); + } + private static Func BuildFilter(string? term) { if (string.IsNullOrWhiteSpace(term)) @@ -226,7 +256,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase AddFolderCommand.ThrownExceptions, ToggleThemeCommand.ThrownExceptions, OpenSettingsCommand.ThrownExceptions, - CloseSettingsCommand.ThrownExceptions) + CloseSettingsCommand.ThrownExceptions, + ClosePlayerCommand.ThrownExceptions) .Subscribe(ex => { _logger.LogError(ex, "A command failed"); @@ -243,7 +274,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase var library = scope.ServiceProvider.GetRequiredService(); var items = await library.GetLibraryAsync(); - _library.AddOrUpdate(items.Select(item => new VideoCardViewModel(item, _shell))); + _library.AddOrUpdate(items.Select(item => CreateCard(item))); } catch (Exception ex) { @@ -416,7 +447,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase break; case LibraryScanEvent.ItemAdded added: - _library.AddOrUpdate(new VideoCardViewModel(added.Item, _shell)); + _library.AddOrUpdate(CreateCard(added.Item)); break; case LibraryScanEvent.ItemUpdated updated: diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs index e791592..8cb4659 100644 --- a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs @@ -14,13 +14,16 @@ namespace PLib.Desktop.ViewModels; /// public sealed partial class VideoCardViewModel : ReactiveObject { - public VideoCardViewModel(VideoItem item, ISystemShell shell) + public VideoCardViewModel(VideoItem item, ISystemShell shell, Action open) { Id = item.Id; FullPath = item.FullPath; Title = item.Title; - PlayCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); + // Activating a card opens the media page inside the application; the system player + // stays available from the context menu for anything PLib cannot decode itself. + PlayCommand = ReactiveCommand.Create(() => open(this)); + OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); Apply(item); @@ -32,6 +35,8 @@ public sealed partial class VideoCardViewModel : ReactiveObject public ReactiveCommand PlayCommand { get; } + public ReactiveCommand OpenExternallyCommand { get; } + public ReactiveCommand RevealCommand { get; } [Reactive] diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs new file mode 100644 index 0000000..7e4e34c --- /dev/null +++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs @@ -0,0 +1,45 @@ +using PLib.Desktop.Services; +using ReactiveUI; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// +/// The media page: one video, opened from the grid. It only carries identity and the few +/// commands around the player — transport state belongs to the media control itself, which +/// already exposes position, duration and playback as bindable properties. +/// +public sealed 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); + OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); + RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); + } + + public string Title { get; } + + public string FullPath { get; } + + /// What the media control plays; a file:// URI built from the path. + public Uri Source { get; } + + /// Quality, duration and size on one line, for the page header. + public string Subtitle { get; } + + public ReactiveCommand CloseCommand { get; } + + public ReactiveCommand OpenExternallyCommand { get; } + + public ReactiveCommand RevealCommand { get; } +} diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml index 3c909c8..987b916 100644 --- a/src/PLib.Desktop/Views/MainWindow.axaml +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -20,6 +20,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs b/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs new file mode 100644 index 0000000..8e26a67 --- /dev/null +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs @@ -0,0 +1,126 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using MediaPlayer.Controls; +using PLib.Desktop.ViewModels; + +namespace PLib.Desktop.Views; + +/// +/// Transport controls for the media page. +/// +/// +/// Playback state lives on itself — position, duration and +/// whether it is playing are all its own properties, and seeking is a method call. Mirroring +/// that into the view model would buy nothing but a second copy to keep in sync, so this +/// code-behind wires the buttons straight to the control. Everything the page knows about +/// the video — title, path, the commands around it — stays in the view model. +/// +public sealed partial class VideoPlayerView : UserControl +{ + /// True while the user is dragging the seek bar, so playback must not fight them. + private bool _isScrubbing; + + public VideoPlayerView() + { + 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; + + Player.GetObservable(GpuMediaPlayer.PositionProperty).Subscribe(OnPositionChanged); + Player.GetObservable(GpuMediaPlayer.DurationProperty).Subscribe(OnDurationChanged); + Player.GetObservable(GpuMediaPlayer.IsPlayingProperty).Subscribe(OnIsPlayingChanged); + Player.GetObservable(GpuMediaPlayer.LastErrorProperty).Subscribe(OnErrorChanged); + + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + // Leaving the page has to stop the decoder; nothing else will. + Player.Stop(); + } + + private void OnPlayPause(object? sender, RoutedEventArgs e) + { + if (Player.IsPlaying) + { + Player.Pause(); + } + else + { + Player.Play(); + } + } + + private void OnToggleMute(object? sender, RoutedEventArgs e) => Player.IsMuted = !Player.IsMuted; + + private void OnScrubStarted(object? sender, PointerPressedEventArgs e) => _isScrubbing = true; + + private void OnScrubFinished(object? sender, PointerReleasedEventArgs e) + { + _isScrubbing = false; + 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); + + if (!_isScrubbing) + { + Seek.Value = position.TotalSeconds; + } + } + + private void OnDurationChanged(TimeSpan duration) + { + DurationText.Text = DisplayText.Duration(duration); + + // 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; + } + + private void OnIsPlayingChanged(bool isPlaying) => + PlayPauseIcon.Kind = isPlaying + ? Material.Icons.MaterialIconKind.Pause + : Material.Icons.MaterialIconKind.Play; + + private void OnErrorChanged(string? error) + { + ErrorText.Text = string.IsNullOrWhiteSpace(error) + ? null + : $"Не удалось воспроизвести файл: {error}"; + + ErrorText.IsVisible = !string.IsNullOrWhiteSpace(error); + } + + private static Material.Icons.MaterialIconKind VolumeIconFor(double volume, bool isMuted) => + isMuted || volume <= 0.001 + ? Material.Icons.MaterialIconKind.VolumeOff + : volume < 0.5 + ? Material.Icons.MaterialIconKind.VolumeMedium + : Material.Icons.MaterialIconKind.VolumeHigh; +}