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:
@@ -14,8 +14,8 @@
|
|||||||
в `settings.json` и подхватывается без перезапуска.
|
в `settings.json` и подхватывается без перезапуска.
|
||||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||||||
перемотка, громкость, кнопка «назад». Внешний плеер и «показать в папке» остались
|
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
||||||
в контекстном меню карточки.
|
Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
@@ -86,6 +86,9 @@ dotnet test
|
|||||||
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
|
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
|
||||||
Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore`
|
Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore`
|
||||||
гасит плеер.
|
гасит плеер.
|
||||||
|
По той же причине в полноэкранном режиме остаётся тонкая полоса управления внизу:
|
||||||
|
всплывающего оверлея поверх видео нативная поверхность не допускает, а движение мыши над
|
||||||
|
ней до Avalonia не доходит — автоскрытию не на что реагировать.
|
||||||
Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана
|
Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана
|
||||||
не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении
|
не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении
|
||||||
`OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит.
|
`OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
|
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
|
||||||
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
|
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
|
||||||
private readonly ObservableAsPropertyHelper<bool> _isPlayerOpen;
|
private readonly ObservableAsPropertyHelper<bool> _isPlayerOpen;
|
||||||
|
private readonly ObservableAsPropertyHelper<bool> _isVideoFullScreen;
|
||||||
|
|
||||||
public MainWindowViewModel(
|
public MainWindowViewModel(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
@@ -109,6 +110,16 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
() => { OpenedVideo = null; },
|
() => { OpenedVideo = null; },
|
||||||
this.WhenAnyValue(x => x.IsPlayerOpen));
|
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
|
// Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling
|
||||||
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
|
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
|
||||||
ScanCommand = ReactiveCommand.CreateFromObservable(
|
ScanCommand = ReactiveCommand.CreateFromObservable(
|
||||||
@@ -161,6 +172,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsPlayerOpen => _isPlayerOpen.Value;
|
public bool IsPlayerOpen => _isPlayerOpen.Value;
|
||||||
|
|
||||||
|
/// <summary>True while the media page has taken over the whole window.</summary>
|
||||||
|
public bool IsVideoFullScreen => _isVideoFullScreen.Value;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The settings panel while it is on screen, or <c>null</c>. Its presence is what the
|
/// 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.
|
/// overlay binds to — settings live in this window rather than a second one.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
|
using ReactiveUI.SourceGenerators;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
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
|
/// commands around the player — transport state belongs to the media control itself, which
|
||||||
/// already exposes position, duration and playback as bindable properties.
|
/// already exposes position, duration and playback as bindable properties.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class VideoPlayerViewModel : ViewModelBase
|
public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
|
public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,7 @@ public sealed class VideoPlayerViewModel : ViewModelBase
|
|||||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||||
|
|
||||||
CloseCommand = ReactiveCommand.Create(close);
|
CloseCommand = ReactiveCommand.Create(close);
|
||||||
|
ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; });
|
||||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(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> 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> OpenExternallyCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ======================= Header ======================= -->
|
<!-- ======================= Header ======================= -->
|
||||||
<Border Grid.Row="0" Classes="appBar">
|
<Border Grid.Row="0" Classes="appBar" IsVisible="{Binding !IsVideoFullScreen}">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="20">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="20">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ======================= Status bar ======================= -->
|
<!-- ======================= Status bar ======================= -->
|
||||||
<Border Grid.Row="2" Classes="statusBar">
|
<Border Grid.Row="2" Classes="statusBar" IsVisible="{Binding !IsVideoFullScreen}">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
|
||||||
|
|
||||||
<ProgressBar Grid.Column="0"
|
<ProgressBar Grid.Column="0"
|
||||||
|
|||||||
@@ -24,23 +24,32 @@ public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
|
|||||||
|
|
||||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The settings panel sits above the media page, so it is the one Escape dismisses
|
// Escape peels one layer at a time: the settings panel sits above everything, then
|
||||||
// first; only once it is gone does Escape mean "back to the library".
|
// full screen is given up, and only a plain media page means "back to the library".
|
||||||
if (viewModel.IsSettingsOpen)
|
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();
|
viewModel.CloseSettingsCommand.Execute().Subscribe();
|
||||||
}
|
break;
|
||||||
else if (viewModel.IsPlayerOpen)
|
|
||||||
{
|
case Key.Escape when viewModel.OpenedVideo is { IsFullScreen: true } fullScreen:
|
||||||
|
fullScreen.IsFullScreen = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.Escape when viewModel.IsPlayerOpen:
|
||||||
viewModel.ClosePlayerCommand.Execute().Subscribe();
|
viewModel.ClosePlayerCommand.Execute().Subscribe();
|
||||||
}
|
break;
|
||||||
else
|
|
||||||
{
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ======================= Page header ======================= -->
|
<!-- ======================= 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">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||||
|
|
||||||
<Button Grid.Column="0"
|
<Button Grid.Column="0"
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
<!-- ======================= Transport ======================= -->
|
<!-- ======================= Transport ======================= -->
|
||||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
<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">
|
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
||||||
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
||||||
@@ -112,6 +112,13 @@
|
|||||||
Value="0.8"
|
Value="0.8"
|
||||||
VerticalAlignment="Center" />
|
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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using System.Reactive.Disposables;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using PLib.Desktop.Controls;
|
using PLib.Desktop.Controls;
|
||||||
using PLib.Desktop.ViewModels;
|
using PLib.Desktop.ViewModels;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
namespace PLib.Desktop.Views;
|
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>
|
/// <summary>True while the user is dragging the seek bar, so playback must not fight them.</summary>
|
||||||
private bool _isScrubbing;
|
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()
|
public VideoPlayerView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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)
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnDetachedFromVisualTree(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.
|
// Leaving the page has to stop the decoder; nothing else will.
|
||||||
Player.Stop();
|
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)
|
private void OnPlayPause(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (Player.IsPlaying)
|
if (Player.IsPlaying)
|
||||||
|
|||||||
Reference in New Issue
Block a user