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.
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <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 IThemeService _theme;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
/// <summary>Every card we know about; <see cref="Videos"/> is the filtered, sorted view of it.</summary>
|
||||
private readonly List<VideoCardViewModel> _all = [];
|
||||
private readonly Dictionary<Guid, VideoCardViewModel> _byId = [];
|
||||
/// <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,
|
||||
@@ -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<VideoCardViewModel> Videos { get; } = [];
|
||||
/// <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 IReadOnlyList<string> 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; }
|
||||
|
||||
/// <summary>True when the library is empty and there is nothing to show but the call to action.</summary>
|
||||
public bool IsEmpty => Videos.Count == 0 && !IsScanning;
|
||||
/// <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;
|
||||
|
||||
partial void OnSearchTextChanged(string value) => RebuildView();
|
||||
public ReactiveCommand<RxVoid, RxVoid> InitializeCommand { get; }
|
||||
|
||||
partial void OnSelectedSortChanged(SortOption value) => RebuildView();
|
||||
public ReactiveCommand<RxVoid, RxVoid> ScanCommand { get; }
|
||||
|
||||
partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty));
|
||||
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>
|
||||
[RelayCommand]
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user