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) =>