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; +}