Files
SleppyOwl/docs/desktop.md
StenFredd 3ef78bd49c
All checks were successful
release / release (push) Successful in 12m40s
Add CI
2026-10-27 17:38:55 +03:00

610 lines
102 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SleepyOwl для Linux (ПК)
Версия SleepyOwl для компьютера с Linux: та же медиатека Jellyfin, что на Android TV, и плеер на libmpv, который умеет висеть маленьким окном поверх всех окон. Здесь записано, как она устроена, почему решения именно такие, какие ловушки встретились по дороге (с замерами) и как её тестировать.
Ядро у ТВ- и ПК-версии общее. Как устроено воспроизведение на ТВ (Exo + VLC, выбор движка, детали протокола Jellyfin), описано в [`playback.md`](playback.md); здесь — только то, что относится к ПК.
Код ПК:
- `desktop/` — приложение для ПК: окна, экраны, плеер, интеграция с системой, сборка пакета;
- `playback/engine-mpv/` — движок libmpv (JVM, JNA);
- `ui/shared/` — общий с ТВ слой интерфейса (ViewModel'и, тема, компоненты);
- `core/*`, `playback/api`, `playback/core` — общее ядро (Kotlin Multiplatform);
- `tools/linux/` — запуск без Gradle, тестовый стенд, сквозная проверка, служебные скрипты.
---
## 0. Коротко
1. **Главный сценарий — смотреть «в фоне».** Выбрал серию — видео идёт в мини-окне без рамки поверх всех окон: его можно таскать и растягивать (с пропорциями видео), в нём доступны все функции плеера, управление прячется, когда мышь уходит. Окно не забирает фокус у других программ, медиаклавиши работают через MPRIS.
2. **Один движок — libmpv** (системная `libmpv2` 0.37), через свою привязку на JNA. mpv играет всё, что встретилось на ТВ-медиатеке, без транскода, своих экстракторов и прокси.
3. **Видео в Compose — копией кадра:** mpv рисует в своём невидимом контексте EGL, кадр через PBO попадает в картинку Skia, OSD рисуется поверх в том же окне. Общий с Skiko контекст GL (без копии) не дал выигрыша в мини-окне и стоил рефлексии и чёрных HDR-кадров.
4. **HDR не цель.** На Cinnamon/X11 HDR-вывода нет; HDR10, HDR10+, HLG и Dolby Vision (P8.x) mpv переводит в SDR (`tone-mapping=auto`).
5. **Окна — X11** (на Wayland — через XWayland): «поверх всех», положение окна и смена рамки на лету доступны только там.
6. **Общий код — KMP + Koin** на обеих платформах; ТВ-версия переехала на них без потери скорости.
7. **Установка — .deb** со своей урезанной JRE 21 внутри, собирается `./gradlew :desktop:packageDeb`.
## 1. Исходные условия
- **Тестовый ПК:** Linux Mint 22.3, Cinnamon на X11 (оконный менеджер Muffin), NVIDIA RTX 4090 (драйвер 580), процессор класса i7-13700KF (24 потока, без встроенной графики), мониторы 3440×1440 175 Гц (основной) и 1080×1920 144 Гц (портретный). В репозиториях Mint — libmpv 0.37.
- **Другие ПК — «по возможности»:** на Intel/AMD аппаратное декодирование идёт через VAAPI, mpv выбирает его сам (`hwdec=auto-safe`).
- **Версии:** JDK 21, Kotlin/JVM + Compose Multiplatform 1.12.2 (Skiko 0.150), JNA 5.18.1, Koin 4.2.2, dbus-java 5.2.2, AGP 9.4.1 (Android-часть KMP-модулей).
## 2. Главные решения
| Что | Решение | Почему |
|---|---|---|
| Язык и интерфейс | Kotlin + Compose Multiplatform (Desktop) | тот же Compose, что на ТВ: переиспользуются ядро, ViewModel'и, тема, компоненты |
| Общий код | модули Kotlin Multiplatform с целями `android` и `jvm("desktop")` | одно ядро на две платформы; при двух JVM-целях `commonMain` компилируется как обычный JVM-код (§5.6) |
| DI | Koin на обеих платформах вместо Hilt | Hilt работает только на Android и не работает в KMP-модулях |
| Общий интерфейс | модуль `ui:shared` (KMP + Compose Multiplatform) | ViewModel'и, навигационные ключи, модели карточек, подписи, тема, шрифты, иконки и визуальные компоненты — общие; раскладки и ввод (пульт против мыши) — свои у каждой платформы |
| Воспроизведение | один движок — libmpv | mpv (ffmpeg + NVDEC/VAAPI) играет всё; связка Exo+VLC, исправленные экстракторы и локальный прокси ТВ на ПК не нужны |
| Привязка к libmpv | своя, через JNA (~300–400 строк), без сторонних обёрток | нужно немного: client API и render API |
| Видео в окне | mpv рисует в текстуру, Compose показывает её картинкой, OSD — поверх **в том же окне** | только так окно плеера — одно целое: таскается, растягивается и висит поверх вместе с управлением. HDR при этом невозможен — он и не нужен |
| HDR / DV | в SDR, тонмаппинг mpv | на Cinnamon/X11 HDR-вывода нет вообще; render API mpv умеет только старый рендерер `gpu` |
| Окна | X11, на Wayland — через XWayland | «поверх всех» и положение окна на Wayland приложению недоступны |
| Плеер | отдельное окно, три режима: мини поверх всех, обычное окно, весь экран | медиатекой можно пользоваться, пока идёт видео; режимы меняются без остановки видео |
| Установка | .deb (свой JRE внутри), Flatpak — потом | на одном ПК .deb проще всего; libmpv — системная |
**Сколько кода переиспользовано** (оценка на старте): ядро (`core:*`, `playback:api`, `playback:core`, ~3,2 тыс. строк) и ViewModel'и с моделями карточек, подписями и темой (~3,2 тыс.) — целиком; экраны ТВ (~8,5 тыс.) — визуальные компоненты частично, раскладки и ввод под мышь написаны заново; логика плеера ТВ (`PlayerViewModel`, `PlaybackSession`, пропуск заставок, «следующая серия») — общая, OSD для мыши и маленького окна — новый; движки Exo/VLC (~2,4 тыс.) на ПК не нужны. Новое: движок mpv (~1 тыс. строк), окна и плеер ПК (~2–3 тыс.), экраны ПК.
## 3. Как это выглядит и ведёт себя
### 3.1 Окна и режимы плеера
- **Окно «Медиатека»** — обычное окно с рамкой, 1280×800 при запуске, минимум 800×500. Боковое меню и экраны как на ТВ.
- **Плеер — отдельное окно.** У каждого окна своё место и размер; закрытие плеера не сбрасывает место в медиатеке.
- **Три режима плеера**, переключаются в любой момент, видео не останавливается и не перезапускается:
1. **Мини поверх всех** — без рамки, `_NET_WM_STATE_ABOVE`. Таскается за любое место видео, размер меняется за края (8 px) и углы (20 px), пропорции видео сохраняются, минимум 320×180.
2. **Обычное окно** — с рамкой, не поверх.
3. **Во весь экран** — на мониторе, где сейчас окно.
- **Переключение:** `T` — мини ↔ обычное окно (из полного экрана — всегда в мини); `F` — весь экран, `Esc` — выход из него; двойной щелчок по видео — мини → окно → весь экран → назад; кнопки режима в OSD; меню правой кнопки. Из полного экрана `F`, `Esc` и двойной щелчок возвращают в тот оконный режим, из которого пришли. Выход из полного экрана сочетанием клавиш самого WM читается обратно в режим.
- **Первый запуск плеера** — мини 640×360 в правом нижнем углу основного монитора с отступом 24 px. Мини-окно при открытии и при смене видео подгоняется под пропорции видео с сохранением ширины и правого нижнего угла (640×266 для 2,39:1). Обычное окно по умолчанию — 1280×720 по центру.
- **Что запоминается** (`desktop_player.preferences_pb`): последний режим (включая весь экран: закрыл во весь экран — следующий плеер откроется так же), место и размер мини и обычного окна (клиентская область в пикселях экрана), «на всех рабочих столах». Место записывается через 0,8 с после перемещения или изменения размера, при смене режима и при закрытии; перед выходом приложение ждёт последнюю запись. Место, которое не видно ни на одном мониторе (центр вне экранов или видно меньше половины), заменяется местом по умолчанию.
- **Настройка «Открывать плеер»** (как в прошлый раз / мини / окно / весь экран) действует только на новое окно плеера; уже открытое (следующая серия, «Смотреть» другое) остаётся в своём режиме.
- **«На всех рабочих столах»** (`_NET_WM_STATE_STICKY`) для мини-окна — в настройках («Компьютер») и пунктом с галочкой в меню правой кнопки мини-окна; по умолчанию выключено.
- **Кнопки:** × закрывает плеер; «К медиатеке» поднимает окно медиатеки (из полного экрана сначала выходит). В OSD значок «картинка в картинке» переводит в мини, значок «окно» — из мини в обычное. «К сериалу» / «Страница фильма» открывает страницу в окне медиатеки и поднимает его.
- **Конец фильма** (или последней серии) закрывает окно плеера, конец серии — следующая серия в том же окне.
### 3.2 Сценарий «смотреть в фоне»
1. В медиатеке выбрал серию → «Смотреть» (или «Продолжить» на главной).
2. Окно плеера открывается в последнем режиме (по умолчанию мини) и **один раз** получает фокус.
3. Щелчок в другую программу — мини-окно остаётся поверх и не забирает фокус обратно.
4. Навёл мышь на мини-окно — появилось управление, увёл — скрылось.
5. Следующая серия начинается в том же окне и тоже не забирает фокус (окно не пересоздаётся, `toFront()` — только при открытии).
6. Медиаклавиши работают, когда фокус в другом окне (MPRIS); апплет звука Cinnamon показывает название и обложку.
7. Закрыл мини-окно — прогресс на сервере, медиатека в прежнем состоянии. Если фокус был у плеера (×, `Ctrl+W`, меню, конец фильма), активным становится окно медиатеки; если плеер шёл в фоне, фокус у другой программы не отнимается.
### 3.3 Управление в плеере
**Показываем управление:** курсор вошёл в окно, мышь сдвинулась, колесо, нажатие, любая клавиша, пауза/продолжение с медиаклавиши.
**Скрываем:** курсор ушёл из окна — сразу (затухание 150 мс); курсор неподвижен 2 с в мини и 3 с в остальных режимах — скрываем и прячем курсор. На паузе те же правила: остаётся стоп-кадр. Тонкой полоски прогресса при скрытом управлении нет.
**Не скрываем:** пока курсор над кнопками, таймлайном, панелью или подсказкой; пока тянут таймлайн, ползунок или окно; пока открыта панель или меню. Панель при уходе мыши из окна закрывается через 1,5 с (вернулся раньше — остаётся).
Когда управление видно, субтитры поднимаются над OSD (`sub-pos`, §4.3).
**Компоновки по ширине окна** (в точках, то есть с учётом «Масштаба интерфейса»: при 125 % переключаются раньше):
- **≥ 900** — OSD как на ТВ: сверху тип, название, сезон/серия, часы, «закончится в», ×; снизу таймлайн с буфером, ⏮ ⟲ ⏯ ⟳ ⏭, чипы «Серии», Аудио, Субтитры, Качество, Direct/Транскод, громкость, весь экран.
- **480–900** — строка названия с × сверху и одна строка снизу: у серии ⏮ ⏯ ⏭, у фильма ⏯ ⟲ ⟳; время, таймлайн, громкость, ⋯, режим окна.
- **< 480** — ⏯ по центру с ⏮/⏭ (у фильма ⟲/⟳) по бокам, тонкий таймлайн снизу; ⋯ и режим — слева сверху, «К медиатеке» и × — справа.
- **Панели** (Аудио, Субтитры с превью, размером, цветом и фоном; Качество; Информация о потоке с «Дополнительно» только для чтения): в окне ≥ 900 и выше 420 — справа, как на ТВ, иначе листом на всё окно с прокруткой. Шторка «Серии / О фильме» в полной компоновке — снизу рядом серий с ‹ ›, в маленьком окне — листом со списком.
**Мышь:**
- щелчок по видео — пауза/продолжить (при открытой панели — закрыть её). В мини нажатие со сдвигом больше 4 px — уже перетаскивание окна;
- двойной щелчок — смена режима. Первый щелчок ставит паузу сразу, второй её снимает и меняет режим (короткая «запинка» звука) — вместо задержки ~0,4 с на каждый щелчок в ожидании двойного. Интервал — системный, не больше 0,5 с;
- колесо над видео — громкость ±5 %, над таймлайном — перемотка ±5 с;
- правая кнопка — меню: режим окна, Аудио, Субтитры, Качество, Информация, Серии / О фильме, К сериалу, «Отметить просмотренным», «На всех рабочих столах» (в мини), К медиатеке, Закрыть. В низком окне меню прокручивается, колесо за концом меню не крутит громкость;
- таймлайн: время под курсором, щелчок — переход, перетаскивание — перемотка. Превью кадров — когда на сервере будет trickplay.
**Громкость:** ползунок до 100 %, колесом и ↑ — до 130 %. Громкость и «без звука» запоминаются, новый движок сразу стартует с ними.
**Клавиатура** (по физическим клавишам, работает и в русской раскладке):
| Клавиша | Действие |
|---|---|
| Пробел, K | пауза |
| ← / → | ±10 с (нажатия копятся: в движок уходит одна перемотка через 0,4 с) |
| Shift+← / → | ±60 с |
| 1–9 | переход на 10–90 % |
| ↑ / ↓ | громкость |
| M | без звука |
| S | пропустить заставку/титры или сразу следующая серия, если показана карточка |
| N / Shift+N | следующая / предыдущая серия |
| A / C | следующая аудиодорожка / субтитры (с подсказкой «Аудио: …») |
| F | весь экран |
| T | мини поверх всех ↔ обычное окно |
| Esc | меню → панель → выход из полного экрана; в обычном окне без панели ничего не делает |
| Ctrl+W | закрыть плеер |
**Пропуск заставок, «Следующая через N с», автопереход** — логика ТВ (`SkipPlan`, `SegmentSet`) один в один, с настройкой «Кнопка / Сразу / Не пропускать». Когда управление скрыто, кнопка пропуска — плашка в правом нижнем углу на время сегмента; через 10 с прячется и возвращается вместе с OSD. При «Сразу» — без плашки. Карточка следующей серии в окне уже 480 — одной строкой.
### 3.4 Медиатека на ПК
- **Экраны те же, что на ТВ:** Вход, Главная, Медиатека, Фильм, Сериал, Поиск, Избранное, Человек, Коллекция/папка, Настройки. Фильм, сериал, человек и коллекция — во весь экран без меню, со стрелкой «‹» вверху слева. Не перенесены лента для оболочки ТВ (`LauncherFeed`), выбор движка, «Вывод видео», Dolby Vision и отладка.
- **Размеры:** dp ТВ-версии = px при масштабе 100 % (умножается на масштаб системы: Java на X11 берёт его из `GDK_SCALE` / Xft.dpi). Текст ~14–16 px. Поверх — «Масштаб интерфейса» 80–150 % (через `LocalDensity`, сразу в обоих окнах, включая OSD).
- **Под ширину окна** (от ~800 до 3440 px): сетки — столько колонок, сколько влезает; «Продолжить просмотр», кнопки, чипы и строка метаданных переносятся (`FlowRow`); страница фильма и сериала прокручивается целиком, если окно низкое. Боковое меню (`Shell`) на окне ≥ 1100 px открыто всегда, с подписями; уже — только иконки, раскрывается поверх содержимого при наведении. Библиотеки сервера — в меню, профиль — внизу.
- **Мышь:** наведение = фокус (`hoverFocus`): элемент под мышью получает тот же вид, что фокус на ТВ (кольцо, свечение); уход мыши фокус снимает; поле ввода, в котором печатают, фокус не отдаёт. Плитку библиотеки и сезон сериала выбирает щелчок, а не фокус — иначе мышь, проходя над плитками, переключала бы библиотеки. Правая кнопка на карточке, серии или сезоне — меню «просмотрено / не просмотрено» у курсора (закрывается щелчком мимо или Esc).
- **Ряды** (`ScrollRow`): колесо листает страницу, Shift+колесо и горизонтальный жест тачпада — ряд; при наведении по краям ряда — круглые ‹ ›.
- **Клавиатура:** стрелки двигают фокус, как пульт; первая стрелка, пока ничего не в фокусе, ставит его на запомненный (или первый) элемент экрана. При возврате «назад» фокус — на карточке, с которой ушли. `Ctrl+F` или `/` — поиск. «Назад» — Esc, `Alt+←`, Backspace (не в поле ввода), боковая кнопка мыши.
- **Поиск** — обычное поле ввода (фокус в нём при открытии), под ним недавние запросы и новинки или результаты по библиотекам и люди.
- **Вход:** сервер (найденные в LAN, сохранённые, «Ввести адрес»), профиль, имя и пароль с клавиатуры или Quick Connect с QR. «Сменить профиль» открывает экран входа на шаге профиля.
- **Настройки:** разделы ТВ плюс «Компьютер» (масштаб, «Открывать плеер», «Мини-окно на всех рабочих столах») и «Клавиши» (плеер, мышь в плеере, медиатека). «О приложении» — версия, устройство и список компонентов с лицензиями (§4.9).
## 4. Архитектура
### 4.1 Модули
```
core:common KMP (android + desktop): Log, monotonicMs(), каталоги платформы и DataStore
core:jellyfin KMP: сессия Jellyfin, ServerRouting
core:data KMP: репозитории
playback:api KMP: интерфейсы движка
playback:core KMP: PlaybackSession, EngineSelector, отчёты, сторожа
ui:shared KMP + Compose Multiplatform: ViewModel'и, Screen, CardMapper, подписи, тема, шрифты, иконки, компоненты
app ТВ (Android): экраны с фокусом под пульт, LauncherFeed
playback:engine-exo только Android
playback:engine-vlc только Android
playback:engine-mpv только desktop (JVM): libmpv через JNA, свой контекст GL, кадр для Compose
desktop приложение ПК: окна, экраны, плеер, MPRIS, хранитель экрана, настройки, пакет .deb
```
Архитектура движков общая: `EngineProvider` → `EngineSelector` → `PlaybackSession`. На ПК в наборе один провайдер — `mpv` (`mpvEngineModule`); отчёты о прогрессе, сторож зависаний, запоминание дорожек, ограничение качества и транскод работают из общего кода без изменений.
Граф Koin ПК: `jellyfinModule`, `dataModule`, `playbackModule`, `mpvEngineModule`, `uiModule`, `desktopModule()`.
### 4.2 Что пришлось развести в общем коде
- `android.os.SystemClock` → `monotonicMs()` (expect/actual): на Android `elapsedRealtime()` (идёт и во сне ТВ — на нём держатся сроки кэша), на ПК `System.nanoTime()`.
- `Context` + `preferencesDataStore` → `AppDirs` + `PreferenceStores` (по DataStore на файл). Android — прежние `files/datastore` и `files` (данные на ТВ сохранились), ПК — XDG-каталоги.
- `Build.MANUFACTURER/MODEL` → `JellyfinPlatform`: на Android — `AndroidJellyfinPlatform`, на ПК — `DesktopJellyfinPlatform` с именем «SleepyOwl · <hostname>» и 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` любой новой сборки идёт как обновление. Номер версии — `sleepyowl.versionName` в `gradle.properties`, общий с ТВ (CI поднимает его на релизе); `BuildInfo.VERSION` генерируется из него.
- **Модули JRE:** `suggestRuntimeModules` дал `java.instrument`, `jdk.security.auth` (dbus-java), `jdk.unsupported`. Добавлены два, которых jdeps не видит: `jdk.crypto.ec` (TLS к публичному адресу сервера по https) и `jdk.localedata` — без него `Locale.getDisplayLanguage(ru)` даёт названия языков дорожек по-английски.
- **Права файлов в пакете** выставляются явно (755/644): при `umask 002` иначе получается групповая запись.
**Лицензии** («О приложении», `LicenseScreen.kt`) — свой список ПК: mpv и FFmpeg (системная libmpv, GPL-2.0+), OpenJDK в пакете (GPL-2.0 + Classpath Exception), Compose Multiplatform и Skiko, Skia (BSD-3), JNA, dbus-java. Тексты — общие с ТВ из `app/src/main/assets/licenses` и свои три в `desktop/src/main/resources/licenses` (GPL-2 из `/usr/share/common-licenses`, лицензия OpenJDK из `legal/java.base` образа, LICENSE Skia). `LicensesTest` проверяет, что у каждой строки есть текст. Экран текста листается колесом, ↑/↓, PgUp/PgDn, пробелом; Esc — назад.
## 5. Находки и ловушки
### 5.1 mpv и libmpv
- **Render API рисует только старым рендерером `gpu`, не `gpu-next`** — и в 0.37, и в 0.41: в `vo_libmpv.c` есть лишь бэкенды `gpu` и `sw`.
- HDR → SDR у `gpu` нормальный, чуть мягче по контрасту, чем у `gpu-next`.
- DV показывается по базовому слою (в 0.41 это явно делает `mp_image_params_restore_dovi_mapping`) — для P8.x это правильно.
- У DV профиля 5 базового слоя нет → по коду ждём неправильных (зелёно-фиолетовых) цветов. Не проверено: в тестовой медиатеке P5 нет, а образец из набора FFmpeg (`fate-suite/mov/dovi-p5.mp4`) — чёрный кадр. Выход, если подтвердится: `evaluate` → Degraded (транскод сервера с тонмаппингом) или `vf=libplacebo`.
- Зато дешевле: в окне 640×360 GPU 3 % против 13–19 % у `gpu-next`.
- **`http-header-fields` режет значение по запятым:** заголовок `MediaBrowser Client="…", Device="…", Token="…"` доходит до сервера тремя обрывками (проверено на локальном HTTP-сервере). Нужно `http-header-fields-append` (одно значение без разбора) или только `Authorization: MediaBrowser Token="…"` — в нём запятых нет.
- **Список `sub-files` делится по `:`** — адреса внешних субтитров добавлять по одному через `change-list sub-files append`.
- **`deinterlace` в 0.37 — только `yes`/`no`**, `auto` появился позже: включать по `IsInterlaced` из Jellyfin. С nvdec 0.37 деинтерлейсит на GPU (`yadif_cuda`, CPU 3 % на 1080i50). В Flatpak-сборке 0.41 нет CUDA-фильтров ffmpeg → `bwdif` на CPU, 20 %. DVD 576i: без деинтерлейса — 25 к/с с гребёнкой, с `deinterlace=yes` — `bwdif` на CPU, 50 к/с, CPU 10 %.
- **`hwdec=auto-safe` на RTX 4090** выбирает nvdec для H.264, HEVC и AV1. MPEG-2, MPEG-4 ASP (нет в `hwdec-codecs` по умолчанию) и H.264 Hi10P (NVDEC не умеет H.264 10 бит) идут программно, CPU ≤ 19 % одного ядра. 0.41 сначала пробует cuda/vulkan/vaapi — ошибки в логе безвредны. VAAPI на ПК без встроенной графики нет: драйвер VAAPI для NVIDIA не загружается.
- **`sub-margin-y` не двигает субтитры ASS** — `sub-pos` двигает; надписи с `\pos` libass оставляет на месте.
- **Локаль:** до `mpv_create` — `setlocale(LC_NUMERIC, "C")`, иначе в русской локали дроби разбираются неверно.
- Безвредный шум в логе: «Multiple Dolby Vision RPUs» (DV P8.1), «mmco: unref short failure» (H.264 1080i50 после перемотки — от самого файла, картинка восстанавливается).
- Номера экранов mpv (`--screen`) не совпадают с `xrandr`; приложение берёт мониторы из AWT, его это не касается.
- **Синхронизация 24p с экраном** (`display-resample`) в render API работает только при точной обратной связи о кадрах; частота кадров Compose привязана к экрану (60/144/175 Гц) — на весь экран возможны лёгкие рывки 24p. Автоматически не измерить (§8).
### 5.2 OpenGL, glvnd, Skiko
- **glvnd и чужой контекст EGL.** На NVIDIA контекст EGL mpv не становился текущим, если до него уже рисовало окно Compose: `eglMakeCurrent` возвращал ошибку с кодом `EGL_SUCCESS` — и с pbuffer, и без поверхности. На Mesa всё работало, а прототип создавал контекст до окна — поэтому всплыло поздно. Причина — libglvnd: он переписывает точки входа GL под первого поставщика (GLX Skiko) и потом отказывает контексту другого API. Лечится `__GLVND_DISALLOW_PATCHING=1`. libGLdispatch читает переменную при загрузке, а грузится он при первой отрисовке Skiko — поэтому `MpvVideo.prepareProcess()` первым делом в `main` ставит её через `setenv`. Ручная проверка — `playback/engine-mpv/src/test/.../EglProbe.kt`.
- **Зависание при выходе** (примерно каждый третий запуск, в обоих вариантах вывода видео). По gdb: JVM в `exit()` вызывает деструктор libGLX (glvnd, через неё рисует Skiko), он зовёт `XESetCloseDisplay` и ждёт блокировку X-дисплея AWT, а её держит поток AWT, который JVM уже заморозила для выхода. Обход — после своей уборки `_exit(0)`: 10 запусков из 10 выходят чисто. SIGTERM идёт обычным путём и может зависнуть.
- **Общий контекст со Skia (вариант A):** Skia оставляет своё состояние GL — включённое смешивание (`GL_BLEND`), свой объект сэмплера на текстурном блоке 0, свою программу; mpv считает состояние исходным. Итог — HDR10 и DV рисовались чёрным (несколько проходов через промежуточные буферы `rgba16f`), а H.264 SDR выглядел нормально — короткий прогон на SDR этого не замечает. Сброс состояния перед `mpv_render_context_render` чинит всё.
- **На Xvfb у Skiko нет GL** («Cannot create Linux GL context») — рисует программно, снимки интерфейса верные. Видео на стенде рисует программный GL llvmpipe (§7.2).
### 5.3 AWT, X11 и Muffin
- **Muffin не выводит из полного экрана окно AWT с рамкой AWT** — ни через Compose `WindowPlacement.Fullscreen`, ни через `_NET_WM_STATE` (даже `wmctrl -b remove,fullscreen`, даже у простого `java.awt.Frame`); у xclock и у окна AWT без рамки работает. Поэтому окно плеера — без рамки AWT, рамку ставит WM по `_MOTIF_WM_HINTS`. Главному окну полный экран не нужен — оно с рамкой AWT.
- **AWT при каждом своём `reshape` переписывает `WM_NORMAL_HINTS` целиком** (`XBaseWindow.setSizeHints`: свои флаги, `NorthWestGravity`) — PAspect, PMinSize и `StaticGravity` пропадают. При изменении размера мышью это происходит на каждом шаге (~30–40 перезаписей за движение), и не из-за `WindowState` Compose: окно без `WindowState` теряет подсказки так же. Без защиты размер мышью шёл без пропорций (680×260 при видео 2,4:1). Обход: `ComponentListener` на перемещение и размер → `Toolkit.sync()` (запись AWT может ещё лежать в буфере его соединения) → если подсказки не наши, записать заново. Пока мини-окно не подогнано под видео — вместе с подгонкой, иначе Muffin подгоняет размер под пропорции по-своему (504×210 вместо 480×200).
- **Геометрия при смене режима** (без этого место режима не восстанавливалось точно):
1. ставить через `XMoveResizeWindow`: кэш геометрии AWT после действий WM устаревает;
2. сначала рамка и подсказки пропорций, потом размер. Пока WM меняет рамку, он игнорирует запрос размера — повторять раз в 60 мс, пока не совпадёт. Положение Muffin понимает как угол видимой рамки (заголовок 32 px); `StaticGravity` не помогает (AWT заменяет её на `NorthWestGravity`) — поправлять на разницу;
3. при выходе из полного экрана Muffin сам возвращает размер «до полного экрана», позже нас — ждать, пока он снимет `FULLSCREEN` (~35 мс), и только потом ставить геометрию.
- **Размер под пропорции округляется** (например, 874×491,6 → 491 или 492) — при сравнении места допуск ±2 px.
- **Пересоздание окна при смене рамки** (отвергнутый путь) падало при переходе «окно → весь экран»: Compose пытался снять рамку у показанного окна (`IllegalComponentStateException: The frame is displayable`, следом SIGSEGV). После исправления (рамка меняется только вместе с пересозданием) работало, но обычное окно за каждый круг теряло 37 px высоты: запоминались внешние размеры AWT, а ставились как клиентские.
- **`toFront()` + `requestFocus()` в Muffin забирают фокус по-настоящему**, даже у окна фонового процесса (окно mpv, запущенное фоном, фокус не получало: Muffin не даёт его окнам без действия пользователя). Тестовое окно с фокусом перехватывает ввод в других программах — автоматические прогоны идут с окнами без фокуса (`SLEEPYOWL_TEST_PASSIVE=1`, `focusable = false`).
- **Перетаскивание через `_NET_WM_MOVERESIZE`:** отпустить захват мыши AWT (`XUngrabPointer` под `awtLock`), иначе WM не получит мышь; Compose после этого не залипает в «нажато» (синтетическое отпускание); передавать точку нажатия, а не текущую.
- **Боковая кнопка мыши «назад»:** X11 шлёт кнопку 8, AWT передаёт её как свою кнопку 6, а Compose кнопки после 5 отбрасывает. Ловится `AWTEventListener` на окне.
- **Русская раскладка:** AWT даёт буквам `keyCode = 0` (в Compose — `Key.Unknown`). Физическая клавиша берётся по коду X11 (`rawCode`): поле закрыто модулем `java.desktop`, у `AWTAccessor` геттера нет, но код есть в `KeyEvent.paramString()`. Проверено с `setxkbmap ru`: Ь→M, Ф→A, С→C, А→F.
- Переходы между режимами могут мелькать ~0,1 с: WM на мгновение показывает промежуточный размер. Виртуальный экран этого не показывает.
- Cinnamon может развернуть или «приклеить» окно, если тащить его к верхнему краю; запасной путь — двигать окно самим, без WM.
### 5.4 Compose Desktop
- **Клавиши получает только элемент в фокусе.** Клавиши «назад» — в `onKeyEvent` окна, а не модификатором на содержимом: на шаге без фокуса (выбор профиля) Esc иначе терялся. Окно получает всё, что не забрал никто.
- **Compose Desktop не двигает фокус стрелками** — только Tab. Стрелки, которые никто не взял, окно переводит в `moveFocus` само.
- **Фокус от наведения прокручивает ряд:** `clickable` при получении фокуса просит показать себя, и наведение на полускрытую карточку дёргало ряд. Отсюда своя `BringIntoViewSpec` на всё окно: прокручивать, только если последним был ввод с клавиатуры.
- Колесо над `LazyRow` уходит в вертикальную прокрутку страницы, Shift+колесо — в ряд: Compose делает это сам.
- **`KeyEvent.nativeKeyEvent` в CMP 1.12 — не событие AWT**, а внутренняя обёртка; событие AWT — через `awtEventOrNull`.
- **`awaitFirstDown` не ловит правую кнопку** — её берём из `onPointerEvent(Press)`.
- **«Движение мыши» без движения:** Compose шлёт его, когда под неподвижным курсором меняется раскладка (например, OSD затухает) — иначе управление показывало бы само себя снова. Движением считается только смена координат.
- **ViewModel'ям на ПК нужен `kotlinx-coroutines-swing`:** без него нет `Dispatchers.Main`, и `viewModelScope` падает при первом `launch`.
### 5.5 Звук: PipeWire и WirePlumber
- **WirePlumber запоминает «без звука» по роли потока**, а не по приложению (`~/.local/state/wireplumber/restore-stream`, запись вида `Output/Audio:media.role:Movie:mute=true`). mpv с видео помечает звук ролью Movie — однажды заглушённый поток (тестовый прогон с `mute=yes` через настоящий вывод или щелчок в микшере) глушит потом все видео через mpv, не только SleepyOwl. Лечится: включить звук у такого потока (WirePlumber запишет `mute=false`). Поэтому тестовый стенд и сквозная проверка запускают mpv с `ao=null` — в звуковую систему рабочего стола они не попадают вовсе.
### 5.6 KMP, Koin, Gradle
- **Если цели только android и jvm, `commonMain` компилируется как обычный JVM-код:** Kotlin пропускает компиляцию метаданных (`compileCommonMainKotlinMetadata SKIPPED`). В общем коде работают JDK (`File`, `ConcurrentHashMap`, `UUID`, `java.net`, `java.time`, `Locale`), OkHttp, SDK Jellyfin и ZXing как есть; expect/actual нужен только там, где Android API. Перевод ядра свёлся к ~15 файлам. С не-JVM целью (iOS) это кончится.
- Android-часть KMP-модуля на AGP 9 — плагин `com.android.kotlin.multiplatform.library`; обычные Android-ресурсы включаются `androidResources { enable = true }` в `android {}` (класс `R` — `ru.stenfredd.sleepyowl.ui.shared.R`).
- **`internal` не работает между модулями:** раскладки, `plural`, `PlayerLabels`, запись `ScreenFocus.lastKey` стали публичными; умный каст на свойство из другого модуля пришлось заменить локальной переменной.
- **R8 сливает объект `Log` с чужим классом** (в первой release-сборке все теги были `WebSocketProtocol`): свой кадр стека теперь отличается по имени класса во время работы, как у Timber.
- **Koin ловит пропущенную зависимость только при запуске** (Hilt — при сборке). Проверку возвращают тесты графа (`KoinGraphTest` на ТВ, `DesktopKoinGraphTest`, `DesktopGraphTest`). `verify()` читает конструктор каждого объявленного типа, даже если объект создаётся в лямбде, — `File`, `OkHttpClient.Builder` и (пока движков нет) `EngineProvider` для `getAll()` записываются в `extraTypes`.
- `Key.Menu` Compose на Android — тот же `KEYCODE_MENU`, поэтому `clickableWithMenu` общий без `nativeKeyCode`.
- **SDK Jellyfin на JVM не знает устройства:** на Android `createJellyfin` берёт `DeviceInfo` из системы, на ПК его надо задать (`JellyfinPlatform.configure`). Без него ввод адреса вручную падал с «Нет связи с сервером» (а поиск в LAN, идущий другим путём, работал).
- Старые данные ТВ читаются новым кодом: аккаунты в формате `org.json` (с `\/` и `null`), запомненные дорожки, история поиска.
- **`build.gradle.kts`:** имя `java` — это расширение Gradle, а не пакет: `java.nio.file.Files` не находится, нужен `import`. Время сборки для версии пакета берётся в действии задачи, а не при настройке: с кэшем конфигурации (`org.gradle.configuration-cache=true`) значение из настройки повторялось бы от сборки к сборке.
### 5.7 D-Bus (dbus-java)
- Свойства отдаются своим `Properties` (Get/GetAll/Set): `Get` возвращает `Variant`, тип в D-Bus — `v`.
- `Variant.equals` сравнивает массивы по ссылке — для «изменилось ли» сравнивать содержимое.
- Адрес обложки: `File.toURI()` даёт `file:/путь` (одна косая черта), а апплеты Cinnamon ждут `file:///путь` — использовать `toPath().toUri()`.
### 5.8 Jellyfin
- Сервер сбрасывает позицию, если она меньше 5 % длительности (настройка «минимум для продолжения»): 20 с просмотра с 5-й минуты фильма дают позицию 0. Это не ошибка отчётов.
- Если приложение убито без отчёта об остановке, сервер через несколько минут сам закрывает его сессию и записывает последнюю позицию — след в данных просмотра появляется с задержкой.
- `reportPlaybackProgress failed: JobCancellationException` при выходе из плеера — отменённый вместе с плеером промежуточный отчёт; отчёт об остановке идёт отдельно (`GlobalScope`) и доходит. Не ошибка.
- Вход на другом устройстве без набора пароля: код Quick Connect с его экрана подтверждается запросом `POST /QuickConnect/Authorize?code=…` с токеном уже вошедшего клиента (удобно для ТВ в проверках).
- В проверенной конфигурации сервер отдавал `static`-поток и внешние субтитры и без токена; клиент на это не полагается.
## 6. mpv на медиатеке: 0.37 против 0.41
Перед кодом mpv 0.37 (пакет Mint, распакован без установки: `apt download mpv`, `dpkg -x`) и 0.41 (Flatpak `io.mpv.Mpv`) прогнаны на файлах, с которыми были проблемы на ТВ (`tools/linux/l0/`, §7.6). Каждый файл: прямая ссылка `static=true`, окно 640×360, звук выключен через настоящий вывод, старт с 40 %, 15 с воспроизведения, точная перемотка +300 с, ещё 10 с; сняты пропуски (`frame-drop`, `decoder-frame-drop`, `vo-delayed-frame`), `hwdec-current`, CPU, GPU и NVDEC (`nvidia-smi`), расхождение A/V, паузы на подгрузку, время до первого кадра и перемотки. Рендерер — `vo=gpu` на OpenGL (то, что умеет render API); для HDR/DV для сравнения ещё `gpu-next` 0.41.
**Итог: на 0.37 нет проблем, которых нет на 0.41** → зависимость от системной `libmpv2`. Во всех 58 прогонах: ноль пропущенных кадров, без пауз на подгрузку, A/V ≤ 0,6 мс, первый кадр за 0,3–1,1 с, перемотка 0,1–0,6 с (MOV — 1,9 с).
| Формат | Декодирование | Замечания |
|---|---|---|
| MKV H.264, 17 SRT, цепочка SeekHead (ломала перемотку Exo на ТВ) | nvdec | перемотка 0,1 с, проблемы ТВ нет |
| AVI XviD + AC3 (на ТВ рвался звук) | программно | CPU 3 %, A/V ≤ 0,4 мс; лишнего чтения, как у демуксера AVI в VLC, нет |
| AVI MPEG-4 ASP 2×AC3 / AVI H.264 + MP3 | программно / nvdec | |
| MPEG-PS 576i, нижнее поле первым | программно | без деинтерлейса гребёнка 25 к/с; `deinterlace=yes` → `bwdif`, 50 к/с, CPU 10 % |
| MKV H.264 1080i50 | nvdec | 0.37: `yadif_cuda`, CPU 3 %; Flatpak 0.41: `bwdif` на CPU, 20 % |
| DV P8.1 + HDR10+, 4K 24p | nvdec | базовый слой HDR10 → SDR, цвета нормальные; GPU 3 %, NVDEC 5 % |
| DV P8.1 1080p | nvdec | «Multiple Dolby Vision RPUs» безвредно |
| DV P8.4 (HLG) / HLG, 4K30 | nvdec | цвета нормальные |
| HEVC 10 бит 4K HDR10, 55 Мбит/с, TrueHD 7.1, ASS | nvdec | CPU 5 %, ASS рисуется |
| HDR10+ 4K | nvdec | цвета нормальные |
| AV1 10 бит 4K HDR10, TrueHD | nvdec | перемотка 0,5 с |
| AV1 1080p, Opus 5.1 | nvdec | |
| PGS | — | субтитры видны |
| 720p60 + ASS | nvdec | 60 к/с |
| внешние SRT (`sub-files-append` с адресом сервера) | — | кириллица верная |
| H.264 Hi10P | программно | NVDEC не умеет H.264 10 бит; CPU 19 % одного ядра |
| H.264 4K25; TS H.264 1080p; DTS 5.1 | nvdec | |
| MOV, mov_text | nvdec | перемотка 1,9 с — самая долгая |
| DV профиль 5 | — | в медиатеке нет; вопрос открыт (§5.1) |
Проблемы ТВ на mpv не повторяются (перемотка MKV с цепочкой SeekHead, звук AC3 в AVI, звук DVD, PGS) — свои экстракторы и прокси на ПК действительно не нужны. Сеть при малом кэше ~5 Мбит/с для AVI 2,1 Мбит/с — так же, как у MKV.
**Обычный mpv плавающим окном в Cinnamon** (`--ontop --no-border --geometry=640x360-24-24`) — эталон поведения мини-окна: `_NET_WM_STATE_ABOVE` ставится и снимается на лету (`set ontop`); рамка включается и выключается у живого окна через `_MOTIF_WM_HINTS` (Muffin добавляет/убирает заголовок 32 px) — отсюда способ смены рамки в приложении; весь экран — на мониторе с окном; окно фонового процесса фокус не получает.
## 7. Тестирование
### 7.1 Тесты без окна
```
./gradlew :desktop:test # DesktopGraphTest, ShellRenderTest, PlayerRenderTest, LicensesTest
./gradlew :ui:shared:desktopTest # DesktopKoinGraphTest, DesktopRenderTest
./gradlew :playback:engine-mpv:test # RenderSizeTest
./gradlew :app:testDebugUnitTest # граф Koin ТВ (KoinGraphTest) — если менялся общий код
```
- `DesktopGraphTest` — граф Koin ПК в том виде, в каком его запускает `main`; `DesktopKoinGraphTest` — граф общих модулей с платформой ПК.
- Тесты отрисовки рисуют без окна (`ImageComposeScene`) реализациями ПК и пишут PNG для просмотра глазами:
- `DesktopRenderTest` → `ui/shared/build/reports/ui-shared/components.png` — шрифты с кириллицей, логотип, иконки, свечение фокуса, BlurHash с затенением, QR, рамка;
- `ShellRenderTest` → `desktop/build/reports/desktop/shell-*.png` — меню на 1280 и 900 px;
- `PlayerRenderTest` → `desktop/build/reports/desktop/player-*.png` — три компоновки OSD, меню, карточка следующей серии.
- `RenderSizeTest` — размер FBO под видео; `EglProbe.kt` — ручная проверка контекста EGL рядом с окном Compose (§5.2).
- Если менялся общий код — ещё компиляция ТВ и `:app:testDebugUnitTest`.
### 7.2 Тестовый стенд: Xvfb + Muffin со своей шиной D-Bus
`tools/linux/stand.sh` поднимает виртуальный экран `:87` (Xvfb 1920×1080) с настоящим оконным менеджером Muffin и **своей сессионной шиной D-Bus** для Muffin и приложения. Окна, мышь, клавиши и фокус проверяются там — рабочий стол разработчика, его фокус, апплет звука, хранитель экрана и звук не затрагиваются.
```
stand.sh up | down поднять / остановить Xvfb, D-Bus стенда и Muffin
stand.sh app [args] (пере)запустить приложение на :87 в фоне; вывод — $STAND/app.out
stand.sh stop остановить приложение
stand.sh shot NAME снимок экрана → $STAND/shots/NAME.png
stand.sh in CMD… ввод через XTest (xinput.py): click X Y , move X Y , key NAME , type ТЕКСТ , drag X1 Y1 X2 Y2
stand.sh log лог приложения
stand.sh bus CMD… команда на шине стенда (gdbus, busctl --user, dbus-monitor)
```
- **Свои файлы приложения:** каталог стенда `$STAND` (по умолчанию в `~/.cache`) — свои настройки и вход тестовой учётной записи, кэш, лог, блокировка; `~/.config/sleepyowl` разработчика не используется.
- **Звук:** mpv на стенде играет в `ao=null` (если `SLEEPYOWL_MPV_OPTIONS` не говорит иного) — §5.5.
- **Своя шина нужна** потому, что с MPRIS приложение стенда иначе появилось бы в апплете звука рабочего стола и держало бы его хранитель экрана. `tools/linux/fake_screensaver.py` — подставной `org.freedesktop.ScreenSaver` на шине стенда, печатает Inhibit/UnInhibit.
- Gradle собирает приложение перед `app`, если не задан `DESKTOP_NOBUILD=1`. `SLEEPYOWL_APP=<запускалка>` запускает её вместо сборки — например, пакет, распакованный `dpkg-deb -x sleepyowl_*.deb DIR` → `SLEEPYOWL_APP=DIR/opt/sleepyowl/bin/sleepyowl` (так же для `k2.py`).
- Полезные переменные приложения: `SLEEPYOWL_TEST_PASSIVE=1` — окна без фокуса, главное свёрнуто; `SLEEPYOWL_PLAYER_BOUNDS=x,y,w,h` — место окна плеера (замеры на настоящем экране); `SLEEPYOWL_SCALE`; `--play <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`, строки «<имя> <itemId> [опции 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 (вид плеера будет отличаться от приложения).