From 625eae7ead8da17597301a7673fcf89d1269ddc9 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 8 Aug 2026 11:37:36 +0300 Subject: [PATCH] Refactor PLib video library manager to use ReactiveUI, replacing CommunityToolkit.Mvvm. Update dependencies, enhance thumbnail caching logic, and improve UI responsiveness with reactive commands. Remove obsolete PLib.slnx file and update README.md to reflect changes. --- Directory.Packages.props | 5 +- PLib.sln | 59 ++++ PLib.slnx | 11 - README.md | 145 ++++----- .../Abstractions/IThumbnailGenerator.cs | 16 + .../Library/LibraryService.cs | 30 ++ src/PLib.Desktop/App.axaml.cs | 110 +++---- src/PLib.Desktop/PLib.Desktop.csproj | 7 +- src/PLib.Desktop/Program.cs | 38 +-- .../ViewModels/DisposableExtensions.cs | 14 + .../ViewModels/MainWindowViewModel.cs | 277 ++++++++++-------- src/PLib.Desktop/ViewModels/SortOption.cs | 52 +++- .../ViewModels/VideoCardViewModel.cs | 157 +++++----- src/PLib.Desktop/ViewModels/ViewModelBase.cs | 28 ++ src/PLib.Desktop/Views/MainWindow.axaml | 2 +- src/PLib.Desktop/Views/MainWindow.axaml.cs | 5 +- .../Media/FfmpegThumbnailGenerator.cs | 63 +++- .../PLib.Tests/Library/LibraryServiceTests.cs | 33 +++ 18 files changed, 693 insertions(+), 359 deletions(-) create mode 100644 PLib.sln delete mode 100644 PLib.slnx create mode 100644 src/PLib.Desktop/ViewModels/DisposableExtensions.cs create mode 100644 src/PLib.Desktop/ViewModels/ViewModelBase.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4ba586f..a6f6b11 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,7 +15,10 @@ - + + + + diff --git a/PLib.sln b/PLib.sln new file mode 100644 index 0000000..00ca65b --- /dev/null +++ b/PLib.sln @@ -0,0 +1,59 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Application", "src\PLib.Application\PLib.Application.csproj", "{CA433FFF-2A0D-D816-7392-E8545C23C7AC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Desktop", "src\PLib.Desktop\PLib.Desktop.csproj", "{7A7DE065-F7FF-F41F-D49F-113744DFA193}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Domain", "src\PLib.Domain\PLib.Domain.csproj", "{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Infrastructure", "src\PLib.Infrastructure\PLib.Infrastructure.csproj", "{277BDC30-D1EA-F232-4993-B240D13E0B25}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Tests", "tests\PLib.Tests\PLib.Tests.csproj", "{D5765D3B-9520-CF00-7091-4E033549389B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Release|Any CPU.Build.0 = Release|Any CPU + {7A7DE065-F7FF-F41F-D49F-113744DFA193}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A7DE065-F7FF-F41F-D49F-113744DFA193}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A7DE065-F7FF-F41F-D49F-113744DFA193}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A7DE065-F7FF-F41F-D49F-113744DFA193}.Release|Any CPU.Build.0 = Release|Any CPU + {78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Release|Any CPU.Build.0 = Release|Any CPU + {277BDC30-D1EA-F232-4993-B240D13E0B25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {277BDC30-D1EA-F232-4993-B240D13E0B25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {277BDC30-D1EA-F232-4993-B240D13E0B25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {277BDC30-D1EA-F232-4993-B240D13E0B25}.Release|Any CPU.Build.0 = Release|Any CPU + {D5765D3B-9520-CF00-7091-4E033549389B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5765D3B-9520-CF00-7091-4E033549389B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5765D3B-9520-CF00-7091-4E033549389B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5765D3B-9520-CF00-7091-4E033549389B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CA433FFF-2A0D-D816-7392-E8545C23C7AC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {7A7DE065-F7FF-F41F-D49F-113744DFA193} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {277BDC30-D1EA-F232-4993-B240D13E0B25} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D5765D3B-9520-CF00-7091-4E033549389B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4BD5DD2D-7DF1-4EA6-A74B-D2B04FDE178C} + EndGlobalSection +EndGlobal diff --git a/PLib.slnx b/PLib.slnx deleted file mode 100644 index b6ee687..0000000 --- a/PLib.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/README.md b/README.md index fd127f8..d99b750 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,79 @@ -# PLib - -Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и -показывает всё сеткой карточек. - -## Что уже работает - -- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. -- Метаданные (длительность, разрешение, кодек) через ffprobe. -- Постеры кадром из видео через ffmpeg, с кэшем на диске. -- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. -- Светлая и тёмная темы. -- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. - -## Требования - -- .NET 10 SDK -- `ffmpeg` и `ffprobe` в `PATH` - -## Запуск - -```bash -dotnet run --project src/PLib.Desktop -``` - -```bash -dotnet test -``` - -## Архитектура - -Четыре слоя, зависимости направлены только внутрь: - -| Проект | Отвечает за | Знает о | -| --- | --- | --- | -| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём | -| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain | -| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application | -| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure | - -Ключевые решения: - -- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает - `IAsyncEnumerable`: карточки появляются по мере находок, а не после - завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через - `Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям - по одному — трекер изменений EF не потокобезопасен. -- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает - каждое событие в UI явно через `Dispatcher.UIThread`. -- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в - визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с - декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. -- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт - `IServiceScopeFactory` и создаёт scope на каждый вызов. - -## Данные - -Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`: - -- `library.db` — SQLite с метаданными; -- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); -- `settings.json` — список папок, перечитывается на лету; -- `logs/` — Serilog, ротация по дням. - -Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на -миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть). +# PLib + +Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и +показывает всё сеткой карточек. + +## Что уже работает + +- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. +- Метаданные (длительность, разрешение, кодек) через ffprobe. +- Постеры кадром из видео через ffmpeg, с кэшем на диске. +- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. +- Светлая и тёмная темы. +- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. + +## Требования + +- .NET 10 SDK +- `ffmpeg` и `ffprobe` в `PATH` + +## Запуск + +```bash +dotnet run --project src/PLib.Desktop +``` + +```bash +dotnet test +``` + +## Архитектура + +Четыре слоя, зависимости направлены только внутрь: + +| Проект | Отвечает за | Знает о | +| --- | --- | --- | +| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём | +| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain | +| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application | +| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure | + +Ключевые решения: + +- **MVVM на ReactiveUI.** Свойства — `[Reactive]` из `ReactiveUI.SourceGenerators`, команды — + `ReactiveCommand`, производные значения (`IsScanning`, `IsEmpty`) — `ToProperty`. Отмена + сканирования сделана штатным способом: скан живёт как observable, а `CancelScanCommand` + просто отписывает его через `TakeUntil`, что отменяет `CancellationToken`. +- **Сетка — проекция DynamicData, а не пересборка.** `SourceCache` → `AutoRefresh` → `Filter` + → `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл, + контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится + на 200 мс, изменения карточек во время скана коалесцируются в 250 мс. +- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает + `IAsyncEnumerable`: карточки появляются по мере находок, а не после + завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через + `Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям + по одному — трекер изменений EF не потокобезопасен. +- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает + каждое событие в UI явно через `Dispatcher.UIThread`. +- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в + визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с + декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. +- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт + `IServiceScopeFactory` и создаёт scope на каждый вызов. +- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU + на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически + на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного + прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются + только если им больше часа — иначе можно снести рендер второго запущенного экземпляра. + +## Данные + +Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`: + +- `library.db` — SQLite с метаданными; +- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); +- `settings.json` — список папок, перечитывается на лету; +- `logs/` — Serilog, ротация по дням. + +Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на +миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть). diff --git a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs index c0c21c1..6f81755 100644 --- a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs +++ b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs @@ -12,4 +12,20 @@ public interface IThumbnailGenerator string videoPath, TimeSpan? duration, CancellationToken cancellationToken = default); + + /// + /// True when a previously generated poster frame is still present in the cache. The + /// cache directory is ordinary user-writable storage, so a path the library remembers + /// is not proof that the file behind it still exists. + /// + bool IsAvailable(string? thumbnailPath); + + /// + /// Deletes cached frames that no library item points at any more, and returns how many + /// files were removed. Call only after a complete scan: anything not in + /// is treated as garbage. + /// + Task PurgeUnusedAsync( + IReadOnlyCollection inUsePaths, + CancellationToken cancellationToken = default); } diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index 7426a66..88d32ca 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -44,6 +44,16 @@ public sealed class LibraryService( if (known.TryGetValue(file.FullPath, out var existing)) { existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt); + + // The cache directory is ordinary user storage: a poster frame we remember + // may simply have been deleted. Trusting the stored path would leave the + // card blank forever, because the item still looks indexed. + if (existing.ThumbnailPath is not null && + !thumbnailGenerator.IsAvailable(existing.ThumbnailPath)) + { + existing.DetachThumbnail(); + yield return new LibraryScanEvent.ItemUpdated(existing); + } } else { @@ -79,9 +89,29 @@ public sealed class LibraryService( } await repository.SaveChangesAsync(cancellationToken); + await PurgeThumbnailCacheAsync(known.Values, cancellationToken); + yield return new LibraryScanEvent.Completed(known.Count); } + /// + /// Drops cached frames nothing points at any more. Safe only here, at the end of a + /// completed scan, because that is the only moment the library is known to be whole — + /// running it mid-scan would delete frames of items not reconciled yet. + /// + private async Task PurgeThumbnailCacheAsync( + IEnumerable library, + CancellationToken cancellationToken) + { + var inUse = library.Select(x => x.ThumbnailPath).OfType().ToArray(); + var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken); + + if (removed > 0) + { + logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed); + } + } + private async Task> DiscoverAsync( IReadOnlyList folders, CancellationToken cancellationToken) diff --git a/src/PLib.Desktop/App.axaml.cs b/src/PLib.Desktop/App.axaml.cs index 3d727f0..4bb411b 100644 --- a/src/PLib.Desktop/App.axaml.cs +++ b/src/PLib.Desktop/App.axaml.cs @@ -1,55 +1,55 @@ -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using Avalonia.Threading; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using PLib.Desktop.Controls; -using PLib.Desktop.Imaging; -using PLib.Desktop.ViewModels; -using PLib.Desktop.Views; - -namespace PLib.Desktop; - -// Fully qualified: the PLib.Application namespace shadows the Application type in this assembly. -public sealed class App : Avalonia.Application -{ - private IHost? _host; - - public override void Initialize() => AvaloniaXamlLoader.Load(this); - - public override void OnFrameworkInitializationCompleted() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - _host = AppHost.Create(desktop.Args ?? []); - _host.Start(); - - AsyncImage.Loader = _host.Services.GetRequiredService(); - - var viewModel = _host.Services.GetRequiredService(); - desktop.MainWindow = new MainWindow { DataContext = viewModel }; - desktop.Exit += OnExit; - - // Kick the first load off once the dispatcher is running, so a failure surfaces - // through the view model instead of disappearing into an unobserved task. - Dispatcher.UIThread.Post( - () => viewModel.InitializeCommand.Execute(null), - DispatcherPriority.Background); - } - - base.OnFrameworkInitializationCompleted(); - } - - private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) - { - if (_host is null) - { - return; - } - - _host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult(); - _host.Dispose(); - _host = null; - } -} +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PLib.Desktop.Controls; +using PLib.Desktop.Imaging; +using PLib.Desktop.ViewModels; +using PLib.Desktop.Views; + +namespace PLib.Desktop; + +// Fully qualified: the PLib.Application namespace shadows the Application type in this assembly. +public sealed class App : Avalonia.Application +{ + private IHost? _host; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + _host = AppHost.Create(desktop.Args ?? []); + _host.Start(); + + AsyncImage.Loader = _host.Services.GetRequiredService(); + + var viewModel = _host.Services.GetRequiredService(); + desktop.MainWindow = new MainWindow { DataContext = viewModel }; + desktop.Exit += OnExit; + + // Kick the first load off once the dispatcher is running, so a failure surfaces + // through the view model instead of disappearing into an unobserved task. + Dispatcher.UIThread.Post( + () => viewModel.InitializeCommand.Execute().Subscribe(), + DispatcherPriority.Background); + } + + base.OnFrameworkInitializationCompleted(); + } + + private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) + { + if (_host is null) + { + return; + } + + _host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult(); + _host.Dispose(); + _host = null; + } +} diff --git a/src/PLib.Desktop/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj index 38c0cff..4a7ec07 100644 --- a/src/PLib.Desktop/PLib.Desktop.csproj +++ b/src/PLib.Desktop/PLib.Desktop.csproj @@ -23,7 +23,12 @@ - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/PLib.Desktop/Program.cs b/src/PLib.Desktop/Program.cs index ca1ee88..9060714 100644 --- a/src/PLib.Desktop/Program.cs +++ b/src/PLib.Desktop/Program.cs @@ -1,18 +1,20 @@ -using Avalonia; - -namespace PLib.Desktop; - -internal static class Program -{ - // Avalonia must be initialised before anything touches its types, so keep Main free of - // any other work and let App own the application host. - [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); - - /// Also used by the XAML previewer, which requires this exact signature. - public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() - .UsePlatformDetect() - .WithInterFont() - .LogToTrace(); -} +using Avalonia; +using ReactiveUI.Avalonia; + +namespace PLib.Desktop; + +internal static class Program +{ + // Avalonia must be initialised before anything touches its types, so keep Main free of + // any other work and let App own the application host. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + /// Also used by the XAML previewer, which requires this exact signature. + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UsePlatformDetect() + .UseReactiveUI(reactive => reactive.WithAvalonia()) + .WithInterFont() + .LogToTrace(); +} diff --git a/src/PLib.Desktop/ViewModels/DisposableExtensions.cs b/src/PLib.Desktop/ViewModels/DisposableExtensions.cs new file mode 100644 index 0000000..7edb229 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/DisposableExtensions.cs @@ -0,0 +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); +} diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index ad3a6c8..7179823 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -1,28 +1,60 @@ using System.Collections.ObjectModel; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; +using DynamicData; +using DynamicData.Kernel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PLib.Application.Library; using PLib.Desktop.Services; +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. +using RxVoid = ReactiveUI.Primitives.RxVoid; namespace PLib.Desktop.ViewModels; -public sealed partial class MainWindowViewModel : ObservableObject +public sealed partial class MainWindowViewModel : ViewModelBase { + /// How long typing has to pause before the grid is re-filtered. + private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(200); + + /// + /// Card property changes are coalesced over this window. During a scan every indexed + /// file updates its card, and re-sorting on each one individually would be wasted work. + /// + private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMilliseconds(250); + private readonly IServiceScopeFactory _scopeFactory; private readonly IOptionsMonitor _options; private readonly ILibrarySettingsStore _settingsStore; private readonly IFolderPicker _folderPicker; private readonly ISystemShell _shell; - private readonly IThemeService _theme; private readonly ILogger _logger; - /// Every card we know about; is the filtered, sorted view of it. - private readonly List _all = []; - private readonly Dictionary _byId = []; + /// + /// The whole library, keyed by identifier. is a filtered and sorted + /// projection of it that DynamicData keeps in sync through fine-grained changes, so the + /// grid never has to be rebuilt from scratch. + /// + 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. + /// + private readonly IScheduler _uiScheduler = + new SynchronizationContextScheduler(new AvaloniaSynchronizationContext()); + + private readonly Subject _cancelScan = new(); + private readonly ReadOnlyObservableCollection _videos; + private readonly ObservableAsPropertyHelper _isScanning; + private readonly ObservableAsPropertyHelper _isEmpty; public MainWindowViewModel( IServiceScopeFactory scopeFactory, @@ -38,77 +70,151 @@ public sealed partial class MainWindowViewModel : ObservableObject _settingsStore = settingsStore; _folderPicker = folderPicker; _shell = shell; - _theme = theme; _logger = logger; SelectedSort = SortOption.All[0]; + + InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync); + AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync); + ToggleThemeCommand = ReactiveCommand.Create(theme.Toggle); + + // 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( + () => Observable.StartAsync(ScanAsync).Select(_ => RxVoid.Default).TakeUntil(_cancelScan)); + + CancelScanCommand = ReactiveCommand.Create( + () => _cancelScan.OnNext(RxVoid.Default), + ScanCommand.IsExecuting); + + _isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning); + + BuildLibraryView(out _videos, out _isEmpty); + ObserveCommandFailures(); } - public ObservableCollection Videos { get; } = []; + /// The cards actually on screen, in the order the user asked for. + public ReadOnlyObservableCollection Videos => _videos; public IReadOnlyList SortOptions => SortOption.All; - public IReadOnlyList Folders => [.. _options.CurrentValue.Folders]; + public bool IsScanning => _isScanning.Value; - [ObservableProperty] - public partial string SearchText { get; set; } = string.Empty; - - [ObservableProperty] - public partial SortOption SelectedSort { get; set; } - - [ObservableProperty] - public partial bool IsScanning { get; set; } - - [ObservableProperty] - public partial string StatusText { get; set; } = string.Empty; - - [ObservableProperty] - public partial double ScanProgress { get; set; } - - [ObservableProperty] - public partial bool IsProgressIndeterminate { get; set; } - - /// True when the library is empty and there is nothing to show but the call to action. - public bool IsEmpty => Videos.Count == 0 && !IsScanning; + /// True when there is nothing to show and no scan is running to change that. + public bool IsEmpty => _isEmpty.Value; public bool HasFolders => _options.CurrentValue.Folders.Count > 0; - partial void OnSearchTextChanged(string value) => RebuildView(); + public ReactiveCommand InitializeCommand { get; } - partial void OnSelectedSortChanged(SortOption value) => RebuildView(); + public ReactiveCommand ScanCommand { get; } - partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty)); + public ReactiveCommand CancelScanCommand { get; } + + public ReactiveCommand AddFolderCommand { get; } + + public ReactiveCommand ToggleThemeCommand { get; } + + [Reactive] + public partial string SearchText { get; set; } + + [Reactive] + public partial SortOption SelectedSort { get; set; } + + [Reactive] + public partial string StatusText { get; set; } + + [Reactive] + public partial double ScanProgress { get; set; } + + [Reactive] + public partial bool IsProgressIndeterminate { get; set; } + + /// + /// Wires the library cache to the bound collection: debounced search, user-chosen order, + /// and a derived "is anything visible" flag — one subscription for all three. + /// + private void BuildLibraryView( + out ReadOnlyObservableCollection videos, + out ObservableAsPropertyHelper isEmpty) + { + var filterChanged = this + .WhenAnyValue(x => x.SearchText) + .Throttle(SearchDebounce, TaskPoolScheduler.Default) + // Throttle swallows the initial value, and the grid must not start out blank. + .StartWith(SearchText) + .DistinctUntilChanged() + .Select(BuildFilter); + + var comparerChanged = this + .WhenAnyValue(x => x.SelectedSort) + .Select(option => option.Comparer); + + isEmpty = _library + .Connect() + // Cards mutate in place while indexing runs; without this their position and + // visibility would be frozen at whatever they were when first inserted. + .AutoRefresh(propertyChangeThrottle: RefreshBuffer, scheduler: TaskPoolScheduler.Default) + .Filter(filterChanged) + .ObserveOn(_uiScheduler) + .SortAndBind(out videos, comparerChanged) + .Count() + .CombineLatest(this.WhenAnyValue(x => x.IsScanning), (count, scanning) => count == 0 && !scanning) + .ToProperty(this, x => x.IsEmpty); + } + + private static Func BuildFilter(string? term) + { + if (string.IsNullOrWhiteSpace(term)) + { + return _ => true; + } + + var trimmed = term.Trim(); + return card => card.Matches(trimmed); + } + + /// + /// An unobserved failure is rethrown on the UI thread by + /// the default handler, which takes the process down. Everything funnels here instead. + /// + private void ObserveCommandFailures() => + Observable + .Merge( + InitializeCommand.ThrownExceptions, + ScanCommand.ThrownExceptions, + CancelScanCommand.ThrownExceptions, + AddFolderCommand.ThrownExceptions, + ToggleThemeCommand.ThrownExceptions) + .Subscribe(ex => + { + _logger.LogError(ex, "A command failed"); + StatusText = "Что-то пошло не так — подробности в журнале"; + }) + .AddTo(Subscriptions); /// Loads whatever is already in the database, then refreshes it against disk. - [RelayCommand] private async Task InitializeAsync() { try { await using var scope = _scopeFactory.CreateAsyncScope(); var library = scope.ServiceProvider.GetRequiredService(); + var items = await library.GetLibraryAsync(); - foreach (var item in await library.GetLibraryAsync()) - { - var card = new VideoCardViewModel(item, _shell); - _all.Add(card); - _byId[card.Id] = card; - } + _library.AddOrUpdate(items.Select(item => new VideoCardViewModel(item, _shell))); } catch (Exception ex) { - // An unobserved failure here would tear the process down through the command's - // task; the user gets a message instead and can still pick a folder. + // The user can still pick a folder even if the stored library cannot be read. _logger.LogError(ex, "Could not load the stored library"); StatusText = "Не удалось открыть базу библиотеки — подробности в журнале"; return; } - RebuildView(); - if (HasFolders) { - await ScanCommand.ExecuteAsync(null); + await ScanCommand.Execute(); } else { @@ -116,7 +222,6 @@ public sealed partial class MainWindowViewModel : ObservableObject } } - [RelayCommand(IncludeCancelCommand = true)] private async Task ScanAsync(CancellationToken cancellationToken) { var folders = _options.CurrentValue.Folders.ToArray(); @@ -127,7 +232,6 @@ public sealed partial class MainWindowViewModel : ObservableObject return; } - IsScanning = true; IsProgressIndeterminate = true; ScanProgress = 0; StatusText = "Поиск файлов…"; @@ -154,20 +258,12 @@ public sealed partial class MainWindowViewModel : ObservableObject { StatusText = "Сканирование отменено"; } - catch (Exception ex) - { - _logger.LogError(ex, "Library scan failed"); - StatusText = "Не удалось просканировать библиотеку — подробности в журнале"; - } finally { - IsScanning = false; IsProgressIndeterminate = false; - RebuildView(); } } - [RelayCommand] private async Task AddFolderAsync() { var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео"); @@ -191,15 +287,11 @@ public sealed partial class MainWindowViewModel : ObservableObject // sees the folder we just added instead of racing the file watcher. await WaitForFolderAsync(folder); - OnPropertyChanged(nameof(Folders)); - OnPropertyChanged(nameof(HasFolders)); + this.RaisePropertyChanged(nameof(HasFolders)); - await ScanCommand.ExecuteAsync(null); + await ScanCommand.Execute(); } - [RelayCommand] - private void ToggleTheme() => _theme.Toggle(); - private async Task WaitForFolderAsync(string folder) { for (var attempt = 0; attempt < 20; attempt++) @@ -224,24 +316,17 @@ public sealed partial class MainWindowViewModel : ObservableObject break; case LibraryScanEvent.ItemAdded added: - { - var card = new VideoCardViewModel(added.Item, _shell); - _all.Add(card); - _byId[card.Id] = card; - InsertIntoView(card); - break; - } - - case LibraryScanEvent.ItemUpdated updated - when _byId.TryGetValue(updated.Item.Id, out var existing): - existing.Apply(updated.Item); + _library.AddOrUpdate(new VideoCardViewModel(added.Item, _shell)); break; - case LibraryScanEvent.ItemRemoved removed - when _byId.Remove(removed.Id, out var dropped): - _all.Remove(dropped); - Videos.Remove(dropped); - OnPropertyChanged(nameof(IsEmpty)); + case LibraryScanEvent.ItemUpdated updated: + // Mutating the card in place is enough: AutoRefresh turns the resulting + // change notification into a re-sort and re-filter of just that one item. + _library.Lookup(updated.Item.Id).IfHasValue(card => card.Apply(updated.Item)); + break; + + case LibraryScanEvent.ItemRemoved removed: + _library.RemoveKey(removed.Id); break; case LibraryScanEvent.IndexingProgress progress: @@ -261,42 +346,4 @@ public sealed partial class MainWindowViewModel : ObservableObject break; } } - - private void InsertIntoView(VideoCardViewModel card) - { - if (!PassesFilter(card)) - { - return; - } - - // Appending keeps the grid stable while a scan streams in; the final RebuildView - // puts everything in the requested order once the scan settles. - Videos.Add(card); - OnPropertyChanged(nameof(IsEmpty)); - } - - private bool PassesFilter(VideoCardViewModel card) => - string.IsNullOrWhiteSpace(SearchText) || card.Matches(SearchText.Trim()); - - private void RebuildView() - { - var visible = _all.Where(PassesFilter); - - visible = SelectedSort.Sort switch - { - LibrarySort.TitleAscending => visible.OrderBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase), - LibrarySort.LongestFirst => visible.OrderByDescending(x => x.RawDuration ?? TimeSpan.Zero), - LibrarySort.LargestFirst => visible.OrderByDescending(x => x.RawSizeInBytes), - _ => visible.OrderByDescending(x => x.AddedAt), - }; - - Videos.Clear(); - - foreach (var card in visible) - { - Videos.Add(card); - } - - OnPropertyChanged(nameof(IsEmpty)); - } } diff --git a/src/PLib.Desktop/ViewModels/SortOption.cs b/src/PLib.Desktop/ViewModels/SortOption.cs index 16d86e2..7d8c5b1 100644 --- a/src/PLib.Desktop/ViewModels/SortOption.cs +++ b/src/PLib.Desktop/ViewModels/SortOption.cs @@ -1,21 +1,45 @@ +using DynamicData.Binding; + namespace PLib.Desktop.ViewModels; -public enum LibrarySort -{ - RecentlyAdded, - TitleAscending, - LongestFirst, - LargestFirst, -} - -/// A sort order together with the label the combo box shows for it. -public sealed record SortOption(LibrarySort Sort, string Label) +/// +/// A sort order, its label, and the comparer DynamicData keeps the bound collection in. +/// +/// +/// Every comparer ends with the identifier so the order is total. Ties would otherwise let +/// DynamicData move equal items around on each refresh, which the user sees as cards +/// twitching while a scan streams in. +/// +public sealed record SortOption(string Label, IComparer Comparer) { public static IReadOnlyList All { get; } = [ - new(LibrarySort.RecentlyAdded, "Недавно добавленные"), - new(LibrarySort.TitleAscending, "По названию"), - new(LibrarySort.LongestFirst, "Сначала длинные"), - new(LibrarySort.LargestFirst, "Сначала большие"), + new("Недавно добавленные", SortExpressionComparer + .Descending(x => x.AddedAt) + .ThenByAscending(x => x.Id)), + + new("По названию", new TitleComparer()), + + new("Сначала длинные", SortExpressionComparer + .Descending(x => x.RawDuration ?? TimeSpan.Zero) + .ThenByAscending(x => x.Id)), + + new("Сначала большие", SortExpressionComparer + .Descending(x => x.RawSizeInBytes) + .ThenByAscending(x => x.Id)), ]; + + /// + /// Culture-aware, case-insensitive title order. + /// would fall back to ordinal comparison, which puts Cyrillic and Latin titles in an + /// order no reader expects. + /// + private sealed class TitleComparer : IComparer + { + public int Compare(VideoCardViewModel? x, VideoCardViewModel? y) + { + var byTitle = string.Compare(x?.Title, y?.Title, StringComparison.CurrentCultureIgnoreCase); + return byTitle != 0 ? byTitle : Comparer.Default.Compare(x?.Id ?? Guid.Empty, y?.Id ?? Guid.Empty); + } + } } diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs index 4565770..e791592 100644 --- a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs @@ -1,74 +1,83 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using PLib.Desktop.Services; -using PLib.Domain.Videos; - -namespace PLib.Desktop.ViewModels; - -/// One card in the library grid. -public sealed partial class VideoCardViewModel : ObservableObject -{ - private readonly ISystemShell _shell; - - public VideoCardViewModel(VideoItem item, ISystemShell shell) - { - _shell = shell; - Id = item.Id; - FullPath = item.FullPath; - Title = item.Title; - Apply(item); - } - - public Guid Id { get; } - - public string FullPath { get; } - - public DateTimeOffset AddedAt { get; private set; } - - public TimeSpan? RawDuration { get; private set; } - - public long RawSizeInBytes { get; private set; } - - [ObservableProperty] - public partial string Title { get; set; } - - [ObservableProperty] - public partial string? ThumbnailPath { get; set; } - - [ObservableProperty] - public partial string DurationText { get; set; } = "—"; - - [ObservableProperty] - public partial string SizeText { get; set; } = string.Empty; - - [ObservableProperty] - public partial string? QualityText { get; set; } - - /// True while the poster frame has not been produced yet. - [ObservableProperty] - public partial bool IsPending { get; set; } = true; - - /// Copies the current state of the entity into the card. - public void Apply(VideoItem item) - { - Title = item.Title; - ThumbnailPath = item.ThumbnailPath; - DurationText = DisplayText.Duration(item.Duration); - SizeText = DisplayText.FileSize(item.SizeInBytes); - QualityText = DisplayText.Quality(item.Width, item.Height); - AddedAt = item.AddedAt; - RawDuration = item.Duration; - RawSizeInBytes = item.SizeInBytes; - IsPending = item.ThumbnailPath is null; - } - - [RelayCommand] - private void Play() => _shell.OpenFile(FullPath); - - [RelayCommand] - private void Reveal() => _shell.RevealInFileManager(FullPath); - - public bool Matches(string term) => - Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) || - FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase); -} +using PLib.Desktop.Services; +using PLib.Domain.Videos; +using ReactiveUI; +using RxVoid = ReactiveUI.Primitives.RxVoid; +using ReactiveUI.SourceGenerators; + +namespace PLib.Desktop.ViewModels; + +/// One card in the library grid. +/// +/// Everything the grid sorts or filters by is a reactive property, because DynamicData's +/// AutoRefresh re-evaluates position and visibility off +/// change notifications — a plain auto-property would silently freeze a card in place. +/// +public sealed partial class VideoCardViewModel : ReactiveObject +{ + public VideoCardViewModel(VideoItem item, ISystemShell shell) + { + Id = item.Id; + FullPath = item.FullPath; + Title = item.Title; + + PlayCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); + RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); + + Apply(item); + } + + public Guid Id { get; } + + public string FullPath { get; } + + public ReactiveCommand PlayCommand { get; } + + public ReactiveCommand RevealCommand { get; } + + [Reactive] + public partial string Title { get; set; } + + [Reactive] + public partial string? ThumbnailPath { get; set; } + + [Reactive] + public partial string DurationText { get; set; } + + [Reactive] + public partial string SizeText { get; set; } + + [Reactive] + public partial string? QualityText { get; set; } + + /// True while the poster frame has not been produced yet. + [Reactive] + public partial bool IsPending { get; set; } + + /// Raw duration, kept for sorting; is what the card shows. + [Reactive] + public partial TimeSpan? RawDuration { get; set; } + + /// Raw size, kept for sorting; is what the card shows. + [Reactive] + public partial long RawSizeInBytes { get; set; } + + public DateTimeOffset AddedAt { get; private set; } + + /// Copies the current state of the entity into the card. + public void Apply(VideoItem item) + { + Title = item.Title; + ThumbnailPath = item.ThumbnailPath; + DurationText = DisplayText.Duration(item.Duration); + SizeText = DisplayText.FileSize(item.SizeInBytes); + QualityText = DisplayText.Quality(item.Width, item.Height); + AddedAt = item.AddedAt; + RawDuration = item.Duration; + RawSizeInBytes = item.SizeInBytes; + IsPending = item.ThumbnailPath is null; + } + + public bool Matches(string term) => + Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) || + FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase); +} diff --git a/src/PLib.Desktop/ViewModels/ViewModelBase.cs b/src/PLib.Desktop/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..d9a1676 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/ViewModelBase.cs @@ -0,0 +1,28 @@ +using System.Reactive.Disposables; +using ReactiveUI; + +namespace PLib.Desktop.ViewModels; + +/// +/// Common base for every view model: change notification from +/// plus a bag to park subscriptions in, so nothing outlives the view model that made it. +/// +public abstract class ViewModelBase : ReactiveObject, IDisposable +{ + private bool _disposed; + + /// Subscriptions torn down together with this view model. + protected CompositeDisposable Subscriptions { get; } = []; + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + Subscriptions.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml index 01a550b..7bef7fc 100644 --- a/src/PLib.Desktop/Views/MainWindow.axaml +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -224,7 +224,7 @@