diff --git a/.editorconfig b/.editorconfig index cf127de..ba0a886 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,52 +1,57 @@ -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 = _ +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 + +# EF writes these; style rules are not ours to enforce on them. +[**/Migrations/*.cs] +generated_code = true +dotnet_analyzer_diagnostic.severity = none + +[*.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.Packages.props b/Directory.Packages.props index 82d55ea..d85cb14 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,7 @@ + @@ -34,6 +35,7 @@ + diff --git a/README.md b/README.md index b9c73d7..0b9eb05 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ ## Что уже работает - Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. +- Слежение за папками: новые файлы подхватываются сами, без кнопки. - Метаданные (длительность, разрешение, кодек) через ffprobe. - Постеры кадром из видео через ffmpeg, с кэшем на диске. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. @@ -94,6 +95,16 @@ dotnet test экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложении `OpenGlControlBase`. Нативное окно VLC через `NativeControlHost`: картинка появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей. +- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них + одинаковая, различается только назначение. Одна сущность — одна таблица связей, один + репозиторий и одно правило именования; разделить потом можно переименованием и миграцией, + а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность — + по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся, + а тег и коллекция с одним именем сосуществуют. +- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько + событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать + из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а + разницу и так умеет считать сканирование. - **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного @@ -110,5 +121,11 @@ dotnet test перечитывается на лету; - `logs/` — Serilog, ротация по дням. -Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на -миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть). +Схема ведётся миграциями EF Core (`src/PLib.Infrastructure/Persistence/Migrations`) и +применяется при старте. База, созданная сборками до появления миграций, распознаётся по +отсутствию истории и пересоздаётся: она кэш над файловой системой, поэтому цена — одно +пересканирование, а превью привязаны к файлам и переживают это нетронутыми. + +```bash +dotnet ef migrations add ИмяМиграции --project src/PLib.Infrastructure --startup-project src/PLib.Infrastructure --output-dir Persistence/Migrations +``` diff --git a/src/PLib.Application/Abstractions/ILabelRepository.cs b/src/PLib.Application/Abstractions/ILabelRepository.cs new file mode 100644 index 0000000..c98e62f --- /dev/null +++ b/src/PLib.Application/Abstractions/ILabelRepository.cs @@ -0,0 +1,16 @@ +using PLib.Domain.Videos; + +namespace PLib.Application.Abstractions; + +/// Persistence boundary for tags and collections. +public interface ILabelRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + /// Finds a label by kind and name, ignoring case and surrounding space. + Task FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default); + + Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default); + + Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Abstractions/ILibraryWatcher.cs b/src/PLib.Application/Abstractions/ILibraryWatcher.cs new file mode 100644 index 0000000..47be1ea --- /dev/null +++ b/src/PLib.Application/Abstractions/ILibraryWatcher.cs @@ -0,0 +1,28 @@ +namespace PLib.Application.Abstractions; + +/// +/// Reports that something under the library folders changed and a rescan is warranted. +/// +/// +/// Deliberately says nothing about what changed. A file system watcher reports +/// creates, renames and writes as separate events, several per file, and a partially copied +/// file arrives as a stream of them — reconciling that into a precise delta is guesswork. +/// The scan already knows how to work out the difference, so the watcher only has to say +/// "look again", coalesced so a folder full of new files is one signal rather than hundreds. +/// +public interface ILibraryWatcher +{ + /// Fires after activity in the watched folders settles. + IObservable Changed { get; } + + /// Starts watching the given roots, replacing whatever was watched before. + void Watch(IReadOnlyList folders); + + void StopWatching(); +} + +/// A signal carrying nothing; the fact that it happened is the whole payload. +public readonly record struct Unit +{ + public static Unit Default => default; +} diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs index 91e241b..fbc352e 100644 --- a/src/PLib.Application/Abstractions/IVideoRepository.cs +++ b/src/PLib.Application/Abstractions/IVideoRepository.cs @@ -1,21 +1,24 @@ -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); -} +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); + + /// Loads one video together with the labels attached to it. + Task FindWithLabelsAsync(Guid id, 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 index 242b6b2..9d72b21 100644 --- a/src/PLib.Application/Library/ILibraryService.cs +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -24,4 +24,25 @@ public interface ILibraryService /// from scratch. Useful after changing the thumbnail width or capture position. /// Task ResetThumbnailsAsync(CancellationToken cancellationToken = default); + + /// Remembers where playback stopped so the video can be resumed later. + Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default); + + /// One video with its labels loaded, or null if it is gone. + Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default); + + /// Every tag and collection in the library, alphabetically. + Task> GetLabelsAsync(CancellationToken cancellationToken = default); + + /// + /// Attaches a label to a video, creating it if this is the first time the name is used. + /// Returns the label, whether it was new or not. + /// + Task AttachLabelAsync( + Guid videoId, + string name, + LabelKind kind, + CancellationToken cancellationToken = default); + + Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default); } diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index 2a821d7..95d5579 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -1,235 +1,301 @@ -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 Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) => - thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); - - public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default) - { - var items = await repository.GetAllAsync(cancellationToken); - - foreach (var item in items) - { - item.DetachThumbnail(); - } - - // Forget the paths before deleting the files. Interrupted the other way round, the - // library would point at frames that no longer exist — recoverable, but only after - // a full scan notices. This order leaves at worst some orphans, which the purge eats. - await repository.SaveChangesAsync(cancellationToken); - - var removed = await thumbnailGenerator.ClearAsync(cancellationToken); - logger.LogInformation("Cleared {Count} cached poster frames on request", removed); - } - - 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); - - // The cache directory is ordinary user storage: a poster frame we remember - // may simply have been deleted. Trusting the stored path would leave the - // card blank forever, because the item still looks indexed. - if (existing.ThumbnailPath is not null && - !thumbnailGenerator.IsAvailable(existing.ThumbnailPath)) - { - existing.DetachThumbnail(); - yield return new LibraryScanEvent.ItemUpdated(existing); - } - } - 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); - await PurgeThumbnailCacheAsync(known.Values, cancellationToken); - - yield return new LibraryScanEvent.Completed(known.Count); - } - - /// - /// Drops cached frames nothing points at any more. Safe only here, at the end of a - /// completed scan, because that is the only moment the library is known to be whole — - /// running it mid-scan would delete frames of items not reconciled yet. - /// - private async Task PurgeThumbnailCacheAsync( - IEnumerable library, - CancellationToken cancellationToken) - { - var inUse = library.Select(x => x.ThumbnailPath).OfType().ToArray(); - var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken); - - if (removed > 0) - { - logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed); - } - } - - 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); -} +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, + ILabelRepository labels, + 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 Task SaveProgressAsync( + Guid videoId, + TimeSpan position, + CancellationToken cancellationToken = default) + { + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken); + + if (video is null) + { + return; + } + + video.RememberProgress(position); + await repository.SaveChangesAsync(cancellationToken); + } + + public Task GetVideoWithLabelsAsync(Guid videoId, CancellationToken cancellationToken = default) => + repository.FindWithLabelsAsync(videoId, cancellationToken); + + public async Task> GetLabelsAsync(CancellationToken cancellationToken = default) + { + var all = await labels.GetAllAsync(cancellationToken); + return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)]; + } + + public async Task AttachLabelAsync( + Guid videoId, + string name, + LabelKind kind, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) + ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); + + // Reuse before create: the name is what the user thinks of as the identity of a tag, + // and two labels differing only in case would read as a duplicate. + var label = await labels.FindAsync(kind, name, cancellationToken); + + if (label is null) + { + label = new LibraryLabel(name, kind); + await labels.AddAsync(label, cancellationToken); + } + + if (video.AddLabel(label)) + { + await repository.SaveChangesAsync(cancellationToken); + logger.LogInformation("Attached {Kind} '{Name}' to {Video}", kind, label.Name, video.Title); + } + + return label; + } + + public async Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default) + { + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken); + + if (video?.RemoveLabel(labelId) == true) + { + await repository.SaveChangesAsync(cancellationToken); + } + } + + public Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) => + thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken); + + public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default) + { + var items = await repository.GetAllAsync(cancellationToken); + + foreach (var item in items) + { + item.DetachThumbnail(); + } + + // Forget the paths before deleting the files. Interrupted the other way round, the + // library would point at frames that no longer exist — recoverable, but only after + // a full scan notices. This order leaves at worst some orphans, which the purge eats. + await repository.SaveChangesAsync(cancellationToken); + + var removed = await thumbnailGenerator.ClearAsync(cancellationToken); + logger.LogInformation("Cleared {Count} cached poster frames on request", removed); + } + + 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); + + // The cache directory is ordinary user storage: a poster frame we remember + // may simply have been deleted. Trusting the stored path would leave the + // card blank forever, because the item still looks indexed. + if (existing.ThumbnailPath is not null && + !thumbnailGenerator.IsAvailable(existing.ThumbnailPath)) + { + existing.DetachThumbnail(); + yield return new LibraryScanEvent.ItemUpdated(existing); + } + } + 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); + await PurgeThumbnailCacheAsync(known.Values, cancellationToken); + + yield return new LibraryScanEvent.Completed(known.Count); + } + + /// + /// Drops cached frames nothing points at any more. Safe only here, at the end of a + /// completed scan, because that is the only moment the library is known to be whole — + /// running it mid-scan would delete frames of items not reconciled yet. + /// + private async Task PurgeThumbnailCacheAsync( + IEnumerable library, + CancellationToken cancellationToken) + { + var inUse = library.Select(x => x.ThumbnailPath).OfType().ToArray(); + var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken); + + if (removed > 0) + { + logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed); + } + } + + 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 index 1867bb1..987f954 100644 --- a/src/PLib.Application/PLib.Application.csproj +++ b/src/PLib.Application/PLib.Application.csproj @@ -1,16 +1,17 @@ - - - - PLib.Application - - - - - - - - - - - - + + + + PLib.Application + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/ViewModels/LabelViewModel.cs b/src/PLib.Desktop/ViewModels/LabelViewModel.cs new file mode 100644 index 0000000..31c0bbd --- /dev/null +++ b/src/PLib.Desktop/ViewModels/LabelViewModel.cs @@ -0,0 +1,28 @@ +using PLib.Domain.Videos; +using ReactiveUI; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// +/// One tag or collection as shown on the media page. Carries its own remove command so the +/// chip template never has to reach up the visual tree. +/// +public sealed class LabelViewModel +{ + public LabelViewModel(LibraryLabel label, Action remove) + { + Id = label.Id; + Name = label.Name; + Kind = label.Kind; + RemoveCommand = ReactiveCommand.Create(() => remove(this)); + } + + public Guid Id { get; } + + public string Name { get; } + + public LabelKind Kind { get; } + + public ReactiveCommand RemoveCommand { get; } +} diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index 5f0cfc1..763c229 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -9,6 +9,7 @@ using DynamicData.Kernel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using PLib.Application.Abstractions; using PLib.Application.Library; using PLib.Desktop.Services; using PLib.Desktop.Settings; @@ -34,6 +35,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase private readonly IServiceScopeFactory _scopeFactory; private readonly IOptionsMonitor _options; private readonly IAppSettingsStore _settingsStore; + private readonly ILibraryWatcher _watcher; private readonly IThemeService _theme; private readonly IFolderPicker _folderPicker; private readonly ISystemShell _shell; @@ -68,12 +70,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase IAppSettingsStore settingsStore, IFolderPicker folderPicker, ISystemShell shell, + ILibraryWatcher watcher, IThemeService theme, ILogger logger) { _scopeFactory = scopeFactory; _options = options; _settingsStore = settingsStore; + _watcher = watcher; _theme = theme; _folderPicker = folderPicker; _shell = shell; @@ -129,6 +133,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase _isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning); BuildLibraryView(out _videos, out _isEmpty); + ObserveFolderChanges(); ObserveCommandFailures(); } @@ -240,9 +245,25 @@ public sealed partial class MainWindowViewModel : ViewModelBase private void OpenVideo(VideoCardViewModel card) { OpenedVideo?.Dispose(); - OpenedVideo = new VideoPlayerViewModel(card, _shell, _settingsStore, _logger, () => OpenedVideo = null); + OpenedVideo = new VideoPlayerViewModel(card, _shell, _scopeFactory, _settingsStore, _logger, () => OpenedVideo = null); } + /// + /// Rescans when the folders change on disk. The watcher has already waited for the + /// activity to settle; this only has to make sure a scan is not started on top of one + /// that is still running. + /// + private void ObserveFolderChanges() => + _watcher.Changed + .ObserveOn(_uiScheduler) + .Where(_ => !IsScanning && !IsSettingsOpen) + .Subscribe(_ => + { + _logger.LogInformation("Library folders changed on disk; rescanning"); + ScanCommand.Execute().Subscribe(); + }) + .AddTo(Subscriptions); + private static Func BuildFilter(string? term) { if (string.IsNullOrWhiteSpace(term)) @@ -313,6 +334,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase ScanProgress = 0; StatusText = "Поиск файлов…"; + // Re-armed on every scan so a folder added or removed in settings is picked up + // without any separate plumbing. + _watcher.Watch(folders); + try { // Task.Run detaches the whole pipeline from the UI synchronisation context, so diff --git a/src/PLib.Desktop/ViewModels/MetadataRow.cs b/src/PLib.Desktop/ViewModels/MetadataRow.cs new file mode 100644 index 0000000..ddea2bd --- /dev/null +++ b/src/PLib.Desktop/ViewModels/MetadataRow.cs @@ -0,0 +1,6 @@ +namespace PLib.Desktop.ViewModels; + +/// One label/value line in the media page's details block. +/// What the value is, in the user's language. +/// Already formatted; the view only prints it. +public sealed record MetadataRow(string Label, string Value); diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs index 8cb4659..b599525 100644 --- a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs @@ -66,8 +66,35 @@ public sealed partial class VideoCardViewModel : ReactiveObject [Reactive] public partial long RawSizeInBytes { get; set; } + /// How far through the video the viewer got, 0..1, for the bar across the poster. + [Reactive] + public partial double WatchedFraction { get; set; } + + /// True once there is progress worth drawing. + [Reactive] + public partial bool HasProgress { get; set; } + + [Reactive] + public partial bool IsWatched { get; set; } + + [Reactive] + public partial string? ResumeText { get; set; } + public DateTimeOffset AddedAt { get; private set; } + /// Where playback stopped last time, or null if there is nothing to resume. + public TimeSpan? ResumePosition { get; private set; } + + public int? Width { get; private set; } + + public int? Height { get; private set; } + + public string? VideoCodec { get; private set; } + + public DateTimeOffset? LastPlayedAt { get; private set; } + + public int PlayCount { get; private set; } + /// Copies the current state of the entity into the card. public void Apply(VideoItem item) { @@ -77,9 +104,21 @@ public sealed partial class VideoCardViewModel : ReactiveObject SizeText = DisplayText.FileSize(item.SizeInBytes); QualityText = DisplayText.Quality(item.Width, item.Height); AddedAt = item.AddedAt; + Width = item.Width; + Height = item.Height; + VideoCodec = item.VideoCodec; + LastPlayedAt = item.LastPlayedAt; + PlayCount = item.PlayCount; RawDuration = item.Duration; RawSizeInBytes = item.SizeInBytes; IsPending = item.ThumbnailPath is null; + WatchedFraction = item.WatchedFraction; + HasProgress = item.WatchedFraction > 0; + IsWatched = item.PlayCount > 0 && item.ResumePosition is null; + ResumePosition = item.ResumePosition; + ResumeText = item.ResumePosition is { } resume + ? $"Продолжить с {DisplayText.Duration(resume)}" + : null; } public bool Matches(string term) => diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs index 1744407..962202f 100644 --- a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs @@ -1,126 +1,306 @@ -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using Microsoft.Extensions.Logging; -using PLib.Desktop.Services; -using ReactiveUI; -using ReactiveUI.SourceGenerators; -using RxVoid = ReactiveUI.Primitives.RxVoid; - -namespace PLib.Desktop.ViewModels; - -/// -/// The media page: one video, opened from the grid. Most of the transport lives on the -/// player control itself; what the page owns is the video's identity, the commands around -/// it, and the settings that have to outlive the page. -/// -public sealed partial class VideoPlayerViewModel : ViewModelBase -{ - /// - /// How long the volume has to sit still before it is written. Dragging the slider - /// produces a value per pixel, and each one would otherwise be a file write. - /// - private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400); - - private readonly IAppSettingsStore _settingsStore; - private readonly ILogger _logger; - - public VideoPlayerViewModel( - VideoCardViewModel card, - ISystemShell shell, - IAppSettingsStore settingsStore, - ILogger logger, - Action close) - { - _settingsStore = settingsStore; - _logger = logger; - - Title = card.Title; - FullPath = card.FullPath; - Source = new Uri(card.FullPath); - - Subtitle = string.Join( - " · ", - new[] { card.QualityText, card.DurationText, card.SizeText } - .Where(part => !string.IsNullOrWhiteSpace(part))); - - var settings = settingsStore.Current; - Volume = settings.Volume; - IsMuted = settings.IsMuted; - - CloseCommand = ReactiveCommand.Create(close); - ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; }); - ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; }); - OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); - RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); - - this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted)) - // Skip the values we just restored: they are already what is on disk. - .Skip(1) - .Throttle(SaveDebounce, TaskPoolScheduler.Default) - .DistinctUntilChanged() - .Subscribe(state => Persist(state.volume, state.muted)) - .AddTo(Subscriptions); - - ObserveCommandFailures(); - } - - public string Title { get; } - - public string FullPath { get; } - - /// What the player plays; a file:// URI built from the path. - public Uri Source { get; } - - /// Quality, duration and size on one line, for the page header. - public string Subtitle { get; } - - public ReactiveCommand CloseCommand { get; } - - public ReactiveCommand ToggleFullScreenCommand { get; } - - public ReactiveCommand ToggleMuteCommand { get; } - - public ReactiveCommand OpenExternallyCommand { get; } - - public ReactiveCommand RevealCommand { get; } - - /// - /// True while the window is given over to the video. The page hides its own header and - /// the window hides its chrome. - /// - [Reactive] - public partial bool IsFullScreen { get; set; } - - /// Volume as a fraction; restored on open and remembered across restarts. - [Reactive] - public partial double Volume { get; set; } - - [Reactive] - public partial bool IsMuted { get; set; } - - private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted); - - private async Task PersistAsync(double volume, bool isMuted) - { - try - { - await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted }); - } - catch (Exception ex) - { - // Losing a volume level is not worth interrupting playback over. - _logger.LogWarning(ex, "Could not save the playback volume"); - } - } - - private void ObserveCommandFailures() => - Observable - .Merge( - CloseCommand.ThrownExceptions, - ToggleFullScreenCommand.ThrownExceptions, - ToggleMuteCommand.ThrownExceptions, - OpenExternallyCommand.ThrownExceptions, - RevealCommand.ThrownExceptions) - .Subscribe(ex => _logger.LogError(ex, "A media page command failed")) - .AddTo(Subscriptions); -} +using System.Collections.ObjectModel; +using System.Globalization; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PLib.Application.Library; +using PLib.Desktop.Services; +using PLib.Domain.Videos; +using ReactiveUI; +using ReactiveUI.SourceGenerators; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// +/// The media page: one video with its player, its details and its labels. +/// +/// +/// Transport state stays on the player control; what the page owns is the video's identity, +/// everything shown around the picture, and the settings that outlive the page. +/// +public sealed partial class VideoPlayerViewModel : ViewModelBase +{ + /// + /// How long the volume has to sit still before it is written. Dragging the slider + /// produces a value per pixel, and each one would otherwise be a file write. + /// + private static readonly TimeSpan SaveDebounce = TimeSpan.FromMilliseconds(400); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly IAppSettingsStore _settingsStore; + private readonly ILogger _logger; + + public VideoPlayerViewModel( + VideoCardViewModel card, + ISystemShell shell, + IServiceScopeFactory scopeFactory, + IAppSettingsStore settingsStore, + ILogger logger, + Action close) + { + _scopeFactory = scopeFactory; + _settingsStore = settingsStore; + _logger = logger; + + Card = card; + VideoId = card.Id; + Title = card.Title; + FullPath = card.FullPath; + Source = new Uri(card.FullPath); + ResumeFrom = card.ResumePosition; + Details = BuildDetails(card); + + Subtitle = string.Join( + " · ", + new[] { card.QualityText, card.DurationText, card.SizeText } + .Where(part => !string.IsNullOrWhiteSpace(part))); + + var settings = settingsStore.Current; + Volume = settings.Volume; + IsMuted = settings.IsMuted; + + CloseCommand = ReactiveCommand.Create(close); + ToggleFullScreenCommand = ReactiveCommand.Create(() => { IsFullScreen = !IsFullScreen; }); + ToggleMuteCommand = ReactiveCommand.Create(() => { IsMuted = !IsMuted; }); + ToggleDetailsCommand = ReactiveCommand.Create(() => { AreDetailsVisible = !AreDetailsVisible; }); + OpenExternallyCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath)); + RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath)); + + AddTagCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewTag, LabelKind.Tag)); + AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection)); + LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync); + + this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted)) + // Skip the values we just restored: they are already what is on disk. + .Skip(1) + .Throttle(SaveDebounce, TaskPoolScheduler.Default) + .DistinctUntilChanged() + .Subscribe(state => Persist(state.volume, state.muted)) + .AddTo(Subscriptions); + + ObserveCommandFailures(); + } + + /// The card this page was opened from; refreshed in place as progress is saved. + public VideoCardViewModel Card { get; } + + public Guid VideoId { get; } + + public string Title { get; } + + public string FullPath { get; } + + /// What the player plays; a file:// URI built from the path. + public Uri Source { get; } + + /// Quality, duration and size on one line, for the page header. + public string Subtitle { get; } + + /// Where to start playback, or null to start from the beginning. + public TimeSpan? ResumeFrom { get; } + + public IReadOnlyList Details { get; } + + public ObservableCollection Tags { get; } = []; + + public ObservableCollection Collections { get; } = []; + + public ReactiveCommand CloseCommand { get; } + + public ReactiveCommand ToggleFullScreenCommand { get; } + + public ReactiveCommand ToggleMuteCommand { get; } + + public ReactiveCommand ToggleDetailsCommand { get; } + + public ReactiveCommand OpenExternallyCommand { get; } + + public ReactiveCommand RevealCommand { get; } + + public ReactiveCommand AddTagCommand { get; } + + public ReactiveCommand AddCollectionCommand { get; } + + public ReactiveCommand LoadLabelsCommand { get; } + + /// + /// True while the window is given over to the video. The page hides its own header and + /// the window hides its chrome. + /// + [Reactive] + public partial bool IsFullScreen { get; set; } + + /// The details and labels panel beside the video. + [Reactive] + public partial bool AreDetailsVisible { get; set; } + + /// Volume as a fraction; restored on open and remembered across restarts. + [Reactive] + public partial double Volume { get; set; } + + [Reactive] + public partial bool IsMuted { get; set; } + + [Reactive] + public partial string NewTag { get; set; } = string.Empty; + + [Reactive] + public partial string NewCollection { get; set; } = string.Empty; + + /// + /// Records where playback stopped and refreshes the card behind the page, so the grid + /// shows the new progress without waiting for a rescan. + /// + public async Task SaveProgressAsync(TimeSpan position) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var library = scope.ServiceProvider.GetRequiredService(); + + await library.SaveProgressAsync(VideoId, position); + + if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed) + { + Card.Apply(refreshed); + } + } + catch (Exception ex) + { + // A lost resume position is not worth surfacing to someone who just closed a video. + _logger.LogWarning(ex, "Could not save playback progress for {Path}", FullPath); + } + } + + private static IReadOnlyList BuildDetails(VideoCardViewModel card) + { + var rows = new List + { + new("Длительность", card.DurationText), + new("Размер", card.SizeText), + }; + + if (card.Width is { } width && card.Height is { } height) + { + rows.Add(new MetadataRow("Разрешение", $"{width} × {height}")); + } + + if (!string.IsNullOrWhiteSpace(card.VideoCodec)) + { + rows.Add(new MetadataRow("Кодек", card.VideoCodec)); + } + + rows.Add(new MetadataRow("Добавлено", card.AddedAt.LocalDateTime.ToString("g", CultureInfo.CurrentCulture))); + + if (card.LastPlayedAt is { } lastPlayed) + { + rows.Add(new MetadataRow( + "Последний просмотр", + lastPlayed.LocalDateTime.ToString("g", CultureInfo.CurrentCulture))); + } + + if (card.PlayCount > 0) + { + rows.Add(new MetadataRow("Просмотров", card.PlayCount.ToString(CultureInfo.CurrentCulture))); + } + + rows.Add(new MetadataRow("Файл", card.FullPath)); + + return rows; + } + + private async Task LoadLabelsAsync() + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var library = scope.ServiceProvider.GetRequiredService(); + + var video = await library.GetVideoWithLabelsAsync(VideoId); + + Tags.Clear(); + Collections.Clear(); + + var labels = video is null + ? [] + : video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToArray(); + + foreach (var label in labels) + { + Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry))); + } + } + + private async Task AttachAsync(string name, LabelKind kind) + { + if (string.IsNullOrWhiteSpace(name)) + { + return; + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var library = scope.ServiceProvider.GetRequiredService(); + + await library.AttachLabelAsync(VideoId, name, kind); + + if (kind == LabelKind.Tag) + { + NewTag = string.Empty; + } + else + { + NewCollection = string.Empty; + } + + await LoadLabelsAsync(); + } + + private async Task DetachAsync(LabelViewModel label) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var library = scope.ServiceProvider.GetRequiredService(); + + await library.DetachLabelAsync(VideoId, label.Id); + Target(label.Kind).Remove(label); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not remove the label {Name}", label.Name); + } + } + + private ObservableCollection Target(LabelKind kind) => + kind == LabelKind.Tag ? Tags : Collections; + + private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted); + + private async Task PersistAsync(double volume, bool isMuted) + { + try + { + await _settingsStore.SaveAsync(_settingsStore.Current with { Volume = volume, IsMuted = isMuted }); + } + catch (Exception ex) + { + // Losing a volume level is not worth interrupting playback over. + _logger.LogWarning(ex, "Could not save the playback volume"); + } + } + + private void ObserveCommandFailures() => + Observable + .Merge( + CloseCommand.ThrownExceptions, + ToggleFullScreenCommand.ThrownExceptions, + ToggleMuteCommand.ThrownExceptions, + ToggleDetailsCommand.ThrownExceptions, + OpenExternallyCommand.ThrownExceptions, + RevealCommand.ThrownExceptions, + AddTagCommand.ThrownExceptions, + AddCollectionCommand.ThrownExceptions, + LoadLabelsCommand.ThrownExceptions) + .Subscribe(ex => _logger.LogError(ex, "A media page command failed")) + .AddTo(Subscriptions); +} diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml index a2dfd20..d9e1991 100644 --- a/src/PLib.Desktop/Views/MainWindow.axaml +++ b/src/PLib.Desktop/Views/MainWindow.axaml @@ -73,6 +73,29 @@ + + + + + + + + + diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml index 6a8c964..4d95e7c 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml @@ -6,6 +6,28 @@ x:Class="PLib.Desktop.Views.VideoPlayerView" x:DataType="vm:VideoPlayerViewModel"> + + + + + + + + + + +