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.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using LibVLCSharp.Shared;
namespace PLib.Desktop.Controls;
/// <summary>
/// A video surface backed by LibVLC.
/// </summary>
/// <remarks>
/// 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.
/// 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
/// rather than on it for exactly that reason.
/// <para>
/// 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
/// other player.
/// </para>
/// </remarks>
public sealed class VlcVideoView : NativeControlHost
{
public static readonly StyledProperty<Uri?> SourceProperty =
AvaloniaProperty.Register<VlcVideoView, Uri?>(nameof(Source));
public static readonly StyledProperty<bool> AutoPlayProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(AutoPlay), defaultValue: true);
public static readonly StyledProperty<TimeSpan> PositionProperty =
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Position));
public static readonly StyledProperty<TimeSpan> DurationProperty =
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Duration));
public static readonly StyledProperty<bool> IsPlayingProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsPlaying));
public static readonly StyledProperty<bool> IsMutedProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsMuted));
/// <summary>Volume as a fraction; LibVLC works in percent and is converted on the way in.</summary>
public static readonly StyledProperty<double> VolumeProperty =
AvaloniaProperty.Register<VlcVideoView, double>(nameof(Volume), defaultValue: 0.8);
public static readonly StyledProperty<string?> LastErrorProperty =
AvaloniaProperty.Register<VlcVideoView, string?>(nameof(LastError));
private MediaPlayer? _player;
/// <summary>
/// True once VLC has been handed the native window. Playback cannot start before that,
/// or VLC opens a top-level window of its own.
/// </summary>
private bool _surfaceReady;
public Uri? Source
{
get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
public bool AutoPlay
{
get => GetValue(AutoPlayProperty);
set => SetValue(AutoPlayProperty, value);
}
public TimeSpan Position
{
get => GetValue(PositionProperty);
private set => SetValue(PositionProperty, value);
}
public TimeSpan Duration
{
get => GetValue(DurationProperty);
private set => SetValue(DurationProperty, value);
}
public bool IsPlaying
{
get => GetValue(IsPlayingProperty);
private set => SetValue(IsPlayingProperty, value);
}
public bool IsMuted
{
get => GetValue(IsMutedProperty);
set => SetValue(IsMutedProperty, value);
}
public double Volume
{
get => GetValue(VolumeProperty);
set => SetValue(VolumeProperty, value);
}
public string? LastError
{
get => GetValue(LastErrorProperty);
private set => SetValue(LastErrorProperty, value);
}
public void Play()
{
if (_player is null)
{
return;
}
if (_player.Media is not null)
{
_player.Play();
}
else
{
OpenCurrentSource();
}
}
public void Pause() => _player?.SetPause(true);
public void Stop() => _player?.Stop();
public void Seek(TimeSpan position)
{
if (_player is { IsSeekable: true })
{
_player.Time = (long)position.TotalMilliseconds;
}
}
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
// Avalonia gives us an empty child window of the right platform kind; VLC renders
// into it once we hand over the handle.
var handle = base.CreateNativeControlCore(parent);
_player = new MediaPlayer(VlcRuntime.Shared);
AttachPlayerEvents(_player);
if (OperatingSystem.IsWindows())
{
_player.Hwnd = handle.Handle;
}
else if (OperatingSystem.IsLinux())
{
_player.XWindow = (uint)handle.Handle;
}
else if (OperatingSystem.IsMacOS())
{
_player.NsObject = handle.Handle;
}
_player.Mute = IsMuted;
_player.Volume = ToVlcVolume(Volume);
_surfaceReady = true;
if (AutoPlay)
{
OpenCurrentSource();
}
return handle;
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
// Tear the player down before the window it draws into disappears.
if (_player is { } player)
{
_player = null;
_surfaceReady = false;
DetachPlayerEvents(player);
player.Stop();
player.Dispose();
}
base.DestroyNativeControlCore(control);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (_player is not { } player)
{
return;
}
if (change.Property == SourceProperty)
{
OpenCurrentSource();
}
else if (change.Property == VolumeProperty)
{
player.Volume = ToVlcVolume(Volume);
}
else if (change.Property == IsMutedProperty)
{
player.Mute = IsMuted;
}
}
private void OpenCurrentSource()
{
if (!_surfaceReady || _player is not { } player || Source is not { } source)
{
return;
}
try
{
LastError = null;
// The media object only has to survive the call: VLC takes its own reference.
using var media = new Media(VlcRuntime.Shared, source);
if (!player.Play(media))
{
LastError = "VLC не смог открыть файл";
}
}
catch (Exception ex)
{
LastError = ex.Message;
}
}
private void AttachPlayerEvents(MediaPlayer player)
{
player.TimeChanged += OnTimeChanged;
player.LengthChanged += OnLengthChanged;
player.Playing += OnPlaying;
player.Paused += OnStoppedPlaying;
player.Stopped += OnStoppedPlaying;
player.EndReached += OnStoppedPlaying;
player.EncounteredError += OnEncounteredError;
}
private void DetachPlayerEvents(MediaPlayer player)
{
player.TimeChanged -= OnTimeChanged;
player.LengthChanged -= OnLengthChanged;
player.Playing -= OnPlaying;
player.Paused -= OnStoppedPlaying;
player.Stopped -= OnStoppedPlaying;
player.EndReached -= OnStoppedPlaying;
player.EncounteredError -= OnEncounteredError;
}
// Every VLC event arrives on one of its own threads, so nothing here may touch an
// Avalonia property directly.
private void OnTimeChanged(object? sender, MediaPlayerTimeChangedEventArgs e) =>
Post(() => Position = TimeSpan.FromMilliseconds(Math.Max(0, e.Time)));
private void OnLengthChanged(object? sender, MediaPlayerLengthChangedEventArgs e) =>
Post(() => Duration = TimeSpan.FromMilliseconds(Math.Max(0, e.Length)));
private void OnPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = true);
private void OnStoppedPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = false);
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);
}
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using LibVLCSharp.Shared;
namespace PLib.Desktop.Controls;
/// <summary>
/// A video surface backed by LibVLC.
/// </summary>
/// <remarks>
/// 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.
/// 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
/// rather than on it for exactly that reason.
/// <para>
/// 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
/// other player.
/// </para>
/// </remarks>
public sealed class VlcVideoView : NativeControlHost
{
public static readonly StyledProperty<Uri?> SourceProperty =
AvaloniaProperty.Register<VlcVideoView, Uri?>(nameof(Source));
public static readonly StyledProperty<bool> AutoPlayProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(AutoPlay), defaultValue: true);
public static readonly StyledProperty<TimeSpan> PositionProperty =
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Position));
public static readonly StyledProperty<TimeSpan> DurationProperty =
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Duration));
public static readonly StyledProperty<bool> IsPlayingProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsPlaying));
public static readonly StyledProperty<bool> IsMutedProperty =
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsMuted));
/// <summary>Volume as a fraction; LibVLC works in percent and is converted on the way in.</summary>
public static readonly StyledProperty<double> VolumeProperty =
AvaloniaProperty.Register<VlcVideoView, double>(nameof(Volume), defaultValue: 0.8);
public static readonly StyledProperty<string?> LastErrorProperty =
AvaloniaProperty.Register<VlcVideoView, string?>(nameof(LastError));
private MediaPlayer? _player;
/// <summary>
/// True once VLC has been handed the native window. Playback cannot start before that,
/// or VLC opens a top-level window of its own.
/// </summary>
private bool _surfaceReady;
public Uri? Source
{
get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
public bool AutoPlay
{
get => GetValue(AutoPlayProperty);
set => SetValue(AutoPlayProperty, value);
}
public TimeSpan Position
{
get => GetValue(PositionProperty);
private set => SetValue(PositionProperty, value);
}
public TimeSpan Duration
{
get => GetValue(DurationProperty);
private set => SetValue(DurationProperty, value);
}
public bool IsPlaying
{
get => GetValue(IsPlayingProperty);
private set => SetValue(IsPlayingProperty, value);
}
public bool IsMuted
{
get => GetValue(IsMutedProperty);
set => SetValue(IsMutedProperty, value);
}
public double Volume
{
get => GetValue(VolumeProperty);
set => SetValue(VolumeProperty, value);
}
public string? LastError
{
get => GetValue(LastErrorProperty);
private set => SetValue(LastErrorProperty, value);
}
public void Play()
{
if (_player is null)
{
return;
}
if (_player.Media is not null)
{
_player.Play();
}
else
{
OpenCurrentSource();
}
}
public void Pause() => _player?.SetPause(true);
public void Stop() => _player?.Stop();
public void Seek(TimeSpan position)
{
if (_player is { IsSeekable: true })
{
_player.Time = (long)position.TotalMilliseconds;
}
}
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
// Avalonia gives us an empty child window of the right platform kind; VLC renders
// into it once we hand over the handle.
var handle = base.CreateNativeControlCore(parent);
_player = new MediaPlayer(VlcRuntime.Shared);
AttachPlayerEvents(_player);
if (OperatingSystem.IsWindows())
{
_player.Hwnd = handle.Handle;
}
else if (OperatingSystem.IsLinux())
{
_player.XWindow = (uint)handle.Handle;
}
else if (OperatingSystem.IsMacOS())
{
_player.NsObject = handle.Handle;
}
// VLC grabs mouse and keyboard on its own window by default, which swallows every
// gesture before Avalonia can see it. We do not need DVD menus, so hand input back.
_player.EnableMouseInput = false;
_player.EnableKeyInput = false;
_player.Mute = IsMuted;
_player.Volume = ToVlcVolume(Volume);
_surfaceReady = true;
if (AutoPlay)
{
OpenCurrentSource();
}
return handle;
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
// Tear the player down before the window it draws into disappears.
if (_player is { } player)
{
_player = null;
_surfaceReady = false;
DetachPlayerEvents(player);
player.Stop();
player.Dispose();
}
base.DestroyNativeControlCore(control);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (_player is not { } player)
{
return;
}
if (change.Property == SourceProperty)
{
OpenCurrentSource();
}
else if (change.Property == VolumeProperty)
{
player.Volume = ToVlcVolume(Volume);
}
else if (change.Property == IsMutedProperty)
{
player.Mute = IsMuted;
}
}
private void OpenCurrentSource()
{
if (!_surfaceReady || _player is not { } player || Source is not { } source)
{
return;
}
try
{
LastError = null;
// The media object only has to survive the call: VLC takes its own reference.
using var media = new Media(VlcRuntime.Shared, source);
if (!player.Play(media))
{
LastError = "VLC не смог открыть файл";
}
}
catch (Exception ex)
{
LastError = ex.Message;
}
}
private void AttachPlayerEvents(MediaPlayer player)
{
player.TimeChanged += OnTimeChanged;
player.LengthChanged += OnLengthChanged;
player.Playing += OnPlaying;
player.Paused += OnStoppedPlaying;
player.Stopped += OnStoppedPlaying;
player.EndReached += OnStoppedPlaying;
player.EncounteredError += OnEncounteredError;
}
private void DetachPlayerEvents(MediaPlayer player)
{
player.TimeChanged -= OnTimeChanged;
player.LengthChanged -= OnLengthChanged;
player.Playing -= OnPlaying;
player.Paused -= OnStoppedPlaying;
player.Stopped -= OnStoppedPlaying;
player.EndReached -= OnStoppedPlaying;
player.EncounteredError -= OnEncounteredError;
}
// Every VLC event arrives on one of its own threads, so nothing here may touch an
// Avalonia property directly.
private void OnTimeChanged(object? sender, MediaPlayerTimeChangedEventArgs e) =>
Post(() => Position = TimeSpan.FromMilliseconds(Math.Max(0, e.Time)));
private void OnLengthChanged(object? sender, MediaPlayerLengthChangedEventArgs e) =>
Post(() => Duration = TimeSpan.FromMilliseconds(Math.Max(0, e.Length)));
private void OnPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = true);
private void OnStoppedPlaying(object? sender, EventArgs e) => Post(() => IsPlaying = false);
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
the error message lives in its own row instead of on top of the picture. -->
<Grid Grid.Row="1" RowDefinitions="*,Auto">
<Panel Grid.Row="0" Background="Black">
<Panel Grid.Row="0" Name="VideoArea" Background="Black">
<controls:VlcVideoView Name="Player"
Source="{Binding Source}"
AutoPlay="True"
@@ -43,6 +43,10 @@ public sealed partial class VideoPlayerView : UserControl
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.DurationProperty).Subscribe(OnDurationChanged);
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 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 OnScrubFinished(object? sender, PointerReleasedEventArgs e)