350 lines
13 KiB
C#
350 lines
13 KiB
C#
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
|
||
{
|
||
/// <summary>How long typing has to pause before the grid is re-filtered.</summary>
|
||
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(200);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMilliseconds(250);
|
||
|
||
private readonly IServiceScopeFactory _scopeFactory;
|
||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||
private readonly ILibrarySettingsStore _settingsStore;
|
||
private readonly IFolderPicker _folderPicker;
|
||
private readonly ISystemShell _shell;
|
||
private readonly ILogger<MainWindowViewModel> _logger;
|
||
|
||
/// <summary>
|
||
/// The whole library, keyed by identifier. <see cref="Videos"/> 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.
|
||
/// </summary>
|
||
private readonly SourceCache<VideoCardViewModel, Guid> _library = new(card => card.Id);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private readonly IScheduler _uiScheduler =
|
||
new SynchronizationContextScheduler(new AvaloniaSynchronizationContext());
|
||
|
||
private readonly Subject<RxVoid> _cancelScan = new();
|
||
private readonly ReadOnlyObservableCollection<VideoCardViewModel> _videos;
|
||
private readonly ObservableAsPropertyHelper<bool> _isScanning;
|
||
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
|
||
|
||
public MainWindowViewModel(
|
||
IServiceScopeFactory scopeFactory,
|
||
IOptionsMonitor<LibraryOptions> options,
|
||
ILibrarySettingsStore settingsStore,
|
||
IFolderPicker folderPicker,
|
||
ISystemShell shell,
|
||
IThemeService theme,
|
||
ILogger<MainWindowViewModel> 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();
|
||
}
|
||
|
||
/// <summary>The cards actually on screen, in the order the user asked for.</summary>
|
||
public ReadOnlyObservableCollection<VideoCardViewModel> Videos => _videos;
|
||
|
||
public IReadOnlyList<SortOption> SortOptions => SortOption.All;
|
||
|
||
public bool IsScanning => _isScanning.Value;
|
||
|
||
/// <summary>True when there is nothing to show and no scan is running to change that.</summary>
|
||
public bool IsEmpty => _isEmpty.Value;
|
||
|
||
public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
|
||
|
||
public ReactiveCommand<RxVoid, RxVoid> InitializeCommand { get; }
|
||
|
||
public ReactiveCommand<RxVoid, RxVoid> ScanCommand { get; }
|
||
|
||
public ReactiveCommand<RxVoid, RxVoid> CancelScanCommand { get; }
|
||
|
||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||
|
||
public ReactiveCommand<RxVoid, RxVoid> 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; }
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private void BuildLibraryView(
|
||
out ReadOnlyObservableCollection<VideoCardViewModel> videos,
|
||
out ObservableAsPropertyHelper<bool> 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<VideoCardViewModel, bool> BuildFilter(string? term)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(term))
|
||
{
|
||
return _ => true;
|
||
}
|
||
|
||
var trimmed = term.Trim();
|
||
return card => card.Matches(trimmed);
|
||
}
|
||
|
||
/// <summary>
|
||
/// An unobserved <see cref="ReactiveCommand"/> failure is rethrown on the UI thread by
|
||
/// the default handler, which takes the process down. Everything funnels here instead.
|
||
/// </summary>
|
||
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);
|
||
|
||
/// <summary>Loads whatever is already in the database, then refreshes it against disk.</summary>
|
||
private async Task InitializeAsync()
|
||
{
|
||
try
|
||
{
|
||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||
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<ILibraryService>();
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|