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 @@