# SleepyOwl для Linux (ПК) Версия SleepyOwl для компьютера с Linux: та же медиатека Jellyfin, что на Android TV, и плеер на libmpv, который умеет висеть маленьким окном поверх всех окон. Здесь записано, как она устроена, почему решения именно такие, какие ловушки встретились по дороге (с замерами) и как её тестировать. Ядро у ТВ- и ПК-версии общее. Как устроено воспроизведение на ТВ (Exo + VLC, выбор движка, детали протокола Jellyfin), описано в [`playback.md`](playback.md); здесь — только то, что относится к ПК. Код ПК: - `desktop/` — приложение для ПК: окна, экраны, плеер, интеграция с системой, сборка пакета; - `playback/engine-mpv/` — движок libmpv (JVM, JNA); - `ui/shared/` — общий с ТВ слой интерфейса (ViewModel'и, тема, компоненты); - `core/*`, `playback/api`, `playback/core` — общее ядро (Kotlin Multiplatform); - `tools/linux/` — запуск без Gradle, тестовый стенд, сквозная проверка, служебные скрипты. --- ## 0. Коротко 1. **Главный сценарий — смотреть «в фоне».** Выбрал серию — видео идёт в мини-окне без рамки поверх всех окон: его можно таскать и растягивать (с пропорциями видео), в нём доступны все функции плеера, управление прячется, когда мышь уходит. Окно не забирает фокус у других программ, медиаклавиши работают через MPRIS. 2. **Один движок — libmpv** (системная `libmpv2` 0.37), через свою привязку на JNA. mpv играет всё, что встретилось на ТВ-медиатеке, без транскода, своих экстракторов и прокси. 3. **Видео в Compose — копией кадра:** mpv рисует в своём невидимом контексте EGL, кадр через PBO попадает в картинку Skia, OSD рисуется поверх в том же окне. Общий с Skiko контекст GL (без копии) не дал выигрыша в мини-окне и стоил рефлексии и чёрных HDR-кадров. 4. **HDR не цель.** На Cinnamon/X11 HDR-вывода нет; HDR10, HDR10+, HLG и Dolby Vision (P8.x) mpv переводит в SDR (`tone-mapping=auto`). 5. **Окна — X11** (на Wayland — через XWayland): «поверх всех», положение окна и смена рамки на лету доступны только там. 6. **Общий код — KMP + Koin** на обеих платформах; ТВ-версия переехала на них без потери скорости. 7. **Установка — .deb** со своей урезанной JRE 21 внутри, собирается `./gradlew :desktop:packageDeb`. ## 1. Исходные условия - **Тестовый ПК:** Linux Mint 22.3, Cinnamon на X11 (оконный менеджер Muffin), NVIDIA RTX 4090 (драйвер 580), процессор класса i7-13700KF (24 потока, без встроенной графики), мониторы 3440×1440 175 Гц (основной) и 1080×1920 144 Гц (портретный). В репозиториях Mint — libmpv 0.37. - **Другие ПК — «по возможности»:** на Intel/AMD аппаратное декодирование идёт через VAAPI, mpv выбирает его сам (`hwdec=auto-safe`). - **Версии:** JDK 21, Kotlin/JVM + Compose Multiplatform 1.12.2 (Skiko 0.150), JNA 5.18.1, Koin 4.2.2, dbus-java 5.2.2, AGP 9.4.1 (Android-часть KMP-модулей). ## 2. Главные решения | Что | Решение | Почему | |---|---|---| | Язык и интерфейс | Kotlin + Compose Multiplatform (Desktop) | тот же Compose, что на ТВ: переиспользуются ядро, ViewModel'и, тема, компоненты | | Общий код | модули Kotlin Multiplatform с целями `android` и `jvm("desktop")` | одно ядро на две платформы; при двух JVM-целях `commonMain` компилируется как обычный JVM-код (§5.6) | | DI | Koin на обеих платформах вместо Hilt | Hilt работает только на Android и не работает в KMP-модулях | | Общий интерфейс | модуль `ui:shared` (KMP + Compose Multiplatform) | ViewModel'и, навигационные ключи, модели карточек, подписи, тема, шрифты, иконки и визуальные компоненты — общие; раскладки и ввод (пульт против мыши) — свои у каждой платформы | | Воспроизведение | один движок — libmpv | mpv (ffmpeg + NVDEC/VAAPI) играет всё; связка Exo+VLC, исправленные экстракторы и локальный прокси ТВ на ПК не нужны | | Привязка к libmpv | своя, через JNA (~300–400 строк), без сторонних обёрток | нужно немного: client API и render API | | Видео в окне | mpv рисует в текстуру, Compose показывает её картинкой, OSD — поверх **в том же окне** | только так окно плеера — одно целое: таскается, растягивается и висит поверх вместе с управлением. HDR при этом невозможен — он и не нужен | | HDR / DV | в SDR, тонмаппинг mpv | на Cinnamon/X11 HDR-вывода нет вообще; render API mpv умеет только старый рендерер `gpu` | | Окна | X11, на Wayland — через XWayland | «поверх всех» и положение окна на Wayland приложению недоступны | | Плеер | отдельное окно, три режима: мини поверх всех, обычное окно, весь экран | медиатекой можно пользоваться, пока идёт видео; режимы меняются без остановки видео | | Установка | .deb (свой JRE внутри), Flatpak — потом | на одном ПК .deb проще всего; libmpv — системная | **Сколько кода переиспользовано** (оценка на старте): ядро (`core:*`, `playback:api`, `playback:core`, ~3,2 тыс. строк) и ViewModel'и с моделями карточек, подписями и темой (~3,2 тыс.) — целиком; экраны ТВ (~8,5 тыс.) — визуальные компоненты частично, раскладки и ввод под мышь написаны заново; логика плеера ТВ (`PlayerViewModel`, `PlaybackSession`, пропуск заставок, «следующая серия») — общая, OSD для мыши и маленького окна — новый; движки Exo/VLC (~2,4 тыс.) на ПК не нужны. Новое: движок mpv (~1 тыс. строк), окна и плеер ПК (~2–3 тыс.), экраны ПК. ## 3. Как это выглядит и ведёт себя ### 3.1 Окна и режимы плеера - **Окно «Медиатека»** — обычное окно с рамкой, 1280×800 при запуске, минимум 800×500. Боковое меню и экраны как на ТВ. - **Плеер — отдельное окно.** У каждого окна своё место и размер; закрытие плеера не сбрасывает место в медиатеке. - **Три режима плеера**, переключаются в любой момент, видео не останавливается и не перезапускается: 1. **Мини поверх всех** — без рамки, `_NET_WM_STATE_ABOVE`. Таскается за любое место видео, размер меняется за края (8 px) и углы (20 px), пропорции видео сохраняются, минимум 320×180. 2. **Обычное окно** — с рамкой, не поверх. 3. **Во весь экран** — на мониторе, где сейчас окно. - **Переключение:** `T` — мини ↔ обычное окно (из полного экрана — всегда в мини); `F` — весь экран, `Esc` — выход из него; двойной щелчок по видео — мини → окно → весь экран → назад; кнопки режима в OSD; меню правой кнопки. Из полного экрана `F`, `Esc` и двойной щелчок возвращают в тот оконный режим, из которого пришли. Выход из полного экрана сочетанием клавиш самого WM читается обратно в режим. - **Первый запуск плеера** — мини 640×360 в правом нижнем углу основного монитора с отступом 24 px. Мини-окно при открытии и при смене видео подгоняется под пропорции видео с сохранением ширины и правого нижнего угла (640×266 для 2,39:1). Обычное окно по умолчанию — 1280×720 по центру. - **Что запоминается** (`desktop_player.preferences_pb`): последний режим (включая весь экран: закрыл во весь экран — следующий плеер откроется так же), место и размер мини и обычного окна (клиентская область в пикселях экрана), «на всех рабочих столах». Место записывается через 0,8 с после перемещения или изменения размера, при смене режима и при закрытии; перед выходом приложение ждёт последнюю запись. Место, которое не видно ни на одном мониторе (центр вне экранов или видно меньше половины), заменяется местом по умолчанию. - **Настройка «Открывать плеер»** (как в прошлый раз / мини / окно / весь экран) действует только на новое окно плеера; уже открытое (следующая серия, «Смотреть» другое) остаётся в своём режиме. - **«На всех рабочих столах»** (`_NET_WM_STATE_STICKY`) для мини-окна — в настройках («Компьютер») и пунктом с галочкой в меню правой кнопки мини-окна; по умолчанию выключено. - **Кнопки:** × закрывает плеер; «К медиатеке» поднимает окно медиатеки (из полного экрана сначала выходит). В OSD значок «картинка в картинке» переводит в мини, значок «окно» — из мини в обычное. «К сериалу» / «Страница фильма» открывает страницу в окне медиатеки и поднимает его. - **Конец фильма** (или последней серии) закрывает окно плеера, конец серии — следующая серия в том же окне. ### 3.2 Сценарий «смотреть в фоне» 1. В медиатеке выбрал серию → «Смотреть» (или «Продолжить» на главной). 2. Окно плеера открывается в последнем режиме (по умолчанию мини) и **один раз** получает фокус. 3. Щелчок в другую программу — мини-окно остаётся поверх и не забирает фокус обратно. 4. Навёл мышь на мини-окно — появилось управление, увёл — скрылось. 5. Следующая серия начинается в том же окне и тоже не забирает фокус (окно не пересоздаётся, `toFront()` — только при открытии). 6. Медиаклавиши работают, когда фокус в другом окне (MPRIS); апплет звука Cinnamon показывает название и обложку. 7. Закрыл мини-окно — прогресс на сервере, медиатека в прежнем состоянии. Если фокус был у плеера (×, `Ctrl+W`, меню, конец фильма), активным становится окно медиатеки; если плеер шёл в фоне, фокус у другой программы не отнимается. ### 3.3 Управление в плеере **Показываем управление:** курсор вошёл в окно, мышь сдвинулась, колесо, нажатие, любая клавиша, пауза/продолжение с медиаклавиши. **Скрываем:** курсор ушёл из окна — сразу (затухание 150 мс); курсор неподвижен 2 с в мини и 3 с в остальных режимах — скрываем и прячем курсор. На паузе те же правила: остаётся стоп-кадр. Тонкой полоски прогресса при скрытом управлении нет. **Не скрываем:** пока курсор над кнопками, таймлайном, панелью или подсказкой; пока тянут таймлайн, ползунок или окно; пока открыта панель или меню. Панель при уходе мыши из окна закрывается через 1,5 с (вернулся раньше — остаётся). Когда управление видно, субтитры поднимаются над OSD (`sub-pos`, §4.3). **Компоновки по ширине окна** (в точках, то есть с учётом «Масштаба интерфейса»: при 125 % переключаются раньше): - **≥ 900** — OSD как на ТВ: сверху тип, название, сезон/серия, часы, «закончится в», ×; снизу таймлайн с буфером, ⏮ ⟲ ⏯ ⟳ ⏭, чипы «Серии», Аудио, Субтитры, Качество, Direct/Транскод, громкость, весь экран. - **480–900** — строка названия с × сверху и одна строка снизу: у серии ⏮ ⏯ ⏭, у фильма ⏯ ⟲ ⟳; время, таймлайн, громкость, ⋯, режим окна. - **< 480** — ⏯ по центру с ⏮/⏭ (у фильма ⟲/⟳) по бокам, тонкий таймлайн снизу; ⋯ и режим — слева сверху, «К медиатеке» и × — справа. - **Панели** (Аудио, Субтитры с превью, размером, цветом и фоном; Качество; Информация о потоке с «Дополнительно» только для чтения): в окне ≥ 900 и выше 420 — справа, как на ТВ, иначе листом на всё окно с прокруткой. Шторка «Серии / О фильме» в полной компоновке — снизу рядом серий с ‹ ›, в маленьком окне — листом со списком. **Мышь:** - щелчок по видео — пауза/продолжить (при открытой панели — закрыть её). В мини нажатие со сдвигом больше 4 px — уже перетаскивание окна; - двойной щелчок — смена режима. Первый щелчок ставит паузу сразу, второй её снимает и меняет режим (короткая «запинка» звука) — вместо задержки ~0,4 с на каждый щелчок в ожидании двойного. Интервал — системный, не больше 0,5 с; - колесо над видео — громкость ±5 %, над таймлайном — перемотка ±5 с; - правая кнопка — меню: режим окна, Аудио, Субтитры, Качество, Информация, Серии / О фильме, К сериалу, «Отметить просмотренным», «На всех рабочих столах» (в мини), К медиатеке, Закрыть. В низком окне меню прокручивается, колесо за концом меню не крутит громкость; - таймлайн: время под курсором, щелчок — переход, перетаскивание — перемотка. Превью кадров — когда на сервере будет trickplay. **Громкость:** ползунок до 100 %, колесом и ↑ — до 130 %. Громкость и «без звука» запоминаются, новый движок сразу стартует с ними. **Клавиатура** (по физическим клавишам, работает и в русской раскладке): | Клавиша | Действие | |---|---| | Пробел, K | пауза | | ← / → | ±10 с (нажатия копятся: в движок уходит одна перемотка через 0,4 с) | | Shift+← / → | ±60 с | | 1–9 | переход на 10–90 % | | ↑ / ↓ | громкость | | M | без звука | | S | пропустить заставку/титры или сразу следующая серия, если показана карточка | | N / Shift+N | следующая / предыдущая серия | | A / C | следующая аудиодорожка / субтитры (с подсказкой «Аудио: …») | | F | весь экран | | T | мини поверх всех ↔ обычное окно | | Esc | меню → панель → выход из полного экрана; в обычном окне без панели ничего не делает | | Ctrl+W | закрыть плеер | **Пропуск заставок, «Следующая через N с», автопереход** — логика ТВ (`SkipPlan`, `SegmentSet`) один в один, с настройкой «Кнопка / Сразу / Не пропускать». Когда управление скрыто, кнопка пропуска — плашка в правом нижнем углу на время сегмента; через 10 с прячется и возвращается вместе с OSD. При «Сразу» — без плашки. Карточка следующей серии в окне уже 480 — одной строкой. ### 3.4 Медиатека на ПК - **Экраны те же, что на ТВ:** Вход, Главная, Медиатека, Фильм, Сериал, Поиск, Избранное, Человек, Коллекция/папка, Настройки. Фильм, сериал, человек и коллекция — во весь экран без меню, со стрелкой «‹» вверху слева. Не перенесены лента для оболочки ТВ (`LauncherFeed`), выбор движка, «Вывод видео», Dolby Vision и отладка. - **Размеры:** dp ТВ-версии = px при масштабе 100 % (умножается на масштаб системы: Java на X11 берёт его из `GDK_SCALE` / Xft.dpi). Текст ~14–16 px. Поверх — «Масштаб интерфейса» 80–150 % (через `LocalDensity`, сразу в обоих окнах, включая OSD). - **Под ширину окна** (от ~800 до 3440 px): сетки — столько колонок, сколько влезает; «Продолжить просмотр», кнопки, чипы и строка метаданных переносятся (`FlowRow`); страница фильма и сериала прокручивается целиком, если окно низкое. Боковое меню (`Shell`) на окне ≥ 1100 px открыто всегда, с подписями; уже — только иконки, раскрывается поверх содержимого при наведении. Библиотеки сервера — в меню, профиль — внизу. - **Мышь:** наведение = фокус (`hoverFocus`): элемент под мышью получает тот же вид, что фокус на ТВ (кольцо, свечение); уход мыши фокус снимает; поле ввода, в котором печатают, фокус не отдаёт. Плитку библиотеки и сезон сериала выбирает щелчок, а не фокус — иначе мышь, проходя над плитками, переключала бы библиотеки. Правая кнопка на карточке, серии или сезоне — меню «просмотрено / не просмотрено» у курсора (закрывается щелчком мимо или Esc). - **Ряды** (`ScrollRow`): колесо листает страницу, Shift+колесо и горизонтальный жест тачпада — ряд; при наведении по краям ряда — круглые ‹ ›. - **Клавиатура:** стрелки двигают фокус, как пульт; первая стрелка, пока ничего не в фокусе, ставит его на запомненный (или первый) элемент экрана. При возврате «назад» фокус — на карточке, с которой ушли. `Ctrl+F` или `/` — поиск. «Назад» — Esc, `Alt+←`, Backspace (не в поле ввода), боковая кнопка мыши. - **Поиск** — обычное поле ввода (фокус в нём при открытии), под ним недавние запросы и новинки или результаты по библиотекам и люди. - **Вход:** сервер (найденные в LAN, сохранённые, «Ввести адрес»), профиль, имя и пароль с клавиатуры или Quick Connect с QR. «Сменить профиль» открывает экран входа на шаге профиля. - **Настройки:** разделы ТВ плюс «Компьютер» (масштаб, «Открывать плеер», «Мини-окно на всех рабочих столах») и «Клавиши» (плеер, мышь в плеере, медиатека). «О приложении» — версия, устройство и список компонентов с лицензиями (§4.9). ## 4. Архитектура ### 4.1 Модули ``` core:common KMP (android + desktop): Log, monotonicMs(), каталоги платформы и DataStore core:jellyfin KMP: сессия Jellyfin, ServerRouting core:data KMP: репозитории playback:api KMP: интерфейсы движка playback:core KMP: PlaybackSession, EngineSelector, отчёты, сторожа ui:shared KMP + Compose Multiplatform: ViewModel'и, Screen, CardMapper, подписи, тема, шрифты, иконки, компоненты app ТВ (Android): экраны с фокусом под пульт, LauncherFeed playback:engine-exo только Android playback:engine-vlc только Android playback:engine-mpv только desktop (JVM): libmpv через JNA, свой контекст GL, кадр для Compose desktop приложение ПК: окна, экраны, плеер, MPRIS, хранитель экрана, настройки, пакет .deb ``` Архитектура движков общая: `EngineProvider` → `EngineSelector` → `PlaybackSession`. На ПК в наборе один провайдер — `mpv` (`mpvEngineModule`); отчёты о прогрессе, сторож зависаний, запоминание дорожек, ограничение качества и транскод работают из общего кода без изменений. Граф Koin ПК: `jellyfinModule`, `dataModule`, `playbackModule`, `mpvEngineModule`, `uiModule`, `desktopModule()`. ### 4.2 Что пришлось развести в общем коде - `android.os.SystemClock` → `monotonicMs()` (expect/actual): на Android `elapsedRealtime()` (идёт и во сне ТВ — на нём держатся сроки кэша), на ПК `System.nanoTime()`. - `Context` + `preferencesDataStore` → `AppDirs` + `PreferenceStores` (по DataStore на файл). Android — прежние `files/datastore` и `files` (данные на ТВ сохранились), ПК — XDG-каталоги. - `Build.MANUFACTURER/MODEL` → `JellyfinPlatform`: на Android — `AndroidJellyfinPlatform`, на ПК — `DesktopJellyfinPlatform` с именем «SleepyOwl · » и id устройства — хэшем `/etc/machine-id`. - `PlaybackEngine.attachVideo(FrameLayout)` убран из общего интерфейса: Exo и VLC реализуют `AndroidVideoOutput`, сессия отдаёт каждый новый движок хозяину вывода `PlaybackSession.attachVideo(VideoHost)` до `load` (и при смене движка на лету). На ТВ хозяин — `FrameLayoutVideoHost`, на ПК — поверхность кадров mpv. Дополнительные возможности движка плеер берёт через `engine as? …` (`AudioDelayCapable`, `SubtitleDelayCapable`, `SubtitleStyleable`). - `DeviceCapabilitiesProbe` стал интерфейсом: Android-пробник (MediaCodecList, Display, AudioTrack) — в `androidMain`, `Codecs.passthroughEncoding` / `encodingName` — расширения в `Codecs.android.kt`; на ПК `PcCapabilitiesProbe` — имя ПК и процессора, декодеры — дело mpv. - Timber → свой `Log` с тем же видом вызовов (`Log.w(e, "… %s", x)`): на Android пишет в Timber с тегом вызывающего класса, на ПК — в stderr и файл. - `org.json` (часть Android, на ПК его нет) → `kotlinx.serialization` (`JsonObject`), формат хранения прежний: аккаунты, выбранные дорожки, история поиска, ответ discovery. - Hilt → Koin: ViewModel'и с параметром — `koinViewModel { parametersOf(id) }`, набор движков — `getAll()` (провайдер объявляется с `bind EngineProvider::class`). - `androidx.tv.material3` → свои Text/Button/Surface на `compose.foundation`; `tv-material` и `material-icons-core` ушли из ТВ-сборки. - **Картинки, запечённые через `android.graphics`** — один интерфейс, две реализации. На Android код прежний (вылизан под Mali), на ПК — простой: свечение фокуса — размытие Skia одним проходом; картинка с сервера — `clip` и градиент поверх, BlurHash растягивается из 32×32 (разбор BlurHash общий). QR-код (пиксели в `argbImageBitmap`) и пунктирная рамка (Canvas Compose) стали общими. - `Context` в `CardMapper` и `SettingsViewModel` → `UiPlatform` (плотность экрана для размера картинок с сервера, строки версии и устройства, доступность отладки). - Ресурсы: логотип и шрифты Onest / JetBrains Mono лежат в `ui:shared` (`res/drawable-xhdpi`, `res/font`); `src/androidMain/res` подключён и к ресурсам ПК, файлы не дублируются. - `android.view.KeyEvent` и фокус под пульт остались в ТВ-экранах. - Мелкие правки для ПК: `secondaryClick` в `clickableWithMenu` (expect/actual: на Android пустой, на ПК — правая кнопка), открытый `SearchViewModel.setQuery`, `LibraryViewModel.selectLibrary(id)`, `PlaybackSession.awaitStopReports()` (§4.8). **Влияние на ТВ.** Переход на KMP, Koin и Compose Multiplatform скорость ТВ не изменил (release с baseline profile, сборки чередовались): | | До перехода (Hilt) | KMP + Koin | + `ui:shared` (CMP) | |---|---|---|---| | Холодный запуск, `am start -W`, среднее | 257 мс | 258 мс | 263 мс | | `ui_bench right` на главной, кадр p50 / p90 | 12–19 / 19–32 мс | 12–19 / 19–32 мс | 15–19 / 31–32 мс | | release APK | 52,85 МБ | 52,87 МБ | 52,84 МБ | Столбцы — из разных прогонов: при прямом сравнении последних двух сборок вперемешку холодный запуск был 274 и 263 мс — разброс между прогонами больше разницы между сборками. Compose Multiplatform 1.12.2 на Android тянет те же androidx Compose 1.12.x, что и BOM приложения; плагин Gradle CMP для этого не нужен — хватает плагина компилятора Compose и координат `org.jetbrains.compose.*` в `commonMain`. После перехода baseline profile переснят (27 740 правил: Koin вместо Hilt). ### 4.3 Движок mpv (`playback:engine-mpv`) **Привязка.** `LibMpv.kt` — client API (`mpv_create`, `mpv_initialize`, `mpv_set_option_string`, `mpv_command_async`, `mpv_observe_property`, `mpv_wait_event`) и render API (`mpv_render_context_create`, `…_render`, `…_set_update_callback`, `…_report_swap`); `Gl.kt` — EGL и OpenGL через glvnd. До `mpv_create` — `setlocale(LC_NUMERIC, "C")`: в русской локали mpv путает дроби. **`MpvEngine`** — `PlaybackEngine` + `AudioDelayCapable`, `SubtitleDelayCapable`, `SubtitleStyleable`. Одно ядро mpv на воспроизведение; из интерфейса — только асинхронные команды; события и свойства — в своём потоке (`mpv-events`). Пока контекст отрисовки не готов, `loadfile` с предшествующими командами ждёт. **Опции:** - `vo=libmpv`, `hwdec=auto-safe` (NVDEC на NVIDIA, VAAPI на Intel/AMD); - `config=no`, `terminal=no`, `input-default-bindings=no`, `input-vo-keyboard=no`, `osc=no`, `ytdl=no`, `idle=yes`; - `keep-open=yes`, `hr-seek=yes`, `cache=yes`, `demuxer-max-bytes=150MiB`, `demuxer-max-back-bytes=50MiB`; - `audio-channels=auto-safe` (сведение в стерео для колонок и наушников), `ao=pipewire,pulse,alsa`, `audio-client-name=SleepyOwl`; - `tone-mapping=auto` (HDR → SDR); - `sub-auto=no`, `sub-ass-override=scale` (ASS сохраняют свои стили); - `user-agent=SleepyOwl`; - на каждый файл: `deinterlace=yes`, если Jellyfin говорит `IsInterlaced` (в 0.37 нет `auto`), иначе `no`; `start` — позиция продолжения. - Для проверок: `SLEEPYOWL_MPV_OPTIONS="mute=yes,…"` (дополнительные опции), `SLEEPYOWL_MPV_LOG=v` (уровень журнала mpv; по умолчанию `warn`). **Адрес потока.** Ссылки на поток и внешние субтитры строит общий `JellyfinSession` — с токеном в параметре `ApiKey` (общий путь всех движков). Если токен передавать заголовком, то только через `http-header-fields-append` или коротким `Authorization: MediaBrowser Token="…"`: обычный `http-header-fields` режет значение по запятым (§5.1). **Дорожки.** Индекс Jellyfin → `aid`/`sid` mpv. До открытия файла `aid`/`sid` ставятся по месту среди дорожек того же типа (`embeddedOrdinal()`, нумерация с 1), чтобы не было переключения на старте; после `file-loaded` движок читает `track-list` (`id`, `type`, `ff-index`, `external-filename`) и уточняет: встроенные — по `ff-index` (это индекс Jellyfin минус число внешних файлов: Jellyfin нумерует внешние первыми), внешние субтитры — по адресу; запасной путь — место среди дорожек типа. Внешние субтитры добавляются по одному `change-list sub-files append `: список `sub-files` делится по `:`, а в адресах их полно. В транскоде аудио не трогаем — его выбрал сервер. **Стиль субтитров** (`SubtitleStyle`) — на лету, без перезапуска: `sub-scale`, `sub-color`, обводка с тенью или `opaque-box` (подложка — цвет обводки), `sub-back-color`. Подъём над OSD — `sub-pos` (100 = низ, % высоты картинки), а не `sub-margin-y`: тот не двигает ASS. **События → состояние:** - `pause` → `playWhenReady`; пока файл не открыт, `paused-for-cache` или `seeking` — BUFFERING; `eof-reached` — ENDED; - первый кадр (`FirstFrameRendered`) — из потока отрисовки, когда известен размер видео; - `end-file` с ошибкой → вид `EngineError` по коду mpv: загрузка → NETWORK, формат → FORMAT, звук, видео; - `seekable` → `isSeekable`; `demuxer-cache-time` → полоса буфера; - `frame-drop-count`, `hwdec-current`, `video-codec`, `audio-codec-name`, `current-ao`, размер кадра в памяти, сведения о GL → «Информация о потоке». **`MpvEngineProvider`:** `evaluate()` → Direct почти для всего; `deviceProfile()` — «всё напрямую»: все контейнеры и кодеки, субтитры встроенные и внешние. «Качество» ниже оригинала — транскод сервера в HLS, mpv его играет. Сквозного вывода звука на ресивер нет (ПК — колонки и наушники). **Освобождение** — в своём потоке: поток событий, поток отрисовки (контекст отрисовки mpv освобождается до `mpv_terminate_destroy`), ядро; ~0,3 с. ### 4.4 Как видео mpv попадает в Compose Рассматривались три варианта, A и B были сделаны и измерены в отдельном прототипе (модуль `:desktop-proto`, позже удалён из репозитория; движок вырос из его варианта B): - **A. Общий контекст, без копирования.** mpv рисует в контексте GLX самого Skiko, в фазе рисования Compose, в свою текстуру, которую Skia «усыновляет» (`Image.adoptTextureFrom`). `DirectContext` достаётся рефлексией (`SkiaLayer` → `getRedrawer` → `contextHandler.context`); перед отрисовкой mpv состояние GL, оставленное Skia, сбрасывается, после — `resetGLAll()`; `video-timing-offset=0`. - **B. Свой контекст у mpv и копия кадра.** Свой поток, невидимый контекст EGL (pbuffer), FBO, `glReadPixels` в PBO → память кадра, три буфера (показываемый, последний готовый, записываемый; кадр, заменённый до показа, считается потерянным). Память кадра — `Data` Skia, `Image` поверх неё без копирования; без `FLIP_Y` кадр идёт сверху вниз, как ждёт Skia. - **C. Программная отрисовка mpv (SW render API)** — только на крайний случай: медленно, хуже масштабирование. Не понадобился. **Замеры прототипа** (30 с после 4 с прогрева, звук выключен; CPU — % одного ядра для всего процесса, GPU — SM процесса по `nvidia-smi pmon`; A — со сбросом GL). Весь экран 3440×1440, 175 Гц: | Видео | A: к/с, потери | A: CPU | A: GPU | B: к/с, потери | B: копия | B: CPU | B: GPU | |---|---|---|---|---|---|---|---| | H.264 1080p24 | 24, 0 | 8,3 % | 1 % | 24, 0 | 2,4 мс | 23,4 % | 6 % | | HEVC 10 бит 4K HDR10 | 24, 0 | 11,2 % | 2 % | 24, 0 | 2,8 мс | 27,3 % | 5 % | | DV P8.1 4K | 24, 0 | 9,5 % | 1 % | 24, 0 | 2,8 мс | 25,3 % | 7 % | | H.264 720p60 | 60, 0 | 14,3 % | 5 % | 60, 0 | 2,3 мс | 48,2 % | 20 % | Мини-окно 640×360 (640×266 у 2,39:1), портретный монитор 144 Гц, окно без фокуса: | Видео | A: к/с, потери | A: CPU | B: к/с, потери | B: копия | B: CPU | |---|---|---|---|---|---| | H.264 1080p24 | 24, 0 | 9,6 % | 24, 0 | 0,3 мс | 9,6 % | | HEVC 10 бит 4K HDR10 | 24, 0 | 11,6 % | 24, 0 | 0,5 мс | 15,1 % | | DV P8.1 4K | 24, 0 | 11,2 % | 24, 0 | 0,6 мс | 15,1 % | | H.264 720p60 | 60, 0 | 14,8 % | 60, 1 | 0,5 мс | 28,6 % | - Пропусков mpv нет нигде; на 60p у обоих по одному долгому интервалу (32 мс) в начале прогона. Разброс интервалов между новыми кадрами в Compose — 0,5–0,6 мс (1,1 мс у B на 60p); это время отрисовки, а не показа — рывки 24p так не измерить. - **Куда уходит лишнее у B на весь экран** (разбивка по потокам из `/proc//task`): поток AWT 10,7 % на 24p и 23,8 % на 60p — Skia каждый кадр загружает в текстуру картинку 3440×1440 (20 МБ) из памяти; поток отрисовки B 7–8 % и 15,7 % — `glReadPixels` и копия. У A поток AWT 2,5–5 %. - **Откуда 10–15 % в мини-окне:** в 30-секундном прогоне 2–5 % — JIT (прогрев); остальное поровну между mpv (потоки `core`, `demux`, `vo`, `cuda`, звук — 2–4 %) и Compose (поток AWT 3 %, Skiko ~1 %, события и корутины ~1 %), у B ещё поток отрисовки 2–3 %. **Установившийся режим** (минута прогрева, минута замера, H.264 1080p24): **A 5,8 %, B 6,6 %** одного ядра; из них mpv ~1,5 %, поток AWT 1,2–1,5 %, Skiko 0,7 %, JIT 1 %, поток отрисовки B 1,2 %. **Выбран B.** Почему: - в мини-окне — главном сценарии — A не даёт выигрыша (5,8 % против 6,6 % одного ядра); - на весь экран разница 15–34 % одного ядра — на 24-поточном процессоре это 0,6–1,4 % всего CPU: заметно в цифрах, не в работе; потерь кадров нет у обоих; - у A три настоящих минуса: рефлексия во внутренности Skiko (непубличный API, может сломаться с обновлением Compose); общее состояние GL — без сброса HDR и DV были чёрными (§5.2), и любое обновление Skia может оставить что-то ещё; контекст отрисовки mpv создаётся в потоке интерфейса, в первом кадре Compose — вместе с запуском CUDA окно замирает на ~2 с и первый кадр видео приходит на ~2 с позже, чем в B (там ~0,3 с, параллельно с открытием окна); - у B mpv живёт в своём потоке и контексте и ничего не знает об окне: окно можно пересоздать или поменять ему рамку — видео не заметит. **B удешевлён в движке** (`MpvVideo.kt`): - **FBO — размер области видео, но не больше самого видео** (`renderSize`): если видео пришлось бы увеличивать, FBO уменьшается с сохранением пропорций области (mpv по-прежнему рисует чёрные поля), дальше растягивает Skia (Catmull-Rom). Для фильма 1080p на весь 3440×1440 кадр в памяти получается в несколько раз меньше окна (таблица ниже). На 4K ничего не меняется — кадр и так размером с окно. Цена — растягивает Skia, а не масштабатор mpv. - **PBO с постоянным отображением** (`glBufferStorage` + `MAP_PERSISTENT | MAP_COHERENT`), без map/unmap на кадр. - **Вторая копия (PBO → память Skia) оставлена сознательно:** картинка Skia поверх памяти PBO может пережить буфер — Compose повторяет записанную картинку и после остановки движка, а у `Data` Skia есть счётчик ссылок. **Итоговые замеры B** (окна без фокуса, звук выключен, 20 с после 12 с прогрева, CPU — % одного ядра, JIT ещё ~2–3 %): | Окно | Видео | Кадр в памяти | CPU | поток AWT | поток отрисовки | B в прототипе | |---|---|---|---|---|---|---| | мини 640×360 | H.264 1080p24 | 640×323 | 9,7 % | 2,2 % | 2,0 % | — | | весь портретный 1080×1920 | H.264 1080p24 | 1056×1841 | 19,1 % | 6,4 % | 4,4 % | — | | весь основной (3440×1367) | H.264 1080p24 | 1107×440 | **10,0 %** | 2,5 % | 2,1 % | 23,4 % | | весь основной (3440×1367) | HEVC 4K HDR10 | 3440×1367 | 25,5 % | 10,0 % | 7,0 % | 27,3 % | Цель (≤ ~15 % одного ядра на 24p на весь экран) выполнена: фильм меньше окна стоит как мини-окно; к варианту A возвращаться незачем. Поправка по ходу: первый кадр иногда рисовался в размер окна, когда mpv уже сообщил ширину видео, а высоту ещё нет. Код варианта A в репозитории не сохранился; суть — в описании выше и в §5.2. ### 4.5 Окно плеера: техника X11 Окно плеера создаётся само (`Window(create = …)`, без `WindowState` Compose): место и размер — дело WM и `PlayerPlacement`. Работа с X11 — `X11.kt` (Xlib через JNA): рамка, полный экран, состояние WM, `startMoveResize`, `geometry`, `moveResize`, `setSizeHints` (PAspect + PMinSize), `setSticky`. **Рамка — от WM, через `_MOTIF_WM_HINTS`.** Окно AWT создаётся без рамки (`undecorated`), рамку включает и выключает WM по подсказке `_MOTIF_WM_HINTS` (так делает mpv; Muffin добавляет или убирает заголовок 32 px у живого окна). AWT этого не замечает: его insets остаются нулевыми, Compose занимает всю клиентскую область. Окно не пересоздаётся. Рассматривались и отвергнуты: - пересоздание окна при смене режима — работало после исправлений, но с дефектами (§5.3); - всегда без рамки и своя строка заголовка — не понадобилось; - рамка AWT — с ней Muffin не выводит окно из полного экрана (§5.3). **Полный экран** — `_NET_WM_STATE_FULLSCREEN` напрямую, на мониторе с окном. Запрос окну, которое WM ещё не взял под управление (нет `WM_STATE`), пропадает — при старте в полном экране ждём `WM_STATE`. **«Поверх всех»** — `_NET_WM_STATE_ABOVE` (только в мини), «на всех рабочих столах» — `_NET_WM_STATE_STICKY`. **Порядок смены режима:** запомнить место старого режима → выйти из полного экрана и дождаться, пока WM снимет `FULLSCREEN` (~35 мс; Muffin сам возвращает размер «до полного экрана», причём позже нас) → рамка, «поверх всех», `STICKY`, подсказки размера → место нового режима через `XMoveResizeWindow` с повторами и поправкой на рамку. Подробности — §5.3. **Перетаскивание и размер** — `_NET_WM_MOVERESIZE`: окно двигает сам WM — плавно, между мониторами. Перед запросом отпускается захват мыши AWT (`XUngrabPointer` на соединении AWT под `awtLock`), а Compose получает синтетическое отпускание кнопки — иначе он остаётся в «нажато». В запрос передаётся **точка нажатия**, а не текущая: иначе окно отстаёт от курсора на путь до порога перетаскивания. Запасной путь — двигать окно самим через `setLocation` (проверен в прототипе, в приложении не нужен). **Пропорции** мини-окна держит сам Muffin по `WM_NORMAL_HINTS` (PAspect, PMinSize 320×180) — при условии, что AWT не сотрёт подсказки (§5.3). Измерено: 840×472 и 569×320 у 16:9, 760×316 у 2,4:1. **Фокус** — один раз при открытии плеера по действию пользователя. При автопереходе к следующей серии и прочих событиях окно не зовёт `toFront()` / `requestFocus()` — в Muffin они забирают фокус по-настоящему. У окна без рамки AWT фокус надо отдать холсту Skiko (`requestFocusInWindow` самому вложенному компоненту), иначе клавиши теряются до первого щелчка. **Наведение и уход мыши** — события Enter/Exit/Move на корне окна; X11 присылает их (и колесо) окну без фокуса. ### 4.6 Интерфейс плеера (`desktop/…/Player*.kt`) - `PlayerWindow.kt` — `DesktopPlayer` (что играет, режим и `windowedMode`, `switchTo`, `toggleOnTop`, `toggleFullscreen`, `cycleMode`, `showLibrary`, `openPage`), `PlayerAudio` (громкость и «без звука»), `PlayerPlacement`, `PlayerMode` (Mini, Window, Fullscreen). - `PlayerFrame.kt` — окно и его режимы, перетаскивание, восстановление фокуса при закрытии. - `PlayerUi.kt` — состояние (`PlayerUiState`: показ OSD, кто держит управление, панель, меню, перемотка, пропуски), три компоновки, мышь и клавиши, `NowPlaying` и `PlayerCommands` (команды извне — медиаклавиши показывают управление, как клавиши). - `PlayerControls.kt` — кнопки и чипы ТВ с подсветкой от наведения, значки ПК, таймлайн, громкость, подсказка-тост. - `PlayerPanels.kt` — панели и шторка «Серии / О фильме»; `PlayerPrompts.kt` — кнопка пропуска, «Заставка пропущена», карточка следующей серии, загрузка, ошибка, меню правой кнопки. - Общий `PlayerViewModel` и `PlaybackSession` — без изменений. ### 4.7 Каркас приложения и медиатека - **`Main.kt`:** один экземпляр → `MpvVideo.prepareProcess()` (до любого окна, §5.2) → лог в файл → WM_CLASS → Koin → Coil → главное окно; выход — через `_exit` (§4.8). - **Навигация — свой стек** (`NavStack`), без navigation3: у каждого экрана свои ViewModel'и (очищаются, когда экран уходит из стека) и сохранённое состояние; рисуется только верхний. - **«Назад»** — `BackDispatcher`: сначала обработчик экрана (шаги входа), потом стек. Клавиши — в `onKeyEvent` окна (§5.4), боковая кнопка мыши — через `AWTEventListener` (§5.4). - **Coil** — клиент API с `ServerRouting` (LAN-адрес сервера, если доступен, иначе публичный — работает на ПК без изменений), диск `~/.cache/sleepyowl/images` 500 МБ, память — четверть кучи. - **Экраны** — по файлу на экран в `desktop/`, той же раскладкой и видом, что на ТВ, на общих ViewModel'ях. - `--play [--start <с>]` — открыть плеер сразу после восстановления сессии (для проверок). ### 4.8 Приложение и система **Файлы** (с учётом `XDG_*`): | Где | Что | |---|---| | `~/.config/sleepyowl` | настройки и аккаунты: `jellyfin_session.preferences_pb`, `desktop.preferences_pb` (настройки ПК), `desktop_player.preferences_pb` (место окон, режим, громкость) и DataStore общего кода | | `~/.cache/sleepyowl` | картинки Coil (`images/`), снимок главной, обложки для MPRIS (`mpris/`) | | `~/.local/state/sleepyowl/log` | `sleepyowl.log`, прошлый запуск — `sleepyowl.1.log`; также stderr | | `$XDG_RUNTIME_DIR/sleepyowl` | `sleepyowl.lock`, `sleepyowl.sock` | Токены — в файле DataStore, как на ТВ; libsecret — потом, если понадобится. **Один экземпляр** (`SingleInstance`): блокировка `sleepyowl.lock` + локальный сокет `sleepyowl.sock`. Второй запуск просит первый поднять окно и выходит. **WM_CLASS.** Java по умолчанию ставит WM_CLASS по имени главного класса, и панель задач группирует окна неправильно («java»). До первого окна WM_CLASS задаётся `sleepyowl` рефлексией на `sun.awt.X11.XToolkit.awtAppClassName` (нужны `--add-opens java.desktop/sun.awt.X11` и `java.desktop/sun.awt`); в .desktop-файле — `StartupWMClass=sleepyowl`. Окна медиатеки и плеера — под одним значком. Значки 16…512 px (сова на синем скруглённом квадрате) генерирует `tools/logo/make_assets.py` в `desktop/src/main/resources/icons/`. **Выход.** Сохранить всё → дождаться отчётов об остановке на сервер (`PlaybackSession.awaitStopReports()`) и записи места окна (до 3 с) → закрыть D-Bus, освободить блокировку, остановить Koin → `_exit(0)` из libc, без деструкторов библиотек. `exitProcess` не подходит: примерно каждый третий выход зависал (§5.2). Закрытие окна — выход за ~0,2 с. **MPRIS** (`SystemMedia.kt`, dbus-java 5.2.2 — чистая Java на unix-сокетах JDK, без нативных библиотек): - имя `org.mpris.MediaPlayer2.sleepyowl` занимается, пока открыто окно плеера, и отпускается при закрытии (плеер пропадает из апплета); при смене серии не отпускается. Второй экземпляр на той же шине берёт `org.mpris.MediaPlayer2.sleepyowl.instance`; - метаданные: у серии название «Сезон 1 · Серия 2 · …» и исполнитель — сериал, у фильма — название и «год · жанры»; длительность; обложка — постер сериала или фильма, скачивается своим клиентом (с маршрутом по LAN) в `~/.cache/sleepyowl/mpris` и отдаётся как `file:///…`; статус, позиция, громкость; - команды: Play, Pause, PlayPause, Stop (= пауза: «остановить» в окне плеера — это пауза, закрыть — ×), Next/Previous (серии), Seek, SetPosition, Volume (запись), Raise; - изменения — `PropertiesChanged` только с изменившимися свойствами; скачок позиции больше 2 с (перемотка откуда угодно) — сигнал `Seeked` (проверка раз в секунду). Пока грузится следующая серия, отдаются прежние данные — плеер не мигает в апплете. **Хранитель экрана** — `org.freedesktop.ScreenSaver.Inhibit`, пока видео идёт (не на паузе, не в ошибке, не в конце), `UnInhibit` на паузе и при закрытии. mpv в режиме render API сам этого не делает. В Cinnamon сервис даёт `csd-screensaver-proxy`; запрет привязан к соединению D-Bus — если приложение упадёт, система снимет его сама. Вызовы D-Bus — в своём потоке; нет шины или ошибка — плеер работает без них (запись в лог). **Настройки ПК** (`DesktopSettings.kt`, читаются до первого окна): «Масштаб интерфейса» 80–150 % (переменная `SLEEPYOWL_SCALE` важнее — в настройках об этом надпись), «Открывать плеер», «Мини-окно на всех рабочих столах». ### 4.9 Сборка и установка ``` ./gradlew :desktop:run # запуск из Gradle tools/linux/desktop.sh # запуск без Gradle вокруг процесса (DESKTOP_NOBUILD=1 — без сборки) ./gradlew :desktop:packageDeb # → desktop/build/deb/sleepyowl_0.1.0-<ГГГГММДДЧЧММ>_amd64.deb, ~65 МБ sudo apt install ./desktop/build/deb/sleepyowl_*.deb sudo apt remove sleepyowl # ~/.config/sleepyowl остаётся ``` Нужен JDK 21 (`JAVA_HOME` или `local/dev.env`, §7.5). **Что в пакете.** Образ jpackage из JDK 21 (`createDistributable` плагина Compose) со своей урезанной JRE (jlink) — в `/opt/sleepyowl`; `/usr/bin/sleepyowl` — ссылка на запускалку; `/usr/share/applications/sleepyowl.desktop` (`StartupWMClass=sleepyowl`, раздел «Аудио и видео»); значки 16…512 — в `hicolor`. Меню и кэш значков обновляют триггеры dpkg, своих скриптов в пакете нет. **Решения:** - **.deb собирается своей задачей** (`PackageDeb` в `desktop/build.gradle.kts`, через `dpkg-deb`), а не `packageDeb` jpackage: его пакет не умеет зависеть от `libmpv2` — libmpv загружается через JNA, и зависимость сама не находится. Свой пакет даёт ещё короткое имя в `/usr/bin` и меню без лишних скриптов. - **libmpv — системная** `libmpv2 (>= 0.37)`: на проверенных файлах 0.37 не хуже 0.41 (§6), своя libmpv не нужна. Остальные зависимости — то, что `dpkg-shlibdeps` нашёл у библиотек JRE, Skiko и JNA (имена Mint 22 / Ubuntu 24.04: `libc6 (>= 2.34)`, `libstdc++6`, `libfontconfig1`, `libx11-6`, `libxext6`, `libxi6`, `libxrender1`, `libxtst6`, `libasound2t64 | libasound2`), плюс то, что грузится через JNA: `libegl1`, `libopengl0`, `libgl1`. - **Версия пакета** — `0.1.0-ГГГГММДДЧЧММ` (время сборки): `apt install` любой новой сборки идёт как обновление. Номер версии — `sleepyowl.versionName` в `gradle.properties`, общий с ТВ (CI поднимает его на релизе); `BuildInfo.VERSION` генерируется из него. - **Модули JRE:** `suggestRuntimeModules` дал `java.instrument`, `jdk.security.auth` (dbus-java), `jdk.unsupported`. Добавлены два, которых jdeps не видит: `jdk.crypto.ec` (TLS к публичному адресу сервера по https) и `jdk.localedata` — без него `Locale.getDisplayLanguage(ru)` даёт названия языков дорожек по-английски. - **Права файлов в пакете** выставляются явно (755/644): при `umask 002` иначе получается групповая запись. **Лицензии** («О приложении», `LicenseScreen.kt`) — свой список ПК: mpv и FFmpeg (системная libmpv, GPL-2.0+), OpenJDK в пакете (GPL-2.0 + Classpath Exception), Compose Multiplatform и Skiko, Skia (BSD-3), JNA, dbus-java. Тексты — общие с ТВ из `app/src/main/assets/licenses` и свои три в `desktop/src/main/resources/licenses` (GPL-2 из `/usr/share/common-licenses`, лицензия OpenJDK из `legal/java.base` образа, LICENSE Skia). `LicensesTest` проверяет, что у каждой строки есть текст. Экран текста листается колесом, ↑/↓, PgUp/PgDn, пробелом; Esc — назад. ## 5. Находки и ловушки ### 5.1 mpv и libmpv - **Render API рисует только старым рендерером `gpu`, не `gpu-next`** — и в 0.37, и в 0.41: в `vo_libmpv.c` есть лишь бэкенды `gpu` и `sw`. - HDR → SDR у `gpu` нормальный, чуть мягче по контрасту, чем у `gpu-next`. - DV показывается по базовому слою (в 0.41 это явно делает `mp_image_params_restore_dovi_mapping`) — для P8.x это правильно. - У DV профиля 5 базового слоя нет → по коду ждём неправильных (зелёно-фиолетовых) цветов. Не проверено: в тестовой медиатеке P5 нет, а образец из набора FFmpeg (`fate-suite/mov/dovi-p5.mp4`) — чёрный кадр. Выход, если подтвердится: `evaluate` → Degraded (транскод сервера с тонмаппингом) или `vf=libplacebo`. - Зато дешевле: в окне 640×360 GPU 3 % против 13–19 % у `gpu-next`. - **`http-header-fields` режет значение по запятым:** заголовок `MediaBrowser Client="…", Device="…", Token="…"` доходит до сервера тремя обрывками (проверено на локальном HTTP-сервере). Нужно `http-header-fields-append` (одно значение без разбора) или только `Authorization: MediaBrowser Token="…"` — в нём запятых нет. - **Список `sub-files` делится по `:`** — адреса внешних субтитров добавлять по одному через `change-list sub-files append`. - **`deinterlace` в 0.37 — только `yes`/`no`**, `auto` появился позже: включать по `IsInterlaced` из Jellyfin. С nvdec 0.37 деинтерлейсит на GPU (`yadif_cuda`, CPU 3 % на 1080i50). В Flatpak-сборке 0.41 нет CUDA-фильтров ffmpeg → `bwdif` на CPU, 20 %. DVD 576i: без деинтерлейса — 25 к/с с гребёнкой, с `deinterlace=yes` — `bwdif` на CPU, 50 к/с, CPU 10 %. - **`hwdec=auto-safe` на RTX 4090** выбирает nvdec для H.264, HEVC и AV1. MPEG-2, MPEG-4 ASP (нет в `hwdec-codecs` по умолчанию) и H.264 Hi10P (NVDEC не умеет H.264 10 бит) идут программно, CPU ≤ 19 % одного ядра. 0.41 сначала пробует cuda/vulkan/vaapi — ошибки в логе безвредны. VAAPI на ПК без встроенной графики нет: драйвер VAAPI для NVIDIA не загружается. - **`sub-margin-y` не двигает субтитры ASS** — `sub-pos` двигает; надписи с `\pos` libass оставляет на месте. - **Локаль:** до `mpv_create` — `setlocale(LC_NUMERIC, "C")`, иначе в русской локали дроби разбираются неверно. - Безвредный шум в логе: «Multiple Dolby Vision RPUs» (DV P8.1), «mmco: unref short failure» (H.264 1080i50 после перемотки — от самого файла, картинка восстанавливается). - Номера экранов mpv (`--screen`) не совпадают с `xrandr`; приложение берёт мониторы из AWT, его это не касается. - **Синхронизация 24p с экраном** (`display-resample`) в render API работает только при точной обратной связи о кадрах; частота кадров Compose привязана к экрану (60/144/175 Гц) — на весь экран возможны лёгкие рывки 24p. Автоматически не измерить (§8). ### 5.2 OpenGL, glvnd, Skiko - **glvnd и чужой контекст EGL.** На NVIDIA контекст EGL mpv не становился текущим, если до него уже рисовало окно Compose: `eglMakeCurrent` возвращал ошибку с кодом `EGL_SUCCESS` — и с pbuffer, и без поверхности. На Mesa всё работало, а прототип создавал контекст до окна — поэтому всплыло поздно. Причина — libglvnd: он переписывает точки входа GL под первого поставщика (GLX Skiko) и потом отказывает контексту другого API. Лечится `__GLVND_DISALLOW_PATCHING=1`. libGLdispatch читает переменную при загрузке, а грузится он при первой отрисовке Skiko — поэтому `MpvVideo.prepareProcess()` первым делом в `main` ставит её через `setenv`. Ручная проверка — `playback/engine-mpv/src/test/.../EglProbe.kt`. - **Зависание при выходе** (примерно каждый третий запуск, в обоих вариантах вывода видео). По gdb: JVM в `exit()` вызывает деструктор libGLX (glvnd, через неё рисует Skiko), он зовёт `XESetCloseDisplay` и ждёт блокировку X-дисплея AWT, а её держит поток AWT, который JVM уже заморозила для выхода. Обход — после своей уборки `_exit(0)`: 10 запусков из 10 выходят чисто. SIGTERM идёт обычным путём и может зависнуть. - **Общий контекст со Skia (вариант A):** Skia оставляет своё состояние GL — включённое смешивание (`GL_BLEND`), свой объект сэмплера на текстурном блоке 0, свою программу; mpv считает состояние исходным. Итог — HDR10 и DV рисовались чёрным (несколько проходов через промежуточные буферы `rgba16f`), а H.264 SDR выглядел нормально — короткий прогон на SDR этого не замечает. Сброс состояния перед `mpv_render_context_render` чинит всё. - **На Xvfb у Skiko нет GL** («Cannot create Linux GL context») — рисует программно, снимки интерфейса верные. Видео на стенде рисует программный GL llvmpipe (§7.2). ### 5.3 AWT, X11 и Muffin - **Muffin не выводит из полного экрана окно AWT с рамкой AWT** — ни через Compose `WindowPlacement.Fullscreen`, ни через `_NET_WM_STATE` (даже `wmctrl -b remove,fullscreen`, даже у простого `java.awt.Frame`); у xclock и у окна AWT без рамки работает. Поэтому окно плеера — без рамки AWT, рамку ставит WM по `_MOTIF_WM_HINTS`. Главному окну полный экран не нужен — оно с рамкой AWT. - **AWT при каждом своём `reshape` переписывает `WM_NORMAL_HINTS` целиком** (`XBaseWindow.setSizeHints`: свои флаги, `NorthWestGravity`) — PAspect, PMinSize и `StaticGravity` пропадают. При изменении размера мышью это происходит на каждом шаге (~30–40 перезаписей за движение), и не из-за `WindowState` Compose: окно без `WindowState` теряет подсказки так же. Без защиты размер мышью шёл без пропорций (680×260 при видео 2,4:1). Обход: `ComponentListener` на перемещение и размер → `Toolkit.sync()` (запись AWT может ещё лежать в буфере его соединения) → если подсказки не наши, записать заново. Пока мини-окно не подогнано под видео — вместе с подгонкой, иначе Muffin подгоняет размер под пропорции по-своему (504×210 вместо 480×200). - **Геометрия при смене режима** (без этого место режима не восстанавливалось точно): 1. ставить через `XMoveResizeWindow`: кэш геометрии AWT после действий WM устаревает; 2. сначала рамка и подсказки пропорций, потом размер. Пока WM меняет рамку, он игнорирует запрос размера — повторять раз в 60 мс, пока не совпадёт. Положение Muffin понимает как угол видимой рамки (заголовок 32 px); `StaticGravity` не помогает (AWT заменяет её на `NorthWestGravity`) — поправлять на разницу; 3. при выходе из полного экрана Muffin сам возвращает размер «до полного экрана», позже нас — ждать, пока он снимет `FULLSCREEN` (~35 мс), и только потом ставить геометрию. - **Размер под пропорции округляется** (например, 874×491,6 → 491 или 492) — при сравнении места допуск ±2 px. - **Пересоздание окна при смене рамки** (отвергнутый путь) падало при переходе «окно → весь экран»: Compose пытался снять рамку у показанного окна (`IllegalComponentStateException: The frame is displayable`, следом SIGSEGV). После исправления (рамка меняется только вместе с пересозданием) работало, но обычное окно за каждый круг теряло 37 px высоты: запоминались внешние размеры AWT, а ставились как клиентские. - **`toFront()` + `requestFocus()` в Muffin забирают фокус по-настоящему**, даже у окна фонового процесса (окно mpv, запущенное фоном, фокус не получало: Muffin не даёт его окнам без действия пользователя). Тестовое окно с фокусом перехватывает ввод в других программах — автоматические прогоны идут с окнами без фокуса (`SLEEPYOWL_TEST_PASSIVE=1`, `focusable = false`). - **Перетаскивание через `_NET_WM_MOVERESIZE`:** отпустить захват мыши AWT (`XUngrabPointer` под `awtLock`), иначе WM не получит мышь; Compose после этого не залипает в «нажато» (синтетическое отпускание); передавать точку нажатия, а не текущую. - **Боковая кнопка мыши «назад»:** X11 шлёт кнопку 8, AWT передаёт её как свою кнопку 6, а Compose кнопки после 5 отбрасывает. Ловится `AWTEventListener` на окне. - **Русская раскладка:** AWT даёт буквам `keyCode = 0` (в Compose — `Key.Unknown`). Физическая клавиша берётся по коду X11 (`rawCode`): поле закрыто модулем `java.desktop`, у `AWTAccessor` геттера нет, но код есть в `KeyEvent.paramString()`. Проверено с `setxkbmap ru`: Ь→M, Ф→A, С→C, А→F. - Переходы между режимами могут мелькать ~0,1 с: WM на мгновение показывает промежуточный размер. Виртуальный экран этого не показывает. - Cinnamon может развернуть или «приклеить» окно, если тащить его к верхнему краю; запасной путь — двигать окно самим, без WM. ### 5.4 Compose Desktop - **Клавиши получает только элемент в фокусе.** Клавиши «назад» — в `onKeyEvent` окна, а не модификатором на содержимом: на шаге без фокуса (выбор профиля) Esc иначе терялся. Окно получает всё, что не забрал никто. - **Compose Desktop не двигает фокус стрелками** — только Tab. Стрелки, которые никто не взял, окно переводит в `moveFocus` само. - **Фокус от наведения прокручивает ряд:** `clickable` при получении фокуса просит показать себя, и наведение на полускрытую карточку дёргало ряд. Отсюда своя `BringIntoViewSpec` на всё окно: прокручивать, только если последним был ввод с клавиатуры. - Колесо над `LazyRow` уходит в вертикальную прокрутку страницы, Shift+колесо — в ряд: Compose делает это сам. - **`KeyEvent.nativeKeyEvent` в CMP 1.12 — не событие AWT**, а внутренняя обёртка; событие AWT — через `awtEventOrNull`. - **`awaitFirstDown` не ловит правую кнопку** — её берём из `onPointerEvent(Press)`. - **«Движение мыши» без движения:** Compose шлёт его, когда под неподвижным курсором меняется раскладка (например, OSD затухает) — иначе управление показывало бы само себя снова. Движением считается только смена координат. - **ViewModel'ям на ПК нужен `kotlinx-coroutines-swing`:** без него нет `Dispatchers.Main`, и `viewModelScope` падает при первом `launch`. ### 5.5 Звук: PipeWire и WirePlumber - **WirePlumber запоминает «без звука» по роли потока**, а не по приложению (`~/.local/state/wireplumber/restore-stream`, запись вида `Output/Audio:media.role:Movie:mute=true`). mpv с видео помечает звук ролью Movie — однажды заглушённый поток (тестовый прогон с `mute=yes` через настоящий вывод или щелчок в микшере) глушит потом все видео через mpv, не только SleepyOwl. Лечится: включить звук у такого потока (WirePlumber запишет `mute=false`). Поэтому тестовый стенд и сквозная проверка запускают mpv с `ao=null` — в звуковую систему рабочего стола они не попадают вовсе. ### 5.6 KMP, Koin, Gradle - **Если цели только android и jvm, `commonMain` компилируется как обычный JVM-код:** Kotlin пропускает компиляцию метаданных (`compileCommonMainKotlinMetadata SKIPPED`). В общем коде работают JDK (`File`, `ConcurrentHashMap`, `UUID`, `java.net`, `java.time`, `Locale`), OkHttp, SDK Jellyfin и ZXing как есть; expect/actual нужен только там, где Android API. Перевод ядра свёлся к ~15 файлам. С не-JVM целью (iOS) это кончится. - Android-часть KMP-модуля на AGP 9 — плагин `com.android.kotlin.multiplatform.library`; обычные Android-ресурсы включаются `androidResources { enable = true }` в `android {}` (класс `R` — `ru.stenfredd.sleepyowl.ui.shared.R`). - **`internal` не работает между модулями:** раскладки, `plural`, `PlayerLabels`, запись `ScreenFocus.lastKey` стали публичными; умный каст на свойство из другого модуля пришлось заменить локальной переменной. - **R8 сливает объект `Log` с чужим классом** (в первой release-сборке все теги были `WebSocketProtocol`): свой кадр стека теперь отличается по имени класса во время работы, как у Timber. - **Koin ловит пропущенную зависимость только при запуске** (Hilt — при сборке). Проверку возвращают тесты графа (`KoinGraphTest` на ТВ, `DesktopKoinGraphTest`, `DesktopGraphTest`). `verify()` читает конструктор каждого объявленного типа, даже если объект создаётся в лямбде, — `File`, `OkHttpClient.Builder` и (пока движков нет) `EngineProvider` для `getAll()` записываются в `extraTypes`. - `Key.Menu` Compose на Android — тот же `KEYCODE_MENU`, поэтому `clickableWithMenu` общий без `nativeKeyCode`. - **SDK Jellyfin на JVM не знает устройства:** на Android `createJellyfin` берёт `DeviceInfo` из системы, на ПК его надо задать (`JellyfinPlatform.configure`). Без него ввод адреса вручную падал с «Нет связи с сервером» (а поиск в LAN, идущий другим путём, работал). - Старые данные ТВ читаются новым кодом: аккаунты в формате `org.json` (с `\/` и `null`), запомненные дорожки, история поиска. - **`build.gradle.kts`:** имя `java` — это расширение Gradle, а не пакет: `java.nio.file.Files` не находится, нужен `import`. Время сборки для версии пакета берётся в действии задачи, а не при настройке: с кэшем конфигурации (`org.gradle.configuration-cache=true`) значение из настройки повторялось бы от сборки к сборке. ### 5.7 D-Bus (dbus-java) - Свойства отдаются своим `Properties` (Get/GetAll/Set): `Get` возвращает `Variant`, тип в D-Bus — `v`. - `Variant.equals` сравнивает массивы по ссылке — для «изменилось ли» сравнивать содержимое. - Адрес обложки: `File.toURI()` даёт `file:/путь` (одна косая черта), а апплеты Cinnamon ждут `file:///путь` — использовать `toPath().toUri()`. ### 5.8 Jellyfin - Сервер сбрасывает позицию, если она меньше 5 % длительности (настройка «минимум для продолжения»): 20 с просмотра с 5-й минуты фильма дают позицию 0. Это не ошибка отчётов. - Если приложение убито без отчёта об остановке, сервер через несколько минут сам закрывает его сессию и записывает последнюю позицию — след в данных просмотра появляется с задержкой. - `reportPlaybackProgress failed: JobCancellationException` при выходе из плеера — отменённый вместе с плеером промежуточный отчёт; отчёт об остановке идёт отдельно (`GlobalScope`) и доходит. Не ошибка. - Вход на другом устройстве без набора пароля: код Quick Connect с его экрана подтверждается запросом `POST /QuickConnect/Authorize?code=…` с токеном уже вошедшего клиента (удобно для ТВ в проверках). - В проверенной конфигурации сервер отдавал `static`-поток и внешние субтитры и без токена; клиент на это не полагается. ## 6. mpv на медиатеке: 0.37 против 0.41 Перед кодом mpv 0.37 (пакет Mint, распакован без установки: `apt download mpv`, `dpkg -x`) и 0.41 (Flatpak `io.mpv.Mpv`) прогнаны на файлах, с которыми были проблемы на ТВ (`tools/linux/l0/`, §7.6). Каждый файл: прямая ссылка `static=true`, окно 640×360, звук выключен через настоящий вывод, старт с 40 %, 15 с воспроизведения, точная перемотка +300 с, ещё 10 с; сняты пропуски (`frame-drop`, `decoder-frame-drop`, `vo-delayed-frame`), `hwdec-current`, CPU, GPU и NVDEC (`nvidia-smi`), расхождение A/V, паузы на подгрузку, время до первого кадра и перемотки. Рендерер — `vo=gpu` на OpenGL (то, что умеет render API); для HDR/DV для сравнения ещё `gpu-next` 0.41. **Итог: на 0.37 нет проблем, которых нет на 0.41** → зависимость от системной `libmpv2`. Во всех 58 прогонах: ноль пропущенных кадров, без пауз на подгрузку, A/V ≤ 0,6 мс, первый кадр за 0,3–1,1 с, перемотка 0,1–0,6 с (MOV — 1,9 с). | Формат | Декодирование | Замечания | |---|---|---| | MKV H.264, 17 SRT, цепочка SeekHead (ломала перемотку Exo на ТВ) | nvdec | перемотка 0,1 с, проблемы ТВ нет | | AVI XviD + AC3 (на ТВ рвался звук) | программно | CPU 3 %, A/V ≤ 0,4 мс; лишнего чтения, как у демуксера AVI в VLC, нет | | AVI MPEG-4 ASP 2×AC3 / AVI H.264 + MP3 | программно / nvdec | | | MPEG-PS 576i, нижнее поле первым | программно | без деинтерлейса гребёнка 25 к/с; `deinterlace=yes` → `bwdif`, 50 к/с, CPU 10 % | | MKV H.264 1080i50 | nvdec | 0.37: `yadif_cuda`, CPU 3 %; Flatpak 0.41: `bwdif` на CPU, 20 % | | DV P8.1 + HDR10+, 4K 24p | nvdec | базовый слой HDR10 → SDR, цвета нормальные; GPU 3 %, NVDEC 5 % | | DV P8.1 1080p | nvdec | «Multiple Dolby Vision RPUs» безвредно | | DV P8.4 (HLG) / HLG, 4K30 | nvdec | цвета нормальные | | HEVC 10 бит 4K HDR10, 55 Мбит/с, TrueHD 7.1, ASS | nvdec | CPU 5 %, ASS рисуется | | HDR10+ 4K | nvdec | цвета нормальные | | AV1 10 бит 4K HDR10, TrueHD | nvdec | перемотка 0,5 с | | AV1 1080p, Opus 5.1 | nvdec | | | PGS | — | субтитры видны | | 720p60 + ASS | nvdec | 60 к/с | | внешние SRT (`sub-files-append` с адресом сервера) | — | кириллица верная | | H.264 Hi10P | программно | NVDEC не умеет H.264 10 бит; CPU 19 % одного ядра | | H.264 4K25; TS H.264 1080p; DTS 5.1 | nvdec | | | MOV, mov_text | nvdec | перемотка 1,9 с — самая долгая | | DV профиль 5 | — | в медиатеке нет; вопрос открыт (§5.1) | Проблемы ТВ на mpv не повторяются (перемотка MKV с цепочкой SeekHead, звук AC3 в AVI, звук DVD, PGS) — свои экстракторы и прокси на ПК действительно не нужны. Сеть при малом кэше ~5 Мбит/с для AVI 2,1 Мбит/с — так же, как у MKV. **Обычный mpv плавающим окном в Cinnamon** (`--ontop --no-border --geometry=640x360-24-24`) — эталон поведения мини-окна: `_NET_WM_STATE_ABOVE` ставится и снимается на лету (`set ontop`); рамка включается и выключается у живого окна через `_MOTIF_WM_HINTS` (Muffin добавляет/убирает заголовок 32 px) — отсюда способ смены рамки в приложении; весь экран — на мониторе с окном; окно фонового процесса фокус не получает. ## 7. Тестирование ### 7.1 Тесты без окна ``` ./gradlew :desktop:test # DesktopGraphTest, ShellRenderTest, PlayerRenderTest, LicensesTest ./gradlew :ui:shared:desktopTest # DesktopKoinGraphTest, DesktopRenderTest ./gradlew :playback:engine-mpv:test # RenderSizeTest ./gradlew :app:testDebugUnitTest # граф Koin ТВ (KoinGraphTest) — если менялся общий код ``` - `DesktopGraphTest` — граф Koin ПК в том виде, в каком его запускает `main`; `DesktopKoinGraphTest` — граф общих модулей с платформой ПК. - Тесты отрисовки рисуют без окна (`ImageComposeScene`) реализациями ПК и пишут PNG для просмотра глазами: - `DesktopRenderTest` → `ui/shared/build/reports/ui-shared/components.png` — шрифты с кириллицей, логотип, иконки, свечение фокуса, BlurHash с затенением, QR, рамка; - `ShellRenderTest` → `desktop/build/reports/desktop/shell-*.png` — меню на 1280 и 900 px; - `PlayerRenderTest` → `desktop/build/reports/desktop/player-*.png` — три компоновки OSD, меню, карточка следующей серии. - `RenderSizeTest` — размер FBO под видео; `EglProbe.kt` — ручная проверка контекста EGL рядом с окном Compose (§5.2). - Если менялся общий код — ещё компиляция ТВ и `:app:testDebugUnitTest`. ### 7.2 Тестовый стенд: Xvfb + Muffin со своей шиной D-Bus `tools/linux/stand.sh` поднимает виртуальный экран `:87` (Xvfb 1920×1080) с настоящим оконным менеджером Muffin и **своей сессионной шиной D-Bus** для Muffin и приложения. Окна, мышь, клавиши и фокус проверяются там — рабочий стол разработчика, его фокус, апплет звука, хранитель экрана и звук не затрагиваются. ``` stand.sh up | down поднять / остановить Xvfb, D-Bus стенда и Muffin stand.sh app [args] (пере)запустить приложение на :87 в фоне; вывод — $STAND/app.out stand.sh stop остановить приложение stand.sh shot NAME снимок экрана → $STAND/shots/NAME.png stand.sh in CMD… ввод через XTest (xinput.py): click X Y , move X Y , key NAME , type ТЕКСТ , drag X1 Y1 X2 Y2 stand.sh log лог приложения stand.sh bus CMD… команда на шине стенда (gdbus, busctl --user, dbus-monitor) ``` - **Свои файлы приложения:** каталог стенда `$STAND` (по умолчанию в `~/.cache`) — свои настройки и вход тестовой учётной записи, кэш, лог, блокировка; `~/.config/sleepyowl` разработчика не используется. - **Звук:** mpv на стенде играет в `ao=null` (если `SLEEPYOWL_MPV_OPTIONS` не говорит иного) — §5.5. - **Своя шина нужна** потому, что с MPRIS приложение стенда иначе появилось бы в апплете звука рабочего стола и держало бы его хранитель экрана. `tools/linux/fake_screensaver.py` — подставной `org.freedesktop.ScreenSaver` на шине стенда, печатает Inhibit/UnInhibit. - Gradle собирает приложение перед `app`, если не задан `DESKTOP_NOBUILD=1`. `SLEEPYOWL_APP=<запускалка>` запускает её вместо сборки — например, пакет, распакованный `dpkg-deb -x sleepyowl_*.deb DIR` → `SLEEPYOWL_APP=DIR/opt/sleepyowl/bin/sleepyowl` (так же для `k2.py`). - Полезные переменные приложения: `SLEEPYOWL_TEST_PASSIVE=1` — окна без фокуса, главное свёрнуто; `SLEEPYOWL_PLAYER_BOUNDS=x,y,w,h` — место окна плеера (замеры на настоящем экране); `SLEEPYOWL_SCALE`; `--play --start <с>`. **Ограничения стенда:** - Skiko на Xvfb рисует без GL — снимки интерфейса верные. - Видео рисует программный GL llvmpipe, mpv при этом декодирует на NVIDIA (`nvdec-copy`). На 1080p успевает (~40 мс на кадр), на 4K HDR — нет (19 пропусков за 15 с). **Кадр на стенде бывает чёрным** при видимых субтитрах (H.264 1080i50 — постоянно, иногда другие файлы): llvmpipe не успевает за 40–50 мс на кадр. Тот же файл на настоящем GPU играет нормально. Поэтому картинку видео на стенде не проверяем — только состояние и журнал; автоскрытие OSD сравнивается по кадрам на паузе, а не по яркости. - Нет Cinnamon и `csd-media-keys`: медиаклавиши, апплет звука и настоящий хранитель экрана проверяются только на рабочем столе. Мелькание при смене режима и плавность перетаскивания на высокой частоте экрана — тоже. - После перезапуска приложение открывается на «Главной» — щелчки по координатам с прошлого запуска попадают в другие элементы (например, в карточки «Продолжить»). - Приложение при запуске переименовывает прошлый лог в `sleepyowl.1.log` — проверки читают свежий. ### 7.3 Сквозная проверка: `tools/linux/k2.py` Одна команда, ~5 мин, на стенде со своей шиной, без звука, с `nice`. Состояние плеера читается через MPRIS (статус, позиция, громкость, название), окна — через X11 (`_NET_WM_STATE`, место и размер, рамка), хранитель экрана — через `fake_screensaver.py`. Настройки плеера начинаются с нуля (первый запуск) и возвращаются после прогона, данные просмотра тестовой учётной записи на сервере возвращаются через `watchdata.py restore`. Печатает PASS/FAIL по каждой проверке, снимки — `$STAND/shots/k2-*.png`. Последний итог — 46/46, в том числе на распакованном .deb (урезанная JRE: mpv через JNA, D-Bus, режимы и клавиши работают так же, как из Gradle). Что проверяется: - первое открытие: мини 640×360 в правом нижнем углу с отступом 24 px, поверх, без рамки, с фокусом; MPRIS «Playing» с названием; запрет хранителя экрана; - клавиши: пробел (пауза снимает запрет), →×3 = одна перемотка +30 с, Shift+← −60 с, ↓/↑, M, M в русской раскладке (физическая Ь), A ×2 (дорожка 2 → 1 → 2); - автоскрытие: показ при наведении, скрытие через 2 с покоя в мини, снова при движении, сразу при уходе мыши; - мини: перетаскивание за видео (без паузы), размер за правый край с 16:9; - режимы: T → окно 1280×720 по центру с рамкой; F → весь экран; Esc → окно; T → мини на прежнем месте; двойной щелчок мини → окно → весь экран; фокус остаётся у плеера; - меню правой кнопки (снимок); - фокус в медиатеке: MPRIS Next → следующая серия в том же окне, фокус не забран; конец серии → следующая сама, фокус не забран; Previous, Seek, Pause, PlayPause; - × закрывает плеер: имя MPRIS ушло, запрет снят, медиатека на месте и активна; перезапуск → мини на последнем месте и размере; ошибок в логе нет. Для прогона нужны три идущие подряд серии одного сезона (`K2_EPISODES`, §7.5), лучше с размеченными заставкой и титрами (`MediaSegments`). ### 7.4 Данные просмотра: `tools/linux/watchdata.py` Тестовые прогоны двигают позиции и отметки на сервере. Скрипт снимает их до прогона и возвращает после: ``` watchdata.py snapshot [FILE] сохранить данные просмотра (позиция, просмотрено, счётчик, дата, избранное) watchdata.py diff [FILE] что изменил прогон watchdata.py restore [FILE] вернуть изменённое как в снимке ``` Файл по умолчанию — `$STAND/watchdata.json`. Токен берётся из сохранённых аккаунтов стенда (по имени `JF_USER`) или из `JF_TOKEN` + `JF_USER_ID`. `restore` пишет сохранённое `UserData` обратно (`POST /UserItems/{id}/UserData`); элемент, которого не было в снимке, сбрасывается (`DELETE /UserPlayedItems`, позиция и счётчик — 0). Порядок «Продолжить просмотр» следует `LastPlayedDate`, поэтому возвращается вместе с датами. `restore` — после того, как сервер закрыл все сессии прогона (§5.8), иначе след появится позже. ### 7.5 Локальная конфигурация Скрипты не содержат адресов и учётных записей: всё личное они читают из локального файла `local/dev.env` (каталог `local/` не входит в git; шаблон — `tools/dev.env.example`). Формат — строки `KEY=value` без кавычек и подстановок, его читают и shell-скрипты (`tools/devenv.sh`), и Python; переменная, уже заданная в окружении, важнее файла. Переменные: - `JAVA_HOME` — JDK 21 для Gradle и приложения (иначе из окружения или `java` из `PATH`); `ADB` — adb из Android SDK; - `JF_URL` — сервер Jellyfin для скриптов (по адресу в LAN); `JF_USER` — тестовая учётная запись на нём (прогоны меняют её данные просмотра, `watchdata.py` их возвращает); - `TV_ADB` — адрес тестового ТВ для adb; - `K2_EPISODES` — три идущие подряд серии одного сезона для `k2.py`; `L0_FLOAT_ITEM`, `L0_CASES` — для `tools/linux/l0/`; `PROFILE_SERIES` — сериал для генератора baseline profile ТВ. `JF_TOKEN` / `JF_USER_ID` в окружении — по желанию, вместо сохранённого аккаунта стенда. Для проверок лучше завести на сервере отдельную тестовую учётную запись — тогда прогоны не трогают «Продолжить просмотр» настоящих пользователей. ### 7.6 Прочие скрипты - `tools/linux/desktop.sh` — запуск приложения без Gradle вокруг процесса. - `tools/linux/l0/` — прогон mpv на файлах медиатеки (§6): `run_all.sh` по списку случаев (`L0_CASES`, строки «<имя> [опции run_case.py]»), `run_case.py` — один файл, `float_test.py` — поведение плавающего окна mpv (`L0_FLOAT_ITEM`). - Замеры прототипа снимались его встроенным оверлеем статистики (кадры mpv и Compose, потерянные, время отрисовки и копии, CPU процесса, пропуски mpv) и разбивкой CPU по потокам из `/proc//task`. - Опыт стенда: снимок всего экрана при полноэкранном окне идёт ~6 с — снимки делать в фоне и печатать опоздание шага; окно искать заново после пересоздания; убивать тестируемый процесс при любой ошибке сценария и ждать его выхода перед следующим тестом. ### 7.7 Как проверяем - После каждого изменения — быстрая проверка (10–15 мин): сборка и тесты ПК, при изменении общего кода — компиляция ТВ и `:app:testDebugUnitTest`, короткий запуск на стенде со снимком. - Перед выпуском — `k2.py` на стенде (в том числе на распакованном пакете), установка пакета, запуск из меню, значок, подъём окна повторным запуском, обновление новой сборкой. - ТВ — короткая проверка, только если менялись версии Compose, Kotlin, AGP, Koin, Coil, корутин или общее поведение плеера (`PlaybackSession`, отчёты, пропуск заставок, «следующая серия», `PlayerViewModel`). - Замеры CPU — на настоящем экране, окнами без фокуса (`SLEEPYOWL_TEST_PASSIVE=1`, `SLEEPYOWL_PLAYER_BOUNDS`), со звуком выключенным (`SLEEPYOWL_MPV_OPTIONS=mute=yes`) и с прогревом JIT. ## 8. Не сделано / отложено - **Flatpak** (манифест с mpv из исходников, как у Jellyfin Desktop на Flathub) и **AppImage** — по желанию. - **SIGTERM** не перехватывается: приложение, убитое сигналом, не отправляет отчёт об остановке (сервер закроет сессию сам через несколько минут) и может зависнуть на выходе (§5.2). Стоит перехватить и закрываться штатно. - **Превью кадров на таймлайне** (trickplay) — когда на сервере будут данные. - **HDR** — только если обойдётся без осложнений: отдельный режим «mpv в своём окне» при HDR-экране (KDE/GNOME на Wayland). - **Сквозной звук на ресивер и смена частоты экрана** — для сценария «ПК к телевизору по HDMI». - **Нативный Wayland** без XWayland — когда Compose Desktop его поддержит; «поверх всех» там придётся делать средствами конкретной оболочки. С выходом Cinnamon на Wayland проверить, держит ли Muffin такие окна поверх через XWayland. - **Токены в libsecret** вместо файла. - **Синхронизация выбора дорожек ТВ ↔ ПК** — нет: выбор запоминается на каждом устройстве свой. - **Открытые вопросы:** цвета DV профиля 5 через render API (нет файла для проверки, §5.1); рывки 24p на весь экран при частоте экрана 60/144/175 Гц; мелькание ~0,1 с при смене режима окна (на стенде не видно); поведение Cinnamon при перетаскивании окна к верхнему краю. - **Запасные варианты** на случай, если вывод видео в Compose перестанет устраивать: (а) mpv рисует в своё X11-окно, вставленное в наше (`--wid`), управление — отдельным прозрачным окном Compose, едущим вместе с ним (два окна при перетаскивании надо синхронизировать); (б) окно целиком принадлежит mpv (поверх всех, перетаскивание и размер mpv умеет сам), управление — скрипт Lua в mpv, приложение командует mpv через API (вид плеера будет отличаться от приложения).