From 1a16111faf21949f61c017ccbba897b90f0d1ad4 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 8 Aug 2026 15:24:15 +0300 Subject: [PATCH] Implement full-screen video playback feature in PLib video library manager. Add toggle functionality for full-screen mode in VideoPlayerViewModel and corresponding UI updates in VideoPlayerView and MainWindow. Adjust key event handling for full-screen toggling and enhance README.md to document new features and usage instructions. --- README.md | 7 ++- .../ViewModels/MainWindowViewModel.cs | 14 ++++++ .../ViewModels/VideoPlayerViewModel.cs | 14 +++++- src/PLib.Desktop/Views/MainWindow.axaml | 4 +- src/PLib.Desktop/Views/MainWindow.axaml.cs | 35 ++++++++----- src/PLib.Desktop/Views/VideoPlayerView.axaml | 11 ++++- .../Views/VideoPlayerView.axaml.cs | 49 +++++++++++++++++++ 7 files changed, 114 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8211960..7c21d84 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ в `settings.json` и подхватывается без перезапуска. - Светлая, тёмная и системная темы; выбор запоминается. - Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, - перемотка, громкость, кнопка «назад». Внешний плеер и «показать в папке» остались - в контекстном меню карточки. + перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — + Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки. ## Требования @@ -86,6 +86,9 @@ dotnet test дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore` гасит плеер. + По той же причине в полноэкранном режиме остаётся тонкая полоса управления внизу: + всплывающего оверлея поверх видео нативная поверхность не допускает, а движение мыши над + ней до Avalonia не доходит — автоскрытию не на что реагировать. Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении `OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит. diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index 10195bc..4e4dd48 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -61,6 +61,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly ObservableAsPropertyHelper _isEmpty; private readonly ObservableAsPropertyHelper _isSettingsOpen; private readonly ObservableAsPropertyHelper _isPlayerOpen; + private readonly ObservableAsPropertyHelper _isVideoFullScreen; public MainWindowViewModel( IServiceScopeFactory scopeFactory, @@ -109,6 +110,16 @@ public sealed partial class MainWindowViewModel : ViewModelBase () => { OpenedVideo = null; }, this.WhenAnyValue(x => x.IsPlayerOpen)); + // Follow whichever page is open: Switch drops the previous page's flag when the + // page is replaced, so a closed player can never leave the window without chrome. + _isVideoFullScreen = this + .WhenAnyValue(x => x.OpenedVideo) + .Select(video => video is null + ? Observable.Return(false) + : video.WhenAnyValue(x => x.IsFullScreen)) + .Switch() + .ToProperty(this, x => x.IsVideoFullScreen); + // 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( @@ -161,6 +172,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase public bool IsPlayerOpen => _isPlayerOpen.Value; + /// True while the media page has taken over the whole window. + public bool IsVideoFullScreen => _isVideoFullScreen.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. diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs index 0b21fc0..7626c46 100644 --- a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs @@ -1,5 +1,6 @@ using PLib.Desktop.Services; using ReactiveUI; +using ReactiveUI.SourceGenerators; using RxVoid = ReactiveUI.Primitives.RxVoid; namespace PLib.Desktop.ViewModels; @@ -9,7 +10,7 @@ namespace PLib.Desktop.ViewModels; /// 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 sealed partial class VideoPlayerViewModel : ViewModelBase { public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close) { @@ -23,6 +24,7 @@ public sealed class VideoPlayerViewModel : ViewModelBase .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)); } @@ -39,6 +41,16 @@ public sealed class VideoPlayerViewModel : ViewModelBase public ReactiveCommand CloseCommand { get; } + public ReactiveCommand ToggleFullScreenCommand { 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. + /// + [Reactive] + public partial bool IsFullScreen { get; set; } + 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 987b916..a2dfd20 100644 --- a/src/PLib.Desktop/Views/MainWindow.axaml +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -103,7 +103,7 @@ - + @@ -236,7 +236,7 @@ - + private void OnPreviewKeyDown(object? sender, KeyEventArgs e) { - if (e.Key is not Key.Escape || DataContext is not MainWindowViewModel viewModel) + if (DataContext is not MainWindowViewModel viewModel) { return; } - // The settings panel sits above the media page, so it is the one Escape dismisses - // first; only once it is gone does Escape mean "back to the library". - if (viewModel.IsSettingsOpen) + // Escape peels one layer at a time: the settings panel sits above everything, then + // full screen is given up, and only a plain media page means "back to the library". + switch (e.Key) { - viewModel.CloseSettingsCommand.Execute().Subscribe(); - } - else if (viewModel.IsPlayerOpen) - { - viewModel.ClosePlayerCommand.Execute().Subscribe(); - } - else - { - return; + case Key.F11 when viewModel.OpenedVideo is { } video: + video.ToggleFullScreenCommand.Execute().Subscribe(); + break; + + case Key.Escape when viewModel.IsSettingsOpen: + viewModel.CloseSettingsCommand.Execute().Subscribe(); + break; + + case Key.Escape when viewModel.OpenedVideo is { IsFullScreen: true } fullScreen: + fullScreen.IsFullScreen = false; + break; + + case Key.Escape when viewModel.IsPlayerOpen: + viewModel.ClosePlayerCommand.Execute().Subscribe(); + break; + + default: + return; } e.Handled = true; diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml index 63a9a7e..7136940 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml @@ -26,7 +26,7 @@ - + + diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs b/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs index a2acebb..eef0298 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs @@ -2,9 +2,11 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; +using System.Reactive.Disposables; using Avalonia.Interactivity; using PLib.Desktop.Controls; using PLib.Desktop.ViewModels; +using ReactiveUI; namespace PLib.Desktop.Views; @@ -23,6 +25,11 @@ public sealed partial class VideoPlayerView : UserControl /// True while the user is dragging the seek bar, so playback must not fight them. private bool _isScrubbing; + private readonly CompositeDisposable _subscriptions = []; + + /// The window state to come back to when full screen is switched off. + private WindowState _stateBeforeFullScreen = WindowState.Normal; + public VideoPlayerView() { InitializeComponent(); @@ -43,14 +50,56 @@ public sealed partial class VideoPlayerView : UserControl } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (DataContext is VideoPlayerViewModel viewModel) + { + _subscriptions.Add(viewModel + .WhenAnyValue(x => x.IsFullScreen) + .Subscribe(ApplyFullScreen)); + } + } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); + _subscriptions.Clear(); + + // Closing the page while full screen would otherwise strand the window with no chrome. + ApplyFullScreen(false); + // Leaving the page has to stop the decoder; nothing else will. Player.Stop(); } + private void ApplyFullScreen(bool isFullScreen) + { + if (TopLevel.GetTopLevel(this) is not Window window) + { + return; + } + + if (isFullScreen) + { + if (window.WindowState != WindowState.FullScreen) + { + _stateBeforeFullScreen = window.WindowState; + window.WindowState = WindowState.FullScreen; + } + } + else if (window.WindowState == WindowState.FullScreen) + { + window.WindowState = _stateBeforeFullScreen; + } + + FullScreenIcon.Kind = isFullScreen + ? Material.Icons.MaterialIconKind.FullscreenExit + : Material.Icons.MaterialIconKind.Fullscreen; + } + private void OnPlayPause(object? sender, RoutedEventArgs e) { if (Player.IsPlaying)