Add media playback functionality to PLib video library manager. Integrate MediaPlayer.Controls and VideoLAN.LibVLC for enhanced video playback capabilities. Update MainWindow and ViewModels to manage video player state and commands, allowing users to play videos within the application. Enhance README.md to document new player features and requirements.
This commit is contained in:
@@ -12,6 +12,10 @@
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
<PackageVersion Include="MediaPlayer.Controls" Version="12.0.0" />
|
||||
<!-- Native LibVLC runtime: without it the player falls back to Media Foundation,
|
||||
which cannot open MKV, AVI or WebM — most of the library. -->
|
||||
<PackageVersion Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="MVVM / Composition">
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется
|
||||
в `settings.json` и подхватывается без перезапуска.
|
||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||
- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
|
||||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||||
перемотка, громкость, кнопка «назад». Внешний плеер и «показать в папке» остались
|
||||
в контекстном меню карточки.
|
||||
|
||||
## Требования
|
||||
|
||||
- .NET 10 SDK
|
||||
- `ffmpeg` и `ffprobe` в `PATH`
|
||||
- `ffmpeg` и `ffprobe` в `PATH` (для превью и метаданных)
|
||||
|
||||
Нативный LibVLC приезжает пакетом и в системе не нужен.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -74,6 +78,14 @@ dotnet test
|
||||
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
||||
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
||||
его не вызывает.
|
||||
- **Плеер — `GpuMediaPlayer` из `MediaPlayer.Controls`.** Он наследует `OpenGlControlBase`,
|
||||
то есть рисует внутрь композиции Avalonia, а не в нативное дочернее окно: контролы можно
|
||||
класть поверх видео, чего дал бы не всякий плеер. Транспорт (позиция, длительность,
|
||||
play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы;
|
||||
дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её.
|
||||
Закрытие страницы обнуляет `OpenedVideo`, вью уходит из дерева — и декодер останавливается.
|
||||
- **Нативный LibVLC подключён намеренно.** Без него бэкенд откатывается на Media Foundation,
|
||||
который не открывает MKV, AVI и WebM — то есть половину того, что сканер кладёт в библиотеку.
|
||||
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
|
||||
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
|
||||
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Semi.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="MediaPlayer.Controls" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" />
|
||||
<PackageReference Include="ReactiveUI.SourceGenerators">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -60,6 +60,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
private readonly ObservableAsPropertyHelper<bool> _isScanning;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isPlayerOpen;
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
@@ -99,6 +100,15 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
() => { SettingsPanel?.CancelCommand.Execute().Subscribe(); },
|
||||
this.WhenAnyValue(x => x.IsSettingsOpen));
|
||||
|
||||
_isPlayerOpen = this
|
||||
.WhenAnyValue(x => x.OpenedVideo)
|
||||
.Select(player => player is not null)
|
||||
.ToProperty(this, x => x.IsPlayerOpen);
|
||||
|
||||
ClosePlayerCommand = ReactiveCommand.Create(
|
||||
() => { OpenedVideo = null; },
|
||||
this.WhenAnyValue(x => x.IsPlayerOpen));
|
||||
|
||||
// Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling
|
||||
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
|
||||
ScanCommand = ReactiveCommand.CreateFromObservable(
|
||||
@@ -140,6 +150,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseSettingsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ClosePlayerCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The media page while it is open, or <c>null</c>. Setting it to null tears the player
|
||||
/// view out of the visual tree, which is what stops playback and releases the decoder.
|
||||
/// </summary>
|
||||
[Reactive]
|
||||
public partial VideoPlayerViewModel? OpenedVideo { get; set; }
|
||||
|
||||
public bool IsPlayerOpen => _isPlayerOpen.Value;
|
||||
|
||||
/// <summary>
|
||||
/// The settings panel while it is on screen, or <c>null</c>. Its presence is what the
|
||||
/// overlay binds to — settings live in this window rather than a second one.
|
||||
@@ -202,6 +223,15 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
.ToProperty(this, x => x.IsEmpty);
|
||||
}
|
||||
|
||||
private VideoCardViewModel CreateCard(Domain.Videos.VideoItem item) =>
|
||||
new(item, _shell, OpenVideo);
|
||||
|
||||
private void OpenVideo(VideoCardViewModel card)
|
||||
{
|
||||
OpenedVideo?.Dispose();
|
||||
OpenedVideo = new VideoPlayerViewModel(card, _shell, () => OpenedVideo = null);
|
||||
}
|
||||
|
||||
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(term))
|
||||
@@ -226,7 +256,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
ToggleThemeCommand.ThrownExceptions,
|
||||
OpenSettingsCommand.ThrownExceptions,
|
||||
CloseSettingsCommand.ThrownExceptions)
|
||||
CloseSettingsCommand.ThrownExceptions,
|
||||
ClosePlayerCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A command failed");
|
||||
@@ -243,7 +274,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
var items = await library.GetLibraryAsync();
|
||||
|
||||
_library.AddOrUpdate(items.Select(item => new VideoCardViewModel(item, _shell)));
|
||||
_library.AddOrUpdate(items.Select(item => CreateCard(item)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -416,7 +447,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemAdded added:
|
||||
_library.AddOrUpdate(new VideoCardViewModel(added.Item, _shell));
|
||||
_library.AddOrUpdate(CreateCard(added.Item));
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemUpdated updated:
|
||||
|
||||
@@ -14,13 +14,16 @@ namespace PLib.Desktop.ViewModels;
|
||||
/// </remarks>
|
||||
public sealed partial class VideoCardViewModel : ReactiveObject
|
||||
{
|
||||
public VideoCardViewModel(VideoItem item, ISystemShell shell)
|
||||
public VideoCardViewModel(VideoItem item, ISystemShell shell, Action<VideoCardViewModel> open)
|
||||
{
|
||||
Id = item.Id;
|
||||
FullPath = item.FullPath;
|
||||
Title = item.Title;
|
||||
|
||||
PlayCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||
// Activating a card opens the media page inside the application; the system player
|
||||
// stays available from the context menu for anything PLib cannot decode itself.
|
||||
PlayCommand = ReactiveCommand.Create(() => open(this));
|
||||
OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
|
||||
RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
|
||||
|
||||
Apply(item);
|
||||
@@ -32,6 +35,8 @@ public sealed partial class VideoCardViewModel : ReactiveObject
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> PlayCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||
|
||||
[Reactive]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using PLib.Desktop.Services;
|
||||
using ReactiveUI;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>What the media control plays; a <c>file://</c> URI built from the path.</summary>
|
||||
public Uri Source { get; }
|
||||
|
||||
/// <summary>Quality, duration and size on one line, for the page header.</summary>
|
||||
public string Subtitle { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> OpenExternallyCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
<views:SettingsView />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="PlayerTemplate" DataType="vm:VideoPlayerViewModel">
|
||||
<views:VideoPlayerView />
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ======================= Video card ======================= -->
|
||||
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
|
||||
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
|
||||
@@ -27,6 +31,7 @@
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Воспроизвести" Command="{Binding PlayCommand}" />
|
||||
<MenuItem Header="Открыть во внешнем плеере" Command="{Binding OpenExternallyCommand}" />
|
||||
<MenuItem Header="Показать в папке" Command="{Binding RevealCommand}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
@@ -167,6 +172,9 @@
|
||||
|
||||
<Panel Grid.Column="0">
|
||||
|
||||
<!-- Библиотека -->
|
||||
<Panel IsVisible="{Binding !IsPlayerOpen}">
|
||||
|
||||
<ScrollViewer Padding="20,18" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsRepeater ItemsSource="{Binding Videos}" ItemTemplate="{StaticResource VideoCardTemplate}">
|
||||
<ItemsRepeater.Layout>
|
||||
@@ -205,6 +213,14 @@
|
||||
Content="Выбрать папку" />
|
||||
</StackPanel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- Страница медиа. Content is cleared when the page closes, which detaches the
|
||||
player view and, with it, stops the decoder. -->
|
||||
<ContentControl Content="{Binding OpenedVideo}"
|
||||
ContentTemplate="{StaticResource PlayerTemplate}"
|
||||
IsVisible="{Binding IsPlayerOpen}" />
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- The panel is a column of the content row, so the bars above and below it stay
|
||||
|
||||
@@ -24,12 +24,26 @@ public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
|
||||
|
||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is not Key.Escape || DataContext is not MainWindowViewModel { IsSettingsOpen: true } viewModel)
|
||||
if (e.Key is not Key.Escape || DataContext is not MainWindowViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings panel sits above the media page, so it is the one Escape dismisses
|
||||
// first; only once it is gone does Escape mean "back to the library".
|
||||
if (viewModel.IsSettingsOpen)
|
||||
{
|
||||
viewModel.CloseSettingsCommand.Execute().Subscribe();
|
||||
}
|
||||
else if (viewModel.IsPlayerOpen)
|
||||
{
|
||||
viewModel.ClosePlayerCommand.Execute().Subscribe();
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
viewModel.CloseSettingsCommand.Execute().Subscribe();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:media="clr-namespace:MediaPlayer.Controls;assembly=MediaPlayer.Controls"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.VideoPlayerView"
|
||||
x:DataType="vm:VideoPlayerViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.transport">
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.time">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="MinWidth" Value="46" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ======================= Page header ======================= -->
|
||||
<Border Grid.Row="0" Classes="panelHeader" Padding="16,10">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
|
||||
<Button Grid.Column="0"
|
||||
Classes="transport"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="Назад к библиотеке">
|
||||
<icons:MaterialIcon Kind="ArrowLeft" Width="18" Height="18" />
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="panelTitle"
|
||||
Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding FullPath}" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding Subtitle}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="transport"
|
||||
Command="{Binding OpenExternallyCommand}"
|
||||
ToolTip.Tip="Открыть во внешнем плеере">
|
||||
<icons:MaterialIcon Kind="OpenInNew" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding RevealCommand}"
|
||||
ToolTip.Tip="Показать в папке">
|
||||
<icons:MaterialIcon Kind="FolderOpenOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Video ======================= -->
|
||||
<Panel Grid.Row="1" Background="Black">
|
||||
<media:GpuMediaPlayer Name="Player"
|
||||
Source="{Binding Source}"
|
||||
AutoPlay="True"
|
||||
LayoutMode="Fit"
|
||||
Volume="0.8" />
|
||||
|
||||
<TextBlock Name="ErrorText"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="440"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Transport ======================= -->
|
||||
<Border Grid.Row="2" Classes="panelFooter" Padding="16,10">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
|
||||
<Button Grid.Column="0" Name="PlayPauseButton" Classes="transport">
|
||||
<icons:MaterialIcon Name="PlayPauseIcon" Kind="Pause" Width="20" Height="20" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Grid.Column="1" Name="PositionText" Classes="time" Text="0:00" />
|
||||
|
||||
<Slider Grid.Column="2"
|
||||
Name="Seek"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Column="3" Name="DurationText" Classes="time" Text="0:00" />
|
||||
|
||||
<Button Grid.Column="4" Name="MuteButton" Classes="transport">
|
||||
<icons:MaterialIcon Name="MuteIcon" Kind="VolumeHigh" Width="18" Height="18" />
|
||||
</Button>
|
||||
|
||||
<Slider Grid.Column="5"
|
||||
Name="VolumeSlider"
|
||||
Width="90"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="0.8"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,126 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using MediaPlayer.Controls;
|
||||
using PLib.Desktop.ViewModels;
|
||||
|
||||
namespace PLib.Desktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Transport controls for the media page.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Playback state lives on <see cref="GpuMediaPlayer"/> 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
|
||||
/// the video — title, path, the commands around it — stays in the view model.
|
||||
/// </remarks>
|
||||
public sealed partial class VideoPlayerView : UserControl
|
||||
{
|
||||
/// <summary>True while the user is dragging the seek bar, so playback must not fight them.</summary>
|
||||
private bool _isScrubbing;
|
||||
|
||||
public VideoPlayerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
PlayPauseButton.Click += OnPlayPause;
|
||||
MuteButton.Click += OnToggleMute;
|
||||
|
||||
// Tunnelled: the Slider's own handlers mark these as handled on the way back up.
|
||||
Seek.AddHandler(PointerPressedEvent, OnScrubStarted, RoutingStrategies.Tunnel);
|
||||
Seek.AddHandler(PointerReleasedEvent, OnScrubFinished, RoutingStrategies.Tunnel);
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
|
||||
// Leaving the page has to stop the decoder; nothing else will.
|
||||
Player.Stop();
|
||||
}
|
||||
|
||||
private void OnPlayPause(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Player.IsPlaying)
|
||||
{
|
||||
Player.Pause();
|
||||
}
|
||||
else
|
||||
{
|
||||
Player.Play();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnToggleMute(object? sender, RoutedEventArgs e) => Player.IsMuted = !Player.IsMuted;
|
||||
|
||||
private void OnScrubStarted(object? sender, PointerPressedEventArgs e) => _isScrubbing = true;
|
||||
|
||||
private void OnScrubFinished(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
_isScrubbing = false;
|
||||
Player.Seek(TimeSpan.FromSeconds(Seek.Value));
|
||||
}
|
||||
|
||||
private void OnVolumeChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property != RangeBase.ValueProperty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Player.Volume = VolumeSlider.Value;
|
||||
MuteIcon.Kind = VolumeIconFor(VolumeSlider.Value, Player.IsMuted);
|
||||
}
|
||||
|
||||
private void OnPositionChanged(TimeSpan position)
|
||||
{
|
||||
PositionText.Text = DisplayText.Duration(position);
|
||||
|
||||
if (!_isScrubbing)
|
||||
{
|
||||
Seek.Value = position.TotalSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDurationChanged(TimeSpan duration)
|
||||
{
|
||||
DurationText.Text = DisplayText.Duration(duration);
|
||||
|
||||
// A zero maximum would pin the thumb to the left and swallow every seek.
|
||||
Seek.Maximum = duration > TimeSpan.Zero ? duration.TotalSeconds : 1;
|
||||
Seek.IsEnabled = duration > TimeSpan.Zero;
|
||||
}
|
||||
|
||||
private void OnIsPlayingChanged(bool isPlaying) =>
|
||||
PlayPauseIcon.Kind = isPlaying
|
||||
? Material.Icons.MaterialIconKind.Pause
|
||||
: Material.Icons.MaterialIconKind.Play;
|
||||
|
||||
private void OnErrorChanged(string? error)
|
||||
{
|
||||
ErrorText.Text = string.IsNullOrWhiteSpace(error)
|
||||
? null
|
||||
: $"Не удалось воспроизвести файл: {error}";
|
||||
|
||||
ErrorText.IsVisible = !string.IsNullOrWhiteSpace(error);
|
||||
}
|
||||
|
||||
private static Material.Icons.MaterialIconKind VolumeIconFor(double volume, bool isMuted) =>
|
||||
isMuted || volume <= 0.001
|
||||
? Material.Icons.MaterialIconKind.VolumeOff
|
||||
: volume < 0.5
|
||||
? Material.Icons.MaterialIconKind.VolumeMedium
|
||||
: Material.Icons.MaterialIconKind.VolumeHigh;
|
||||
}
|
||||
Reference in New Issue
Block a user