From 7d88fe0a0135f4b58cc91dd45d8f972d6069a0bf Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 8 Aug 2026 15:31:52 +0300 Subject: [PATCH] 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. --- src/PLib.Desktop/Controls/VlcVideoView.cs | 553 +++++++++--------- src/PLib.Desktop/Views/VideoPlayerView.axaml | 2 +- .../Views/VideoPlayerView.axaml.cs | 13 + 3 files changed, 293 insertions(+), 275 deletions(-) diff --git a/src/PLib.Desktop/Controls/VlcVideoView.cs b/src/PLib.Desktop/Controls/VlcVideoView.cs index 71510c8..74fa73e 100644 --- a/src/PLib.Desktop/Controls/VlcVideoView.cs +++ b/src/PLib.Desktop/Controls/VlcVideoView.cs @@ -1,274 +1,279 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Platform; -using Avalonia.Threading; -using LibVLCSharp.Shared; - -namespace PLib.Desktop.Controls; - -/// -/// A video surface backed by LibVLC. -/// -/// -/// 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. -/// -/// 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. -/// -/// -public sealed class VlcVideoView : NativeControlHost -{ - public static readonly StyledProperty SourceProperty = - AvaloniaProperty.Register(nameof(Source)); - - public static readonly StyledProperty AutoPlayProperty = - AvaloniaProperty.Register(nameof(AutoPlay), defaultValue: true); - - public static readonly StyledProperty PositionProperty = - AvaloniaProperty.Register(nameof(Position)); - - public static readonly StyledProperty DurationProperty = - AvaloniaProperty.Register(nameof(Duration)); - - public static readonly StyledProperty IsPlayingProperty = - AvaloniaProperty.Register(nameof(IsPlaying)); - - public static readonly StyledProperty IsMutedProperty = - AvaloniaProperty.Register(nameof(IsMuted)); - - /// Volume as a fraction; LibVLC works in percent and is converted on the way in. - public static readonly StyledProperty VolumeProperty = - AvaloniaProperty.Register(nameof(Volume), defaultValue: 0.8); - - public static readonly StyledProperty LastErrorProperty = - AvaloniaProperty.Register(nameof(LastError)); - - private MediaPlayer? _player; - - /// - /// True once VLC has been handed the native window. Playback cannot start before that, - /// or VLC opens a top-level window of its own. - /// - 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; + +/// +/// A video surface backed by LibVLC. +/// +/// +/// 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. +/// +/// 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. +/// +/// +public sealed class VlcVideoView : NativeControlHost +{ + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + public static readonly StyledProperty AutoPlayProperty = + AvaloniaProperty.Register(nameof(AutoPlay), defaultValue: true); + + public static readonly StyledProperty PositionProperty = + AvaloniaProperty.Register(nameof(Position)); + + public static readonly StyledProperty DurationProperty = + AvaloniaProperty.Register(nameof(Duration)); + + public static readonly StyledProperty IsPlayingProperty = + AvaloniaProperty.Register(nameof(IsPlaying)); + + public static readonly StyledProperty IsMutedProperty = + AvaloniaProperty.Register(nameof(IsMuted)); + + /// Volume as a fraction; LibVLC works in percent and is converted on the way in. + public static readonly StyledProperty VolumeProperty = + AvaloniaProperty.Register(nameof(Volume), defaultValue: 0.8); + + public static readonly StyledProperty LastErrorProperty = + AvaloniaProperty.Register(nameof(LastError)); + + private MediaPlayer? _player; + + /// + /// True once VLC has been handed the native window. Playback cannot start before that, + /// or VLC opens a top-level window of its own. + /// + 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); +} diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml index 7136940..febdaba 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml @@ -64,7 +64,7 @@ - + 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)