Files
SleppyOwl/docs/desktop.md
StenFredd b077388b74 Add documentation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:23:58 +03:00

102 KiB
Raw Blame History

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. Коротко

  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<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 → память кадра, три буфера (показываемый, последний готовый, записываемый; кадр, заменённый до показа, считается потерянным). Память кадра — 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/<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 повторяет записанную картинку и после остановки движка, а у 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 <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), а не 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 любой новой сборки идёт как обновление. Номер версии приложения — одно место, 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.
  • 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 <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 (вид плеера будет отличаться от приложения).