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.

This commit is contained in:
Leonid Pershin
2026-08-08 15:24:15 +03:00
parent 45814cf4d2
commit 1a16111faf
7 changed files with 114 additions and 20 deletions
+5 -2
View File
@@ -14,8 +14,8 @@
в `settings.json` и подхватывается без перезапуска.
- Светлая, тёмная и системная темы; выбор запоминается.
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
перемотка, громкость, кнопка «назад». Внешний плеер и «показать в папке» остались
в контекстном меню карточки.
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.
## Требования
@@ -86,6 +86,9 @@ dotnet test
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore`
гасит плеер.
По той же причине в полноэкранном режиме остаётся тонкая полоса управления внизу:
всплывающего оверлея поверх видео нативная поверхность не допускает, а движение мыши над
ней до Avalonia не доходит — автоскрытию не на что реагировать.
Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана
не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении
`OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит.
@@ -61,6 +61,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
private readonly ObservableAsPropertyHelper<bool> _isPlayerOpen;
private readonly ObservableAsPropertyHelper<bool> _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;
/// <summary>True while the media page has taken over the whole window.</summary>
public bool IsVideoFullScreen => _isVideoFullScreen.Value;
/// <summary>
/// The settings panel while it is on screen, or <c>null</c>. Its presence is what the
/// overlay binds to — settings live in this window rather than a second one.
@@ -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.
/// </summary>
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<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; }
+2 -2
View File
@@ -103,7 +103,7 @@
<Grid RowDefinitions="Auto,*,Auto">
<!-- ======================= Header ======================= -->
<Border Grid.Row="0" Classes="appBar">
<Border Grid.Row="0" Classes="appBar" IsVisible="{Binding !IsVideoFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="20">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
@@ -236,7 +236,7 @@
</Grid>
<!-- ======================= Status bar ======================= -->
<Border Grid.Row="2" Classes="statusBar">
<Border Grid.Row="2" Classes="statusBar" IsVisible="{Binding !IsVideoFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
<ProgressBar Grid.Column="0"
+19 -10
View File
@@ -24,23 +24,32 @@ public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
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)
{
case Key.F11 when viewModel.OpenedVideo is { } video:
video.ToggleFullScreenCommand.Execute().Subscribe();
break;
case Key.Escape when viewModel.IsSettingsOpen:
viewModel.CloseSettingsCommand.Execute().Subscribe();
}
else if (viewModel.IsPlayerOpen)
{
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();
}
else
{
break;
default:
return;
}
+9 -2
View File
@@ -26,7 +26,7 @@
<Grid RowDefinitions="Auto,*,Auto">
<!-- ======================= Page header ======================= -->
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10">
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10" IsVisible="{Binding !IsFullScreen}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<Button Grid.Column="0"
@@ -84,7 +84,7 @@
<!-- ======================= Transport ======================= -->
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="10">
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
@@ -112,6 +112,13 @@
Value="0.8"
VerticalAlignment="Center" />
<Button Grid.Column="6"
Classes="transport"
Command="{Binding ToggleFullScreenCommand}"
ToolTip.Tip="Во весь экран (F11)">
<icons:MaterialIcon Name="FullScreenIcon" Kind="Fullscreen" Width="19" Height="19" />
</Button>
</Grid>
</Border>
@@ -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
/// <summary>True while the user is dragging the seek bar, so playback must not fight them.</summary>
private bool _isScrubbing;
private readonly CompositeDisposable _subscriptions = [];
/// <summary>The window state to come back to when full screen is switched off.</summary>
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)