Enhance VlcVideoView and VideoPlayerView for improved user interaction. Add a named VideoArea panel for better layout management and implement double-tap functionality to toggle full-screen mode. Update event handling in VideoPlayerView to accommodate these changes.

This commit is contained in:
Leonid Pershin
2026-08-08 15:31:52 +03:00
parent 1a16111faf
commit 7d88fe0a01
3 changed files with 293 additions and 275 deletions
+279 -274
View File
@@ -1,274 +1,279 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Threading; using Avalonia.Threading;
using LibVLCSharp.Shared; using LibVLCSharp.Shared;
namespace PLib.Desktop.Controls; namespace PLib.Desktop.Controls;
/// <summary> /// <summary>
/// A video surface backed by LibVLC. /// A video surface backed by LibVLC.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// VLC draws straight into a native child window that Avalonia creates for us, so no frame /// VLC draws straight into a native child window that Avalonia creates for us, so no frame
/// ever crosses into managed memory — playback costs what the decoder costs and nothing more. /// ever crosses into managed memory — playback costs what the decoder costs and nothing more.
/// The price is airspace: this region is a separate window on top of the Avalonia surface, so /// The price is airspace: this region is a separate window on top of the Avalonia surface, so
/// nothing can be drawn over the picture. The media page keeps its controls beside the video /// nothing can be drawn over the picture. The media page keeps its controls beside the video
/// rather than on it for exactly that reason. /// rather than on it for exactly that reason.
/// <para> /// <para>
/// The property surface deliberately mirrors what a media control is expected to expose — /// The property surface deliberately mirrors what a media control is expected to expose —
/// source, position, duration, volume — so the page binds to it the same way it would to any /// source, position, duration, volume — so the page binds to it the same way it would to any
/// other player. /// other player.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class VlcVideoView : NativeControlHost public sealed class VlcVideoView : NativeControlHost
{ {
public static readonly StyledProperty<Uri?> SourceProperty = public static readonly StyledProperty<Uri?> SourceProperty =
AvaloniaProperty.Register<VlcVideoView, Uri?>(nameof(Source)); AvaloniaProperty.Register<VlcVideoView, Uri?>(nameof(Source));
public static readonly StyledProperty<bool> AutoPlayProperty = public static readonly StyledProperty<bool> AutoPlayProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(AutoPlay), defaultValue: true); AvaloniaProperty.Register<VlcVideoView, bool>(nameof(AutoPlay), defaultValue: true);
public static readonly StyledProperty<TimeSpan> PositionProperty = public static readonly StyledProperty<TimeSpan> PositionProperty =
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Position)); AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Position));
public static readonly StyledProperty<TimeSpan> DurationProperty = public static readonly StyledProperty<TimeSpan> DurationProperty =
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Duration)); AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Duration));
public static readonly StyledProperty<bool> IsPlayingProperty = public static readonly StyledProperty<bool> IsPlayingProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsPlaying)); AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsPlaying));
public static readonly StyledProperty<bool> IsMutedProperty = public static readonly StyledProperty<bool> IsMutedProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsMuted)); AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsMuted));
/// <summary>Volume as a fraction; LibVLC works in percent and is converted on the way in.</summary> /// <summary>Volume as a fraction; LibVLC works in percent and is converted on the way in.</summary>
public static readonly StyledProperty<double> VolumeProperty = public static readonly StyledProperty<double> VolumeProperty =
AvaloniaProperty.Register<VlcVideoView, double>(nameof(Volume), defaultValue: 0.8); AvaloniaProperty.Register<VlcVideoView, double>(nameof(Volume), defaultValue: 0.8);
public static readonly StyledProperty<string?> LastErrorProperty = public static readonly StyledProperty<string?> LastErrorProperty =
AvaloniaProperty.Register<VlcVideoView, string?>(nameof(LastError)); AvaloniaProperty.Register<VlcVideoView, string?>(nameof(LastError));
private MediaPlayer? _player; private MediaPlayer? _player;
/// <summary> /// <summary>
/// True once VLC has been handed the native window. Playback cannot start before that, /// True once VLC has been handed the native window. Playback cannot start before that,
/// or VLC opens a top-level window of its own. /// or VLC opens a top-level window of its own.
/// </summary> /// </summary>
private bool _surfaceReady; private bool _surfaceReady;
public Uri? Source public Uri? Source
{ {
get => GetValue(SourceProperty); get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value); set => SetValue(SourceProperty, value);
} }
public bool AutoPlay public bool AutoPlay
{ {
get => GetValue(AutoPlayProperty); get => GetValue(AutoPlayProperty);
set => SetValue(AutoPlayProperty, value); set => SetValue(AutoPlayProperty, value);
} }
public TimeSpan Position public TimeSpan Position
{ {
get => GetValue(PositionProperty); get => GetValue(PositionProperty);
private set => SetValue(PositionProperty, value); private set => SetValue(PositionProperty, value);
} }
public TimeSpan Duration public TimeSpan Duration
{ {
get => GetValue(DurationProperty); get => GetValue(DurationProperty);
private set => SetValue(DurationProperty, value); private set => SetValue(DurationProperty, value);
} }
public bool IsPlaying public bool IsPlaying
{ {
get => GetValue(IsPlayingProperty); get => GetValue(IsPlayingProperty);
private set => SetValue(IsPlayingProperty, value); private set => SetValue(IsPlayingProperty, value);
} }
public bool IsMuted public bool IsMuted
{ {
get => GetValue(IsMutedProperty); get => GetValue(IsMutedProperty);
set => SetValue(IsMutedProperty, value); set => SetValue(IsMutedProperty, value);
} }
public double Volume public double Volume
{ {
get => GetValue(VolumeProperty); get => GetValue(VolumeProperty);
set => SetValue(VolumeProperty, value); set => SetValue(VolumeProperty, value);
} }
public string? LastError public string? LastError
{ {
get => GetValue(LastErrorProperty); get => GetValue(LastErrorProperty);
private set => SetValue(LastErrorProperty, value); private set => SetValue(LastErrorProperty, value);
} }
public void Play() public void Play()
{ {
if (_player is null) if (_player is null)
{ {
return; return;
} }
if (_player.Media is not null) if (_player.Media is not null)
{ {
_player.Play(); _player.Play();
} }
else else
{ {
OpenCurrentSource(); OpenCurrentSource();
} }
} }
public void Pause() => _player?.SetPause(true); public void Pause() => _player?.SetPause(true);
public void Stop() => _player?.Stop(); public void Stop() => _player?.Stop();
public void Seek(TimeSpan position) public void Seek(TimeSpan position)
{ {
if (_player is { IsSeekable: true }) if (_player is { IsSeekable: true })
{ {
_player.Time = (long)position.TotalMilliseconds; _player.Time = (long)position.TotalMilliseconds;
} }
} }
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent) protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{ {
// Avalonia gives us an empty child window of the right platform kind; VLC renders // Avalonia gives us an empty child window of the right platform kind; VLC renders
// into it once we hand over the handle. // into it once we hand over the handle.
var handle = base.CreateNativeControlCore(parent); var handle = base.CreateNativeControlCore(parent);
_player = new MediaPlayer(VlcRuntime.Shared); _player = new MediaPlayer(VlcRuntime.Shared);
AttachPlayerEvents(_player); AttachPlayerEvents(_player);
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
_player.Hwnd = handle.Handle; _player.Hwnd = handle.Handle;
} }
else if (OperatingSystem.IsLinux()) else if (OperatingSystem.IsLinux())
{ {
_player.XWindow = (uint)handle.Handle; _player.XWindow = (uint)handle.Handle;
} }
else if (OperatingSystem.IsMacOS()) else if (OperatingSystem.IsMacOS())
{ {
_player.NsObject = handle.Handle; _player.NsObject = handle.Handle;
} }
_player.Mute = IsMuted; // VLC grabs mouse and keyboard on its own window by default, which swallows every
_player.Volume = ToVlcVolume(Volume); // gesture before Avalonia can see it. We do not need DVD menus, so hand input back.
_surfaceReady = true; _player.EnableMouseInput = false;
_player.EnableKeyInput = false;
if (AutoPlay)
{ _player.Mute = IsMuted;
OpenCurrentSource(); _player.Volume = ToVlcVolume(Volume);
} _surfaceReady = true;
return handle; if (AutoPlay)
} {
OpenCurrentSource();
protected override void DestroyNativeControlCore(IPlatformHandle control) }
{
// Tear the player down before the window it draws into disappears. return handle;
if (_player is { } player) }
{
_player = null; protected override void DestroyNativeControlCore(IPlatformHandle control)
_surfaceReady = false; {
// Tear the player down before the window it draws into disappears.
DetachPlayerEvents(player); if (_player is { } player)
player.Stop(); {
player.Dispose(); _player = null;
} _surfaceReady = false;
base.DestroyNativeControlCore(control); DetachPlayerEvents(player);
} player.Stop();
player.Dispose();
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) }
{
base.OnPropertyChanged(change); base.DestroyNativeControlCore(control);
}
if (_player is not { } player)
{ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
return; {
} base.OnPropertyChanged(change);
if (change.Property == SourceProperty) if (_player is not { } player)
{ {
OpenCurrentSource(); return;
} }
else if (change.Property == VolumeProperty)
{ if (change.Property == SourceProperty)
player.Volume = ToVlcVolume(Volume); {
} OpenCurrentSource();
else if (change.Property == IsMutedProperty) }
{ else if (change.Property == VolumeProperty)
player.Mute = IsMuted; {
} player.Volume = ToVlcVolume(Volume);
} }
else if (change.Property == IsMutedProperty)
private void OpenCurrentSource() {
{ player.Mute = IsMuted;
if (!_surfaceReady || _player is not { } player || Source is not { } source) }
{ }
return;
} private void OpenCurrentSource()
{
try if (!_surfaceReady || _player is not { } player || Source is not { } source)
{ {
LastError = null; return;
}
// The media object only has to survive the call: VLC takes its own reference.
using var media = new Media(VlcRuntime.Shared, source); try
{
if (!player.Play(media)) LastError = null;
{
LastError = "VLC не смог открыть файл"; // The media object only has to survive the call: VLC takes its own reference.
} using var media = new Media(VlcRuntime.Shared, source);
}
catch (Exception ex) if (!player.Play(media))
{ {
LastError = ex.Message; LastError = "VLC не смог открыть файл";
} }
} }
catch (Exception ex)
private void AttachPlayerEvents(MediaPlayer player) {
{ LastError = ex.Message;
player.TimeChanged += OnTimeChanged; }
player.LengthChanged += OnLengthChanged; }
player.Playing += OnPlaying;
player.Paused += OnStoppedPlaying; private void AttachPlayerEvents(MediaPlayer player)
player.Stopped += OnStoppedPlaying; {
player.EndReached += OnStoppedPlaying; player.TimeChanged += OnTimeChanged;
player.EncounteredError += OnEncounteredError; player.LengthChanged += OnLengthChanged;
} player.Playing += OnPlaying;
player.Paused += OnStoppedPlaying;
private void DetachPlayerEvents(MediaPlayer player) player.Stopped += OnStoppedPlaying;
{ player.EndReached += OnStoppedPlaying;
player.TimeChanged -= OnTimeChanged; player.EncounteredError += OnEncounteredError;
player.LengthChanged -= OnLengthChanged; }
player.Playing -= OnPlaying;
player.Paused -= OnStoppedPlaying; private void DetachPlayerEvents(MediaPlayer player)
player.Stopped -= OnStoppedPlaying; {
player.EndReached -= OnStoppedPlaying; player.TimeChanged -= OnTimeChanged;
player.EncounteredError -= OnEncounteredError; player.LengthChanged -= OnLengthChanged;
} player.Playing -= OnPlaying;
player.Paused -= OnStoppedPlaying;
// Every VLC event arrives on one of its own threads, so nothing here may touch an player.Stopped -= OnStoppedPlaying;
// Avalonia property directly. player.EndReached -= OnStoppedPlaying;
private void OnTimeChanged(object? sender, MediaPlayerTimeChangedEventArgs e) => player.EncounteredError -= OnEncounteredError;
Post(() => Position = TimeSpan.FromMilliseconds(Math.Max(0, e.Time))); }
private void OnLengthChanged(object? sender, MediaPlayerLengthChangedEventArgs e) => // Every VLC event arrives on one of its own threads, so nothing here may touch an
Post(() => Duration = TimeSpan.FromMilliseconds(Math.Max(0, e.Length))); // Avalonia property directly.
private void OnTimeChanged(object? sender, MediaPlayerTimeChangedEventArgs e) =>
private void OnPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = true); Post(() => Position = TimeSpan.FromMilliseconds(Math.Max(0, e.Time)));
private void OnStoppedPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = false); private void OnLengthChanged(object? sender, MediaPlayerLengthChangedEventArgs e) =>
Post(() => Duration = TimeSpan.FromMilliseconds(Math.Max(0, e.Length)));
private void OnEncounteredError(object? sender, EventArgs e) =>
Post(() => LastError = "VLC сообщил об ошибке воспроизведения"); private void OnPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = true);
private static void Post(Action action) => Dispatcher.UIThread.Post(action, DispatcherPriority.Background); private void OnStoppedPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = false);
private static int ToVlcVolume(double volume) => (int)Math.Round(Math.Clamp(volume, 0, 1) * 100); private void OnEncounteredError(object? sender, EventArgs e) =>
} Post(() => LastError = "VLC сообщил об ошибке воспроизведения");
private static void Post(Action action) => Dispatcher.UIThread.Post(action, DispatcherPriority.Background);
private static int ToVlcVolume(double volume) => (int)Math.Round(Math.Clamp(volume, 0, 1) * 100);
}
+1 -1
View File
@@ -64,7 +64,7 @@
<!-- VLC paints into a native child window, which sits above the Avalonia surface, so <!-- VLC paints into a native child window, which sits above the Avalonia surface, so
the error message lives in its own row instead of on top of the picture. --> the error message lives in its own row instead of on top of the picture. -->
<Grid Grid.Row="1" RowDefinitions="*,Auto"> <Grid Grid.Row="1" RowDefinitions="*,Auto">
<Panel Grid.Row="0" Background="Black"> <Panel Grid.Row="0" Name="VideoArea" Background="Black">
<controls:VlcVideoView Name="Player" <controls:VlcVideoView Name="Player"
Source="{Binding Source}" Source="{Binding Source}"
AutoPlay="True" AutoPlay="True"
@@ -43,6 +43,10 @@ public sealed partial class VideoPlayerView : UserControl
VolumeSlider.PropertyChanged += OnVolumeChanged; VolumeSlider.PropertyChanged += OnVolumeChanged;
// Handled on the surrounding panel rather than the video itself: the native window
// is not part of Avalonia's hit-test tree, so the gesture can only arrive here.
VideoArea.DoubleTapped += OnVideoDoubleTapped;
Player.GetObservable(VlcVideoView.PositionProperty).Subscribe(OnPositionChanged); Player.GetObservable(VlcVideoView.PositionProperty).Subscribe(OnPositionChanged);
Player.GetObservable(VlcVideoView.DurationProperty).Subscribe(OnDurationChanged); Player.GetObservable(VlcVideoView.DurationProperty).Subscribe(OnDurationChanged);
Player.GetObservable(VlcVideoView.IsPlayingProperty).Subscribe(OnIsPlayingChanged); Player.GetObservable(VlcVideoView.IsPlayingProperty).Subscribe(OnIsPlayingChanged);
@@ -114,6 +118,15 @@ public sealed partial class VideoPlayerView : UserControl
private void OnToggleMute(object? sender, RoutedEventArgs e) => Player.IsMuted = !Player.IsMuted; private void OnToggleMute(object? sender, RoutedEventArgs e) => Player.IsMuted = !Player.IsMuted;
private void OnVideoDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is VideoPlayerViewModel viewModel)
{
viewModel.IsFullScreen = !viewModel.IsFullScreen;
e.Handled = true;
}
}
private void OnScrubStarted(object? sender, PointerPressedEventArgs e) => _isScrubbing = true; private void OnScrubStarted(object? sender, PointerPressedEventArgs e) => _isScrubbing = true;
private void OnScrubFinished(object? sender, PointerReleasedEventArgs e) private void OnScrubFinished(object? sender, PointerReleasedEventArgs e)