Refactor video playback implementation in PLib video library manager. Replace MediaPlayer.Controls with LibVLCSharp for improved performance and compatibility. Update VideoPlayerView and associated ViewModels to accommodate the new video control, ensuring seamless integration with the existing UI. Revise README.md to document changes and new player features.
This commit is contained in:
@@ -12,9 +12,12 @@
|
|||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||||
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
||||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||||
<PackageVersion Include="MediaPlayer.Controls" Version="12.0.0" />
|
</ItemGroup>
|
||||||
<!-- Native LibVLC runtime: without it the player falls back to Media Foundation,
|
|
||||||
which cannot open MKV, AVI or WebM — most of the library. -->
|
<ItemGroup Label="Playback">
|
||||||
|
<!-- LibVLCSharp is UI-agnostic; the video surface is our own control, so nothing here
|
||||||
|
is tied to a particular Avalonia release. -->
|
||||||
|
<PackageVersion Include="LibVLCSharp" Version="3.10.1" />
|
||||||
<PackageVersion Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
|
<PackageVersion Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -78,14 +78,17 @@ dotnet test
|
|||||||
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
||||||
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
||||||
его не вызывает.
|
его не вызывает.
|
||||||
- **Плеер — `GpuMediaPlayer` из `MediaPlayer.Controls`.** Он наследует `OpenGlControlBase`,
|
- **Плеер — свой контрол `VlcVideoView` поверх LibVLCSharp.** Avalonia создаёт нативное
|
||||||
то есть рисует внутрь композиции Avalonia, а не в нативное дочернее окно: контролы можно
|
дочернее окно, VLC рисует прямо в него: ни один кадр не проходит через управляемую память.
|
||||||
класть поверх видео, чего дал бы не всякий плеер. Транспорт (позиция, длительность,
|
Расплата — airspace: поверх видео ничего нарисовать нельзя, поэтому контролы и сообщения
|
||||||
|
об ошибках живут рядом с картинкой, а не на ней. Транспорт (позиция, длительность,
|
||||||
play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы;
|
play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы;
|
||||||
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
|
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
|
||||||
Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева — и декодер останавливается.
|
Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева, `DestroyNativeControlCore`
|
||||||
- **Нативный LibVLC подключён намеренно.** Без него бэкенд откатывается на Media Foundation,
|
гасит плеер.
|
||||||
который не открывает MKV, AVI и WebM — то есть половину того, что сканер кладёт в библиотеку.
|
Готовый `MediaPlayer.Controls` пробовали до этого: декодер работал, но кадры до экрана
|
||||||
|
не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении
|
||||||
|
`OpenGlControlBase`. Свой контрол ни от чьей версии Avalonia не зависит.
|
||||||
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
||||||
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
||||||
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
|
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using LibVLCSharp.Shared;
|
||||||
|
|
||||||
|
namespace PLib.Desktop.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one <see cref="LibVLC"/> instance the application uses.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class VlcRuntime
|
||||||
|
{
|
||||||
|
private static readonly Lazy<LibVLC> 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LibVLCSharp.Shared;
|
||||||
|
|
||||||
|
namespace PLib.Desktop.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A video surface backed by LibVLC.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VlcVideoView : NativeControlHost
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<Uri?> SourceProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, Uri?>(nameof(Source));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> AutoPlayProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(AutoPlay), defaultValue: true);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<TimeSpan> PositionProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Position));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<TimeSpan> DurationProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, TimeSpan>(nameof(Duration));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsPlayingProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsPlaying));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsMutedProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, bool>(nameof(IsMuted));
|
||||||
|
|
||||||
|
/// <summary>Volume as a fraction; LibVLC works in percent and is converted on the way in.</summary>
|
||||||
|
public static readonly StyledProperty<double> VolumeProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, double>(nameof(Volume), defaultValue: 0.8);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> LastErrorProperty =
|
||||||
|
AvaloniaProperty.Register<VlcVideoView, string?>(nameof(LastError));
|
||||||
|
|
||||||
|
private MediaPlayer? _player;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once VLC has been handed the native window. Playback cannot start before that,
|
||||||
|
/// or VLC opens a top-level window of its own.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
<PackageReference Include="Semi.Avalonia" />
|
<PackageReference Include="Semi.Avalonia" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" />
|
<PackageReference Include="Material.Icons.Avalonia" />
|
||||||
<PackageReference Include="MediaPlayer.Controls" />
|
<PackageReference Include="LibVLCSharp" />
|
||||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'" />
|
<PackageReference Include="VideoLAN.LibVLC.Windows" Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'" />
|
||||||
<PackageReference Include="ReactiveUI.Avalonia" />
|
<PackageReference Include="ReactiveUI.Avalonia" />
|
||||||
<PackageReference Include="ReactiveUI.SourceGenerators">
|
<PackageReference Include="ReactiveUI.SourceGenerators">
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Reactive.Disposables;
|
using System.Reactive.Disposables;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
internal static class DisposableExtensions
|
internal static class DisposableExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parks a subscription in the owner's bag so it dies with the owner. ReactiveUI 24 moved
|
/// Parks a subscription in the owner's bag so it dies with the owner. Spelled out here
|
||||||
/// its own <c>DisposeWith</c> into a namespace whose operator set collides with
|
/// rather than pulled from an Rx extension namespace, because more than one library in
|
||||||
/// System.Reactive's, so this project keeps its own two-line version instead.
|
/// this project ships a <c>DisposeWith</c> and importing either invites ambiguity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void AddTo(this IDisposable disposable, CompositeDisposable subscriptions) =>
|
public static void AddTo(this IDisposable disposable, CompositeDisposable subscriptions) =>
|
||||||
subscriptions.Add(disposable);
|
subscriptions.Add(disposable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One library folder in the settings list. It carries its own remove command so the row
|
/// 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.
|
/// template never has to reach up the visual tree for the parent view model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FolderEntryViewModel
|
public sealed class FolderEntryViewModel
|
||||||
{
|
{
|
||||||
public FolderEntryViewModel(string path, Action<FolderEntryViewModel> remove)
|
public FolderEntryViewModel(string path, Action<FolderEntryViewModel> remove)
|
||||||
{
|
{
|
||||||
Path = path;
|
Path = path;
|
||||||
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Path { get; }
|
public string Path { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ using PLib.Desktop.Services;
|
|||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using ReactiveUI.SourceGenerators;
|
using ReactiveUI.SourceGenerators;
|
||||||
// Type alias, not a namespace import: pulling in ReactiveUI.Primitives would put a second
|
// Alias for readability: ReactiveCommand<Unit, Unit> says nothing, and the name survived
|
||||||
// set of Rx operators next to System.Reactive's and make every Select/Subscribe ambiguous.
|
// a swap of the underlying void type when the ReactiveUI version changed.
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
@@ -48,9 +48,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
private readonly SourceCache<VideoCardViewModel, Guid> _library = new(card => card.Id);
|
private readonly SourceCache<VideoCardViewModel, Guid> _library = new(card => card.Id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DynamicData is built on System.Reactive, whose schedulers are a different abstraction
|
/// The scheduler the DynamicData chain hops to before touching the bound collection.
|
||||||
/// from ReactiveUI 24's. Avalonia's synchronisation context bridges the two: posting to
|
/// Built on Avalonia's synchronisation context, so posting to it is posting to the
|
||||||
/// it is posting to the dispatcher.
|
/// dispatcher — named explicitly rather than taken from ambient state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly IScheduler _uiScheduler =
|
private readonly IScheduler _uiScheduler =
|
||||||
new SynchronizationContextScheduler(new AvaloniaSynchronizationContext());
|
new SynchronizationContextScheduler(new AvaloniaSynchronizationContext());
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
namespace PLib.Desktop.ViewModels;
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The media page: one video, opened from the grid. It only carries identity and the few
|
/// 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
|
/// commands around the player — transport state belongs to the media control itself, which
|
||||||
/// already exposes position, duration and playback as bindable properties.
|
/// already exposes position, duration and playback as bindable properties.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class VideoPlayerViewModel : ViewModelBase
|
public sealed class VideoPlayerViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
|
public VideoPlayerViewModel(VideoCardViewModel card, ISystemShell shell, Action close)
|
||||||
{
|
{
|
||||||
Title = card.Title;
|
Title = card.Title;
|
||||||
FullPath = card.FullPath;
|
FullPath = card.FullPath;
|
||||||
Source = new Uri(card.FullPath);
|
Source = new Uri(card.FullPath);
|
||||||
|
|
||||||
Subtitle = string.Join(
|
Subtitle = string.Join(
|
||||||
" · ",
|
" · ",
|
||||||
new[] { card.QualityText, card.DurationText, card.SizeText }
|
new[] { card.QualityText, card.DurationText, card.SizeText }
|
||||||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||||
|
|
||||||
CloseCommand = ReactiveCommand.Create(close);
|
CloseCommand = ReactiveCommand.Create(close);
|
||||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Title { get; }
|
public string Title { get; }
|
||||||
|
|
||||||
public string FullPath { get; }
|
public string FullPath { get; }
|
||||||
|
|
||||||
/// <summary>What the media control plays; a <c>file://</c> URI built from the path.</summary>
|
/// <summary>What the media control plays; a <c>file://</c> URI built from the path.</summary>
|
||||||
public Uri Source { get; }
|
public Uri Source { get; }
|
||||||
|
|
||||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||||
public string Subtitle { get; }
|
public string Subtitle { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,119 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:media="clr-namespace:MediaPlayer.Controls;assembly=MediaPlayer.Controls"
|
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||||
x:DataType="vm:VideoPlayerViewModel">
|
x:DataType="vm:VideoPlayerViewModel">
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="Button.transport">
|
<Style Selector="Button.transport">
|
||||||
<Setter Property="Padding" Value="8" />
|
<Setter Property="Padding" Value="8" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.time">
|
<Style Selector="TextBlock.time">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
<Setter Property="MinWidth" Value="46" />
|
<Setter Property="MinWidth" Value="46" />
|
||||||
<Setter Property="TextAlignment" Value="Center" />
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ======================= Page header ======================= -->
|
<!-- ======================= Page header ======================= -->
|
||||||
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10">
|
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||||
|
|
||||||
<Button Grid.Column="0"
|
<Button Grid.Column="0"
|
||||||
Classes="transport"
|
Classes="transport"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding CloseCommand}"
|
||||||
ToolTip.Tip="Назад к библиотеке">
|
ToolTip.Tip="Назад к библиотеке">
|
||||||
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="panelTitle"
|
<TextBlock Classes="panelTitle"
|
||||||
Text="{Binding Title}"
|
Text="{Binding Title}"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
ToolTip.Tip="{Binding FullPath}" />
|
ToolTip.Tip="{Binding FullPath}" />
|
||||||
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding OpenExternallyCommand}"
|
Command="{Binding OpenExternallyCommand}"
|
||||||
ToolTip.Tip="Открыть во внешнем плеере">
|
ToolTip.Tip="Открыть во внешнем плеере">
|
||||||
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding RevealCommand}"
|
Command="{Binding RevealCommand}"
|
||||||
ToolTip.Tip="Показать в папке">
|
ToolTip.Tip="Показать в папке">
|
||||||
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ======================= Video ======================= -->
|
<!-- ======================= Video ======================= -->
|
||||||
<Panel Grid.Row="1" Background="Black">
|
<!-- VLC paints into a native child window, which sits above the Avalonia surface, so
|
||||||
<media:GpuMediaPlayer Name="Player"
|
the error message lives in its own row instead of on top of the picture. -->
|
||||||
Source="{Binding Source}"
|
<Grid Grid.Row="1" RowDefinitions="*,Auto">
|
||||||
AutoPlay="True"
|
<Panel Grid.Row="0" Background="Black">
|
||||||
LayoutMode="Fit"
|
<controls:VlcVideoView Name="Player"
|
||||||
Volume="0.8" />
|
Source="{Binding Source}"
|
||||||
|
AutoPlay="True"
|
||||||
<TextBlock Name="ErrorText"
|
Volume="0.8" />
|
||||||
HorizontalAlignment="Center"
|
</Panel>
|
||||||
VerticalAlignment="Center"
|
|
||||||
MaxWidth="440"
|
<Border Grid.Row="1"
|
||||||
TextWrapping="Wrap"
|
Name="ErrorBar"
|
||||||
TextAlignment="Center"
|
Background="{DynamicResource SurfaceBrush}"
|
||||||
IsVisible="False"
|
Padding="16,10"
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
IsVisible="False">
|
||||||
</Panel>
|
<TextBlock Name="ErrorText"
|
||||||
|
TextWrapping="Wrap"
|
||||||
<!-- ======================= Transport ======================= -->
|
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
</Border>
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
|
</Grid>
|
||||||
|
|
||||||
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
<!-- ======================= Transport ======================= -->
|
||||||
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||||
</Button>
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
|
||||||
|
|
||||||
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
||||||
|
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
||||||
<Slider Grid.Column="2"
|
</Button>
|
||||||
Name="Seek"
|
|
||||||
Minimum="0"
|
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
||||||
Maximum="1"
|
|
||||||
VerticalAlignment="Center" />
|
<Slider Grid.Column="2"
|
||||||
|
Name="Seek"
|
||||||
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
Minimum="0"
|
||||||
|
Maximum="1"
|
||||||
<Button Grid.Column="4" Name="MuteButton" Classes="transport">
|
VerticalAlignment="Center" />
|
||||||
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
|
||||||
</Button>
|
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
||||||
|
|
||||||
<Slider Grid.Column="5"
|
<Button Grid.Column="4" Name="MuteButton" Classes="transport">
|
||||||
Name="VolumeSlider"
|
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
||||||
Width="90"
|
</Button>
|
||||||
Minimum="0"
|
|
||||||
Maximum="1"
|
<Slider Grid.Column="5"
|
||||||
Value="0.8"
|
Name="VolumeSlider"
|
||||||
VerticalAlignment="Center" />
|
Width="90"
|
||||||
|
Minimum="0"
|
||||||
</Grid>
|
Maximum="1"
|
||||||
</Border>
|
Value="0.8"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using MediaPlayer.Controls;
|
using PLib.Desktop.Controls;
|
||||||
using PLib.Desktop.ViewModels;
|
using PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
namespace PLib.Desktop.Views;
|
namespace PLib.Desktop.Views;
|
||||||
@@ -12,7 +12,7 @@ namespace PLib.Desktop.Views;
|
|||||||
/// Transport controls for the media page.
|
/// Transport controls for the media page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Playback state lives on <see cref="GpuMediaPlayer"/> itself — position, duration and
|
/// Playback state lives on <see cref="VlcVideoView"/> itself — position, duration and
|
||||||
/// whether it is playing are all its own properties, and seeking is a method call. Mirroring
|
/// 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
|
/// 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
|
/// 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;
|
VolumeSlider.PropertyChanged += OnVolumeChanged;
|
||||||
|
|
||||||
Player.GetObservable(GpuMediaPlayer.PositionProperty).Subscribe(OnPositionChanged);
|
Player.GetObservable(VlcVideoView.PositionProperty).Subscribe(OnPositionChanged);
|
||||||
Player.GetObservable(GpuMediaPlayer.DurationProperty).Subscribe(OnDurationChanged);
|
Player.GetObservable(VlcVideoView.DurationProperty).Subscribe(OnDurationChanged);
|
||||||
Player.GetObservable(GpuMediaPlayer.IsPlayingProperty).Subscribe(OnIsPlayingChanged);
|
Player.GetObservable(VlcVideoView.IsPlayingProperty).Subscribe(OnIsPlayingChanged);
|
||||||
Player.GetObservable(GpuMediaPlayer.LastErrorProperty).Subscribe(OnErrorChanged);
|
Player.GetObservable(VlcVideoView.LastErrorProperty).Subscribe(OnErrorChanged);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ public sealed partial class VideoPlayerView : UserControl
|
|||||||
? null
|
? null
|
||||||
: $"Не удалось воспроизвести файл: {error}";
|
: $"Не удалось воспроизвести файл: {error}";
|
||||||
|
|
||||||
ErrorText.IsVisible = !string.IsNullOrWhiteSpace(error);
|
ErrorBar.IsVisible = !string.IsNullOrWhiteSpace(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Material.Icons.MaterialIconKind VolumeIconFor(double volume, bool isMuted) =>
|
private static Material.Icons.MaterialIconKind VolumeIconFor(double volume, bool isMuted) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user