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:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user