diff --git a/README.md b/README.md index 7c21d84..be5cee7 100644 --- a/README.md +++ b/README.md @@ -78,20 +78,19 @@ dotnet test только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра его не вызывает. -- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** Avalonia создаёт нативное - дочернее окно, VLC рисует прямо в него: ни один кадр не проходит через управляемую память. - Расплата — airspace: поверх видео ничего нарисовать нельзя, поэтому контролы и сообщения - об ошибках живут рядом с картинкой, а не на ней. Транспорт (позиция, длительность, - play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы; - дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её. - Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore` - гасит плеер. - По той же причине в полноэкранном режиме остаётся тонкая полоса управления внизу: - всплывающего оверлея поверх видео нативная поверхность не допускает, а движение мыши над - ней до Avalonia не доходит — автоскрытию не на что реагировать. - Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана - не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении - `OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит. +- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** VLC декодирует в память, + которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia — + участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно + копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения. + Буферов два: VLC декодирует в один, пока мы читаем другой; на `Display` они меняются + местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого + контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель + значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляет + `OpenedVideo`, вью уходит из дерева, и плеер гасится вместе с буферами. + До этого пробовали два готовых пути. `MediaPlayer.Controls`: декодер работал, но кадры до + экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в + приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка + появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей. - **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного diff --git a/src/PLib.Desktop/Controls/VlcVideoView.cs b/src/PLib.Desktop/Controls/VlcVideoView.cs index 74fa73e..628a7fa 100644 --- a/src/PLib.Desktop/Controls/VlcVideoView.cs +++ b/src/PLib.Desktop/Controls/VlcVideoView.cs @@ -1,279 +1,463 @@ -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); -} +using System.Runtime.InteropServices; +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using LibVLCSharp.Shared; + +namespace PLib.Desktop.Controls; + +/// +/// A video surface backed by LibVLC that draws through Avalonia. +/// +/// +/// VLC decodes into memory we hand it and we paint the result ourselves, so the video is an +/// ordinary control: it takes part in hit testing, gestures reach it, and anything can be +/// drawn on top. The alternative — letting VLC render into a native child window — costs +/// nothing per frame but puts the picture in its own window above the Avalonia surface, +/// where no click and no overlay can reach it. +/// +/// The price paid here is one copy per displayed frame. At 1080p that is a few megabytes; +/// at 4K it becomes the dominant cost of playback. +/// +/// +public sealed class VlcVideoView : Control +{ + 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)); + + /// VLC's 32-bit packed BGRX; matches byte for byte. + private const string Chroma = "RV32"; + + /// + /// Guards the frame buffers and the bitmap. Held only for a pointer swap on the decoder + /// side and for the copy on the render side, never across a call into VLC. + /// + private readonly Lock _gate = new(); + + // Kept in fields because they are handed to native code: a local delegate would be + // collected while VLC still holds the function pointer. + private readonly MediaPlayer.LibVLCVideoFormatCb _formatCallback; + private readonly MediaPlayer.LibVLCVideoCleanupCb _cleanupCallback; + private readonly MediaPlayer.LibVLCVideoLockCb _lockCallback; + private readonly MediaPlayer.LibVLCVideoDisplayCb _displayCallback; + + private MediaPlayer? _player; + private WriteableBitmap? _bitmap; + + /// The buffer VLC is decoding into right now. + private IntPtr _back; + + /// The most recently completed frame, safe to read while VLC fills the other one. + private IntPtr _front; + + private int _width; + private int _height; + private int _stride; + private bool _hasNewFrame; + + public VlcVideoView() + { + _formatCallback = OnFormat; + _cleanupCallback = OnCleanup; + _lockCallback = OnLock; + _displayCallback = OnDisplay; + } + + 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?.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) + { + player.Time = (long)position.TotalMilliseconds; + } + } + + public override void Render(DrawingContext context) + { + Rect source; + WriteableBitmap bitmap; + + lock (_gate) + { + if (_width <= 0 || _height <= 0 || _front == IntPtr.Zero) + { + return; + } + + EnsureBitmap(); + bitmap = _bitmap!; + + if (_hasNewFrame) + { + using var locked = bitmap.Lock(); + CopyFrame(_front, _stride, locked.Address, locked.RowBytes, _height); + _hasNewFrame = false; + } + + source = new Rect(0, 0, _width, _height); + } + + context.DrawImage(bitmap, source, FitInto(source.Size, Bounds.Size)); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + _player = new MediaPlayer(VlcRuntime.Shared); + AttachPlayerEvents(_player); + + _player.SetVideoFormatCallbacks(_formatCallback, _cleanupCallback); + _player.SetVideoCallbacks(_lockCallback, null, _displayCallback); + + _player.Mute = IsMuted; + _player.Volume = ToVlcVolume(Volume); + + if (AutoPlay) + { + OpenCurrentSource(); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + if (_player is { } player) + { + _player = null; + + DetachPlayerEvents(player); + + // Stop before releasing anything: the callbacks must not fire into freed memory. + player.Stop(); + player.Dispose(); + } + + lock (_gate) + { + ReleaseBuffers(); + _bitmap?.Dispose(); + _bitmap = null; + } + } + + 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 (_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; + } + } + + // ======================= VLC video callbacks ======================= + // All of these run on VLC's own threads. Nothing here may touch an Avalonia property + // directly, and nothing may block for long: the decoder is waiting. + + private uint OnFormat( + ref IntPtr opaque, + IntPtr chroma, + ref uint width, + ref uint height, + ref uint pitches, + ref uint lines) + { + var frameWidth = (int)width; + var frameHeight = (int)height; + var stride = frameWidth * 4; + + Marshal.Copy(Encoding.ASCII.GetBytes(Chroma), 0, chroma, 4); + pitches = (uint)stride; + lines = (uint)frameHeight; + + lock (_gate) + { + ReleaseBuffers(); + + _width = frameWidth; + _height = frameHeight; + _stride = stride; + + var size = stride * frameHeight; + _back = Marshal.AllocHGlobal(size); + _front = Marshal.AllocHGlobal(size); + _hasNewFrame = false; + } + + // One buffer set; VLC hands it back through the lock callback. + return 1; + } + + private void OnCleanup(ref IntPtr opaque) + { + lock (_gate) + { + ReleaseBuffers(); + } + } + + private IntPtr OnLock(IntPtr opaque, IntPtr planes) + { + Marshal.WriteIntPtr(planes, 0, _back); + return _back; + } + + private void OnDisplay(IntPtr opaque, IntPtr picture) + { + lock (_gate) + { + // Swap rather than copy: the finished frame becomes readable and VLC carries on + // decoding into the buffer we were showing. + (_front, _back) = (_back, _front); + _hasNewFrame = true; + } + + Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render); + } + + // ======================= Player events ======================= + + 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; + } + + 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 сообщил об ошибке воспроизведения"); + + // ======================= Helpers ======================= + + /// Must be called with held. + private void EnsureBitmap() + { + if (_bitmap is not null && + _bitmap.PixelSize.Width == _width && + _bitmap.PixelSize.Height == _height) + { + return; + } + + _bitmap?.Dispose(); + _bitmap = new WriteableBitmap( + new PixelSize(_width, _height), + new Vector(96, 96), + PixelFormat.Bgra8888, + AlphaFormat.Opaque); + } + + /// Must be called with held. + private void ReleaseBuffers() + { + if (_back != IntPtr.Zero) + { + Marshal.FreeHGlobal(_back); + _back = IntPtr.Zero; + } + + if (_front != IntPtr.Zero) + { + Marshal.FreeHGlobal(_front); + _front = IntPtr.Zero; + } + + _width = 0; + _height = 0; + _stride = 0; + _hasNewFrame = false; + } + + private static unsafe void CopyFrame(IntPtr source, int sourceStride, IntPtr target, int targetStride, int rows) + { + var rowBytes = Math.Min(sourceStride, targetStride); + + if (sourceStride == targetStride) + { + Buffer.MemoryCopy((void*)source, (void*)target, (long)targetStride * rows, (long)rowBytes * rows); + return; + } + + for (var row = 0; row < rows; row++) + { + Buffer.MemoryCopy( + (void*)(source + (row * sourceStride)), + (void*)(target + (row * targetStride)), + targetStride, + rowBytes); + } + } + + /// Largest rectangle of the source aspect ratio that fits, centred. + private static Rect FitInto(Size source, Size available) + { + if (source.Width <= 0 || source.Height <= 0 || available.Width <= 0 || available.Height <= 0) + { + return default; + } + + var scale = Math.Min(available.Width / source.Width, available.Height / source.Height); + var width = source.Width * scale; + var height = source.Height * scale; + + return new Rect((available.Width - width) / 2, (available.Height - height) / 2, width, height); + } + + 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/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj index b5b8a69..2b92d20 100644 --- a/src/PLib.Desktop/PLib.Desktop.csproj +++ b/src/PLib.Desktop/PLib.Desktop.csproj @@ -7,6 +7,9 @@ true app.manifest true + + true diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml index febdaba..a70ce4f 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml @@ -61,26 +61,26 @@ - - - - - + + - - +