using System.Collections.ObjectModel; using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Reactive.Subjects; using Avalonia.Threading; 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 : 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 ILogger _logger; /// /// 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, IOptionsMonitor options, ILibrarySettingsStore settingsStore, IFolderPicker folderPicker, ISystemShell shell, IThemeService theme, ILogger logger) { _scopeFactory = scopeFactory; _options = options; _settingsStore = settingsStore; _folderPicker = folderPicker; _shell = shell; _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(); } /// The cards actually on screen, in the order the user asked for. public ReadOnlyObservableCollection Videos => _videos; public IReadOnlyList SortOptions => SortOption.All; public bool IsScanning => _isScanning.Value; /// 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; public ReactiveCommand InitializeCommand { get; } public ReactiveCommand ScanCommand { get; } 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. private async Task InitializeAsync() { try { await using var scope = _scopeFactory.CreateAsyncScope(); var library = scope.ServiceProvider.GetRequiredService(); var items = await library.GetLibraryAsync(); _library.AddOrUpdate(items.Select(item => new VideoCardViewModel(item, _shell))); } catch (Exception ex) { // 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; } if (HasFolders) { await ScanCommand.Execute(); } else { StatusText = "Библиотека пуста — добавьте папку с видео"; } } private async Task ScanAsync(CancellationToken cancellationToken) { var folders = _options.CurrentValue.Folders.ToArray(); if (folders.Length == 0) { StatusText = "Не выбрано ни одной папки"; return; } IsProgressIndeterminate = true; ScanProgress = 0; StatusText = "Поиск файлов…"; try { // Task.Run detaches the whole pipeline from the UI synchronisation context, so // scanning, probing and database work never touch the render thread. Every event // is marshalled back explicitly. await Task.Run( async () => { await using var scope = _scopeFactory.CreateAsyncScope(); var library = scope.ServiceProvider.GetRequiredService(); await foreach (var scanEvent in library.ScanAsync(folders, cancellationToken)) { await Dispatcher.UIThread.InvokeAsync(() => Handle(scanEvent)); } }, cancellationToken); } catch (OperationCanceledException) { StatusText = "Сканирование отменено"; } finally { IsProgressIndeterminate = false; } } private async Task AddFolderAsync() { var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео"); if (folder is null) { return; } var folders = _options.CurrentValue.Folders.ToList(); if (folders.Contains(folder, LibraryPathComparer.Instance)) { return; } folders.Add(folder); await _settingsStore.SaveFoldersAsync(folders); // IOptionsMonitor reloads from the file asynchronously; wait for it so the scan below // sees the folder we just added instead of racing the file watcher. await WaitForFolderAsync(folder); this.RaisePropertyChanged(nameof(HasFolders)); await ScanCommand.Execute(); } private async Task WaitForFolderAsync(string folder) { for (var attempt = 0; attempt < 20; attempt++) { if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance)) { return; } await Task.Delay(50); } _logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder); } private void Handle(LibraryScanEvent scanEvent) { switch (scanEvent) { case LibraryScanEvent.DiscoveryCompleted discovery: StatusText = $"Найдено файлов: {discovery.FilesFound}"; break; case LibraryScanEvent.ItemAdded added: _library.AddOrUpdate(new VideoCardViewModel(added.Item, _shell)); break; 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: IsProgressIndeterminate = false; ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total; StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}"; break; case LibraryScanEvent.Completed completed: ScanProgress = 100; StatusText = completed.LibrarySize == 0 ? "В выбранных папках не нашлось видео" : $"В библиотеке {completed.LibrarySize} видео"; break; default: break; } } }