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 @@
-
-
-
-
-
+
+
-
-
+