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;
}
}
}