102 KiB
SleepyOwl для Linux (ПК)
Версия SleepyOwl для компьютера с Linux: та же медиатека Jellyfin, что на Android TV, и плеер на libmpv, который умеет висеть маленьким окном поверх всех окон. Здесь записано, как она устроена, почему решения именно такие, какие ловушки встретились по дороге (с замерами) и как её тестировать.
Ядро у ТВ- и ПК-версии общее. Как устроено воспроизведение на ТВ (Exo + VLC, выбор движка, детали протокола Jellyfin), описано в playback.md; здесь — только то, что относится к ПК.
Код ПК:
desktop/— приложение для ПК: окна, экраны, плеер, интеграция с системой, сборка пакета;playback/engine-mpv/— движок libmpv (JVM, JNA);ui/shared/— общий с ТВ слой интерфейса (ViewModel'и, тема, компоненты);core/*,playback/api,playback/core— общее ядро (Kotlin Multiplatform);tools/linux/— запуск без Gradle, тестовый стенд, сквозная проверка, служебные скрипты.
0. Коротко
- Главный сценарий — смотреть «в фоне». Выбрал серию — видео идёт в мини-окне без рамки поверх всех окон: его можно таскать и растягивать (с пропорциями видео), в нём доступны все функции плеера, управление прячется, когда мышь уходит. Окно не забирает фокус у других программ, медиаклавиши работают через MPRIS.
- Один движок — libmpv (системная
libmpv20.37), через свою привязку на JNA. mpv играет всё, что встретилось на ТВ-медиатеке, без транскода, своих экстракторов и прокси. - Видео в Compose — копией кадра: mpv рисует в своём невидимом контексте EGL, кадр через PBO попадает в картинку Skia, OSD рисуется поверх в том же окне. Общий с Skiko контекст GL (без копии) не дал выигрыша в мини-окне и стоил рефлексии и чёрных HDR-кадров.
- HDR не цель. На Cinnamon/X11 HDR-вывода нет; HDR10, HDR10+, HLG и Dolby Vision (P8.x) mpv переводит в SDR (
tone-mapping=auto). - Окна — X11 (на Wayland — через XWayland): «поверх всех», положение окна и смена рамки на лету доступны только там.
- Общий код — KMP + Koin на обеих платформах; ТВ-версия переехала на них без потери скорости.
- Установка — .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. Боковое меню и экраны как на ТВ.
- Плеер — отдельное окно. У каждого окна своё место и размер; закрытие плеера не сбрасывает место в медиатеке.
- Три режима плеера, переключаются в любой момент, видео не останавливается и не перезапускается:
- Мини поверх всех — без рамки,
_NET_WM_STATE_ABOVE. Таскается за любое место видео, размер меняется за края (8 px) и углы (20 px), пропорции видео сохраняются, минимум 320×180. - Обычное окно — с рамкой, не поверх.
- Во весь экран — на мониторе, где сейчас окно.
- Мини поверх всех — без рамки,
- Переключение:
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 Сценарий «смотреть в фоне»
- В медиатеке выбрал серию → «Смотреть» (или «Продолжить» на главной).
- Окно плеера открывается в последнем режиме (по умолчанию мини) и один раз получает фокус.
- Щелчок в другую программу — мини-окно остаётся поверх и не забирает фокус обратно.
- Навёл мышь на мини-окно — появилось управление, увёл — скрылось.
- Следующая серия начинается в том же окне и тоже не забирает фокус (окно не пересоздаётся,
toFront()— только при открытии). - Медиаклавиши работают, когда фокус в другом окне (MPRIS); апплет звука Cinnamon показывает название и обложку.
- Закрыл мини-окно — прогресс на сервере, медиатека в прежнем состоянии. Если фокус был у плеера (×,
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): на AndroidelapsedRealtime()(идёт и во сне ТВ — на нём держатся сроки кэша), на ПК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<EngineProvider>()(провайдер объявляется с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 <url>: список 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 → память кадра, три буфера (показываемый, последний готовый, записываемый; кадр, заменённый до показа, считается потерянным). Память кадра —DataSkia,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/<pid>/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 повторяет записанную картинку и после остановки движка, а у
DataSkia есть счётчик ссылок.
Итоговые замеры 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/images500 МБ, память — четверть кучи. - Экраны — по файлу на экран в
desktop/, той же раскладкой и видом, что на ТВ, на общих ViewModel'ях. --play <id> [--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<pid>; - метаданные: у серии название «Сезон 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), а неpackageDebjpackage: его пакет не умеет зависеть от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любой новой сборки идёт как обновление. Номер версии приложения — одно место,appVersionвdesktop/build.gradle.kts;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.
- HDR → SDR у
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двигает; надписи с\poslibass оставляет на месте.- Локаль: до
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 перезаписей за движение), и не из-заWindowStateCompose: окно безWindowStateтеряет подсказки так же. Без защиты размер мышью шёл без пропорций (680×260 при видео 2,4:1). Обход:ComponentListenerна перемещение и размер →Toolkit.sync()(запись AWT может ещё лежать в буфере его соединения) → если подсказки не наши, записать заново. Пока мини-окно не подогнано под видео — вместе с подгонкой, иначе Muffin подгоняет размер под пропорции по-своему (504×210 вместо 480×200). - Геометрия при смене режима (без этого место режима не восстанавливалось точно):
- ставить через
XMoveResizeWindow: кэш геометрии AWT после действий WM устаревает; - сначала рамка и подсказки пропорций, потом размер. Пока WM меняет рамку, он игнорирует запрос размера — повторять раз в 60 мс, пока не совпадёт. Положение Muffin понимает как угол видимой рамки (заголовок 32 px);
StaticGravityне помогает (AWT заменяет её наNorthWestGravity) — поправлять на разницу; - при выходе из полного экрана 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.MenuCompose на 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 <id> --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/<pid>/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 (вид плеера будет отличаться от приложения).