diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cf127de --- /dev/null +++ b/.editorconfig @@ -0,0 +1,52 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Namespaces +csharp_style_namespace_declarations = file_scoped:warning + +# var +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Modern language features +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_readonly_field = true:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# Usings +dotnet_sort_system_directives_first = true +csharp_using_directive_placement = outside_namespace:warning + +# Naming: private fields are _camelCase +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = underscore_prefix +dotnet_naming_rule.private_fields_underscore.severity = warning + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_style.underscore_prefix.capitalization = camel_case +dotnet_naming_style.underscore_prefix.required_prefix = _ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0e210b8 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + net10.0 + latest + enable + enable + true + true + false + true + + + + PLib + PLib + 0.1.0 + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..4ba586f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,51 @@ + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PLib.slnx b/PLib.slnx new file mode 100644 index 0000000..b6ee687 --- /dev/null +++ b/PLib.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/README.md b/README.md index b3a5ded..fd127f8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,66 @@ # 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` — единственное место, которое надо будет тронуть). diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/PLib.Application/Abstractions/IMediaProbe.cs b/src/PLib.Application/Abstractions/IMediaProbe.cs new file mode 100644 index 0000000..13ab31b --- /dev/null +++ b/src/PLib.Application/Abstractions/IMediaProbe.cs @@ -0,0 +1,14 @@ +using PLib.Domain.Videos; + +namespace PLib.Application.Abstractions; + +/// Reads duration, resolution and codec out of a media container. +public interface IMediaProbe +{ + /// + /// Probes . Returns + /// when the file cannot be read or is not a media file — probing must never throw for + /// a single bad file, otherwise one corrupt video would abort a whole library scan. + /// + Task ProbeAsync(string fullPath, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs new file mode 100644 index 0000000..c0c21c1 --- /dev/null +++ b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs @@ -0,0 +1,15 @@ +namespace PLib.Application.Abstractions; + +/// Produces (and caches on disk) a poster frame for a video file. +public interface IThumbnailGenerator +{ + /// + /// Returns the absolute path of the poster frame for , + /// generating it if it is not cached yet. Returns null when no frame could be + /// extracted; a missing thumbnail is a normal outcome, not an error. + /// + Task GetOrCreateAsync( + string videoPath, + TimeSpan? duration, + CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Abstractions/IVideoFileScanner.cs b/src/PLib.Application/Abstractions/IVideoFileScanner.cs new file mode 100644 index 0000000..8516aff --- /dev/null +++ b/src/PLib.Application/Abstractions/IVideoFileScanner.cs @@ -0,0 +1,13 @@ +namespace PLib.Application.Abstractions; + +/// A video file discovered on disk, before it is known to the library. +/// Absolute path of the file. +/// Size of the file on disk. +/// Last write time of the file. +public readonly record struct DiscoveredVideoFile(string FullPath, long SizeInBytes, DateTimeOffset ModifiedAt); + +/// Walks a folder tree and yields every file that looks like a video. +public interface IVideoFileScanner +{ + IAsyncEnumerable ScanAsync(string rootFolder, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs new file mode 100644 index 0000000..91e241b --- /dev/null +++ b/src/PLib.Application/Abstractions/IVideoRepository.cs @@ -0,0 +1,21 @@ +using PLib.Domain.Videos; + +namespace PLib.Application.Abstractions; + +/// +/// Persistence boundary for the library. The application layer only ever talks to this +/// interface, which keeps EF Core (and SQLite) an implementation detail of the outer ring. +/// +public interface IVideoRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default); + + Task AddAsync(VideoItem item, CancellationToken cancellationToken = default); + + Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default); + + /// Flushes every pending change made to tracked entities. + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs new file mode 100644 index 0000000..6b3ed4c --- /dev/null +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -0,0 +1,18 @@ +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// Use cases the UI needs in order to show and refresh the video library. +public interface ILibraryService +{ + /// Everything currently stored in the library, newest first. + Task> GetLibraryAsync(CancellationToken cancellationToken = default); + + /// + /// Reconciles the library with the configured folders and then fills in metadata and + /// poster frames for anything that is missing them, streaming progress as it goes. + /// + IAsyncEnumerable ScanAsync( + IReadOnlyList folders, + CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/LibraryOptions.cs b/src/PLib.Application/Library/LibraryOptions.cs new file mode 100644 index 0000000..825ff84 --- /dev/null +++ b/src/PLib.Application/Library/LibraryOptions.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace PLib.Application.Library; + +/// User-tunable settings for how the library is discovered and indexed. +public sealed class LibraryOptions +{ + public const string SectionName = "Library"; + + /// Root folders that make up the library. + public IList Folders { get; init; } = []; + + /// File extensions treated as video, lower case and including the leading dot. + public IList VideoExtensions { get; init; } = + [ + ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg", ".flv", ".ts", ".m2ts", + ]; + + /// Width of generated poster frames in pixels; height follows the source aspect ratio. + [Range(160, 1920)] + public int ThumbnailWidth { get; init; } = 480; + + /// + /// Fraction of the duration at which the poster frame is captured. Grabbing the very + /// first frame usually yields a black or logo frame, so we sample a bit into the file. + /// + [Range(0.0, 0.9)] + public double ThumbnailPositionRatio { get; init; } = 0.15; + + /// How many files may be probed / rendered concurrently. + [Range(1, 32)] + public int MaxIndexingConcurrency { get; init; } = 4; + + /// Files smaller than this are skipped as they are almost certainly not real videos. + [Range(0, long.MaxValue)] + public long MinimumFileSizeInBytes { get; init; } = 64 * 1024; +} diff --git a/src/PLib.Application/Library/LibraryPathComparer.cs b/src/PLib.Application/Library/LibraryPathComparer.cs new file mode 100644 index 0000000..a81b1d9 --- /dev/null +++ b/src/PLib.Application/Library/LibraryPathComparer.cs @@ -0,0 +1,12 @@ +namespace PLib.Application.Library; + +/// +/// Compares file system paths the way the host platform does: case-insensitively on +/// Windows, byte-for-byte everywhere else. +/// +public static class LibraryPathComparer +{ + public static StringComparer Instance { get; } = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +} diff --git a/src/PLib.Application/Library/LibraryScanEvent.cs b/src/PLib.Application/Library/LibraryScanEvent.cs new file mode 100644 index 0000000..acd43e3 --- /dev/null +++ b/src/PLib.Application/Library/LibraryScanEvent.cs @@ -0,0 +1,32 @@ +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// +/// Something that happened during a scan. The scan is exposed as a stream of these so the +/// UI can render cards as soon as they are known instead of waiting for the whole pass. +/// +public abstract record LibraryScanEvent +{ + private LibraryScanEvent() + { + } + + /// The file system walk finished and we know how much work there is. + public sealed record DiscoveryCompleted(int FilesFound) : LibraryScanEvent; + + /// A video is now part of the library (possibly still without a poster frame). + public sealed record ItemAdded(VideoItem Item) : LibraryScanEvent; + + /// A video that is already displayed gained metadata or a poster frame. + public sealed record ItemUpdated(VideoItem Item) : LibraryScanEvent; + + /// A video disappeared from disk and was dropped from the library. + public sealed record ItemRemoved(Guid Id) : LibraryScanEvent; + + /// Indexing progress, reported after every processed file. + public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent; + + /// The scan finished successfully. + public sealed record Completed(int LibrarySize) : LibraryScanEvent; +} diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs new file mode 100644 index 0000000..7426a66 --- /dev/null +++ b/src/PLib.Application/Library/LibraryService.cs @@ -0,0 +1,184 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PLib.Application.Abstractions; +using PLib.Domain.Videos; + +namespace PLib.Application.Library; + +/// +public sealed class LibraryService( + IVideoRepository repository, + IVideoFileScanner scanner, + IMediaProbe mediaProbe, + IThumbnailGenerator thumbnailGenerator, + IOptions options, + ILogger logger) : ILibraryService +{ + /// How many indexed items to accumulate before flushing them to storage. + private const int SaveBatchSize = 25; + + private readonly LibraryOptions _options = options.Value; + + public async Task> GetLibraryAsync(CancellationToken cancellationToken = default) + { + var items = await repository.GetAllAsync(cancellationToken); + return [.. items.OrderByDescending(x => x.AddedAt)]; + } + + public async IAsyncEnumerable ScanAsync( + IReadOnlyList folders, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var known = (await repository.GetAllAsync(cancellationToken)) + .ToDictionary(x => x.FullPath, LibraryPathComparer.Instance); + + var discovered = await DiscoverAsync(folders, cancellationToken); + yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count); + + var pending = new List(); + + foreach (var file in discovered.Values) + { + if (known.TryGetValue(file.FullPath, out var existing)) + { + existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt); + } + else + { + existing = new VideoItem( + file.FullPath, + Path.GetFileNameWithoutExtension(file.FullPath), + file.SizeInBytes, + file.ModifiedAt); + + await repository.AddAsync(existing, cancellationToken); + known.Add(existing.FullPath, existing); + yield return new LibraryScanEvent.ItemAdded(existing); + } + + if (!existing.IsIndexed) + { + pending.Add(existing); + } + } + + foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList()) + { + await repository.RemoveAsync(orphan, cancellationToken); + known.Remove(orphan.FullPath); + yield return new LibraryScanEvent.ItemRemoved(orphan.Id); + } + + await repository.SaveChangesAsync(cancellationToken); + + await foreach (var indexed in IndexAsync(pending, cancellationToken)) + { + yield return indexed; + } + + await repository.SaveChangesAsync(cancellationToken); + yield return new LibraryScanEvent.Completed(known.Count); + } + + private async Task> DiscoverAsync( + IReadOnlyList folders, + CancellationToken cancellationToken) + { + var discovered = new Dictionary(LibraryPathComparer.Instance); + + foreach (var folder in folders) + { + await foreach (var file in scanner.ScanAsync(folder, cancellationToken)) + { + if (file.SizeInBytes < _options.MinimumFileSizeInBytes) + { + continue; + } + + // Overlapping roots are legal, so the first sighting of a path wins. + discovered.TryAdd(file.FullPath, file); + } + } + + return discovered; + } + + /// + /// Probes and renders poster frames with bounded concurrency. The expensive work runs in + /// parallel, but the results are applied to the entities one at a time by the consumer + /// because change tracking is not thread safe. + /// + private async IAsyncEnumerable IndexAsync( + IReadOnlyList pending, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (pending.Count == 0) + { + yield break; + } + + var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4) + { + SingleReader = true, + }); + + var producer = Task.Run( + async () => + { + try + { + var parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = _options.MaxIndexingConcurrency, + CancellationToken = cancellationToken, + }; + + await Parallel.ForEachAsync( + pending, + parallelOptions, + async (item, token) => + { + var info = await mediaProbe.ProbeAsync(item.FullPath, token); + var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token); + await channel.Writer.WriteAsync(new IndexResult(item, info, thumbnail), token); + }); + + channel.Writer.Complete(); + } + catch (Exception ex) + { + channel.Writer.Complete(ex); + } + }, + cancellationToken); + + var processed = 0; + + await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken)) + { + result.Item.ApplyTechnicalInfo(result.Info); + + if (result.ThumbnailPath is not null) + { + result.Item.AttachThumbnail(result.ThumbnailPath); + } + + processed++; + + yield return new LibraryScanEvent.ItemUpdated(result.Item); + yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count); + + if (processed % SaveBatchSize == 0) + { + await repository.SaveChangesAsync(cancellationToken); + } + } + + await producer; + logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count); + } + + private readonly record struct IndexResult(VideoItem Item, VideoTechnicalInfo Info, string? ThumbnailPath); +} diff --git a/src/PLib.Application/PLib.Application.csproj b/src/PLib.Application/PLib.Application.csproj new file mode 100644 index 0000000..1867bb1 --- /dev/null +++ b/src/PLib.Application/PLib.Application.csproj @@ -0,0 +1,16 @@ + + + + PLib.Application + + + + + + + + + + + + diff --git a/src/PLib.Desktop/App.axaml b/src/PLib.Desktop/App.axaml new file mode 100644 index 0000000..048969f --- /dev/null +++ b/src/PLib.Desktop/App.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/App.axaml.cs b/src/PLib.Desktop/App.axaml.cs new file mode 100644 index 0000000..3d727f0 --- /dev/null +++ b/src/PLib.Desktop/App.axaml.cs @@ -0,0 +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; + } +} diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs new file mode 100644 index 0000000..b871cf7 --- /dev/null +++ b/src/PLib.Desktop/AppHost.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PLib.Desktop.Imaging; +using PLib.Desktop.Services; +using PLib.Desktop.ViewModels; +using PLib.Infrastructure; +using PLib.Infrastructure.Storage; +using Serilog; + +namespace PLib.Desktop; + +/// +/// Composition root. Everything the application is made of is wired up here and nowhere else. +/// +internal static class AppHost +{ + public static IHost Create(string[] args) + { + // The paths are needed to locate the user settings file, which is itself a + // configuration source — so they are built before the container exists and then + // handed to it as an instance. + var paths = new AppPaths(); + + // A desktop app is launched from arbitrary working directories, so the content root + // has to be the folder the executable lives in rather than Environment.CurrentDirectory. + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = args, + ContentRootPath = AppContext.BaseDirectory, + }); + + builder.Configuration.AddJsonFile( + Path.Combine(paths.DataDirectory, "settings.json"), + optional: true, + reloadOnChange: true); + + ConfigureLogging(builder, paths); + + builder.Services.AddSingleton(paths); + builder.Services.AddPLibInfrastructure(builder.Configuration); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + return builder.Build(); + } + + private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File( + Path.Combine(paths.DataDirectory, "logs", "plib-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7) + .CreateLogger(); + + builder.Logging.ClearProviders(); + builder.Logging.AddSerilog(Log.Logger, dispose: true); + } +} diff --git a/src/PLib.Desktop/Controls/AsyncImage.cs b/src/PLib.Desktop/Controls/AsyncImage.cs new file mode 100644 index 0000000..9879365 --- /dev/null +++ b/src/PLib.Desktop/Controls/AsyncImage.cs @@ -0,0 +1,201 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using PLib.Desktop.Imaging; + +namespace PLib.Desktop.Controls; + +/// +/// Draws a poster frame that is loaded only while the control is actually on screen. +/// +/// +/// A virtualised grid recycles containers as the user scrolls, so binding a decoded +/// straight into the view model would keep every frame the user has ever +/// passed alive. Instead the control asks the shared loader when it is attached to the visual +/// tree and drops its reference when it is detached, which keeps memory proportional to what +/// is visible rather than to the size of the library. +/// +public sealed class AsyncImage : Control +{ + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + public static readonly StyledProperty DecodeWidthProperty = + AvaloniaProperty.Register(nameof(DecodeWidth), defaultValue: 400); + + public static readonly StyledProperty PlaceholderBrushProperty = + AvaloniaProperty.Register(nameof(PlaceholderBrush)); + + /// + /// Shared loader, assigned once by the composition root. A control is created by the XAML + /// runtime and therefore cannot take constructor dependencies. + /// + public static IThumbnailLoader? Loader { get; set; } + + private CancellationTokenSource? _pending; + private Bitmap? _bitmap; + private bool _isAttached; + + static AsyncImage() + { + AffectsRender(SourceProperty, PlaceholderBrushProperty); + } + + public AsyncImage() + { + Opacity = 0; + Transitions = + [ + new DoubleTransition + { + Property = OpacityProperty, + Duration = TimeSpan.FromMilliseconds(220), + Easing = new CubicEaseOut(), + }, + ]; + } + + public string? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + /// Target width in pixels for decoding; smaller means less memory per card. + public int DecodeWidth + { + get => GetValue(DecodeWidthProperty); + set => SetValue(DecodeWidthProperty, value); + } + + public IBrush? PlaceholderBrush + { + get => GetValue(PlaceholderBrushProperty); + set => SetValue(PlaceholderBrushProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _isAttached = true; + BeginLoad(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _isAttached = false; + Release(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SourceProperty || change.Property == DecodeWidthProperty) + { + Release(); + BeginLoad(); + } + } + + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + if (_bitmap is null) + { + if (PlaceholderBrush is { } placeholder) + { + context.FillRectangle(placeholder, bounds); + } + + return; + } + + context.DrawImage(_bitmap, CoverSourceRect(_bitmap.PixelSize, bounds), bounds); + } + + /// + /// Picks the largest centred crop of the source that has the same aspect ratio as the + /// destination, i.e. CSS object-fit: cover: fill the card, never letterbox. + /// + private static Rect CoverSourceRect(PixelSize source, Rect destination) + { + var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height); + var width = destination.Width / scale; + var height = destination.Height / scale; + + return new Rect((source.Width - width) / 2, (source.Height - height) / 2, width, height); + } + + private void BeginLoad() + { + if (!_isAttached || Loader is not { } loader || string.IsNullOrEmpty(Source)) + { + return; + } + + var cts = new CancellationTokenSource(); + _pending = cts; + + _ = LoadAsync(loader, Source, DecodeWidth, cts); + } + + private async Task LoadAsync(IThumbnailLoader loader, string path, int decodeWidth, CancellationTokenSource cts) + { + try + { + var bitmap = await loader.GetAsync(path, decodeWidth, cts.Token); + + if (bitmap is null || cts.IsCancellationRequested) + { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + // The container may have been recycled onto a different item while we decoded. + if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts)) + { + return; + } + + _bitmap = bitmap; + Opacity = 1; + InvalidateVisual(); + }); + } + catch (OperationCanceledException) + { + // Scrolled away before the frame was ready; nothing to show and nothing to report. + } + } + + /// + /// Cancels any in-flight decode and drops the reference to the bitmap. The bitmap itself + /// belongs to the cache and is never disposed here. + /// + private void Release() + { + if (_pending is { } cts) + { + _pending = null; + cts.Cancel(); + cts.Dispose(); + } + + _bitmap = null; + Opacity = 0; + InvalidateVisual(); + } +} diff --git a/src/PLib.Desktop/Imaging/IThumbnailLoader.cs b/src/PLib.Desktop/Imaging/IThumbnailLoader.cs new file mode 100644 index 0000000..de2cacb --- /dev/null +++ b/src/PLib.Desktop/Imaging/IThumbnailLoader.cs @@ -0,0 +1,9 @@ +using Avalonia.Media.Imaging; + +namespace PLib.Desktop.Imaging; + +/// The view-side contract for turning a poster frame path into a drawable bitmap. +public interface IThumbnailLoader +{ + Task GetAsync(string path, int decodeWidth, CancellationToken cancellationToken); +} diff --git a/src/PLib.Desktop/Imaging/ThumbnailCache.cs b/src/PLib.Desktop/Imaging/ThumbnailCache.cs new file mode 100644 index 0000000..3a550c9 --- /dev/null +++ b/src/PLib.Desktop/Imaging/ThumbnailCache.cs @@ -0,0 +1,114 @@ +using Avalonia.Media.Imaging; +using Microsoft.Extensions.Logging; + +namespace PLib.Desktop.Imaging; + +/// +/// Decodes poster frames off the UI thread and keeps the most recently used ones in memory. +/// +/// +/// A library can hold thousands of files, so decoded bitmaps cannot all stay alive. Entries +/// are evicted by least-recent use but never disposed: a card that is still on screen may be +/// rendering the very bitmap we drop, and the garbage collector is the only party that knows +/// when the last reference is gone. +/// +public sealed class ThumbnailCache(ILogger logger) : IThumbnailLoader +{ + private const int Capacity = 256; + + /// Decoding is CPU bound; a couple of workers keeps scrolling smooth without thrashing. + private readonly SemaphoreSlim _decodeSlots = new(Math.Max(2, Environment.ProcessorCount / 2)); + + private readonly Lock _gate = new(); + private readonly Dictionary> _entries = []; + private readonly LinkedList _recency = []; + + public async Task GetAsync(string path, int decodeWidth, CancellationToken cancellationToken) + { + var key = new CacheKey(path, decodeWidth); + + if (TryTouch(key, out var cached)) + { + return cached; + } + + await _decodeSlots.WaitAsync(cancellationToken); + + try + { + // Another card may have decoded the same frame while we waited for a slot. + if (TryTouch(key, out cached)) + { + return cached; + } + + var bitmap = await Task.Run(() => Decode(path, decodeWidth), cancellationToken); + + if (bitmap is not null) + { + Store(key, bitmap); + } + + return bitmap; + } + finally + { + _decodeSlots.Release(); + } + } + + private Bitmap? Decode(string path, int decodeWidth) + { + try + { + using var stream = File.OpenRead(path); + return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not decode thumbnail {Path}", path); + return null; + } + } + + private bool TryTouch(CacheKey key, out Bitmap? bitmap) + { + lock (_gate) + { + if (_entries.TryGetValue(key, out var node)) + { + _recency.Remove(node); + _recency.AddFirst(node); + bitmap = node.Value.Bitmap; + return true; + } + } + + bitmap = null; + return false; + } + + private void Store(CacheKey key, Bitmap bitmap) + { + lock (_gate) + { + if (_entries.ContainsKey(key)) + { + return; + } + + _entries[key] = _recency.AddFirst(new CacheEntry(key, bitmap)); + + while (_recency.Count > Capacity) + { + var evicted = _recency.Last!; + _recency.RemoveLast(); + _entries.Remove(evicted.Value.Key); + } + } + } + + private readonly record struct CacheKey(string Path, int DecodeWidth); + + private readonly record struct CacheEntry(CacheKey Key, Bitmap Bitmap); +} diff --git a/src/PLib.Desktop/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj new file mode 100644 index 0000000..38c0cff --- /dev/null +++ b/src/PLib.Desktop/PLib.Desktop.csproj @@ -0,0 +1,38 @@ + + + + WinExe + PLib.Desktop + PLib + true + app.manifest + true + + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/Program.cs b/src/PLib.Desktop/Program.cs new file mode 100644 index 0000000..ca1ee88 --- /dev/null +++ b/src/PLib.Desktop/Program.cs @@ -0,0 +1,18 @@ +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(); +} diff --git a/src/PLib.Desktop/Services/IFolderPicker.cs b/src/PLib.Desktop/Services/IFolderPicker.cs new file mode 100644 index 0000000..c4399fc --- /dev/null +++ b/src/PLib.Desktop/Services/IFolderPicker.cs @@ -0,0 +1,7 @@ +namespace PLib.Desktop.Services; + +/// Asks the user for a folder. Returns null when the dialog is dismissed. +public interface IFolderPicker +{ + Task PickFolderAsync(string title, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Desktop/Services/ILibrarySettingsStore.cs b/src/PLib.Desktop/Services/ILibrarySettingsStore.cs new file mode 100644 index 0000000..fb82dc4 --- /dev/null +++ b/src/PLib.Desktop/Services/ILibrarySettingsStore.cs @@ -0,0 +1,11 @@ +namespace PLib.Desktop.Services; + +/// +/// Persists the parts of the user can change +/// at runtime. Writes land in a JSON file that is also a configuration source, so +/// IOptionsMonitor picks the change up without a restart. +/// +public interface ILibrarySettingsStore +{ + Task SaveFoldersAsync(IReadOnlyList folders, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs b/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs new file mode 100644 index 0000000..a3c5dad --- /dev/null +++ b/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using PLib.Application.Library; +using PLib.Infrastructure.Storage; + +namespace PLib.Desktop.Services; + +/// +public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore +{ + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + + private readonly SemaphoreSlim _writeLock = new(1, 1); + + private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json"); + + public async Task SaveFoldersAsync( + IReadOnlyList folders, + CancellationToken cancellationToken = default) + { + await _writeLock.WaitAsync(cancellationToken); + + try + { + var root = await ReadRootAsync(cancellationToken); + + if (root[LibraryOptions.SectionName] is not JsonObject section) + { + section = []; + root[LibraryOptions.SectionName] = section; + } + + section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]); + + // Write through a temp file so an interrupted save cannot corrupt the settings. + var staging = SettingsFile + ".tmp"; + await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken); + File.Move(staging, SettingsFile, overwrite: true); + } + finally + { + _writeLock.Release(); + } + } + + private async Task ReadRootAsync(CancellationToken cancellationToken) + { + if (!File.Exists(SettingsFile)) + { + return []; + } + + try + { + var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken); + return JsonNode.Parse(json) as JsonObject ?? []; + } + catch (JsonException) + { + // A hand-edited, broken settings file should not stop the app from saving. + return []; + } + } +} diff --git a/src/PLib.Desktop/Services/StorageProviderFolderPicker.cs b/src/PLib.Desktop/Services/StorageProviderFolderPicker.cs new file mode 100644 index 0000000..928e0c7 --- /dev/null +++ b/src/PLib.Desktop/Services/StorageProviderFolderPicker.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; + +namespace PLib.Desktop.Services; + +/// +public sealed class StorageProviderFolderPicker : IFolderPicker +{ + public async Task PickFolderAsync(string title, CancellationToken cancellationToken = default) + { + if (Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime + { + MainWindow.StorageProvider: { } storageProvider, + }) + { + return null; + } + + var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = title, + AllowMultiple = false, + }); + + return folders.Count > 0 ? folders[0].TryGetLocalPath() : null; + } +} diff --git a/src/PLib.Desktop/Services/SystemShell.cs b/src/PLib.Desktop/Services/SystemShell.cs new file mode 100644 index 0000000..8a4ee2d --- /dev/null +++ b/src/PLib.Desktop/Services/SystemShell.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace PLib.Desktop.Services; + +/// Hands a file over to whatever the operating system uses to open or show it. +public interface ISystemShell +{ + void OpenFile(string path); + + void RevealInFileManager(string path); +} + +/// +public sealed class SystemShell(ILogger logger) : ISystemShell +{ + public void OpenFile(string path) => Start(new ProcessStartInfo(path) { UseShellExecute = true }, path); + + public void RevealInFileManager(string path) + { + ProcessStartInfo startInfo; + + if (OperatingSystem.IsWindows()) + { + startInfo = new ProcessStartInfo("explorer.exe", $"/select,\"{path}\""); + } + else if (OperatingSystem.IsMacOS()) + { + startInfo = new ProcessStartInfo("open", ["-R", path]); + } + else + { + var folder = Path.GetDirectoryName(path); + + if (folder is null) + { + return; + } + + startInfo = new ProcessStartInfo("xdg-open", [folder]); + } + + Start(startInfo, path); + } + + private void Start(ProcessStartInfo startInfo, string path) + { + try + { + using var process = Process.Start(startInfo); + } + catch (Exception ex) + { + // Nothing actionable for the user here; a missing handler is not a crash. + logger.LogWarning(ex, "Could not hand {Path} to the shell", path); + } + } +} diff --git a/src/PLib.Desktop/Services/ThemeService.cs b/src/PLib.Desktop/Services/ThemeService.cs new file mode 100644 index 0000000..a66945d --- /dev/null +++ b/src/PLib.Desktop/Services/ThemeService.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Styling; + +namespace PLib.Desktop.Services; + +/// Switches the application between the light and dark variants. +public interface IThemeService +{ + void Toggle(); +} + +/// +public sealed class ThemeService : IThemeService +{ + public void Toggle() + { + if (Avalonia.Application.Current is not { } application) + { + return; + } + + // ActualThemeVariant resolves "follow the system" to whatever is on screen right now, + // which is what the user is actually toggling away from. + application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark + ? ThemeVariant.Light + : ThemeVariant.Dark; + } +} diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml new file mode 100644 index 0000000..9b111a4 --- /dev/null +++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/Themes/Palette.axaml b/src/PLib.Desktop/Themes/Palette.axaml new file mode 100644 index 0000000..5e0629c --- /dev/null +++ b/src/PLib.Desktop/Themes/Palette.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/ViewModels/DisplayText.cs b/src/PLib.Desktop/ViewModels/DisplayText.cs new file mode 100644 index 0000000..0e007e9 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/DisplayText.cs @@ -0,0 +1,47 @@ +using System.Globalization; + +namespace PLib.Desktop.ViewModels; + +/// Turns raw numbers into the short strings the cards show. +internal static class DisplayText +{ + private static readonly string[] SizeUnits = ["Б", "КБ", "МБ", "ГБ", "ТБ"]; + + public static string Duration(TimeSpan? duration) => duration switch + { + null or { TotalSeconds: < 1 } => "—", + { TotalHours: >= 1 } value => value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture), + var value => value.Value.ToString(@"m\:ss", CultureInfo.InvariantCulture), + }; + + public static string FileSize(long bytes) + { + double value = bytes; + var unit = 0; + + while (value >= 1024 && unit < SizeUnits.Length - 1) + { + value /= 1024; + unit++; + } + + var precision = value < 10 && unit > 0 ? 1 : 0; + return string.Create(CultureInfo.CurrentCulture, $"{Math.Round(value, precision)} {SizeUnits[unit]}"); + } + + /// + /// Names the vertical resolution the way people talk about it, falling back to the raw + /// dimensions for anything that does not match a familiar tier. + /// + public static string? Quality(int? width, int? height) => (width, height) switch + { + (null, _) or (_, null) => null, + ( >= 7000, _) => "8K", + ( >= 3500, _) => "4K", + (_, >= 2000) => "1440p", + (_, >= 1000) => "1080p", + (_, >= 700) => "720p", + (_, >= 460) => "480p", + var (w, h) => $"{w}×{h}", + }; +} diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..ad3a6c8 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,302 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PLib.Application.Library; +using PLib.Desktop.Services; + +namespace PLib.Desktop.ViewModels; + +public sealed partial class MainWindowViewModel : ObservableObject +{ + 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 = []; + + 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; + _theme = theme; + _logger = logger; + + SelectedSort = SortOption.All[0]; + } + + public ObservableCollection Videos { get; } = []; + + public IReadOnlyList SortOptions => SortOption.All; + + public IReadOnlyList Folders => [.. _options.CurrentValue.Folders]; + + [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; + + public bool HasFolders => _options.CurrentValue.Folders.Count > 0; + + partial void OnSearchTextChanged(string value) => RebuildView(); + + partial void OnSelectedSortChanged(SortOption value) => RebuildView(); + + partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty)); + + /// 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(); + + foreach (var item in await library.GetLibraryAsync()) + { + var card = new VideoCardViewModel(item, _shell); + _all.Add(card); + _byId[card.Id] = card; + } + } + 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. + _logger.LogError(ex, "Could not load the stored library"); + StatusText = "Не удалось открыть базу библиотеки — подробности в журнале"; + return; + } + + RebuildView(); + + if (HasFolders) + { + await ScanCommand.ExecuteAsync(null); + } + else + { + StatusText = "Библиотека пуста — добавьте папку с видео"; + } + } + + [RelayCommand(IncludeCancelCommand = true)] + private async Task ScanAsync(CancellationToken cancellationToken) + { + var folders = _options.CurrentValue.Folders.ToArray(); + + if (folders.Length == 0) + { + StatusText = "Не выбрано ни одной папки"; + return; + } + + IsScanning = true; + 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 = "Сканирование отменено"; + } + 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("Выберите папку с видео"); + + 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); + + OnPropertyChanged(nameof(Folders)); + OnPropertyChanged(nameof(HasFolders)); + + await ScanCommand.ExecuteAsync(null); + } + + [RelayCommand] + private void ToggleTheme() => _theme.Toggle(); + + 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: + { + 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); + break; + + case LibraryScanEvent.ItemRemoved removed + when _byId.Remove(removed.Id, out var dropped): + _all.Remove(dropped); + Videos.Remove(dropped); + OnPropertyChanged(nameof(IsEmpty)); + 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; + } + } + + 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 new file mode 100644 index 0000000..16d86e2 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/SortOption.cs @@ -0,0 +1,21 @@ +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) +{ + public static IReadOnlyList All { get; } = + [ + new(LibrarySort.RecentlyAdded, "Недавно добавленные"), + new(LibrarySort.TitleAscending, "По названию"), + new(LibrarySort.LongestFirst, "Сначала длинные"), + new(LibrarySort.LargestFirst, "Сначала большие"), + ]; +} diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs new file mode 100644 index 0000000..4565770 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs @@ -0,0 +1,74 @@ +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); +} diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml new file mode 100644 index 0000000..01a550b --- /dev/null +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +