diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7ce3110..82d55ea 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,9 +12,12 @@
-
-
+
+
+
+
+
diff --git a/README.md b/README.md
index b106283..8211960 100644
--- a/README.md
+++ b/README.md
@@ -78,14 +78,17 @@ dotnet test
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
его не вызывает.
-- **Плеер — `GpuMediaPlayer` из `MediaPlayer.Controls`.** Он наследует `OpenGlControlBase`,
- то есть рисует внутрь композиции Avalonia, а не в нативное дочернее окно: контролы можно
- класть поверх видео, чего дал бы не всякий плеер. Транспорт (позиция, длительность,
+- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** Avalonia создаёт нативное
+ дочернее окно, VLC рисует прямо в него: ни один кадр не проходит через управляемую память.
+ Расплата — airspace: поверх видео ничего нарисовать нельзя, поэтому контролы и сообщения
+ об ошибках живут рядом с картинкой, а не на ней. Транспорт (позиция, длительность,
play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы;
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
- Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева — и декодер останавливается.
-- **Нативный LibVLC подключён намеренно.** Без него бэкенд откатывается на Media Foundation,
- который не открывает MKV, AVI и WebM — то есть половину того, что сканер кладёт в библиотеку.
+ Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore`
+ гасит плеер.
+ Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана
+ не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении
+ `OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит.
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
diff --git a/src/PLib.Desktop/Controls/VlcRuntime.cs b/src/PLib.Desktop/Controls/VlcRuntime.cs
new file mode 100644
index 0000000..cac38dd
--- /dev/null
+++ b/src/PLib.Desktop/Controls/VlcRuntime.cs
@@ -0,0 +1,29 @@
+using LibVLCSharp.Shared;
+
+namespace PLib.Desktop.Controls;
+
+///
+/// The one instance the application uses.
+///
+///
+/// Creating a LibVLC instance spins up a whole media framework, so it is shared rather than
+/// made per player view. A static holder rather than a DI service because the consumer is a
+/// control, and controls are built by the XAML runtime with no container in reach.
+///
+internal static class VlcRuntime
+{
+ private static readonly Lazy Instance = new(
+ () =>
+ {
+ // Locates the native binaries that VideoLAN.LibVLC.Windows drops next to the app.
+ Core.Initialize();
+
+ return new LibVLC(
+ "--no-osd",
+ "--no-video-title-show",
+ "--no-snapshot-preview");
+ },
+ LazyThreadSafetyMode.ExecutionAndPublication);
+
+ public static LibVLC Shared => Instance.Value;
+}
diff --git a/src/PLib.Desktop/Controls/VlcVideoView.cs b/src/PLib.Desktop/Controls/VlcVideoView.cs
new file mode 100644
index 0000000..71510c8
--- /dev/null
+++ b/src/PLib.Desktop/Controls/VlcVideoView.cs
@@ -0,0 +1,274 @@
+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);
+}
diff --git a/src/PLib.Desktop/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj
index 262592b..b5b8a69 100644
--- a/src/PLib.Desktop/PLib.Desktop.csproj
+++ b/src/PLib.Desktop/PLib.Desktop.csproj
@@ -23,7 +23,7 @@
-
+
diff --git a/src/PLib.Desktop/ViewModels/DisposableExtensions.cs b/src/PLib.Desktop/ViewModels/DisposableExtensions.cs
index 7edb229..64e298e 100644
--- a/src/PLib.Desktop/ViewModels/DisposableExtensions.cs
+++ b/src/PLib.Desktop/ViewModels/DisposableExtensions.cs
@@ -1,14 +1,14 @@
-using System.Reactive.Disposables;
-
-namespace PLib.Desktop.ViewModels;
-
-internal static class DisposableExtensions
-{
- ///
- /// Parks a subscription in the owner's bag so it dies with the owner. ReactiveUI 24 moved
- /// its own DisposeWith into a namespace whose operator set collides with
- /// System.Reactive's, so this project keeps its own two-line version instead.
- ///
- public static void AddTo(this IDisposable disposable, CompositeDisposable subscriptions) =>
- subscriptions.Add(disposable);
-}
+using System.Reactive.Disposables;
+
+namespace PLib.Desktop.ViewModels;
+
+internal static class DisposableExtensions
+{
+ ///
+ /// Parks a subscription in the owner's bag so it dies with the owner. Spelled out here
+ /// rather than pulled from an Rx extension namespace, because more than one library in
+ /// this project ships a DisposeWith and importing either invites ambiguity.
+ ///
+ public static void AddTo(this IDisposable disposable, CompositeDisposable subscriptions) =>
+ subscriptions.Add(disposable);
+}
diff --git a/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs b/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs
index f1a052b..565747f 100644
--- a/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs
@@ -1,21 +1,21 @@
-using ReactiveUI;
-using RxVoid = ReactiveUI.Primitives.RxVoid;
-
-namespace PLib.Desktop.ViewModels;
-
-///
-/// One library folder in the settings list. It carries its own remove command so the row
-/// template never has to reach up the visual tree for the parent view model.
-///
-public sealed class FolderEntryViewModel
-{
- public FolderEntryViewModel(string path, Action remove)
- {
- Path = path;
- RemoveCommand = ReactiveCommand.Create(() => remove(this));
- }
-
- public string Path { get; }
-
- public ReactiveCommand RemoveCommand { get; }
-}
+using ReactiveUI;
+using RxVoid = ReactiveUI.Primitives.RxVoid;
+
+namespace PLib.Desktop.ViewModels;
+
+///
+/// One library folder in the settings list. It carries its own remove command so the row
+/// template never has to reach up the visual tree for the parent view model.
+///
+public sealed class FolderEntryViewModel
+{
+ public FolderEntryViewModel(string path, Action remove)
+ {
+ Path = path;
+ RemoveCommand = ReactiveCommand.Create(() => remove(this));
+ }
+
+ public string Path { get; }
+
+ public ReactiveCommand RemoveCommand { get; }
+}
diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
index 48fe908..10195bc 100644
--- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
@@ -14,8 +14,8 @@ using PLib.Desktop.Services;
using PLib.Desktop.Settings;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
-// Type alias, not a namespace import: pulling in ReactiveUI.Primitives would put a second
-// set of Rx operators next to System.Reactive's and make every Select/Subscribe ambiguous.
+// Alias for readability: ReactiveCommand says nothing, and the name survived
+// a swap of the underlying void type when the ReactiveUI version changed.
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
@@ -48,9 +48,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly SourceCache _library = new(card => card.Id);
///
- /// DynamicData is built on System.Reactive, whose schedulers are a different abstraction
- /// from ReactiveUI 24's. Avalonia's synchronisation context bridges the two: posting to
- /// it is posting to the dispatcher.
+ /// The scheduler the DynamicData chain hops to before touching the bound collection.
+ /// Built on Avalonia's synchronisation context, so posting to it is posting to the
+ /// dispatcher — named explicitly rather than taken from ambient state.
///
private readonly IScheduler _uiScheduler =
new SynchronizationContextScheduler(new AvaloniaSynchronizationContext());
diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
index 7e4e34c..0b21fc0 100644
--- a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
@@ -1,45 +1,45 @@
-using PLib.Desktop.Services;
-using ReactiveUI;
-using RxVoid = ReactiveUI.Primitives.RxVoid;
-
-namespace PLib.Desktop.ViewModels;
-
-///
-/// The media page: one video, opened from the grid. It only carries identity and the few
-/// commands around the player — transport state belongs to the media control itself, which
-/// already exposes position, duration and playback as bindable properties.
-///
-public sealed class VideoPlayerViewModel : ViewModelBase
-{
- public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
- {
- Title = card.Title;
- FullPath = card.FullPath;
- Source = new Uri(card.FullPath);
-
- Subtitle = string.Join(
- " · ",
- new[] { card.QualityText, card.DurationText, card.SizeText }
- .Where(part => !string.IsNullOrWhiteSpace(part)));
-
- CloseCommand = ReactiveCommand.Create(close);
- OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
- RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
- }
-
- public string Title { get; }
-
- public string FullPath { get; }
-
- /// What the media control plays; a file:// URI built from the path.
- public Uri Source { get; }
-
- /// Quality, duration and size on one line, for the page header.
- public string Subtitle { get; }
-
- public ReactiveCommand CloseCommand { get; }
-
- public ReactiveCommand OpenExternallyCommand { get; }
-
- public ReactiveCommand RevealCommand { get; }
-}
+using PLib.Desktop.Services;
+using ReactiveUI;
+using RxVoid = ReactiveUI.Primitives.RxVoid;
+
+namespace PLib.Desktop.ViewModels;
+
+///
+/// The media page: one video, opened from the grid. It only carries identity and the few
+/// commands around the player — transport state belongs to the media control itself, which
+/// already exposes position, duration and playback as bindable properties.
+///
+public sealed class VideoPlayerViewModel : ViewModelBase
+{
+ public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
+ {
+ Title = card.Title;
+ FullPath = card.FullPath;
+ Source = new Uri(card.FullPath);
+
+ Subtitle = string.Join(
+ " · ",
+ new[] { card.QualityText, card.DurationText, card.SizeText }
+ .Where(part => !string.IsNullOrWhiteSpace(part)));
+
+ CloseCommand = ReactiveCommand.Create(close);
+ OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
+ RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
+ }
+
+ public string Title { get; }
+
+ public string FullPath { get; }
+
+ /// What the media control plays; a file:// URI built from the path.
+ public Uri Source { get; }
+
+ /// Quality, duration and size on one line, for the page header.
+ public string Subtitle { get; }
+
+ public ReactiveCommand CloseCommand { get; }
+
+ public ReactiveCommand OpenExternallyCommand { get; }
+
+ public ReactiveCommand RevealCommand { get; }
+}
diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml
index c3c94be..63a9a7e 100644
--- a/src/PLib.Desktop/Views/VideoPlayerView.axaml
+++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml
@@ -1,115 +1,119 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs b/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs
index 8e26a67..a2acebb 100644
--- a/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs
+++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml.cs
@@ -3,7 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
-using MediaPlayer.Controls;
+using PLib.Desktop.Controls;
using PLib.Desktop.ViewModels;
namespace PLib.Desktop.Views;
@@ -12,7 +12,7 @@ namespace PLib.Desktop.Views;
/// Transport controls for the media page.
///
///
-/// Playback state lives on itself — position, duration and
+/// Playback state lives on itself — position, duration and
/// whether it is playing are all its own properties, and seeking is a method call. Mirroring
/// that into the view model would buy nothing but a second copy to keep in sync, so this
/// code-behind wires the buttons straight to the control. Everything the page knows about
@@ -36,10 +36,10 @@ public sealed partial class VideoPlayerView : UserControl
VolumeSlider.PropertyChanged += OnVolumeChanged;
- Player.GetObservable(GpuMediaPlayer.PositionProperty).Subscribe(OnPositionChanged);
- Player.GetObservable(GpuMediaPlayer.DurationProperty).Subscribe(OnDurationChanged);
- Player.GetObservable(GpuMediaPlayer.IsPlayingProperty).Subscribe(OnIsPlayingChanged);
- Player.GetObservable(GpuMediaPlayer.LastErrorProperty).Subscribe(OnErrorChanged);
+ Player.GetObservable(VlcVideoView.PositionProperty).Subscribe(OnPositionChanged);
+ Player.GetObservable(VlcVideoView.DurationProperty).Subscribe(OnDurationChanged);
+ Player.GetObservable(VlcVideoView.IsPlayingProperty).Subscribe(OnIsPlayingChanged);
+ Player.GetObservable(VlcVideoView.LastErrorProperty).Subscribe(OnErrorChanged);
}
@@ -114,7 +114,7 @@ public sealed partial class VideoPlayerView : UserControl
? null
: $"Не удалось воспроизвести файл: {error}";
- ErrorText.IsVisible = !string.IsNullOrWhiteSpace(error);
+ ErrorBar.IsVisible = !string.IsNullOrWhiteSpace(error);
}
private static Material.Icons.MaterialIconKind VolumeIconFor(double volume, bool isMuted) =>