diff --git a/Directory.Packages.props b/Directory.Packages.props
index ed107ea..7ce3110 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,6 +12,10 @@
+
+
+
diff --git a/README.md b/README.md
index 0cecf8e..b106283 100644
--- a/README.md
+++ b/README.md
@@ -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`), и перерисовывает удалённые; после полного
diff --git a/src/PLib.Desktop/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj
index 4a7ec07..262592b 100644
--- a/src/PLib.Desktop/PLib.Desktop.csproj
+++ b/src/PLib.Desktop/PLib.Desktop.csproj
@@ -23,6 +23,8 @@
+
+ all
diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
index a1c58d8..48fe908 100644
--- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
@@ -60,6 +60,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ObservableAsPropertyHelper _isScanning;
private readonly ObservableAsPropertyHelper _isEmpty;
private readonly ObservableAsPropertyHelper _isSettingsOpen;
+ private readonly ObservableAsPropertyHelper _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 CloseSettingsCommand { get; }
+ public ReactiveCommand ClosePlayerCommand { get; }
+
+ ///
+ /// The media page while it is open, or null. Setting it to null tears the player
+ /// view out of the visual tree, which is what stops playback and releases the decoder.
+ ///
+ [Reactive]
+ public partial VideoPlayerViewModel? OpenedVideo { get; set; }
+
+ public bool IsPlayerOpen => _isPlayerOpen.Value;
+
///
/// The settings panel while it is on screen, or null. 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 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();
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:
diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
index e791592..8cb4659 100644
--- a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
@@ -14,13 +14,16 @@ namespace PLib.Desktop.ViewModels;
///
public sealed partial class VideoCardViewModel : ReactiveObject
{
- public VideoCardViewModel(VideoItem item, ISystemShell shell)
+ public VideoCardViewModel(VideoItem item, ISystemShell shell, Action 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 PlayCommand { get; }
+ public ReactiveCommand OpenExternallyCommand { get; }
+
public ReactiveCommand RevealCommand { get; }
[Reactive]
diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
new file mode 100644
index 0000000..7e4e34c
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs
@@ -0,0 +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; }
+}
diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml
index 3c909c8..987b916 100644
--- a/src/PLib.Desktop/Views/MainWindow.axaml
+++ b/src/PLib.Desktop/Views/MainWindow.axaml
@@ -20,6 +20,10 @@
+
+
+
+