10 KiB
10 KiB
PLib
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и показывает всё сеткой карточек.
Что уже работает
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
- Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется
в
settings.jsonи подхватывается без перезапуска. - Светлая, тёмная и системная темы; выбор запоминается.
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — Escape. Внешний плеер и «показать в папке» остались в контекстном меню карточки.
Требования
- .NET 10 SDK
ffmpegиffprobeвPATH(для превью и метаданных)
Нативный LibVLC приезжает пакетом и в системе не нужен.
Запуск
dotnet run --project src/PLib.Desktop
dotnet test
Архитектура
Четыре слоя, зависимости направлены только внутрь:
| Проект | Отвечает за | Знает о |
|---|---|---|
PLib.Domain |
Сущность VideoItem и её инварианты |
ни о чём |
PLib.Application |
Сценарии (LibraryService) и абстракции портов |
Domain |
PLib.Infrastructure |
EF Core + SQLite, ffmpeg, файловая система | Application |
PLib.Desktop |
Avalonia, ViewModel'и, composition root | Infrastructure |
Ключевые решения:
- MVVM на ReactiveUI. Свойства —
[Reactive]изReactiveUI.SourceGenerators, команды —ReactiveCommand, производные значения (IsScanning,IsEmpty) —ToProperty. Отмена сканирования сделана штатным способом: скан живёт как observable, аCancelScanCommandпросто отписывает его черезTakeUntil, что отменяетCancellationToken. - Сетка — проекция DynamicData, а не пересборка.
SourceCache→AutoRefresh→Filter→SortAndBindотдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл, контейнерыItemsRepeaterи уже загруженные превью остаются на месте. Поиск дебаунсится на 200 мс, изменения карточек во время скана коалесцируются в 250 мс. - Сканирование — поток событий.
ILibraryService.ScanAsyncвозвращаетIAsyncEnumerable<LibraryScanEvent>: карточки появляются по мере находок, а не после завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно черезParallel.ForEachAsync, результаты собираются вChannelи применяются к сущностям по одному — трекер изменений EF не потокобезопасен. - Вся работа вне UI-потока. ViewModel оборачивает конвейер в
Task.Runи возвращает каждое событие в UI явно черезDispatcher.UIThread. - Превью живут только пока видны.
AsyncImageзапрашивает битмап при попадании в визуальное дерево и отпускает при выходе;ThumbnailCache— LRU на 256 записей с декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. - Scope на операцию.
DbContextживёт ровно одну операцию — ViewModel берётIServiceScopeFactoryи создаёт scope на каждый вызов. - Одно окно. Настройки — колонка макета, а не второе окно и не оверлей: открываясь, она
сдвигает сетку, и та переливается в меньшее число столбцов, оставаясь целиком доступной.
В alt-tab ничего не добавляется, и приложение остаётся переносимым на
ISingleViewApplicationLifetime, гдеShowDialogпопросту не существует. Панель занимает только строку контента: шапка и статус-бар остаются цельными на всю ширину окна. Собственные заголовок и строка действий у панели заведомо легче оконных — равные по весу читались как два приложения, сшитых по шву. - Снимок настроек берётся из одного места. Файл пишется целиком, поэтому собирать
AppSettingsвручную — верный способ затереть секцию, о которой не подумал. Все, кто пишет, начинают сIAppSettingsStore.Currentи правят его черезwith. - Настройки — рабочая копия. Панель правит снимок
AppSettingsи записывает его целиком только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра его не вызывает. - Плеер — свой контрол
VlcVideoViewповерх LibVLCSharp. VLC декодирует в память, которую мы ему выдаём, а рисуем кадр сами: видео остаётся обычным контролом Avalonia — участвует в hit-тесте, принимает жесты, поверх него можно класть что угодно. Цена — одно копирование на показанный кадр, и на 4K оно становится основной стоимостью воспроизведения. Буферов два: VLC декодирует в один, пока мы читаем другой; наDisplayони меняются местами под коротким локом. Транспорт (позиция, длительность, play/pause) — свойства самого контрола, поэтому им управляет code-behind страницы; дублировать это состояние во вьюмодель значило бы держать вторую копию и синхронизировать её. Закрытие страницы обнуляетOpenedVideo, вью уходит из дерева, и плеер гасится вместе с буферами. До этого пробовали два готовых пути.MediaPlayer.Controls: декодер работал, но кадры до экрана не доходили — чёрный экран и на GPU-, и на CPU-пути, при полностью рабочем в приложенииOpenGlControlBase. Нативное окно VLC черезNativeControlHost: картинка появилась, но окно поверх поверхности Avalonia не пропускает ни клик, ни оверлей. - Кэш превью самовосстанавливается. Диск — ключ
sha256(путь|размер|mtime), память — LRU на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически на месте (IThumbnailGenerator.IsAvailable), и перерисовывает удалённые; после полного прохода лишние файлы вычищаются (PurgeUnusedAsync). Незавершённые.tmpудаляются только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
Данные
Всё пользовательское лежит в %LOCALAPPDATA%\PLib:
library.db— SQLite с метаданными;thumbnails/— кэш постеров (ключ = путь + размер + время изменения файла);settings.json— папки, параметры превью и сканирования, тема, громкость; перечитывается на лету;logs/— Serilog, ротация по дням.
Схема создаётся через EnsureCreated. Когда форма таблицы устоится — заменить на
миграции EF Core (DatabaseInitializer — единственное место, которое надо будет тронуть).