Add documentation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:23:58 +03:00
parent 84fb48f39d
commit b077388b74
24 changed files with 7157 additions and 8 deletions

View File

@@ -54,7 +54,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
* The player's controls ("OSD") as drawn in the mockup's player screen; sizes are the mockup's px / 2.
* Everything here is drawn over the video, which on TVs sits on its own hardware plane: the UI layer is only
* redrawn when something in it changes (a second of the clock, a focus move), so the shades cost nothing while
* the picture plays. Through the GPU path (SDR on the Xiaomi TV) every video frame redraws them, see APP_PLAN.md.
* the picture plays. Through the GPU path (SDR on the Xiaomi TV) every video frame redraws them, see docs/ui-performance.md.
*/
internal object OsdColors {

View File

@@ -1,6 +1,6 @@
// SleepyOwl for the Linux desktop (LINUX_PLAN.md): windows, desktop screens, later the mpv player and system integration.
// SleepyOwl for the Linux desktop (docs/desktop.md): windows, desktop screens, the mpv player and system integration.
// Run: ./gradlew :desktop:run, or tools/linux/desktop.sh without Gradle around the process.
// Package: ./gradlew :desktop:packageDeb → desktop/build/deb/sleepyowl_<version>_amd64.deb (LINUX_PLAN.md L10).
// Package: ./gradlew :desktop:packageDeb → desktop/build/deb/sleepyowl_<version>_amd64.deb.
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermissions

34
docs/README.md Normal file
View File

@@ -0,0 +1,34 @@
# SleepyOwl
SleepyOwl — open-source клиент Jellyfin на Kotlin: сначала для Android TV, теперь есть и версия для Linux (ПК).
Лицензия GPL-3.0-or-later. Название и логотип свои, без бренда Jellyfin. Пакет `ru.stenfredd.sleepyowl`
(прежнее рабочее название — Movies, `ru.stenfredd.movies`).
**Цель:** воспроизводить всё, что можно, без серверного транскода и с правильным HDR и Dolby Vision. Другие клиенты
на тестовом ТВ спотыкались на разных файлах: транскодировали, давали тусклый HDR или зависали.
- **Android TV:** гибрид media3/ExoPlayer (основной: аппаратный MediaCodec, HDR10/HDR10+/HLG/DV, passthrough) +
libVLC (запасной, программный ffmpeg). Движок выбирается автоматически по «весу потерь»; при падении или зависании
сессия переходит на другой движок с той же позиции. Серверный транскод — последний вариант. На тестовой медиатеке
(12 671 файл: MKV/AVI/MP4, DivX, H.264 Hi10P, HEVC HDR10, DV, AV1, MPEG-2, AC3/DTS/TrueHD, ASS/PGS) транскод
не понадобился ни разу.
- **Linux (ПК):** движок libmpv, плеер в окне, во весь экран и плавающим окном поверх остальных, MPRIS, пакет .deb.
HDR на ПК сводится к SDR.
- **Общий код** — модули Kotlin Multiplatform (android + desktop): `core:*`, `playback:api`, `playback:core`,
`ui:shared` (Compose Multiplatform). DI — Koin.
- **Стек:** Kotlin, Jetpack Compose / Compose Multiplatform, Navigation 3, Coil 3, `jellyfin-sdk-kotlin` 1.9
(сервер Jellyfin 12.x), media3, libVLC, ass-media (libass), libmpv (JNA), OkHttp, DataStore.
- **Тестовое устройство:** Xiaomi TV S Pro Mini LED 75 (MediaTek MT9689, Android 14, только 32-bit, 2,4 ГБ RAM,
слабый Mali GPU). APK содержит `armeabi-v7a` и `arm64-v8a`.
## Документы
| Файл | О чём |
|---|---|
| [development.md](development.md) | Сборка, инструменты в `tools/`, личные настройки в `local/` |
| [playback.md](playback.md) | **Главный документ по воспроизведению:** архитектура, выбор движка, все найденные проблемы media3/libVLC/ТВ MediaTek и их решения, сеть, память, как тестировали |
| [tv-app.md](tv-app.md) | Приложение для Android TV: архитектура интерфейса, решения, ловушки фокуса и пульта, особенности сервера, по экранам |
| [ui-performance.md](ui-performance.md) | Производительность интерфейса на слабом GPU ТВ: что сколько стоит, как мерить, какие приёмы использовать |
| [launcher-feed.md](launcher-feed.md) | Контракт «Продолжить просмотр» для своей оболочки главного экрана ТВ |
| [desktop.md](desktop.md) | Версия для Linux: решения, архитектура (mpv в Compose, окна X11, MPRIS), находки и ловушки, тестовый стенд |
| [research/](research/) | Исходные исследования до кода: Jellyfin API и клиенты, media3, libVLC против libmpv, тестовый ТВ, профиль медиатеки, дампы ТВ |

609
docs/desktop.md Normal file
View File

@@ -0,0 +1,609 @@
# 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` любой новой сборки идёт как обновление. Номер версии приложения — одно место, `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`, строки «<имя> <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 (вид плеера будет отличаться от приложения).

84
docs/development.md Normal file
View File

@@ -0,0 +1,84 @@
# Разработка
## Сборка
Нужны JDK 21 и Android SDK (compileSdk 37). Gradle — через обёртку `./gradlew` (9.8.1).
| Что | Команда |
|---|---|
| ТВ, debug | `./gradlew :app:assembleDebug` → `app/build/outputs/apk/debug/app-debug.apk` |
| ТВ, release | `./gradlew :app:assembleRelease` — R8 без обфускации, пока подписан debug-ключом; ABI `armeabi-v7a` + `arm64-v8a` |
| ПК, запуск | `./gradlew :desktop:run` или `tools/linux/desktop.sh` (без Gradle вокруг процесса) |
| ПК, пакет | `./gradlew :desktop:packageDeb` → `desktop/build/deb/sleepyowl_<версия>_amd64.deb` (зависит от `libmpv2`) |
| Тесты | `./gradlew test` (в т.ч. рендер-тесты экранов ПК и `ui:shared` без дисплея) |
Производительность интерфейса на ТВ мерить только на release-сборке (см. [ui-performance.md](ui-performance.md)).
Baseline profile лежит в `app/src/release/generated/baselineProfiles/` и снимается заново на ТВ (`gen_profile`, ниже).
## Личные настройки инструментов: `local/`
Папка `local/` целиком в `.gitignore`: там всё, что относится к конкретной машине, серверу и аккаунтам —
настройки инструментов, рабочие заметки, тестовые данные, резервные копии. В git она не попадает.
Инструменты в `tools/` берут адреса и идентификаторы из `local/dev.env`. Шаблон с описанием каждой переменной —
[`tools/dev.env.example`](../tools/dev.env.example):
```sh
mkdir -p local && cp tools/dev.env.example local/dev.env # и заполнить
```
Формат — строки `KEY=value` без кавычек и подстановок; переменная, уже заданная в окружении, важнее файла.
Shell-скрипты подключают файл через `tools/devenv.sh`, Python-скрипты — через `tools/devenv.py`. Если нужной
переменной нет, скрипт останавливается и говорит, какую задать.
| Переменная | Для чего |
|---|---|
| `JAVA_HOME`, `ADB` | JDK 21 и adb, если не из окружения/`PATH` |
| `TV_ADB` | адрес ТВ для adb (`<ip-тв>:5555`) |
| `JF_URL` | сервер Jellyfin по адресу в LAN |
| `JF_USER` | тестовый аккаунт на сервере: тестовые прогоны меняют его данные просмотра |
| `PROFILE_SERIES` | id сериала для прохода по странице сериала при съёмке baseline profile |
| `K2_EPISODES` | три подряд идущие серии одного сезона для `tools/linux/k2.py` |
| `L0_FLOAT_ITEM`, `L0_CASES` | файл и список файлов для проверок mpv в `tools/linux/l0/` |
Debug-тур (`app/src/debug/assets/tour.json`, список файлов для обхода всех форматов из отладочного экрана) тоже
не в git: он собирается со своего сервера.
## Инструменты
### `tools/tv/` — тестовый ТВ по adb
`source tools/tv/env.sh` даёт функции:
- `install_debug`, `install_release` (release сразу компилируется по baseline profile);
- `play <itemId> [exo|vlc]` — открыть карточку и играть (debug deep-link `--es item`, `--es engine`, `--el start <мс>`);
- `k KEY…` — нажатия пульта, `shot NAME` — скриншот в `$OUT`;
- `plog` — журнал решений сессии воспроизведения, `vproxy` — журнал локального прокси VLC, `aout` — что реально идёт на аудиовыход;
- `tv_token` — токен текущего аккаунта (только debug-сборка, через `run-as`), `sess` — что играет на ТВ по данным сервера;
- `ui_bench [right|down] [N]` — замер плавности (перцентили кадра и GPU, разбивка по фазам через `framestats.py`);
- `gen_profile` — снять baseline profile на ТВ (~8 мин, на ТВ должен быть выполнен вход).
`tools/tv/run-case.sh <name> <itemId> [exo|vlc]` — прогон одного файла: скриншот и журнал.
### `tools/tour/build_tour.py`
Собирает debug-тур: минимальный набор файлов медиатеки, покрывающий все форматные признаки сервера
(контейнер, кодеки, HDR, частоты, чересстрочность, субтитры) и важные для выбора движка пары.
### `tools/logo/make_assets.py`
Все растровые логотипы (баннер ТВ, адаптивная иконка, сова и надпись в интерфейсе, иконки ПК 16…512) генерируются
из `design/logo/sleepyowl.png`. PNG не править руками — перезапускать скрипт.
### `tools/linux/` — версия для ПК
- `desktop.sh` — запуск приложения без Gradle (`DESKTOP_NOBUILD=1` — без сборки, `SLEEPYOWL_SCALE` — масштаб).
- `stand.sh` — тестовый стенд: свой виртуальный дисплей Xvfb `:87` с оконным менеджером Muffin и своей сессией
D-Bus, звук в `ao=null`. Окна, мышь, MPRIS и хранитель экрана проверяются, не трогая рабочий стол.
Файлы приложения стенда — отдельно от обычных (`$STAND`).
- `xinput.py` — ввод на дисплей стенда; `fake_screensaver.py` — заглушка `org.freedesktop.ScreenSaver` на шине стенда.
- `k2.py` — сквозная проверка плеера и интеграции с системой на стенде (PASS/FAIL по пунктам, скриншоты).
- `watchdata.py snapshot|diff|restore` — снимок данных просмотра тестового аккаунта до прогона и возврат после.
- `l0/` — проверка, как сам mpv играет файлы медиатеки (версии 0.37 и 0.41, `vo=gpu`/`gpu-next`), и поведение плавающего окна mpv.
Подробности о стенде и его ограничениях — в [desktop.md](desktop.md).

162
docs/launcher-feed.md Normal file
View File

@@ -0,0 +1,162 @@
# Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 1)
Своя домашняя оболочка Android TV (пакет `ru.stenfredd.tvhome`, отдельный проект) показывает ряд «Продолжить просмотр». Содержимое ряда присылает SleepyOwl (`ru.stenfredd.sleepyowl`). Документ описывает контракт между ними и то, как его выполняет приложение. Менять контракт можно только одновременно в обоих проектах.
## 1. Модель: приложение само присылает список
Приложение отправляет оболочке **готовый список карточек, когда он меняется**. Оболочка хранит его в файле и показывает; при нажатии Home она ничего не спрашивает и процесс приложения не будит.
Почему не иначе:
- **Провайдер в приложении, который опрашивает оболочка** — каждый возврат на главный экран поднимал бы процесс приложения (DI, OkHttp, десятки МБ, ~0,5 с процессора). Это противоречит смыслу лёгкой оболочки.
- **Стандартный Watch Next / «Play Next» (TvProvider)** — проверено на эталонном ТВ: сторонний лаунчер чужие записи не видит. Запись от другого пакета даёт оболочке 0 строк даже с `READ_EPG_DATA`; нужен `ACCESS_ALL_EPG_DATA`, а он `signature|privileged`.
- **Виджеты (AppWidget)** — RemoteViews: нет свечения и увеличения из макета, плохо с фокусом пульта, тяжелее.
- **Собственный вход оболочки в Jellyfin** — второй логин и только один заранее выбранный профиль.
## 2. Вызов
```kotlin
context.contentResolver.call(
Uri.parse("content://ru.stenfredd.tvhome.feed"),
"continue_watching", // метод
json, // arg: JSON-массив карточек строкой
null, // extras
) // → Bundle { "version" = 1 }
```
| Параметр | Значение |
|---|---|
| Authority провайдера | `ru.stenfredd.tvhome.feed` |
| Метод | `continue_watching` |
| `arg` | JSON-массив карточек (§3) строкой |
| `extras` | `null` |
| Ответ | `Bundle` с `"version" = 1` (версия контракта) |
- **Пустой массив `[]` прячет ряд.** Отправлять при выходе из аккаунта и когда продолжать нечего.
- **Порядок в массиве = порядок в ряду.** Оболочка принимает до 20 карточек, показывает первые 5. Приложение шлёт 10 (запас).
- Вызов делать не на главном потоке.
### 2.1 Доступ и подпись
- Оболочка принимает вызов **только от приложения, подписанного тем же ключом**, что и она (`checkSignatures` по uid вызывающего). Чужой ключ → `SecurityException`.
- **Смена ключа подписи приложения** (например, переход с debug-ключа на release-ключ) отключит ряд. Тогда либо подписать оболочку тем же ключом, либо научить её проверять конкретный сертификат приложения.
- Вызовы из `adb shell` оболочка тоже принимает (для отладки, §5).
### 2.2 Видимость пакетов
На targetSdk 30+ без записи в манифесте вызывающего `call` падает с `IllegalArgumentException: Unknown authority`, как будто оболочки нет:
```xml
<queries>
<provider android:authorities="ru.stenfredd.tvhome.feed" />
</queries>
```
### 2.3 Ошибки
| Ситуация | Что приходит | Что делать |
|---|---|---|
| Оболочки нет на устройстве (другой ТВ, эмулятор) | `IllegalArgumentException` | глотать молча; лучше заранее проверить `packageManager.resolveContentProvider(...) == null` и ничего не считать |
| Приложение подписано другим ключом | `SecurityException` | записать в лог |
## 3. Карточка
```json
[
{
"id": "<id элемента Jellyfin, 32 hex без дефисов>",
"title": "Сериал · S2 E5",
"subtitle": "осталось 12 мин",
"progress": 0.24,
"image": "http://<адрес-сервера-в-LAN>:8096/Items/<id>/Images/Primary?fillWidth=326&fillHeight=184&quality=90&tag=<tag>",
"intent": "intent:#Intent;component=ru.stenfredd.sleepyowl/.MainActivity;S.item=<id>;B.play=true;l.start=<мс>;end"
}
]
```
| Поле | Обяз. | Смысл |
|---|---|---|
| `id` | да | id элемента Jellyfin; по нему оболочка удерживает фокус при обновлении ряда |
| `title` | да | одна строка, влезает ~28 символов (шрифт 19 px в карточке 326 px). «Сериал · S2 E5» или название фильма; название серии не влезет. У серии без номеров на сервере — только название сериала |
| `subtitle` | нет | «осталось 31 мин» у начатого, «следующая серия» у следующей. Оболочка сама добавляет перед ним название приложения («SleepyOwl · …») |
| `progress` | нет | 0..1; нет поля или `null` — без полоски (следующая серия) |
| `image` | нет | картинка 16:9 размером 326×184 px (§3.1) |
| `intent` | да | `Intent.toUri(Intent.URI_INTENT_SCHEME)` (§3.2) |
### 3.1 Картинка
- Оболочка качает её простым `HttpURLConnection` **без токена** и кладёт на диск с ключом по URL. Jellyfin отдаёт картинки без авторизации; HTTP в локальной сети у оболочки разрешён.
- Источник — как у широкой карточки приложения: кадр серии → фон → thumb.
- **Адрес должен быть уже переписан на LAN-адрес сервера**, если он известен (иначе — публичный адрес). В самом приложении адреса картинок строятся на публичном адресе и переводятся на LAN перехватчиком OkHttp (`ServerRouting`), а у оболочки такого перехватчика нет.
- **Параметр `tag` оставлять:** при смене картинки на сервере меняется URL, и оболочка скачает её заново.
- `fillWidth`/`fillHeight` сохраняют пропорции, поэтому сервер отдаёт 327–328×184 вместо 326×184; оболочка режет по центру, это незаметно.
### 3.2 Intent
- Оболочка разбирает строку через `Intent.parseUri` и запускает интент, **только если компонент или пакет — `ru.stenfredd.sleepyowl`**. Сама добавляет `FLAG_ACTIVITY_NEW_TASK` и снимает флаги выдачи прав на URI.
- Сборка:
```kotlin
Intent().setClassName("ru.stenfredd.sleepyowl", MainActivity::class.java.name)
.putExtra("item", id) // String, id элемента
.putExtra("play", true) // сразу воспроизведение
.putExtra("start", positionMs) // Long, место остановки; у следующей серии не передаётся
.putExtra("series", seriesId) // String, только у серии
.toUri(Intent.URI_INTENT_SCHEME)
```
| Экстра | Тип | Смысл |
|---|---|---|
| `item` | String | элемент для воспроизведения |
| `play` | Boolean | `true` — сразу плеер |
| `start` | Long | позиция в мс. **Без неё плеер начнёт с нуля** (и элемент выпадет из «Продолжить просмотр»); у следующей серии без прогресса не нужна |
| `series` | String | id сериала: под плеером окажется страница сериала на этой серии, а не страница серии |
**Поведение приложения при запуске с карточки:** сразу воспроизведение с места остановки (у карточки в фокусе оболочки кнопка ▶). Стек — «главная приложения → страница фильма (или страница сериала на этой серии) → плеер», так что «Назад» из плеера ведёт на страницу, а дальше на главную приложения. Запомненные для фильма или сериала дорожки звука и субтитров подхватываются.
Требования к приёмной стороне (выполнены в `MainActivity`):
- `android:launchMode="singleTask"` + `onNewIntent(intent)` → `setIntent(intent)` и передача цели в Compose. Без этого при открытом приложении запуск с `NEW_TASK` лишь поднимает старую задачу: цель читается один раз при создании, и открывается прежний экран.
- По новой цели стек сбрасывается так же, как при холодном старте. Открытый в этот момент плеер закрывается и отправляет позицию на сервер, как при обычном выходе.
- Пользователь не вошёл (экран входа) — цель игнорируется. Запуск без цели (иконка приложения) экран не меняет.
## 4. Когда и что отправляет приложение
Реализация — `LauncherFeed` в модуле `app` (интент ссылается на `MainActivity`, поэтому не в общих модулях).
- **Старт:** из `Application.onCreate` одной корутиной на `Dispatchers.IO`, без работы на старте: ждёт восстановления сессии (`JellyfinSession.restored` — до него «нет сессии» не значит «вышли») и ещё 3 с, чтобы не мешать главной. Время показа главной от этого не меняется.
- **Пересчёт:** при старте процесса; при смене аккаунта (вход, переключение профиля, выход); по событиям изменения пользовательских данных (`SessionCache.invalidations`: остановка плеера, «просмотрено», избранное). Пачки событий схлопываются за ~1 с.
- **Содержимое:** сессии нет → `[]`; иначе `WatchRepository.continueWatching()` (тот же вызов и тот же ключ кэша, что у главной приложения, — лишних запросов нет) → первые 10 → JSON. Один тайтл — одна карточка: от сериала остаётся последняя по времени просмотра начатая серия; дальше — следующие серии сериалов без начатых.
- **Отправка — только если строка JSON отличается** от последней отправленной.
- **Ошибка сети — ничего не отправлять:** у оболочки остаётся прошлый список.
- **Выход из плеера кнопкой Home:** плеер на `ON_STOP` ставит паузу, отправляет позицию на сервер и сообщает об изменении данных → пересчёт → оболочка получает новую позицию через 1–2 с.
### Ограничения
- Если процесс приложения убит сразу после Home из плеера (до ответа сервера), у оболочки остаётся старая позиция до следующего запуска приложения. Отправлять поправленный список, не дожидаясь сервера, можно, но не сделано.
- Просмотр на других устройствах (телефон, веб) оболочка увидит только после следующего запуска приложения. Периодической фоновой работы (WorkManager) нет намеренно: на эталонном ТВ фоновая активность сознательно сведена к минимуму.
## 5. Проверка
```bash
# что сейчас лежит у оболочки (по источникам)
adb -s <ip-тв>:5555 shell content call --uri content://ru.stenfredd.tvhome.feed --method dump
# подставить список от имени приложения без самого приложения;
# JSON — только в --arg: в --extra утилита content не принимает двоеточия
adb -s <ip-тв>:5555 shell 'content call --uri content://ru.stenfredd.tvhome.feed --method continue_watching --extra source:s:ru.stenfredd.sleepyowl --arg "[]"'
```
Сценарии:
1. Запуск приложения → на главном экране ТВ появился ряд, `dump` совпадает с «Продолжить просмотр» в приложении.
2. Начать или досмотреть что-то, выйти кнопкой Home → карточка поднялась первой с новой позицией.
3. «Просмотрено» на элементе → он пропал из ряда.
4. Переключить профиль → ряд другого профиля; выйти из аккаунта → ряда нет; войти обратно → ряд вернулся.
5. Карточка при закрытом приложении → открылось нужное с места остановки; при открытом приложении на другом экране → тоже нужное; при открытом плеере → плеер закрылся с сохранением позиции, запустилось новое, «Назад» — страница фильма/сериала.
6. Время показа главной (`Home shown in` в логе) не выросло; `call` не на главном потоке.
## 6. На будущее
- Параллельно публиковать тот же список в стандартный Watch Next (`androidx.tvprovider`), чтобы ряд был и на главном экране Google TV.
- Код оболочки: `FeedProvider` (приём, проверка подписи, разбор, хранение, загрузка обложек) и обновление ряда в её `MainActivity`.

507
docs/playback.md Normal file
View File

@@ -0,0 +1,507 @@
# Воспроизведение видео на Android TV: итоговые выводы
Итог работы над воспроизведением в клиенте Jellyfin для Android TV (2026-10-12…14). Документ написан так, чтобы по нему можно было **повторить то же качество воспроизведения в другом приложении**. В нём: как устроен движок, какие правила выбора, что сломано в media3, libVLC и на телевизорах MediaTek и как это обходится, как тестировать.
Исходное исследование, сделанное до кода, лежит в [`research/`](research/). Здесь только то, что подтвердилось или выяснилось на реальном устройстве.
Код, на который ссылается документ, находится в модулях `playback/*` этого репозитория:
- `playback/api` — интерфейсы;
- `playback/core` — сессия, выбор движка, опрос устройства;
- `playback/engine-exo` — ExoPlayer;
- `playback/engine-vlc` — libVLC.
---
## 0. Коротко: главные выводы
1. **Гибрид из двух движков работает.** media3/ExoPlayer — основной (аппаратный MediaCodec, HDR10/HDR10+/HLG/Dolby Vision, passthrough). libVLC — запасной (программный ffmpeg). На тестовой медиатеке (12 671 файл, треть — AVI) **ни один файл не потребовал серверного транскода**.
2. **Выбор движка — автоматический, по «весу потерь»**, до запроса к серверу. Плюс два сторожа во время просмотра: «нет первого кадра за 60 с» и «застой». По их сигналу сессия переходит на следующий движок с той же позиции.
3. **Большинство проблем оказались не в выборе движка, а в мелочах**, которые не видны в документации:
- экстракторы media3 ломаются на реальных файлах: MKV с цепочкой SeekHead, AC3 в AVI, MPEG-2 в VOB без PTS, DVD private stream;
- libVLC не дружит с HTTPS через reverse proxy и перекачивает данные при каждом шаге назад;
- на ТВ многоканальный PCM беззвучен, а видеослой «улучшает» SDR-картинку;
- убитый посреди видео процесс оставляет мусор в видеоконвейере MTK, и тот лечится только перезагрузкой ТВ.
4. **Путь вывода видео — отдельное решение**, не равное выбору движка:
- SurfaceView (аппаратный видеослой) нужен для HDR, 4K, чересстрочного видео и частот 24/25/50 (ТВ сам переключает частоту панели);
- TextureView (через GPU) даёт более естественную SDR-картинку ≤1080p 30/60 к/с на Xiaomi: обходит постобработку ТВ.
5. **Dolby Vision**: DV-декодер с базовым слоем HDR10 работает, ТВ включает режим DV. Если DV-декодера нет или дисплей не DV — играть базовый слой (HEVC/AV1/AVC) через свой `MediaCodecSelector`.
6. **Звук**: passthrough всего, что ТВ принимает (AC3, E-AC3, JOC, TrueHD, DTS, DTS-HD, AC4, MAT — декодирует Dolby MS12 телевизора). Остальное — стерео PCM. Для устройств без passthrough — ffmpeg-аудиодекодер в media3.
7. **Сеть**: дома видео нужно брать с LAN-адреса сервера по HTTP, а не через HTTPS-hairpin на reverse proxy. Иначе 4K-ремукс на 60+ Мбит/с уходит в подзагрузку каждые 5–8 с.
8. **Память**: на 32-bit ТВ с кучей 192 МБ ExoPlayer по умолчанию копит до ~145 МБ буфера → OOM. Нужны `largeHeap` и ограничение буфера.
---
## 1. Исходные условия
### 1.1 Тестовое устройство
| Параметр | Значение |
|---|---|
| ТВ | Xiaomi TV S Pro Mini LED 75 (`MiTV-MOEU1`), Google TV, Android 14 / API 34 |
| SoC | MediaTek MT9689 (`ro.hardware=mt5896`), 4×Cortex-A73, GPU Mali (слабый) |
| ABI | **только `armeabi-v7a`**, 32-bit userspace. APK обязан содержать 32-bit библиотеки |
| RAM | 2,4 ГБ, свободно ~870 МБ. Java-куча 192 МБ, с `largeHeap` 512 МБ |
| Дисплей | приложения рисуются в 1920×1080, видеоплоскость 4K. Android видит один режим 3840×2160@60. HDR: DV, HDR10, HDR10+, HLG |
| Видеодекодеры (рантайм) | AVC (включая **High10**), HEVC, VP9, AV1 — до 8K; MPEG-2, VC-1, MPEG-4 — до 1080p (MPEG-4 до 1344×1152). DV: P4, P5, P8, P9, P10. **P7 нет** |
| Звук | Dolby MS12. Passthrough на встроенные динамики: AC3, E-AC3, E-AC3 JOC, TrueHD, DTS, DTS-HD, AC4, Dolby MAT |
### 1.2 Сервер и медиатека
- Jellyfin 12.2, `jellyfin-sdk-kotlin` 1.9.2.
- Медиатека: 12 671 файл. MKV / AVI / MP4 = 6924 / 4207 / 1524. MPEG-4 ASP (DivX/Xvid) — 4182, H.264 Hi10P — 151, HEVC HDR — 42, DV P8 — 46, MPEG-2 (в т. ч. чересстрочный) — 14, ASS-субтитров — 2475. Подробно: [`research/library-profile.md`](research/library-profile.md).
- Сервер доступен по публичному адресу (`https://jellyfin.example.com`) через openresty (HTTP/1.1) и в LAN по `http://<адрес-сервера-в-LAN>:8096`.
### 1.3 Версии библиотек
| Библиотека | Версия | Зачем |
|---|---|---|
| `androidx.media3` | 1.11.1 | основной движок |
| `org.videolan.android:libvlc-all` | 3.7.7 (внутри libVLC 3.0.25) | запасной движок |
| `io.github.peerless2012:ass-media` | 0.5.1 | ASS/SSA в ExoPlayer через libass |
| `org.jellyfin.media3:media3-ffmpeg-decoder` | 1.9.0+1 | программное аудио (TrueHD, DTS…) в ExoPlayer, если нет passthrough |
| OkHttp | 5.5.0 | локальный прокси для libVLC |
---
## 2. Архитектура
### 2.1 Модули и интерфейсы
```
playback/api PlaybackEngine, EngineProvider, Verdict/Loss, DeviceCapabilities, Codecs
+ интерфейсы-возможности: VideoPathSwitchable, DolbyVisionSwitchable,
SubtitleStyleable, AudioDelayCapable, SubtitleDelayCapable
playback/core DeviceCapabilitiesProbe, EngineSelector, PlaybackSession, PlaybackPreferences
playback/engine-exo ExoEngineProvider, ExoEngine, исправленные экстракторы
playback/engine-vlc VlcEngineProvider, VlcEngine, LoopbackProxy, RecentBytesCache
```
- **`EngineProvider`** — точка подключения движка. Регистрируется в модуле Koin своего движка (`bind EngineProvider::class`; до 2026-10-25 — Hilt `@IntoSet`). Новый движок (mpv, libVLC 4) — это новый модуль, ядро не меняется. Методы:
- `evaluate(source, selection, device) → Verdict` — может ли движок сыграть файл напрямую и чего это будет стоить;
- `deviceProfile(device)` — свой DeviceProfile для сервера;
- `create(device)` — экземпляр движка.
- **`Verdict`** = `Direct` | `Degraded(losses)` | `No(reasons)`.
- **`PlaybackEngine`** — узкий интерфейс: `load`, `play`, `pause`, `seekTo`, выбор дорожек, `isSeekable`, `state`/`events`/`stats` (Flow), `attachVideo(FrameLayout)`, `release`. Остальные возможности — отдельными интерфейсами, которые проверяются через `as?`.
- Дорожки адресуются **индексами Jellyfin `MediaStream`**. Каждый движок сам сопоставляет их со своими треками. Так выбор дорожки переживает смену движка.
Почему не «движок как media3 `Player`»: в клиентах, которые так сделали (Findroid, Wholphin), обёртка над mpv полна `TODO()` и `UnsupportedOperationException`. Если нужен MediaSession, ставится один адаптер `SimpleBasePlayer` поверх своего интерфейса.
### 2.2 Жизненный цикл воспроизведения (`PlaybackSession`)
1. Выбор движка: `EngineSelector.rank(...)` — список движков по возрастанию суммарного веса потерь.
2. `POST /Items/{id}/PlaybackInfo` с DeviceProfile **выбранного движка**. Флаги: `enableDirectPlay/DirectStream/Transcoding = true`, `allowVideoStreamCopy/AudioStreamCopy = true`, `alwaysBurnInSubtitleWhenTranscoding = false`, выбранные индексы аудио и субтитров, `mediaSourceId`, `startTimeTicks`.
3. Если `supportsDirectPlay` — URL `/Videos/{id}/stream?static=true&mediaSourceId&playSessionId&deviceId&tag`. Иначе — `transcodingUrl`. Причины транскода (`TranscodeReasons` в URL) пишутся в лог и показываются в UI.
4. Внешние субтитры (`deliveryMethod = External`) передаются движку отдельными URL.
5. Запуск движка, отчёт `Playing`, `Progress` каждые 10 с, в конце `Stopped`.
6. Сторожа (§7): первый кадр, застой. По ошибке — следующий движок из ранжирования с той же позиции. Если упали все — транскод.
7. После `Stopped` вызывается `DELETE /Videos/ActiveEncodings` (чтобы сервер не держал ffmpeg) и событие «данные просмотра изменились» для UI.
---
## 3. Выбор движка
### 3.1 Веса потерь (`Loss`)
| Потеря | Вес | Когда |
|---|---|---|
| `HDR_LOST` | 100 | HDR-файл, а движок или дисплей HDR не выведут |
| `SOFTWARE_DECODE_HEAVY` | 40 | программно >1080p, 10-bit или >30 к/с при потоке > 1280×720×50 пикс/с |
| `DV_TO_BASE_LAYER` | 30 | DV будет сыгран как HDR10/HLG базового слоя |
| `BROKEN_AUDIO_TIMING` | 30 | известная поломка синхронизации звука (сейчас не используется: AC3 в AVI исправлен, §5.5) |
| `DVD_COMBING` | 30 | чересстрочный MPEG-PS (DVD) в ExoPlayer: «гребёнка» на видеослое ТВ |
| `HDR_UNVERIFIED` | 25 | HDR через VLC с аппаратным декодером: формально выводится, не проверено |
| `SOFTWARE_DECODE` | 15 | программное декодирование ≤1080p |
| `NO_DEINTERLACE` | 5 | чересстрочное в ExoPlayer: деинтерлейсит видеослой ТВ, поэтому вес малый |
- `Direct` = 0, `No` = 10 000 (такой движок ещё может играть серверный транскод).
- При равенстве выигрывает приоритет: Exo = 0, VLC = 10.
### 3.2 Правила ExoPlayer (`ExoEngineProvider.evaluate`)
- **Контейнеры:** mkv, webm, mp4, m4v, mov, ts, mpegts, avi, mpeg, ogg, flv, 3gp. Остальное (M2TS, ASF/WMV, ISO, BDMV, VIDEO_TS) → `No`.
- **Видео:**
- нет MIME для кодека → `No`;
- H.264 10-bit без аппаратного `AVCProfileHigh10` → `No` (media3 не умеет программно);
- нет аппаратного декодера под разрешение, но программный есть → `SOFTWARE_DECODE_HEAVY`, иначе `No`.
- **HDR/DV** (`evaluateRange`):
- DV-декодер «есть», только если `MediaCodecList` показывает профиль **и** дисплей сообщает `HDR_TYPE_DOLBY_VISION`;
- P5 (`DOVI`, без совместимого слоя) без DV-декодера → `No`;
- P7 (`DOVI_WITH_EL…`) без DV-декодера → `No`: media3 не откатывается на HEVC;
- P8.x / P9 / P10 без DV-декодера → `DV_TO_BASE_LAYER`, и если дисплей не умеет HDR10/HLG — ещё `HDR_LOST`;
- HDR10 / HDR10+ / HLG без поддержки дисплеем → `HDR_LOST`.
- **Чересстрочное:** `NO_DEINTERLACE`. Если к тому же контейнер mpeg/mpg/vob/mpegps — ещё и `DVD_COMBING` (§5.8).
- **Аудио:** passthrough, если кодек есть в `device.passthroughEncodings`; иначе аппаратный или системный декодер; иначе ffmpeg-расширение; иначе `No`.
- **Субтитры:** внешние PGS / VobSub / DVB → `No` (media3 их как внешние не умеет).
### 3.3 Правила libVLC (`VlcEngineProvider.evaluate`)
- Контейнеры и кодеки не ограничиваются: libVLC через ffmpeg открывает практически всё.
- Аппаратный декодер VLC используется **только для >1080p** (§6.4), поэтому всё ≤1080p считается программным: `SOFTWARE_DECODE` или `SOFTWARE_DECODE_HEAVY` (>1080p, 10-bit, или >30 к/с с учётом удвоения деинтерлейсом при потоке > 1280×720×50 пикс/с).
- DV → `DV_TO_BASE_LAYER`, P5 → ещё и `HDR_LOST`. HDR при аппаратном декодере → `HDR_UNVERIFIED`, при программном → `HDR_LOST`.
### 3.4 Что получается на практике (авто)
| Файл | Движок | Вывод |
|---|---|---|
| HEVC/AV1 4K HDR10/HDR10+/HLG, TrueHD Atmos, ASS | Exo | видеослой, passthrough |
| DV P8.1, DV + HDR10+, DV P8.4 (HLG) | Exo | видеослой, DV-декодер, ТВ в режиме Dolby Vision |
| H.264/HEVC SDR ≤1080p, 30/60 к/с | Exo | через GPU (только Xiaomi, §5.2) |
| SDR 23,976/24/25/50 к/с, SDR 4K | Exo | видеослой |
| AVI DivX/Xvid (+ AC3) | Exo | аппаратно, AC3 через исправленный экстрактор |
| H.264 Hi10P | Exo | аппаратно (на этом ТВ есть High10) |
| MPEG-2 чересстрочный в MKV | Exo | видеослой (ТВ деинтерлейсит) |
| MPEG-2 чересстрочный в VOB/MPEG-PS (DVD) | **VLC** | yadif |
| M2TS, WMV, MKV-zlib, внешние PGS/VobSub | **VLC** | — |
| Ничего не подошло / упали оба | транскод | HLS |
### 3.5 Пересчёт при смене дорожки
Когда пользователь меняет аудио или субтитры, ранжирование пересчитывается (`PlaybackSession.onSelectionChanged`). В авторежиме сессия переходит на движок, который стал **строго лучше**, кроме уже упавших в этой сессии. Если движок выбран вручную, он не меняется.
---
## 4. Jellyfin: детали протокола
- **Авторизация в 12.x:** `X-Emby-Token` **отклоняется (401)**. Работает только заголовок `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…, Version=…`. Там, где заголовок не передать (URL для libVLC), — query `ApiKey=`.
- `/Videos/{id}/stream?static=true` сервер отдаёт даже без токена (особенность Jellyfin).
- **SDK `jellyfin-sdk-kotlin` читает тело ответа в вызывающем потоке** → `NetworkOnMainThreadException`. Все вызовы SDK — на `Dispatchers.IO`. SDK требует slf4j в рантайме.
- **DeviceProfile отдельный для каждого движка** (`buildDeviceProfile` из SDK):
- Exo: перечислены контейнеры и кодеки, которые реально есть на устройстве; `codecProfile` для hevc/av1/vp9 с `VIDEO_RANGE_TYPE inCollection` (список HDR-типов, включая DV-варианты, только если есть DV-декодер; P7 — только если есть `DvheDtb`); для h264 без High10 — `VIDEO_BIT_DEPTH ≤ 8`. Транскод — HLS fMP4 (`mp4`), hevc/h264, aac/ac3/eac3. Субтитры — EMBED (srt, ass, pgs, dvdsub, dvbsub, vtt…) и EXTERNAL (srt, ass, vtt, ttml).
- VLC: `directPlayProfile` без ограничений, транскод — HLS `ts`, субтитры — всё, включая sup/idx/sub.
- `maxStreamingBitrate` = `maxStaticBitrate` = 400 Мбит/с, чтобы сервер не транскодировал по битрейту.
- **ASS по умолчанию — главная причина транскода у других клиентов**: если клиент не рисует ASS, сервер прожигает их в картинку (полный транскод 4K HDR с тонмаппингом → тусклая картинка). ASS нужно рендерить на клиенте и не включать `alwaysBurnInSubtitleWhenTranscoding`.
- **`/Videos/ActiveEncodings`** удалять после остановки транскода.
- **Встроенная обложка** (mjpeg/png как видеопоток, 499 файлов в медиатеке): «видеодорожка» — первый поток `Video` с кодеком не из mjpeg/png/bmp/gif/webp. Обложку пропускать и при сопоставлении индексов Jellyfin с номерами треков движка: Jellyfin нумерует потоки глобально (индекс ffprobe), движки — по типу.
- **Сервер помнит дорожки прошлого просмотра** (`UserData.AudioStreamIndex/SubtitleStreamIndex`, если в профиле включено «Запоминать выбор»). Для таких фильмов языковые настройки пользователя не действуют.
- **Транскод DV-файла в fMP4 ломает ExoPlayer** (`FragmentedMp4Extractor: Top bit not zero`). Не исправлено, см. §10.
### 4.1 Сеть: LAN-адрес для видео
- **Симптом:** 4K-ремукс (66 Мбит/с) в Exo уходил в подзагрузку каждые 5–8 с.
- **Причина:** ТВ ходил на публичный адрес `https://jellyfin.example.com`. Split DNS резолвил его в LAN-адрес, но запрос шёл через reverse proxy с TLS на слабом CPU ТВ. Exo получал 40–115 Мбит/с, прямой `http://<адрес-сервера-в-LAN>:8096` даёт ~240 Мбит/с.
- **Решение:**
- поиск сервера в LAN: UDP broadcast на порт 7359, сообщение `Who is JellyfinServer?`. Берётся IP отправителя и порт 8096, проверяется `/System/Info/Public` → тот же `Id`;
- поле `LocalAddress` сервера нельзя брать как есть: за прокси сервер отдаёт в нём публичное имя;
- найденный LAN-адрес запоминается в аккаунте. Один `OkHttpClient` с перехватчиком переписывает запросы на публичный адрес в LAN-адрес. Если LAN не ответил за 1,5 с — запрос уходит на публичный адрес, и запускается новый поиск;
- ключи кэша картинок строятся на публичном адресе, чтобы не меняться дома и вне дома.
- **Итог:** 0 подзагрузок. **Минус:** токен в URL видео идёт по LAN открытым HTTP.
---
## 5. ExoPlayer (media3 1.11.1)
### 5.1 Настройки плеера (`ExoEngine`)
- `DefaultRenderersFactory`:
- `setEnableDecoderFallback(true)`;
- `setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)` — ffmpeg-аудио **после** MediaCodec, чтобы аппаратные декодеры и passthrough были главнее;
- свой `MediaCodecSelector` (§5.3).
- `DefaultTrackSelector`:
- `setTunnelingEnabled(false)` — tunneling ломает часть сценариев и не понадобился;
- `setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)`.
- **`DefaultLoadControl.setTargetBufferBytes` = ¼ кучи, не больше 64 МБ** + `setPrioritizeTimeOverSizeThresholds(false)`. По умолчанию media3 копит до ~145 МБ видео: при куче 192 МБ это OOM и тормоза от GC (§8). 64 МБ — это ~8 с 4K-ремукса 60 Мбит/с, по LAN хватает.
- В манифесте `android:largeHeap="true"`.
- **Ложные отказы `Tracks.Group.isSupported`**: без `allowExceedsCapabilities = true` дорожка помечается неподдерживаемой. Пример: HEVC Main10 L5.1 High tier 55 Мбит/с media3 считает `EXCEEDS_CAPABILITIES`, а декодер играет его корректно. Декодеры MTK заявляют возможности ниже реальных.
- Собственные экстракторы подключаются через свою `ExtractorsFactory` (`ExoExtractorsFactory`) в `DefaultMediaSourceFactory`.
### 5.2 Вывод видео: SurfaceView или TextureView
Это самое неочевидное открытие проекта.
- **SurfaceView = аппаратный видеослой.**
- Только через него работает HDR/DV и выводится 4K: UI-поверхность приложения всего 1920×1080.
- ТВ сам переключает частоту панели под видео (23,976 → 119,88 Гц, 25 → 100 Гц), хотя Android видит единственный режим 60 Гц. Рывков 24p нет.
- ТВ деинтерлейсит чересстрочное видео.
- **Но** на видеослой ТВ накладывает свою постобработку (резкость, контраст MediaTek PQ). Сжатое SDR-видео выглядит «жёстче», артефакты сжатия заметнее. Метаданные цвета ExoPlayer читает правильно (BT.709 limited), дело не в них.
- **TextureView = через GPU**, как обычная графика (так выводит VLC).
- Постобработки ТВ нет, SDR-картинка «мягче и естественнее» — проверено на глаз A/B-сравнением.
- Нет HDR, нет 4K (UI 1080p), нет деинтерлейса, частота UI 60 Гц → 3:2 judder на 24p и неровно на 25p.
- 4K HDR через GPU тормозит.
- **Правило AUTO (`ExoEngine`):** через GPU идёт только SDR, не чересстрочное, ≤1920×1088, с частотой, которая ровно делится в 60 Гц (`fitsUiRefreshRate`: 30, 60, 59,94). Всё остальное — видеослой. И только на ТВ Xiaomi (`Build.MANUFACTURER == "Xiaomi"` и `UI_MODE_TYPE_TELEVISION`); на других устройствах всегда видеослой: постобработка — особенность этого ТВ.
- Режим вывода хранится **в экземпляре движка**, а не статически. При переходе на другой движок внутри одного воспроизведения он переносится. Новый фильм начинается с AUTO или с настройки пользователя. Ошибка, которую мы сделали: статический режим «через GPU» перетекал на следующие файлы, и 4K шёл через GPU.
- Пользователь может переключить режим вручную (Авто / Видеослой ТВ / Через GPU) в «Информации о потоке → Дополнительно» и в настройках.
### 5.3 Dolby Vision
- media3 распознаёт DV в MKV (`BlockAdditionMapping` с `dvcC`/`dvvC`) и в MP4 и выбирает декодер `video/dolby-vision`.
- **Свой `MediaCodecSelector`.** Если он для `video/dolby-vision` возвращает пустой список, media3 через `getAlternativeCodecMimeType` берёт декодер базового слоя (HEVC/AVC/AV1). Это самый простой способ «выключить DV» и играть P8/P9/P10 как HDR10/HLG/SDR. P5 базового слоя не имеет, поэтому для него DV-декодер нужен всегда (`requiresDolbyVisionDecoder`).
- **На тестовом ТВ DV-декодер `c2.mtk.dvhe.st.decoder` работает:** ТВ показывает значок Dolby Vision, DispService пишет `Hdr Dolby`. Поэтому DV-декодер включён по умолчанию, если дисплей сообщает `HDR_TYPE_DOLBY_VISION`.
- Сначала картинка через DV-декодер казалась «тусклее», чем базовый слой HDR10. Оказалось, это режим изображения Dolby Vision в настройках ТВ, а не поломка. При сравнении учитывать режимы изображения ТВ.
- **DV + HDR10+** (известный баг media3 на MediaTek, #1895) на этом ТВ не воспроизвёлся: играет и через DV-декодер, и базовым слоем.
- **DV P7 (BD-remux)**: на этом ТВ в рантайме декодера нет. media3 откат на HEVC для P7 явно запрещает (PR #3160 не смержен). Нужно переписать формат дорожки на HEVC (декодер HEVC игнорирует NAL 62/63) — в медиатеке P7 нет, вслепую не делали. До тех пор P7 без DV-декодера → VLC (базовый слой).
- Выбор «DV-декодер / базовый слой» — переключатель в UI и настройках, показывается только для DV-файлов и только если у ТВ есть DV-декодер и DV-экран.
### 5.4 HDR10 / HDR10+ / HLG
- Работает «из коробки» через SurfaceView: статические метаданные идут в `KEY_HDR_STATIC_INFO`, HDR10+ для HEVC/AV1 — внутри потока. Встроенный ТВ включает HDR-режим сам по метаданным декодера.
- Через TextureView HDR не выводится никогда — поэтому HDR всегда на видеослое.
- `screencap` HDR/DV-видеослой не захватывает (чёрное). SDR — захватывает и через GPU, и (для AVC) через видеослой. Оценку HDR-картинки даёт только человек.
### 5.5 Исправленные экстракторы media3
Все подключаются через `ExoExtractorsFactory` и проверены на реальных файлах. При обновлении media3 их нужно перепроверять.
| Проблема | Симптом | Причина | Исправление |
|---|---|---|---|
| **MKV с цепочкой SeekHead** (после mkvpropedit/MakeMKV) | файл «неперематываемый», seek откатывает к началу | первый `SeekHead` ссылается только на второй `SeekHead` в конце файла, а уже тот — на `Cues`. `MatroskaExtractor` по цепочке не идёт → `SeekMap.Unseekable` | `SeekHeadChainExtractor`: через peek читает первую SeekHead; если в ней только ссылка на SeekHead — `RESULT_SEEK` к хвостовой, берёт позицию `Cues`, возвращается на 0 и на лету подменяет в первой SeekHead ID и позицию (`PatchingInput`). Обычные файлы не получают лишних запросов |
| **AC3/E-AC3/DTS в AVI** | рывки, постоянная буферизация, 173 пропуска за 16 с | AVI-куски по 1280 байт не совпадают с кадрами AC3 (1024 байта). `AviExtractor` отдаёт кусок как кадр, аудиосинк считает 32 мс вместо 40 → `UnexpectedDiscontinuityException` | `AviAudioFramingExtractor`: режет поток на настоящие кадры. Время кадра = время первого куска после старта/перемотки + байты × длительность кадра / размер кадра (время куска у `AviExtractor` в начале файла расходится на 0,5 с) |
| **MPEG-2 в VOB/MPEG-PS** | ~1–1,5 к/с, при этом счётчик пропусков молчит | `PsExtractor` даёт PES без PTS время **0**, а не «неизвестно»; `H262Reader` достраивает времена «предыдущее + кадр» в порядке декодирования. 14 из 15 кадров GOP получали 0, рендерер их **пропускал как «до старта»** (skipped, не dropped) | `Mpeg2TimestampFixExtractor`: читает `temporal_reference` из заголовка картинки и ставит время `ключевой + (tr − tr ключевого) × длительность кадра`. Частота кадров — из `frame_rate_code` в CSD |
| **DVD private stream 1 (0xBD)** | «квакающий» звук через passthrough | `PsExtractor` отдаёт 0xBD в `Ac3Reader` как есть: с 4-байтовым заголовком подпотока и со всеми подпотоками (субтитры, DTS, LPCM, вторая AC3) | Форк `PsExtractor`/`PsBinarySearchSeeker`/`PsDurationReader` (Apache 2.0, `engine-exo/src/main/java/.../exo/ps/`): `DvdPsExtractor` срезает заголовок подпотока, каждому AC3-подпотоку (0x80–0x87) даёт свой `Ac3Reader` и дорожку, ищет дорожки в окне 1 МБ. PES без PTS → `C.TIME_UNSET` |
Урок: **для диагностики нужен счётчик «кадров/с на экране» и «пропущено декодером»** (`DecoderCounters.skippedOutputBufferCount`), а не только «пропущено кадров» (dropped). Проблему MPEG-PS поймал именно он.
### 5.6 ASS/SSA
- `ass-media` 0.5.1 (libass), `AssRenderType.OVERLAY_OPEN_GL`, кэш 32 МБ, рендер не больше 1080p.
- `AssSubtitleView` кладётся **внутрь `AspectRatioFrameLayout`**, чтобы раскладка шла относительно кадра, а не экрана.
- `AssMatroskaExtractor` библиотеки нужно обернуть в `SeekHeadChainExtractor`, иначе теряется исправление перемотки. Внешние ASS — через `setSubtitleParserFactory`.
- Библиотека собрана под media3 1.8.0 и лезет рефлексией в приватные поля `MatroskaExtractor` (`extractorOutput`, `subtitleSample`): при обновлении media3 проверять, при R8 нужны keep-правила.
- Проверено: 720p60 со стилизованными ASS — 60 к/с, 0 пропусков. После этого ASS-файлы остаются в Exo, VLC для них не нужен.
### 5.7 Звук
- **Passthrough** определяется через `AudioTrack.isDirectPlaybackSupported` для каждого `ENCODING_*` (`DeviceCapabilitiesProbe`). На встроенных динамиках Xiaomi passthrough проходит для AC3, E-AC3, JOC, TrueHD, DTS, DTS-HD, AC4, MAT — ТВ декодирует их сам (Dolby MS12). Исследование ожидало, что TrueHD и DTS-HD «из приложения не пройдут», — на этом ТВ проходят.
- **ffmpeg-аудио** (`media3-ffmpeg-decoder`) нужен для устройств без passthrough (телефоны, саундбары без декодера). С media3 1.11.1 работает (TrueHD и DTS декодировались при принудительном PREFER). APK вырос на ~40 МБ → нужны ABI-сплиты.
- Многоканальный PCM ExoPlayer сам направляет правильно (в отличие от VLC, §6.3).
### 5.8 Чересстрочное видео
- MPEG-2 декодирует аппаратный `c2.mtk.mpeg2.decoder`, а на видеослое ТВ деинтерлейсит сам (DispService: `interlace 1, scanType 1`). Через GPU видна «гребёнка», поэтому чересстрочное в AUTO всегда идёт на видеослой.
- **Исключение — DVD (MPEG-PS) с нижним полем первым**: гребёнка в движении остаётся и на видеослое. MKV с верхним полем первым — чисто. Jellyfin порядок полей не сообщает, поэтому правило сделано по контейнеру (`DVD_COMBING` → VLC с yadif). Точнее было бы читать `top_field_first` из picture coding extension.
### 5.9 Частота кадров (24p/25p)
- На этом ТВ ничего делать не нужно, если видео идёт через видеослой: ТВ сам переключает панель (проверено по DispService `PanelTiming`/`outFmt`).
- Через GPU — 60 Гц UI, отсюда правило AUTO из §5.2.
- На ТВ-приставках с HDMI нужно самим ставить `preferredDisplayModeId` и ждать `onDisplayChanged`: media3 сам умеет только бесшовную смену через `Surface.setFrameRate` (см. исследование [`research/media3.md`](research/media3.md) §3).
### 5.10 Стиль субтитров и диагностика
- Стиль текстовых субтитров (размер, цвет, фон) — через `SubtitleView` на лету. Пока виден OSD, субтитры поднимаются над ним.
- `AnalyticsListener` для статистики: декодер, кадры/с на экране, пропущено, пропущено декодером, срывы звука (`onAudioUnderrun`), число буферизаций (после первых 3 с от загрузки или перемотки).
---
## 6. libVLC (3.7.7 / libVLC 3.0.25)
### 6.1 Опции
```
--network-caching=3000
--audio-time-stretch
--no-sub-autodetect-file
--deinterlace=-1 # авто: только для кадров, помеченных interlaced
--deinterlace-mode=yadif
-v # -vv для отладки: в logcat видны полные HTTP-заголовки
```
Плюс опции freetype для стиля субтитров (§6.6).
### 6.2 Сеть: локальный прокси (`LoopbackProxy`) — обязательно
**Проблема.** libVLC ходит по HTTPS через GnuTLS. Через reverse proxy (openresty, HTTP/1.1):
- после Range-запроса к концу MKV (чтение индекса) — «connection failed»;
- перемотка к концу файла ждёт ~10 с;
- иногда prefetch уходит в бесконечный цикл: 97 % CPU, >1 ГБ памяти;
- `--http-reconnect` делает только хуже;
- напрямую по `http://<адрес-сервера-в-LAN>:8096`, без reverse proxy, зацикливание не воспроизводилось.
Рассматривались и другие пути: HTTP/2 на reverse proxy, старый http-модуль VLC.
Произвольных HTTP-заголовков libVLC не умеет, поэтому токен можно передать только в URL.
**Решение — HTTP-прокси внутри приложения на OkHttp.**
- VLC ходит только на `http://127.0.0.1:<port>/<random-key>/<путь сервера>?<query без ApiKey>`.
- Прокси повторяет запрос через OkHttp (общий пул соединений) и ставит токен заголовком `Authorization`.
- Путь сервера сохраняется, поэтому относительные URL в HLS-плейлистах тоже идут через прокси. Внешние субтитры (`addSlave`) — тоже.
- Каждый ответ с `Connection: close`: одно соединение на запрос, закрытие сокета отменяет запрос к серверу.
- Сокет явно на `127.0.0.1`: `InetAddress.getLoopbackAddress()` на Android даёт `::1` → Connection refused.
- `VlcEngine.release()` первым делом закрывает прокси, тогда libVLC освобождается за ~36 мс.
- Бонус: TLS, пользовательские CA и `network_security_config` работают как у остального приложения.
- Запись `http stream: connection failed` в логе VLC после этого — штатная (закрытие соединения).
**Результат:** перемотка по сети ~0,1 с, зависаний нет.
### 6.3 Кэш недавних байт (`RecentBytesCache`)
- Фильтр `prefetch` VLC 3.0 выбрасывает прочитанное, как только заполнится буфер 16 МБ (`modules/stream_filter/prefetch.c`). Любой шаг назад становится новым Range-запросом с повторной закачкой ~16–20 МБ.
- Демуксер AVI (OpenDML) делает такой шаг каждые ~1,5 с. Файл 2 Мбит/с без кэша качался на 40–80 Мбит/с при 191 % CPU.
- Кэш в прокси: 32 МБ на движок, блоки по 256 КБ. С ним с сервера идёт только новое (≈ битрейт файла), CPU ~20 %.
- `If-Range`/`If-Unmodified-Since` от VLC в пути через кэш игнорируются — безопасно, пока файл не меняется во время просмотра.
### 6.4 Аппаратное декодирование
- Модуль `mediacodec` VLC при старте опрашивает все декодеры. На MediaTek каждый `getCapabilitiesForType` бросает исключения, и **опрос занимает ~45 с**.
- Поэтому `setHWDecoderEnabled(true)` только для видео больше 1080p (4K там программно не потянуть). Всё ≤1080p VLC декодирует программно.
- Программно на 4×A73 (32-bit) VLC тянет 1080p 24–30 к/с и SD 50 к/с (деинтерлейс DVD). 720p60 и 1080i50 (yadif удваивает кадры) — с пропусками. Отсюда правило `SOFTWARE_DECODE_HEAVY` в §3.3.
### 6.5 Звук
- **Многоканальный PCM на этом ТВ беззвучен.** Устройство `pcm` (по умолчанию в libvlc-android) отдаёт 6-канальный PCM. Android направляет его в DIRECT-выход (`AUDIO_OUTPUT_FLAG_DIRECT|DEEP_BUFFER`): трек создаётся и удаляется, `Total writes: 0`. Без звука были AC3 5.1 и HE-AAC 5.1.
- Решение (`VlcEngine.applyAudioOutput`, пересчитывается при смене дорожки):
- кодек есть в `passthroughEncodings` → `forceAudioDigitalEncodings(int[])`, passthrough, выход OFFLOAD, ТВ декодирует сам;
- иначе → `setAudioOutputDevice("stereo")` — сведение в 2 канала через обычный микшер.
- Устройства модуля `android_audiotrack`: `stereo` (до 2 каналов), `pcm` (до 8), `encoded[:mask]` (passthrough).
- **При транскоде** выходной аудиокодек нужно брать из `AudioCodec` в URL транскода. Ошибка, которую мы сделали: VLC включал passthrough исходного TrueHD, а сервер отдавал AAC.
- Минус: многоканальный не-passthrough звук (AAC/FLAC/Opus 5.1) сводится в стерео. На системе 5.1 по HDMI это потеря — выбирать `pcm`/`stereo` по реальному выходу.
### 6.6 Субтитры
- ASS, PGS, VobSub, DVB VLC рисует сам.
- Стиль текстовых субтитров задаётся только **опциями freetype при создании `LibVLC`**: `--freetype-rel-fontsize`, `--freetype-color`, `--freetype-background-opacity`, `--freetype-shadow-opacity`, `--freetype-outline-thickness`. Поэтому `LibVLC` создаётся лениво, а смена стиля при включённых субтитрах перезапускает VLC с той же позиции.
- **Внешние субтитры:** слейв-дорожка появляется позже первого `ESAdded`. Выбор нужно повторять на каждом `ESAdded`, пока нужная дорожка не появится (`pendingSubtitle`), иначе внешние субтитры не включаются.
### 6.7 Перемотка в MPEG-PS
- VLC перематывает MPEG-PS по среднему битрейту: старт с 30:00 попадал на 26:25.
- `VlcEngine.checkSeek`: через 1,5 с после перемотки или старта сравнивает, куда попали, и перематывает повторно с поправкой (до 3 раз, допуск 5 с). 30:00 → 30:02 за 2 поправки.
### 6.8 Жизненный цикл
- `MediaPlayer.release()` и `LibVLC.release()` **блокируют на зависшем потоке** → освобождать только в фоновом потоке.
- Свой `LibVLC` на каждое воспроизведение.
- `VLCVideoLayout` создавать с контекстом **Activity**, иначе видео выводится в нативном размере в углу экрана.
- Буфер для таймлайна libVLC не сообщает.
---
## 7. Сессия: fallback и сторожа (`PlaybackSession`)
- **Сторож первого кадра:** нет кадра за 60 с → ошибка `NO_VIDEO_OUTPUT` → следующий движок.
- **Сторож застоя** (тик 1 с):
- движок в READY и `playWhenReady`, а позиция не растёт ≥ 8 с, **или** за 10 с пропущено > 25 % кадров → `STALLED` → следующий движок;
- 5 с после перемотки не судим;
- долгая буферизация застоем **не** считается: это сеть, другой движок быстрее не скачает;
- если движок выбран вручную — только запись в лог.
- Проверено: AVI с AC3 до исправления экстрактора — переход exo → vlc на 611 с; ложных срабатываний при перемотках нет.
- **Неперематываемый файл:** `PlaybackEngine.isSeekable` (в Exo: `currentTimeline.isEmpty || isCurrentMediaItemSeekable`). При seek в таком файле сессия сразу запускает следующий движок с целевой позиции.
- **Позиция до первого кадра.** Пока движок не показал первый кадр, его позиция равна 0 (или отсчитывается от начала транскода). Для передачи другому движку брать позицию, с которой движок запускали. Иначе быстрое переключение или fallback начинает фильм сначала.
- **Поколения.** Каждый запуск движка увеличивает `generation`. События и сторожа старого движка сверяют поколение и игнорируются.
- При переходе на другой движок переносятся режим вывода и выбор DV.
- Если не справился ни один движок — транскод (`enableDirectPlay = false`).
- Отчёт `Stopped` уходит через `GlobalScope`. При убийстве процесса сессия на сервере «висит» — некритично.
---
## 8. Память
- Куча Java на ТВ — 192 МБ. ExoPlayer `DefaultLoadControl` по умолчанию копит до ~145 МБ (в дампе 2222 блока по 64 КБ). Возле лимита GC молотит, плеер тормозит, потом `OutOfMemoryError` (в том числе внутри JNI VLC).
- Исправление: `largeHeap="true"` (512 МБ) + буфер ¼ кучи, но не больше 64 МБ. После: 10 кругов переключения движков на 4K AV1 без падений, куча ~78 МБ.
- Свой экземпляр движка на воспроизведение, освобождение старого до создания нового.
- PGS в media3 декодирует все дорожки, даже невыбранные (#2667) — следить за памятью на файлах с десятком PGS.
---
## 9. Особенности телевизора MediaTek (Xiaomi MT9689)
| Особенность | Что делать |
|---|---|
| **«Засор» видеоконвейера.** Если процесс убит (`am force-stop`, вылет по OOM) во время воспроизведения, поток остаётся висеть в DispService (`srcT[0]`). После этого любой 4K HEVC получает от `c2.mtk.hevc.decoder` `NO_MEMORY` (0xfffffff4) — и в Exo, и в VLC — до перезагрузки ТВ | Не допускать вылетов (§8). При штатном выходе поток освобождается. **Если 4K вдруг перестал декодироваться и в логе `NO_MEMORY` — сначала перезагрузить ТВ.** Тестировать только со штатным выходом |
| **Застрявший звук.** После `force-stop` посреди AC3 passthrough аудио-HAL Xiaomi продолжал играть звук и после закрытия приложения | Закрывать плеер «Назад», force-stop только после этого |
| ТВ иногда целиком работает втрое медленнее (UI, декодеры) до перезагрузки; признаки — большой аптайм, занятая подкачка | Перед серьёзными замерами перезагружать ТВ |
| Постобработка изображения на видеослое | §5.2: SDR ≤1080p через TextureView |
| Многоканальный PCM беззвучен | §6.5: passthrough или стерео |
| `MediaCodecList` отличается от `media_codecs.xml` (P7 в XML есть, в рантайме нет) | Опрашивать только рантайм |
| VC-1 зарегистрирован как `video/VC1` (регистр), media3 ищет `video/wvc1` | Сравнивать MIME без учёта регистра; VC-1 в Exo только после явной проверки |
| HEVC заявлен ниже реального (L5.1 High tier → `EXCEEDS_CAPABILITIES`) | `allowExceedsCapabilities` и не доверять пределам |
| AVC High10 есть аппаратно | Проверять `AVCProfileHigh10` в профилях декодера, а не считать Hi10P программным |
| `c2.mtk.mpeg4.decoder` — максимум 1344×1152 | DivX 1080p — не аппаратно |
| Частота панели переключается сама только на видеослое | §5.9 |
| Порт беспроводной отладки меняется после перезагрузки | Классический `adb connect <ip-тв>:5555` тоже был открыт |
### 9.1 Как опрашивать устройство (`DeviceCapabilitiesProbe`)
- `MediaCodecList(REGULAR_CODECS)`: декодеры, аппаратный ли (`isHardwareAccelerated`), профили (`CodecProfileLevel`), пределы размеров (`VideoCapabilities.isSizeSupported`).
- HDR-типы дисплея (DV, HDR10, HDR10+, HLG): на API 34+ из режима дисплея, раньше — `Display.hdrCapabilities.supportedHdrTypes`.
- Passthrough: `AudioTrack.isDirectPlaybackSupported(AudioFormat(encoding, 48000, 7.1 / 5.1 / стерео), attrs)` для AC3, E-AC3, E-AC3 JOC, TrueHD, DTS, DTS-HD, AC4, MAT.
- Результат кэшируется на запуск (и при смене дисплея).
---
## 10. Что осталось нерешённым
- **Транскод DV-файла в ExoPlayer падает**: `FragmentedMp4Extractor: Top bit not zero: -2048` на HLS fMP4 от сервера; повтор тоже падает, VLC не пробуется. SDR HEVC транскодом играет. Затрагивает выбор качества ниже оригинала для DV-файлов. Вероятные пути: транскод в `ts` вместо fMP4 для DV или fallback на VLC при ошибке транскода.
- **DV P7** без DV-декодера: переписывать формат на HEVC в Exo (нет тестовых файлов).
- **SDR 4K** идёт через видеослой с постобработкой ТВ (GPU 4K не тянет). Можно поискать способ отключить PQ у Xiaomi.
- **Порядок полей** определяется по контейнеру, а не по потоку.
- Многоканальный не-passthrough звук в VLC сводится в стерео.
- Сервер с самоподписанным сертификатом не проверен (настройка доверия пользовательским CA есть).
- Буфер на таймлайне только у Exo.
- Exo → VLC при неперематываемом файле: обратно на Exo не возвращаемся, нажатия во время переключения теряются.
---
## 11. Как тестировали
### 11.1 Режим обхода и варианты (только debug-сборка)
- **Варианты** — цифрами с пульта или из меню:
- `0` — авто;
- `1` — Exo, видеослой (DV как базовый слой);
- `2` — Exo через GPU;
- `3` — VLC;
- `4` — Exo, видеослой, DV-декодер;
- `9` — транскод.
Значок в углу: «N ДВИЖОК · вывод», «авто/вручную», время, пропуски. Так можно сравнивать один и тот же момент фильма в разных режимах.
- **Режим обхода** — список из 38 файлов, жадно покрывающих 155 признаков медиатеки: контейнер, кодек/битность, разрешение, HDR-тип, fps, interlaced, аудиокодек/каналы, субтитры, плюс пары «контейнер × кодек», «кодек × HDR». Генератор — `tools/tour/build_tour.py` (по API сервера). Файлы играются с середины с заранее выбранными дорожками:
- ↑ — следующий вариант;
- ↓ — следующий файл (запуск через 2 с после последнего нажатия, чтобы быстрое листание не рвало плеер);
- ←/→ — перемотка.
- Обход проходили вручную и оценивали глазами и ушами. Почти все находки §5–§9 пришли из двух таких прогонов.
### 11.2 Диагностика
- Панель «Информация о потоке»: режим (DirectPlay/транскод и причина по-русски), контейнер, видео, частота, битрейт, аудио, субтитры, движок, декодер, вывод звука, буфер, пропуски, кадров/с на экране, пропущено декодером, срывы звука, буферизации, строка прокси VLC.
- `tools/tv/env.sh` (`source` перед работой):
| Функция | Что делает |
|---|---|
| `play <id> [exo\|vlc]` | запустить воспроизведение через intent |
| `k KEY…` | нажатия пульта |
| `shot name` | скриншот |
| `plog` | журнал `PlaybackSession` (выбор движка, причины, fallback) |
| `sess` | что играет по данным сервера: позиция, PlayMethod |
| `aout` | реально ли звук уходит на выход (`dumpsys media.audio_flinger`: `Total writes`) |
| `vproxy` | журнал прокси VLC: диапазоны, попадания в кэш, сколько скачано |
- Полезные системные команды:
- `adb shell dumpsys media.audio_flinger` — выходы, флаги DIRECT/OFFLOAD, `Total writes`;
- DispService в logcat — `Hdr Dolby`, `PanelTiming`, `interlace`, `srcT[…]` (висящие потоки);
- `dumpsys display` — HDR-типы и режимы;
- `adb logcat -v raw -s VLC` с `-vv` — HTTP-заголовки libVLC.
- Отладочный запуск: `adb shell am start -S -n <pkg>/.MainActivity --es item <id> [--es engine exo|vlc] [--ez play true] [--el start <ms>]`.
### 11.3 Итог тестов (проверено на глаз и на слух)
| Что | Результат |
|---|---|
| Фильм HEVC 4K HDR10 55 Мбит/с, TrueHD Atmos, ASS forced | Exo, видеослой: «отлично», 0 пропусков (после перехода на LAN-адрес) |
| Фильм DV P8.1 + HDR10+, AC3 | Exo, DV-декодер: значок DV, «цвета хорошие» |
| SDR ≤1080p 30/60 к/с | Exo через GPU «намного лучше», чем видеослой |
| 24p/25p | видеослой: «нормально и плавно» |
| AVI Xvid + AC3 | Exo после исправления экстрактора, 0 разрывов |
| DVD 576i (VOB) | VLC (yadif); Exo после исправлений тоже играет, но с гребёнкой |
| Звук в VLC (AC3 passthrough, AAC → стерео) | «звук теперь везде норм» |
| MKV с цепочкой SeekHead | Exo перематывает |
| ASS 720p60 | Exo, 60 к/с, 0 пропусков |
---
## 12. Чек-лист для своего приложения
1. Два движка за одним узким интерфейсом. Дорожки адресовать индексами сервера. Свой DeviceProfile на движок.
2. Опрашивать устройство в рантайме: `MediaCodecList`, `HdrCapabilities`, `isDirectPlaybackSupported`. Не доверять `media_codecs.xml` и заявленным пределам (`allowExceedsCapabilities`).
3. Выбор движка по весам потерь; HDR дороже всего. Пересчитывать при смене дорожки.
4. Сторожа первого кадра и застоя; fallback с позиции запуска, если кадра ещё не было; транскод последним.
5. ExoPlayer: ограничить буфер (¼ кучи, ≤64 МБ), `largeHeap`, decoder fallback, ffmpeg-аудио после MediaCodec, tunneling выключен.
6. Свой `MediaCodecSelector` для DV (пустой список → базовый слой). DV-декодер — только при DV-дисплее.
7. HDR, 4K, чересстрочное и 24/25/50 к/с — всегда SurfaceView. SDR ≤1080p 30/60 к/с — по устройству (на Xiaomi через TextureView).
8. Исправленные экстракторы: SeekHead-цепочка в MKV, AC3 в AVI, MPEG-2 PTS в PS, DVD private stream.
9. ASS через `ass-media` в `AspectRatioFrameLayout`, без серверного прожига.
10. libVLC только через локальный HTTP-прокси с кэшем недавних байт; токен — заголовком из прокси; сокет на `127.0.0.1`.
11. libVLC: аппаратное декодирование только >1080p на MediaTek; звук — passthrough или `stereo`, никогда многоканальный `pcm` на ТВ; release в фоне; `VLCVideoLayout` с Activity-контекстом; повтор выбора внешних субтитров на каждом `ESAdded`; поправка перемотки в MPEG-PS.
12. Видео и картинки дома — с LAN-адреса сервера (UDP 7359), а не через HTTPS reverse proxy.
13. Авторизация Jellyfin 12 — только `Authorization: MediaBrowser …` или `ApiKey`; вызовы SDK — на IO.
14. В диагностике обязательно «кадров/с на экране» и «пропущено декодером», а не только dropped.
15. Тестировать на реальном ТВ, по обходу, покрывающему признаки медиатеки, со штатным выходом из плеера. HDR оценивает только человек: `screencap` его не видит.

61
docs/research/README.md Normal file
View File

@@ -0,0 +1,61 @@
# Исследование движков воспроизведения (начало проекта, 2026-10-11…12)
Это исследование сделано **до** написания кода: по исходникам, issue-трекерам, Maven и дампу тестового ТВ. Часть выводов потом подтвердилась на реальном телевизоре, часть оказалась неверной. Что изменилось после тестов, сказано в конце этого файла, а итог описан в [`../playback.md`](../playback.md).
## Файлы
| Файл | О чём |
|---|---|
| [`decisions.md`](decisions.md) | Принятые решения: гибридный движок, выбор движка, fallback, тестовый ТВ, что проверить прототипом (часть 7 общего [`jellyfin-client-research.md`](jellyfin-client-research.md)) |
| [`media3.md`](media3.md) | media3/ExoPlayer: Dolby Vision, HDR, tunneling, частота дисплея, passthrough, FFmpeg, ASS (libass), контейнеры, DeviceProfile из jellyfin-androidtv |
| [`engines.md`](engines.md) | Как устроены несколько движков в других клиентах (Findroid, Wholphin, JellyPlay, Swiftfin, Plezy, Kodi…), дизайн абстракции, Gradle-модули, libVLC против libmpv, алгоритм выбора, runtime-fallback |
| [`device-xiaomi.md`](device-xiaomi.md) | Тестовый ТВ Xiaomi S Pro Mini LED 75 и семейство MediaTek: известные баги декодеров, DV, passthrough, чек-лист проверок |
| [`library-profile.md`](library-profile.md) | Профиль медиатеки тестового сервера: какие контейнеры, кодеки, HDR, аудио и субтитры встречаются и сколько |
| [`tv-dump/`](tv-dump/) | Сырые дампы ТВ: `media_codecs-mt5896.xml`, `dumpsys display`, `audio_policy` |
Полный исходный документ со всеми частями (Jellyfin API, обзор клиентов, libVLC, Android TV, инструменты): [`jellyfin-client-research.md`](jellyfin-client-research.md).
## Главное из исследования
**Цель** — воспроизводить всё, что можно, без серверного транскода и с правильным HDR и Dolby Vision. Официальный клиент Jellyfin, Moonfin и Dune на этом ТВ спотыкались каждый на своих файлах: транскодировали, давали тусклый HDR или зависали.
1. **Одного движка недостаточно.**
- media3/ExoPlayer — единственный путь к аппаратному декодированию с HDR10, HDR10+, HLG и DV, а также к passthrough. Но он не открывает M2TS, ASF/WMV, ISO/BDMV, MKV со сжатием zlib, не умеет программно H.264 Hi10P, VC-1, MPEG-4 ASP и не рисует ASS сам.
- libVLC и libmpv играют почти всё через ffmpeg, но HDR у них ненадёжен.
- Отсюда **гибрид: media3 основной, libVLC запасной.**
2. **Запасным выбран libVLC 3.7.x, а не libmpv.**
- Это официальный артефакт VideoLAN со стабильным Java API, внутри libass, PGS и VobSub, есть passthrough.
- Стоковый mpv-android переводит HDR в SDR через GLES, а AAR у него неофициальный.
- Цена libVLC — +24–55 МБ на ABI.
3. **Автоматический выбор движка до запроса PlaybackInfo.** Данные файла от Jellyfin (контейнер, кодек, битность, `VideoRangeType`, профиль DV, дорожки) сравниваются с возможностями устройства (`MediaCodecList`, HDR-типы дисплея, `AudioTrack.isDirectPlaybackSupported`). У каждого движка свой DeviceProfile.
4. **Runtime-fallback по образцу Plezy.** Если движок упал, завис или не дал кадра, сессия переходит на другой движок с той же позиции; если не справился и он — серверный транскод. Среди Jellyfin-клиентов такого нет ни у кого.
5. **Архитектура:**
- свой узкий интерфейс `PlaybackEngine` плюс интерфейсы-возможности (задержка звука, стиль субтитров и т. п.);
- каждый движок — отдельный Gradle-модуль, подключается через Hilt `@IntoSet` (позже DI перевели на Koin);
- реализация движка «как media3 `Player`» (Findroid, Wholphin) приводит к десяткам `TODO()`, поэтому так не делаем.
6. **Риски, найденные заранее:**
- DV P7 в media3 без DV-декодера не откатывается на HEVC;
- DV + HDR10+ зависает на MediaTek (#1895);
- HEVC-декодер MTK падает посреди потока (#3421), и `setEnableDecoderFallback` это не ловит;
- вечная буферизация при AC3 passthrough после быстрых перемоток (#3407);
- TrueHD и DTS-HD из приложения на встроенных динамиках, скорее всего, не выйдут в passthrough;
- смену частоты и HDR-режима media3 сам не делает.
7. **Тестовый ТВ** (дамп через adb):
- MediaTek MT9689, Android 14, **только 32-bit userspace (armeabi-v7a)**, 2,4 ГБ RAM;
- один режим дисплея 4K@60;
- дисплей заявляет DV, HDR10, HDR10+ и HLG;
- аппаратные AVC/HEVC/VP9/AV1 до 8K, MPEG-2/VC-1/MPEG-4 до 1080p.
## Что тесты потом опровергли или уточнили
| Ожидание из исследования | Как оказалось на ТВ |
|---|---|
| DV P7 есть в железе (по XML-дампу) | В рантайме `MediaCodecList` **не показывает** `dvhe.dtb` (P7). Наличие в XML не гарантирует наличие в API |
| H.264 Hi10P — только программно (libVLC) | `c2.mtk.avc.decoder` заявляет High10 и **играет Hi10P аппаратно** |
| DV + HDR10+ виснет на MediaTek (#1895) | На этом ТВ играет без зависаний, и с DV-декодером, и как базовый слой |
| DV-декодер даёт «тусклую» картинку | ТВ действительно включает режим Dolby Vision (значок, DispService `Hdr Dolby`). «Тускло» — это режим изображения DV в настройках ТВ, а не поломка. DV-декодер включён по умолчанию |
| Смена частоты нужна вручную (`preferredDisplayModeId`) | Android видит один режим 4K@60, но **на аппаратном видеослое ТВ сам переключает панель**: 23,976 → 119,88 Гц, 25 → 100 Гц. Через GPU-путь (TextureView) этого нет |
| HTTPS для libVLC достаточно проверить на самоподписанном сертификате | libVLC (GnuTLS) через reverse proxy ненадёжен даже с нормальным сертификатом: зацикливается prefetch, перемотка к концу файла ждёт по ~10 с. Решено локальным HTTP-прокси внутри приложения |
| `VLCVideoLayout` и libVLC — вопрос API | Главные проблемы VLC оказались в звуке (многоканальный PCM на этом ТВ беззвучен) и в сетевом кэше (prefetch выбрасывает прочитанное) |
| VC-1 через media3, если есть декодер | Декодер зарегистрирован как MIME `video/VC1` (регистр!), а media3 ищет `video/wvc1` |
| Аппаратное декодирование в libVLC — по умолчанию | Опрос MediaCodec модулем VLC на MediaTek длится ~45 с. Аппаратно в VLC включаем только для видео больше 1080p |

View File

@@ -0,0 +1,55 @@
# Часть 7. Гибридный движок воспроизведения (дополнение от 2026-10-12)
Цель проекта уточнена: не «клиент на libVLC», а **клиент, который воспроизводит всё возможное без серверного транскода и с корректным HDR/Dolby Vision**. Полные отчёты: [media3.md](media3.md), [engines.md](engines.md), [device-xiaomi.md](device-xiaomi.md); сырые дампы тестового ТВ — [tv-dump/](tv-dump/).
## 7.1 Принятые решения
| Вопрос | Решение |
|---|---|
| Движок | Гибрид: **media3/ExoPlayer — основной** (аппаратный MediaCodec, HDR10/HDR10+/HLG/DV, passthrough), **libVLC 3.7.7 — запасной** (программный ffmpeg для того, что не тянет железо/media3). mpv и libVLC 4 — возможные будущие модули. |
| Расширяемость | Свой узкий интерфейс `PlaybackEngine` + capability-интерфейсы (AudioDelay, SubtitleDelay, Passthrough, SubtitleStyle…); каждый движок — отдельный Gradle-модуль, регистрируется через Hilt `@IntoSet`; один адаптер `SimpleBasePlayer` в ядре для MediaSession/UI. Треки адресуются индексами Jellyfin `MediaStream`. |
| Выбор движка | Автоматический, **до** `PlaybackInfo`: данные файла (контейнер, кодек, профиль, битность, `VideoRangeType`, DV-профиль, субтитры, аудио) × возможности устройства (`MediaCodecList`, HDR-типы дисплея, `AudioCapabilities`, `RendererCapabilities` media3). Порядок: ручной выбор → media3 Direct → вариант с наименьшими потерями (HDR важнее всего) → серверный транскод. DeviceProfile — свой для каждого движка. |
| Runtime-fallback | Как в Plezy: при ошибке декодера / зависании / 0 кадров — один переход на другой движок с сохранением позиции и треков; если упал и он — транскод. Ни один Jellyfin-клиент этого не делает — это ключевое отличие проекта. |
| Сервер / SDK | Только Jellyfin 12.x, `jellyfin-sdk-kotlin` 1.9.x. |
| Платформа | Сначала Android TV, общие модули для будущего phone-UI. |
| DI / лицензия | Hilt (позже заменён на Koin); GPL-3.0-or-later; собственное название без Jellyfin-бренда. |
| Сборки | `armeabi-v7a` обязателен (тестовый ТВ — 32-bit userspace), сплиты по ABI; flavors `full` (Exo+VLC) / `lite` (только Exo). Dynamic Feature Delivery не подходит (sideload/F-Droid). |
## 7.2 media3: что играет и что нет (кратко)
- **Да:** HDR10/HDR10+/HLG, DV в MKV и MP4 (`dvcC`/`dvvC`), passthrough AC3/E-AC3/JOC/TrueHD/DTS/DTS-HD, ASS через `io.github.peerless2012:ass-media` (libass), PGS/VobSub/DVB внутри MKV, AVI. Вывод только через `SurfaceView` (Compose `PlayerSurface` с `SURFACE_TYPE_SURFACE_VIEW`).
- **Нет (→ libVLC):** M2TS, ASF/WMV, ISO/VIDEO_TS/BDMV, MKV с zlib, ordered chapters; H.264 Hi10P; VC-1/MPEG-2/MPEG-4 ASP без HW-декодера; TrueHD/PGS/VC-1 в TS; внешние `.sup`/`.idx`. FFmpeg-расширение media3 — только аудио (`org.jellyfin.media3:media3-ffmpeg-decoder`).
- **Риски:** DV P7 без DV-декодера не имеет фолбэка на HEVC (нужен свой `MediaCodecSelector` или `DOVI_WITH_EL` как неподдерживаемый); DV P8.1+HDR10+ виснет на MediaTek (#1895); падения HEVC-декодера MTK посреди потока не ловятся `setEnableDecoderFallback`; смену частоты/HDR-режима дисплея делать самим (`preferredDisplayModeId` + ожидание `onDisplayChanged`); все PGS-дорожки декодируются даже невыбранные (#2667, память). Tunneling — опция, по умолчанию выкл.
- jellyfin-androidtv объявляет в direct play ASF/WMV/VOB, которые media3 не открывает, — вероятный источник ошибок официального клиента.
## 7.3 libVLC vs libmpv как запасной
libVLC 3.7.7 выбран: официальный артефакт VideoLAN с регулярными релизами, стабильный Java API, libass/PGS/VobSub внутри, passthrough, выравнивание 16 KB есть. libmpv (`dev.jdtech.mpv:libmpv`) меньше (≈25 МБ против 55 МБ несжатых `.so` на arm64), но артефакт неофициальный, а HDR в стоковом mpv-android уходит в SDR через GLES. Для VLC при `useLegacyPackaging=true` размер ≈ +24 МБ.
## 7.4 Тестовый ТВ: Xiaomi TV S Pro Mini LED 75 (снято через adb 2026-10-12)
| Параметр | Значение |
|---|---|
| Модель / ОС | `MiTV-MOEU1` (`lalaland`), Android 14 / API 34, `leanback_only` |
| SoC | MediaTek **MT9689** (`ro.hardware=mt5896`, платформа `merak`) |
| ABI | **только `armeabi-v7a`** (32-bit userspace) |
| RAM | 2,4 ГБ видно системе; `MemAvailable` ≈ 870 МБ (MemFree ≈ 60–240 МБ вводит в заблуждение — остальное кэш), swap 600 МБ почти занят — бюджет памяти умеренно жёсткий; хранилище 22 ГБ свободно |
| UI / дисплей | приложения рисуются в 1920×1080 (320 dpi), видеоплоскость 4K; Android видит **один режим 3840×2160@60** — переключение частоты через Android API недоступно; есть Xiaomi-настройка `mitv_vrr_frame_rate_enable_23` (не исследовано) |
| HDR дисплея | DV, HDR10, HLG, HDR10+ |
| HW-видео (`media_codecs-mt5896.xml`) | AVC/HEVC/VP9/AV1 до 8K; MPEG-2, VC-1, WMV3, MPEG-4, H.263, RealVideo, AVS/AVS2 до 1080p; DV-декодеры `dvav.per/pen`, `dvhe.der/den/dtr/stn/dth/dtb/st`, `dvav.se`, `dvav1` — т.е. профили 1–10, **включая P7 (`dvhe.dtb`)** |
| Аудио | Dolby MS12; выходы заявляют AC3, E-AC3, E-AC3-JOC, TrueHD, DTS, DTS-HD; сейчас активен только `speaker` |
Расхождения с веб-исследованием: исследование по открытым источникам предполагало отсутствие P7 и VC-1 в железе — дамп устройства это опровергает (наличие в XML ≠ гарантированная работа через MediaCodecList; проверить в прототипе).
Известные проблемы этого семейства (проверить на устройстве): jellyfin-androidtv #5825 — на Xiaomi S Pro (кит. HyperOS) media3-клиенты не включают DV, а Just Player включает; stutter `c2.mtk.hevc.decoder` (media3 #3347); вечная буферизация при AC-3 passthrough после быстрых seek на MTK audio HAL (#3407); искажённый E-AC3 passthrough на Xiaomi (jellyfin #5366); MTK DV-декодеру нужен 4-байтный start code для NAL 62/63 (квирк Kodi).
## 7.5 Что проверить первым прототипом
1. DV P5, P8.1, P7 (MEL/FEL), P8.1+HDR10+ через media3 — включается ли DV-режим ТВ (кейс #5825).
2. HDR10 / HDR10+ / HLG; возврат дисплея из HDR после выхода.
3. 24p-контент: есть ли judder при фиксированных 60 Гц; что делает `Surface.setFrameRate`.
4. VC-1 и MPEG-2 через media3+MediaCodec vs libVLC; H.264 Hi10P через libVLC (хватит ли CPU).
5. ASS со шрифтами (ass-media vs libVLC), PGS, VobSub.
6. Звук: динамики ТВ и (если есть) eARC — passthrough E-AC3/JOC, TrueHD, DTS-HD; быстрые seek при passthrough.
7. M2TS / WMV / ISO через libVLC; runtime-переход Exo → VLC при сбое.
8. Потребление памяти обоих движков на 2,4 ГБ RAM.

View File

@@ -0,0 +1,134 @@
# Xiaomi TV S Pro Mini LED 75: что важно знать для Jellyfin-клиента (media3 + libVLC/libmpv)
Дата исследования: 2026-10-12. Пометка **(не проверено)** означает, что в первоисточнике факт не подтверждён: это вывод по аналогии, данные агрегатора или сообщение одного пользователя.
---
## 0. Главное: под одним названием продаются три разных устройства
| Вариант | Где продаётся | ОС | SoC | RAM/ROM | Комментарий |
|---|---|---|---|---|---|
| **Xiaomi TV S Pro Mini LED 75 2026** (глобальная, RU: артикул вида L75MB-S**RU**/-SME) | Глобальный и российский рынок, с конца 2025 / начала 2026 | **Google TV на Android TV 14** | Quad Cortex-A73 + **Mali-G52 (2EE) MC1**, по классу это MediaTek Pentonic 700 (вероятно MT9653) **(модель чипа не проверена)** | 3 / 32 ГБ | Наиболее вероятный тестовый девайс, если ТВ куплен в РФ/ЕС с Google TV |
| **Xiaomi TV S Mini LED 75 2025** (без «Pro», глобальная) | ЕС/РФ с конца 2024 | Google TV | Тот же Quad A73 + Mali-G52 MC1 | 3 / 32 ГБ | Платформа та же. На 4PDA модель 2026 называют «урезанной китайской S 2025» |
| **Xiaomi TV S Pro 75 2025/2026, китайская версия** | Китай (бывает в серых поставках) | **HyperOS 2/3** (Xiaomi TV OS, без GMS) | **MediaTek MT9655 (Pentonic 800)** + сопроцессор Xiaomi XM9000 | 4 / 64 ГБ | 240–330 Гц, до 5200 нит. Отдельный issue Jellyfin про DV (см. §3) |
**Что сделать сразу:** определить вариант по `adb shell getprop ro.product.model`, `ro.board.platform`, `ro.hardware`, `ro.build.version.release` и `ro.product.device`. Список кодеков взять из `adb shell dumpsys media.player` или `/vendor/etc/media_codecs*.xml`, плюс посмотреть `dumpsys audio` и `dumpsys display`. Дальше в отчёте основной вариант — глобальная модель на Google TV, особенности китайской указаны отдельно.
---
## 1. Характеристики (глобальная S Pro Mini LED 75 2026)
Источник: официальная страница mi.com/global, проверено загрузкой страницы.
- **CPU/GPU:** Quad Cortex-A73, Mali-G52 (2EE) MC1. **RAM 3 ГБ, ROM 32 ГБ.**
- По таблице Wikipedia сочетание A73 @1.4 ГГц + Mali-G52 MC1 соответствует **Pentonic 700 (MT9618 / MT9653 / MT9689)**. Pentonic 800 (MT9655) использует уже Mali-G57.
- Clubic называет для S Mini LED 2025 чип **MT9653** **(не проверено официально)**.
- Похожее сочетание есть у MT9613 (A73 1.3 ГГц, G52 MC1), но у него максимум 120 Гц, а у Pentonic 700 VRR до 144 Гц. Поэтому MT9653 вероятнее.
- **ОС:** Google TV, Android TV 14 (обзоры Onliner и FormulaTV на 55" 2026, модель L55MB-SRU). Это Android 14, API 34.
- **Панель:** QD-Mini LED VA, 4K, 704 зоны (75"), пиковая яркость 1700 нит. Xiaomi заявляет 144 Гц, Onliner пишет о **нативных 120 Гц** (144 Гц доступны в игровом режиме / HDMI enhanced). Game Boost даёт 288 Гц ценой падения разрешения до 1440p и глубины цвета до 8 бит. MEMC 4K120. VRR (FreeSync Premium), ALLM.
- **HDR:** Dolby Vision, HDR10+, HDR10, HLG, Filmmaker Mode.
- Dolby Vision IQ и IMAX Enhanced официально указаны только у S Mini LED 2025. У 2026 DV IQ, по Onliner, нет.
- Отзывы: в DV тёмные сцены теряют детали и выглядят темнее, чем в HDR10+ (Onliner, FormulaTV). Choose.tv о HDR тоже отзывается негативно. Can Buy or Not (индийская S Mini LED 2026) пишет, что на их образце DV не было вовсе. Индийская линейка, вероятно, на другой платформе **(не проверено)**.
- **Звук:** 2×15 Вт, Dolby Atmos, Harman AudioEFX (приходит через OTA).
- На глобальной странице mi.com **DTS не указан**. В российских обзорах (Onliner, FormulaTV) и в обзоре AbsoluteGeeks упомянут **DTS:X**. Сам чип MediaTek поддерживает «DTS Virtual:X».
- Вывод: декодер DTS в системе, скорее всего, есть, но это нужно проверить через `AudioManager`/`dumpsys audio` **(не проверено)**.
- **HDMI:** 3× HDMI 2.1 (CEC, ALLM, VRR). HDMI 1 и 2 поддерживают 4K144. **eARC на HDMI 2.** Третий порт без FRL (4K60, 8 бит). Есть оптический выход, 3.5 мм, USB 2.0 + USB 3.0, LAN, Wi-Fi 6, BT 5.2.
- **Netflix:** по Onliner, приложение не сертифицировано («эта версия несовместима с устройством»), FormulaTV пишет обратное. Это косвенный признак «сырой» сертификации прошивки.
- **Китайская версия (HyperOS):** MT9655, 4/64 ГБ, HyperOS 3.0, заявлен DTS:X. Источник — блог-обзоры, **(частично не проверено)**.
---
## 2. Аппаратные видеодекодеры
По данным MediaTek для MT9613 и таблицы Wikipedia для семейства MT96xx и Pentonic.
| Кодек | Поддержка | Комментарий |
|---|---|---|
| HEVC Main10 4K60 | Да | Основной путь. Имена декодеров на MTK TV: `OMX.MTK.VIDEO.DECODER.HEVC` или `c2.mtk.hevc.decoder` |
| AV1 4K60 10-bit | Да | Заявлен у всего семейства MT96xx и Pentonic |
| VP9 Profile 2 (10-bit) | Да | 4K60@10bit по спецификации. Поддержку HDR-профиля проверить в MediaCodecList |
| H.264 | Да | High 4K. Hi10P (10-bit H.264) аппаратно почти наверняка **нет** — нужен софт-фоллбэк **(не проверено)** |
| AVS2/AVS3 | Да | Для нас неважно |
| MPEG-2 | Почти наверняка да | ТВ с DVB-T2/C/S2 обязан декодировать MPEG-2. Выставлен ли он в MediaCodec, **(не проверено)** |
| VC-1 / WMV3 | Неизвестно | На MTK TV чипах исторически есть, но наружу в MediaCodec часто не выставляется **(не проверено)**. Предусмотреть софт (ffmpeg в libVLC/libmpv) |
| Dolby Vision | Да (декодер `video/dolby-vision`) | Имена у MTK: `OMX.MTK.VIDEO.DECODER.DVHE.STN` (видели на Fire TV на MTK) или `…DVHE.STH`. Профили 5 и 8.x — да. **Профиль 7 (dual-layer, FEL/MEL) аппаратно — скорее всего нет** (общая ситуация для Android TV, PR Jellyfin #5470) **(для этого SoC не проверено)** |
**Квирк MTK DV-декодера (Kodi).** Декодер `OMX.MTK…DVHE` ожидает 4-байтовые start code (`00 00 00 01`) для NAL-юнитов типов 62/63 (RPU и EL). Kodi включает «DoviWorkaround» в BitstreamConverter для `OMX.MTK*` + `video/dolby-vision` и для `OMX.MTK` задаёт invalid-PTS = −1 («use DTS»). Источник: патч quietvoid, вошёл в Kodi 20 через PR #19099. Для собственного пайплайна (не media3) это обязательно.
---
## 3. Известные проблемы на этом ТВ и SoC-семействе
### 3.1. Прямо про Xiaomi S Pro
- **jellyfin-androidtv #5825 (2026-09, открыт).** Xiaomi S Pro 2025 65", китайская, **HyperOS 3.0**. После обновления до HyperOS 3.0 Dolby Vision во встроенном плеере Jellyfin (ExoPlayer/media3) **не включается**: «неправильные цвета», т.е. сигнал идёт как HEVC/HDR10 или вообще без HDR. То же в Wholphin. А **Just Player** (тоже media3) и системный плеер Xiaomi включают DV на том же файле (WEB-DL DV P8 без HDR10+). Подтверждено вторым пользователем на beta2.
- Вывод: на этой платформе ExoPlayer-путь Jellyfin неверно выбирает декодер или неверно определяет DV, а у Just Player это работает. Стоит изучить, как Just Player выбирает декодер `video/dolby-vision` (MediaCodecSelector / DV→HEVC fallback) и как сигнализирует HDR.
- **jellyfin-androidtv #5366.** «Xiaomi TV / Mi Box», Android 9: E-AC3 в passthrough звучит «как из коробки», Kodi же декодирует в stereo нормально. Закрыт без решения. Модель не указана, поэтому к нашему ТВ относится только косвенно.
- **4PDA (тема S Mini LED 2025):** со временем перестаёт работать MEMC и появляются подёргивания. **Проблемы со звуком по SPDIF при включённом MEMC** (вероятно, рассинхрон или задержка) **(не проверено, пересказ)**. Тема S Pro 2026: https://4pda.to/forum/index.php?showtopic=1115060 (сам 4PDA вернул 403, содержание взято из поисковых сниппетов).
- Onliner (55" 2025, глобальная): жалобы на зависания и проблемы с приложениями.
### 3.2. Семейство MediaTek Pentonic 700 / MT96xx (TCL, Hisense, Philips, Sony)
- **Kodi на Hisense U7K (MT9618, Pentonic 700):** в dev-сборке Kodi 21 DV-файлы давали чёрный экран при рабочем звуке, после ноябрьского nightly MP4-DV заиграл. На U8H предыдущего поколения DV через Kodi работал (forum.kodi.tv tid=374191). Там же сказано, что Jellyfin и Plex DV на U7K играют.
- **Hisense:** Kodi видит DV-дисплей, но при DV-воспроизведении ТВ показывает HDR10+. Значит, сигнализация HDR через MediaCodec не всегда соответствует ожиданиям.
- **DV P7:** по общему консенсусу Kodi-форума, Android-устройства P7 «как есть» не поддерживают. Варианты: режим совместимости Kodi (P7 → P8.1, EL отбрасывается, по качеству это примерно MEL) или фоллбэк на HDR10 base layer. FEL на встроенных ТВ-приложениях фактически недостижим.
- **Philips Android TV на MediaTek (jellyfin #5827, media3 #3421):** `OMX.MTK.VIDEO.DECODER.HEVC` **падает посреди потока** (vendor-internal error после успешной инициализации, при resume/seek в конкретный файл). `setEnableDecoderFallback` это не ловит, потому что работает только при ошибке init. Обходной путь: свой `MediaCodecSelector`, который исключает упавший декодер, затем re-prepare + seek, с переходом на `c2.android.hevc.decoder`. Jellyfin PR отклонил, issue в media3 открыт.
- **media3 #2711 / #2765:** MediaTek HEVC перестаёт выдавать кадры или выдаёт чёрный/замёрзший кадр после OS-апдейтов (это телефоны Dimensity, но драйверы того же вендора).
- **media3 #3347:** `c2.mtk.hevc.decoder` на Google TV Streamer (MT8696) отдаёт около 30% кадров с опозданием 30–130 мс на некоторых x265-файлах, отсюда постоянный stutter. **VLC на том же железе работает нормально.** Аргумент в пользу libVLC/libmpv как фоллбэка.
- **media3 #3407:** Sony Bravia (Android 12, **MediaTek audio HAL**), AC-3 passthrough без tunneling. После двух быстрых seek плеер навсегда остаётся в BUFFERING: позиция AudioTrack идёт назад и трактуется как 32-битный wrap. В tunneled-режиме (HW_AV_SYNC) не воспроизводится.
- **jellyfin #5028:** Philips (MediaTek) — E-AC3 7.1 при транскоде даёт slow-motion без звука, downmix в stereo помогает.
- **jellyfin #5093:** TCL C855 (MediaTek, Android 12) — регрессии в capability-репорте 0.19.x (DOVIInvalid, P8 с CCID 6, «range type not supported») вызывают лишний транскод. Урок: не отбрасывать «невалидные» DV-потоки, а давать фоллбэк на HDR10.
- **jellyfin #4021:** файлы DV + HDR10+ на многих Android TV дают чёрный экран. Нужна настройка «игнорировать HDR10+ в DV-файлах», как «Allowed HDR dynamic metadata formats» в Kodi. Для нашего ТВ (поддерживает и DV, и HDR10+) это менее критично, но правило полезно для универсальности.
### 3.3. Аудио passthrough на ТВ (встроенные приложения → eARC)
- По опыту TCL 2022/2023 (MediaTek) из XDA: из **внутренних приложений** ТВ отдаёт по eARC DD, DD+, DD+ Atmos (JOC) и AC-4, а **TrueHD — «no support»** (по сообщениям, конвертируется в DD+ без Atmos). DTS-HD MA и DTS:X на TCL 2022 тоже отмечены как «no support». Форумная формулировка: «нет ТВ на рынке, который выдаёт TrueHD из внутренних приложений».
- Для Xiaomi это **не проверено**, но ожидаемо то же самое: **TrueHD и DTS-HD MA bitstream из приложения, скорее всего, не пройдут**. Правильное поведение клиента — опрашивать `AudioManager` (`getDirectProfilesForAttributes` / `isDirectPlaybackSupported` на API 33+, `AudioCapabilities` в media3) и при отсутствии поддержки декодировать в PCM на клиенте (ffmpeg/libVLC: TrueHD → PCM 7.1 или EAC3-реэнкод), а не транскодировать на сервере.
- **DTS:** у глобальной модели в спецификации нет, в российских обзорах указан DTS:X **(не проверено)**. Опираться только на рантайм-проверку `ENCODING_DTS`/`ENCODING_DTS_HD` и иметь софтверный DTS-декодер (ffmpeg dca) как фоллбэк. Тренд на отказ от DTS подтверждается: LG убрал DTS из ТВ 2025 года, Google TV Streamer не умеет DTS-HD/TrueHD passthrough.
- Сценарий «Jellyfin/Kodi на внешнем боксе → HDMI → ТВ → eARC → AVR» пропускает TrueHD и DTS-HD. Для приложения на самом ТВ это не применимо.
---
## 4. Общие особенности MediaTek Android TV для media3 и libVLC/libmpv
1. **Dolby Vision через MediaCodec.**
- Опрашивать `MediaCodecList` по `video/dolby-vision` с профилями: DvheStn = P5, DvheSt = P8. P7 на MTK обычно не объявлен.
- При отсутствии P7: преобразовывать P7 → P8.1 на лету (выкинуть EL, переписать RPU; так делают Kodi, `DvCompatVideoRenderer` из PR #5470 и dovi_tool) или играть BL как HDR10.
- Учесть, что media3 `MatroskaExtractor` может не распознать DV при «раздутом» CodecPrivate от MakeMKV (~800 байт). Фоллбэк — `videoRangeType` от сервера Jellyfin.
- На этом ТВ (особенно HyperOS 3.0) проверить, какой декодер выбирает media3 и запускается ли DV-режим: см. #5825, Just Player справляется.
- Для libVLC/libmpv: DV через mediacodec в libmpv (`hwdec=mediacodec`) зависит от передачи `video/dolby-vision`. libmpv обычно отдаёт HEVC, и получается HDR10 base layer. libVLC 4.x умеет DV через MediaCodec ограниченно **(не проверено)**. Реалистично DV только через media3, а libVLC/libmpv использовать для HDR10/HLG/SDR и экзотических кодеков.
2. **HDR-сигнализация.** На ТВ (в отличие от бокса) режим HDR включает видеопайплайн по метаданным декодера, переключения HDMI-режима нет. Нужен корректный `KEY_COLOR_TRANSFER`/`KEY_HDR_STATIC_INFO`, иначе картинка «блёклая». Для libmpv лучше `vo=mediacodec_embed` (прямой вывод в Surface с HDR-метаданными), а не gpu/gpu-next: GPU-рендер на Mali-G52 MC1 4K HDR не потянет, и HDR-режим ТВ не включится.
3. **Tunneled playback.** MTK TV декодеры обычно объявляют `FEATURE_TunneledPlayback` **(для этого SoC не проверено)**. Плюсы: точный A/V sync с HW_AV_SYNC, меньше рывков, в #3405 туннелирование помогло пропускной способности, баг #3407 в туннельном режиме не воспроизводится. Минусы: не все аудиоформаты (passthrough + tunneling на некоторых HAL ломается), не работают субтитры поверх с некоторыми рендерерами, ограничения seek. Нужен пользовательский переключатель и тест на устройстве.
4. **Смена частоты (AFR).**
- Встроенная панель 120/144 Гц: «переключение режима HDMI» как на боксе не нужно, но ТВ должен перейти на кратную частоту (24p → 120 Гц, 5:5) без judder.
- На Android 12+ есть системная опция «Match content frame rate» (Display & Sound → Advanced display) и API `Surface.setFrameRate` (media3: `setVideoChangeFrameRateStrategy`). Seamless-переход поддерживается, non-seamless требует `preferredDisplayModeId`.
- Как Xiaomi реализует это на 144-герцовой панели и не сбивает ли MEMC, **(не проверено)**. Протестировать 23.976/25/50/59.94 и проверить `dumpsys display` (supportedModes) и `dumpsys SurfaceFlinger`.
5. **Слабое железо.** 3 ГБ RAM и Mali-G52 MC1: софт-декод 4K HEVC/AV1 невозможен. libVLC/libmpv использовать только с hwdec (mediacodec). ASS-субтитры (libass) на 4K-Surface — нагрузка на CPU, а overlay-рендер поверх HDR-видео может ломать HDR (#5576: PGS + DV/HDR вызывает ошибку). Высокие битрейты (UHD-ремуксы 80–100 Мбит/с) требуют большого буфера и быстрой сети.
- **Проверить скорость Ethernet-порта:** у многих ТВ он 100 Мбит/с **(для этой модели не проверено)**. Для ремуксов UHD BD это критично, Wi-Fi 6 может оказаться быстрее.
6. **Ошибки декодера посреди потока** (MTK HEVC, #5827/#3421): реализовать runtime-fallback (исключить декодер → re-prepare → seek) и/или переход на libVLC/libmpv.
7. **AudioTrack passthrough на MTK HAL** (#3407): после быстрых seek возможна «вечная буферизация». Нужен watchdog: если плеер буферизуется дольше N секунд при полном буфере, делать re-seek или flush.
8. **E-AC3 passthrough** на некоторых Xiaomi/MTK звучит искажённо (#5366). Нужна настройка «декодировать EAC3 в PCM» на клиенте.
---
## 5. Чек-лист проверок на реальном ТВ
1. `adb shell getprop | grep -iE "model|platform|hardware|board|version.release|build.fingerprint"`: какая из трёх моделей и какой SoC.
2. `adb shell dumpsys media.player` и `cat /vendor/etc/media_codecs*.xml`: список декодеров, профили DV (dvhe.05/.07/.08?), VC-1, MPEG-2, H.264 Hi10, tunneling.
3. `adb shell dumpsys audio | grep -iA5 "encodings\|formats"` + `dumpsys media.audio_flinger`: какие форматы HAL поддерживает в direct/offload при подключённом eARC-ресивере. Проверить AC3/EAC3/EAC3-JOC/TrueHD/DTS/DTS-HD.
4. `adb shell dumpsys display | grep -iE "mode|hdr"`: supportedModes (60/120/144?), HdrCapabilities (DV, HDR10, HDR10+, HLG).
5. Тест-файлы: DV P5 (MP4), P8.1 (MKV WEB-DL), P7 MEL/FEL (MakeMKV), DV + HDR10+; HDR10+ AV1; VP9.2; VC-1 и MPEG-2 (BD/DVD ремуксы); H.264 Hi10P; TrueHD Atmos, DTS-HD MA, DTS:X, EAC3-JOC.
6. Сравнить media3 и Just Player на одном DV-файле: воспроизвести #5825.
---
## Источники
- Официальные спецификации: https://www.mi.com/global/product/xiaomi-tv-s-pro-mini-led-75-2026/specs/ , https://www.mi.com/global/product/xiaomi-tv-s-mini-led-75-2025/specs/ , FAQ https://www.mi.com/ph/support/faq/details/KA-483202/
- MediaTek MT9613: https://www.mediatek.com/products/pentonic/mt9613 ; таблица SoC: https://en.wikipedia.org/wiki/List_of_MediaTek_systems_on_chips
- Обзоры: https://tech.onliner.by/2026/02/12/xiaomi-s-pro-mini-led-2026 , https://mo.formulatv.ru/obzory/televizory/obzor-televizora-xiaomi-tv-s-2026.html , https://www.absolutegeeks.com/reviews/xiaomi-tv-s-pro-mini-led-75-2026-review-mini-led-mastery-unleashed/ , https://xiaomiforall.com/xiaomi-tv-s-pro-mini-led-2026-review/ (китайская: MT9655, 4/64, HyperOS 3), https://www.clubic.com/544037-xiaomi-s-mini-led-2025.html (MT9653), https://www.notebookcheck.net/2026-Xiaomi-TV-S-Pro-Mini-LED-series-goes-global-but-specs-fall-short-of-Chinese-models.1122730.0.html
- 4PDA: https://4pda.to/forum/index.php?showtopic=1115060 , https://4pda.to/forum/index.php?showtopic=1102774
- Jellyfin: https://github.com/jellyfin/jellyfin-androidtv/issues/5825 , /issues/5366 , /pull/5827 , /issues/5093 , /pull/5470 , /issues/5028 , /issues/4021
- media3: https://github.com/androidx/media/issues/3421 , /issues/3347 , /issues/3407 , /issues/3405 , /issues/2711
- Kodi: https://forum.kodi.tv/showthread.php?tid=374191 (U7K MT9618), https://gist.github.com/quietvoid/db7ac49bd8621a82f914888bca730c74 (MTK DV NAL quirk), https://github.com/xbmc/xbmc/pull/22950 (OMX.MTK.VIDEO.DECODER.DVHE.STN)
- Аудио: https://xdaforums.com/t/tcl-2023-google-tvs-p745-p755-c645-c745-c755-c805-c845-c855-c955-x955.4576005/page-36 , https://xdaforums.com/t/tcl-2022-google-tvs-p635-p735-c635-c735-c835-c935.4432635/page-41 , https://www.techradar.com/televisions/lg-removes-dts-audio-from-its-2025-tvs-heres-what-that-means-in-practice , https://www.aftvnews.com/google-tv-streamer-does-not-support-dolby-truehd-dts-hd-or-dtsx-audio-passthrough/

511
docs/research/engines.md Normal file
View File

@@ -0,0 +1,511 @@
# Несколько движков воспроизведения в Android-клиенте Jellyfin: архитектура, выбор движка, fallback
Дата: 2026-10-12. Метод: shallow-клоны репозиториев (HEAD на 10–12.10.2026) и чтение исходников, Maven Central (maven-metadata, POM, скачанные AAR: размеры `.so`, выравнивание ELF LOAD через `readelf`), GitHub API, документация Android/Kodi.
Всё, что помечено «(не проверено)», не подтверждено кодом или первоисточником.
---
## 0. TL;DR
1. **Почти все клиенты выбирают движок вручную, глобальной настройкой** (Findroid, AFinity, JellyPlay, Swiftfin, Streamyfin, Nova, Just Player). Автоматика есть только у трёх:
- **Wholphin**: режим `PREFER_MPV` — если контент HDR (или 4K при software-декоде), берётся ExoPlayer, иначе mpv.
- **Void**: режим `AUTO` — HDR или Dolby Vision идут в ExoPlayer, всё остальное в mpv.
- **Kodi**: правила в `playercorefactory.xml` по протоколу, расширению, кодеку, разрешению и `hdrtype`; плюс меню «Play using…».
2. **Автоматическое переключение на другой движок при runtime-ошибке декодера реализовано только в Plezy** (Flutter-клиент Plex, нативная часть на Kotlin). Схема ExoPlayer → mpv:
- сначала попытка восстановиться внутри ExoPlayer;
- срабатывает по `onPlayerError`, когда трек видео не выбран, по watchdog «декодер завис» (входные буферы есть, выходных нет) и по watchdog «чёрный экран»;
- воспроизведение продолжается с той же позиции.
Wholphin и AFinity при ошибке переходят на **серверный транскод тем же движком**. JellyPlay сознательно не делает «тихого» fallback.
3. **DeviceProfile везде свой для каждого движка, объединения возможностей нет ни у кого.**
- Wholphin: `if (backend == EXO_PLAYER) deviceProfileService… else mpvDeviceProfile`.
- Swiftfin: `VideoPlayerType.directPlayProfiles/transcodingProfiles/subtitleProfiles`.
- JellyPlay: `DeviceProfileProvider.forPlayer(PlayerType)`.
- Findroid: шлёт пустой профиль «Direct play all» и всегда берёт статический поток.
4. **Абстракция бывает двух видов.**
- Движок как media3 `Player`. Findroid и AFinity: `MPVPlayer : BasePlayer`. Wholphin: `MpvPlayer : BasePlayer`, стаб `: SimpleBasePlayer`.
- Собственный интерфейс: jellyfin-androidtv `PlayerBackend`, JellyPlay `MediaEngine` (~640 строк контракта + `EngineCapabilities`), Swiftfin `MediaPlayerProxy` + capability-протоколы, Nova `IMediaPlayer`.
Реализации «mpv как Player» полны `TODO()` и `UnsupportedOperationException`. В Findroid около 15 мест `TODO("Not yet implemented")`, включая `setVideoSurface` и `setVideoTextureView`.
5. **Рекомендация по архитектуре.** Свой узкий интерфейс `PlaybackEngine`, разбитый на capability-интерфейсы (как у Swiftfin), и **один** адаптер `EngineSessionPlayer : SimpleBasePlayer` в ядре. Адаптер нужен для MediaSession, состояний media3-ui-compose и `PlayerSurface`; подход как у jellyfin-androidtv `MediaSessionPlayer : SimpleBasePlayer`. Каждый движок живёт в отдельном Gradle-модуле и регистрируется через Hilt `@IntoSet`. Наборы движков задаются product flavors `full`/`lite`. Стаб-модули не нужны.
6. **Fallback-движок сейчас: libVLC 3.7.x (`org.videolan.android:libvlc-all:3.7.7`).**
- официальный артефакт VideoLAN с регулярными релизами (3.7.6 — 11.09.2026, 3.7.7 — 01.10.2026);
- стабильный Java API;
- HDR через MediaCodec на Surface (не проверено в этом раунде);
- внутри libass, PGS, DVB и VobSub;
- выравнивание 16 КБ на месте.
Минус — размер: arm64 `.so` занимают 55 МБ без сжатия и 24 МБ со сжатием. libmpv стоит держать вторым движком на будущее, но не делать основным fallback: у него нет официального Android-артефакта, и в стоковом виде он не выводит HDR (тонмапит в SDR через GLES).
---
## 1. Как устроено в существующих клиентах
### 1.1 Findroid (jarnedemeulemeester/findroid, Kotlin)
- **Модули:** `:player:core`, `:player:local`, `:app:phone`, `:app:tv`. Движки лежат в `player/local/src/main/java/dev/jdtech/jellyfin/player/local/`:
- `mpv/MPVPlayer.kt` (1659 строк) — `class MPVPlayer(...) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener`.
- ExoPlayer используется напрямую.
- **Выбор:** `PlayerViewModel.kt` читает `appPreferences.playerBackend` и делает `when ("exoplayer" | "mpv")` (стр. 135–173). Это глобальная пользовательская настройка, по умолчанию `"exoplayer"` (`AppPreferences.kt:29`). Автоматики нет.
- **Ошибки:** fallback отсутствует, `onPlayerError` в ViewModel не используется для смены движка.
- **DeviceProfile:** один для всех движков — `DeviceProfile(name="Direct play all", directPlayProfiles=empty, transcodingProfiles=empty, …, subtitleProfiles=[srt,ass EXTERNAL])` (`data/.../JellyfinRepositoryImpl.kt:~315`). Дальше всегда `getVideoStreamUrl(static=true)`, то есть клиент сознательно не пользуется серверным транскодом.
- **Зависимость:** `dev.jdtech.mpv:libmpv:1.0.0` подключена как `implementation` в `:player:local` и как `compileOnly` в `:app:phone`.
- **Сборка:** один flavor `libre`, ABI splits для `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`. Для сборки bundle splits отключаются из-за issue AGP 402800800.
- **Состояние MPVPlayer:** около 15 `TODO("Not yet implemented")`, включая `setVideoSurface`, `setVideoSurfaceHolder` и `setVideoTextureView`. Реализован только `setVideoSurfaceView`.
### 1.2 Wholphin (damontecres/Wholphin, Android TV, Compose)
- **Модули:** `:app` и `:wholphin-mpv-stub`. Настоящие нативные модули живут в отдельном репозитории `damontecres/wholphin-extensions`: `wholphin-mpv` и `wholphin-media3` (ffmpeg- и av1-декодеры). Они публикуются в **GitHub Packages, а там анонимное чтение невозможно** — README требует classic PAT.
- **Подключение в `app/build.gradle.kts` (стр. 30–36, 370–397), каскадом:**
1. локальный `libs/wholphin-mpv-release.aar`;
2. иначе prebuilt из репозитория `WholphinExtensions…`;
3. иначе `project(":wholphin-mpv-stub")`.
То же сделано для ffmpeg и av1.
- **Стаб:** `wholphin-mpv-stub/.../MpvPlayer.kt` — `class MpvPlayer(...) : SimpleBasePlayer(Looper.getMainLooper()) { override fun getState() = throw MpvStubException() }`. Он нужен потому, что код `:app` напрямую ссылается на класс `MpvPlayer`.
- **Абстракция:** media3 `Player`. `services/PlayerFactory.kt` возвращает `PlayerCreation(player: Player, assHandler)`. Настоящий `MpvPlayer` в extensions — `BasePlayer()`, около 1600 строк; часть методов бросает `UnsupportedOperationException`.
- **MediaSession:** `MediaSessionPlayer : ForwardingSimpleBasePlayer(player)`.
- **Видео:** `PlayerSurface(player, surfaceType = SURFACE_TYPE_SURFACE_VIEW)` из media3-ui-compose, один и тот же для Exo и mpv.
- **Выбор** (`ui/playback/PlaybackViewModel.kt:234–270`):
```kotlin
PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) EXO_PLAYER else MPV
```
Варианты `PlayerBackend`: `EXO_PLAYER`, `MPV`, `PREFER_MPV`, `EXTERNAL_PLAYER`. Движок можно передать и через `Destination.Playback.backend`, то есть выбрать «Play with…» для конкретного элемента. Если движок изменился, старый `Player` уничтожается и создаётся новый.
- **Ошибки** (`onPlayerError`, стр. ~1377): при `DIRECT_PLAY`/`DIRECT_STREAM` вызывается `changeStreams(..., positionMs = player.currentPosition, enableDirectPlay=false, enableDirectStream=false)`, то есть **переход на транскод тем же движком** с той же позиции. Смены движка нет.
- **DeviceProfile на каждый движок** (стр. ~660):
```kotlin
deviceProfile = if (backend == EXO_PLAYER) deviceProfileService.getOrCreateDeviceProfile(...) else mpvDeviceProfile
```
`util/mpv/MpvDeviceProfile.kt` — статический профиль с широким набором контейнеров и кодеков, у PGS/DVDSUB/DVBSUB стоят флаги `embedded=true, encode=true`.
- **Упаковка:** `packaging.jniLibs.pickFirsts += "lib/*/libc++_shared.so"`, потому что libass-android и wholphin-mpv оба кладут `libc++_shared`. Flavors `default`, `appstore`, `firetv` отличаются фичами, не движками.
### 1.3 JellyPlay (raulshma/jellyplay, KMP, бета, создан в мае 2026)
- **Документ:** `docs/player-engines.md`. Три движка: ExoPlayer (по умолчанию), libmpv (`io.github.abdallahmehiz:mpv-android-lib:0.1.12`), LibVLC (`libvlc-all:3.7.5`). Есть ещё External.
- **Абстракция:** собственный `MediaEngine` (`shared/core/player-contract/src/commonMain/.../engine/MediaEngine.kt`, 640 строк):
- `load(PlaybackRequest)`, `play/pause/seekTo`, `StateFlow` для состояния, позиции, треков и cues;
- `capabilities: EngineCapabilities` — около 25 флагов: `supportsAudioDelay`, `supportsSubtitleDelay`, `supportsAudioPassthrough`, `supportsAssOverride`, `supportsVideoFilters` и т. д.;
- `updateConfig(EngineConfig)`, `setAspectRatio`, `zoomSafeSubtitleStrategy`.
UI скрывает элементы управления по флагам capability. Поверхность вынесена в платформенный seam `AndroidSurfaceProvider`.
- **MediaSession:** только у Exo, через `Media3PlayerHost`/`asMedia3Player()`. Для mpv и VLC «JellyPlay provides its own».
- **Выбор:** в документе сказано прямо — «The choice is **per-device and manual** — JellyPlay does not auto-pick an engine based on the file's codec». Раздел «**No silent fallback.** If the chosen engine fails to decode a file, JellyPlay reports the error…»
- **DeviceProfile на каждый движок:** `shared/core/network/.../DeviceProfileProvider.kt` → `forPlayer(PlayerType)`:
- MPV получает свой профиль (два варианта, с direct play для PGS и без);
- EXO_PLAYER и LIBVLC получают общий `hardwareProfile`.
Passthrough-кодеки убираются из direct-play только для движков, которые умеют bitstream.
- **Сборка:** flavors `phone`/`tv` (по платформе, не по движкам). ABI split по умолчанию только arm64 (`extraAbis` через property).
- **Неточности в документе:**
- «ExoPlayer — no audio passthrough» неверно: media3 делает passthrough AC3, E-AC3, DTS и TrueHD через `AudioCapabilities`, а Plezy поверх этого реализует IEC-упаковку.
- «LibVLC does not render ASS» неверно для libVLC как такового: в `libvlc.so` из 3.7.7 есть `ass_render_frame` и исходники `libass/*.c`, это проверено через `strings`.
Скорее всего, оба утверждения описывают их собственную интеграцию, а не возможности библиотек.
### 1.4 AFinity (MakD/AFinity)
- Использует `dev.jdtech.mpv:libmpv:1.0.0`. `app/.../player/mpv/MPVPlayer.kt` — производная от Findroid, media3 `Player`.
- **Выбор:** булева настройка `preferencesRepository.useExoPlayer` (`PlayerViewModel.initializePlayer`). Автоматики нет.
- **Ошибки:** `retryWithTranscodeFallback(error)` — переход на транскод, движок не меняется.
- **MediaSession:** строится поверх любого `Player` (`MediaSession.Builder(context, player)`). Это главный плюс подхода «движок = Player».
### 1.5 Void for Jellyfin (hritwikjohri/Void-for-jellyfin; VoidTV отдельно)
- **mpv:** `dev.marcelsoftware.mpvcompose:mpv-compose:1.0.0` — Compose-обёртка, исходники в nitanmarcel/mpv-compose, единственная версия от 29.06.2025. В `MPVPlayerView.kt` работа идёт прямо через `MPVLib`.
- **Выбор:** `PlayerType { AUTO, MPV, EXOPLAYER }`. В `VideoPlayerScreen.kt:~120–170` для `AUTO` действует правило `selectedVideoStream.isHdrOrDovi() → EXOPLAYER, иначе MPV`; если выбранный движок не может получить URL, берётся второй. Это решение в момент запуска, а не реакция на ошибку.
- **Активность:** последний push Void-for-jellyfin — 31.03.2026, VoidTV — 30.01.2026. Проект стоит.
### 1.6 Streamyfin (React Native/Expo)
- **Модули:** собственные Expo-модули `modules/mpv-player` (Android: `dev.jdtech.mpv:libmpv:1.0.0`, iOS: свой) и `modules/exoplayer-player`.
- **Выбор:** `utils/atoms/settings.ts`. **MPV — «universal default», ExoPlayer — opt-in только на Android TV**: `getActiveVideoPlayerEngine()`, `isExoPlayerSupported`. VLC из проекта убрали (раньше был основным, не проверено).
### 1.7 Swiftfin (jellyfin/Swiftfin, iOS/tvOS)
- **Перечисление движков:** `Shared/Objects/VideoPlayerType/VideoPlayerType.swift` — `enum VideoPlayerType { avPlayer = "native", vlc, mpv }`. mpv включается экспериментальным флагом `Defaults[.Experimental.mpvPlayer]`.
- **Профиль:** каждый тип **сам отдаёт** `directPlayProfiles`, `transcodingProfiles` и `subtitleProfiles` (vlc и mpv пока делят VLC-профили). DeviceProfile собирается из профилей выбранного движка: `MediaPlayerItem+Build.swift` → `for: videoPlayerType`.
- **Абстракция** (`MediaPlayerProxy.swift`) — набор протоколов, разделённых по возможностям. Это удачный шаблон:
- `MediaPlayerProxy` (play/pause/stop/jump/setRate/setSeconds);
- `VideoMediaPlayerProxy` (videoSize, droppedFrames);
- `MediaPlayerAudioTrackConfigurable`, `MediaPlayerSubtitleTrackConfigurable` (работают с Jellyfin `MediaStream`, а не с внутренними треками движка);
- `MediaPlayerOffsetConfigurable` (`setAudioOffset`/`setSubtitleOffset`);
- `MediaPlayerPictureInPictureCapable`, `MediaPlayerSubtitleConfigurable`, `VideoMediaPlayerLayoutConfigurable`.
Реализации: `MediaPlayerProxy+VLC.swift`, `+AVPlayer.swift`, `+MPV.swift`.
- **Выбор:** глобальная настройка `Defaults[.VideoPlayer.videoPlayerType]`, по умолчанию `.vlc`. Автоматики и fallback нет.
### 1.8 Plezy (edde746/plezy, Flutter, клиент Plex) — единственный с авто-fallback
- **Схема:** на Android основной движок ExoPlayer (`android/app/.../exoplayer/ExoPlayerCore.kt`), запасной — mpv (`.../mpv/MpvPlayerCore.kt`). Оба управляются плагином `ExoPlayerPlugin.kt`, который хранит `playerCore` и `mpvCore` и флаг `usingMpvFallback`. Настройка `android_use_exoplayer` позволяет сразу выбрать mpv.
- **Триггеры fallback**, все сводятся к `requestFormatFallback(...)` → `delegate.onFormatUnsupported(uri, headers, positionMs, playWhenReady, errorMessage)`:
1. `onPlayerError`, если не помогла попытка восстановиться внутри Exo (`retryVideoDecoderInPlace`);
2. `onTracksChanged`: группа видео есть, но ни одна не выбрана, то есть нет декодера (`requestFallbackForUnsupportedTracks`);
3. **watchdog «decoder hang»**: `queuedInputBufferCount > 0`, а выходных буферов 0 после таймаута;
4. **watchdog «чёрный экран»**: 0 отрисованных кадров за `WATCHDOG_TIMEOUT_MS` при `isPlaying`;
5. отказ аудио-sink после исчерпания восстановления внутри Exo.
- **Политика повторов** (`VideoDecoderRecoveryPolicy`): если кадр уже был отрисован, формат заведомо поддерживается, поэтому сначала повтор в Exo (`MAX_CONSECUTIVE_ATTEMPTS=1`, `MAX_ATTEMPTS_PER_MEDIA=3`), и только потом mpv. Для Dolby Vision перед fallback делается попытка `retryWithDvConversion` (DV → HEVC/HDR10).
- **Передача между движками:**
- счётчик `mediaGeneration` отсекает устаревшие события;
- повторные запросы fallback схлопываются;
- позиция ограничивается у конца файла (`EndOfStreamPolicy.fallbackStartPositionMs`);
- внешние субтитры и mpv-свойства, выставленные до fallback, воспроизводятся заново;
- таймаут инициализации mpv — 10 с.
- **mpv:** собственная сборка `edde746/mpv-build` (`mpv-build.lock.json` с SHA-256, ffmpeg 8.0.1). При HW-декоде используется **форк `vo=mediacodec`**: буферы MediaCodec сразу идут в compositor, 10-бит и HDR10/HLG dataspace сохраняются, субтитры рисуются на соседней OSD-поверхности. Комментарий в `MpvPlayerCore.kt:165–189` фиксирует, что `gpu-next` + HW-декод ломается на Tegra (синий экран на Shield). Это хорошо показывает, что HDR в стоковом mpv-android не решён.
### 1.9 jellyfin-androidtv (официальный)
- **Модули:** `playback/core` (`backend/PlayerBackend.kt`, `BackendService.kt`, `support/PlaySupportReport.kt`), `playback/media3/exoplayer` (`ExoPlayerBackend`), `playback/media3/session` (`MediaSessionPlayer : SimpleBasePlayer(looper)` поверх `PlaybackManager`), `playback/jellyfin`.
- **`PlayerBackend`:**
- `supportsStream(MediaStream): PlaySupportReport` — Exo реализует через `exoPlayer.getPlaySupportReport(stream.toFormats())` по `RendererCapabilities`;
- `setSurfaceView`, `setSubtitleView`, `prepareItem/playItem`, `seekTo`, `setSpeed`, `setTimedEvents`.
`BackendService.switchBackend()` останавливает старый backend и переносит на новый surface, subtitle view и listener. Задел под несколько движков есть, но в коде только ExoPlayer; libVLC из клиента давно убрали, VLC остался внешним плеером (`VlcExternalPlayerApi.kt`).
### 1.10 Just Player (moneytoo/Player)
Движок один — ExoPlayer с локальными AAR `lib-decoder-ffmpeg`, `av1`, `iamf`, `mpegh`. Настройка `decoderPriority` → `setExtensionRendererMode` (OFF/ON/PREFER). Это «выбор декодера внутри одного движка», а не выбор движка. Приём полезен и нам: `EXTENSION_RENDERER_MODE_ON` плюс `setEnableDecoderFallback(true)`.
### 1.11 Nova Video Player (nova-video-player/aos-*)
- **Интерфейс:** `aos-MediaLib/src/com/archos/medialib/IMediaPlayer.java` с `TYPE_AVOS=0` и `TYPE_ANDROID=1`. Методы `setSubtitleDelay`, `setAvDelay`, `setSubtitleRatio`, `setAudioFilter` присутствуют прямо в интерфейсе.
- **Фабрика** `MediaFactory.createPlayer(ctx, forceSoftwareDecoding)`:
- настройка `KEY_DEC_CHOICE == -1` → `AndroidMediaPlayer`;
- иначе `AvosMediaPlayer` — свой нативный плеер на ffmpeg с `MP_DECODER_ANY` (HW, затем SW) или `MP_DECODER_SW`;
- если `LibAvos` недоступен, используется Android-плеер.
- В манифесте `aos-AVP/exoplayer.xml` есть экспериментальная ветка `dev/exoplayer`.
### 1.12 Kodi
- **Выбор плеера:** `system/playercorefactory.xml` и пользовательский `userdata/playercorefactory.xml` со списком `<players>` и `<rules>`. Атрибуты правил (`xbmc/cores/playercorefactory/PlayerSelectionRule.cpp`):
- `internetstream`, `remote`, `audio`, `video`, `dvd`, `bd`, `discimage`;
- `protocols`, `filetypes`, `mimetypes`, `filename` (regex);
- `audiocodec`, `audiochannels`, `videocodec`, `videoresolution`, `videoaspect`, **`hdrtype`**.
Правила по stream details работают, только если включено извлечение флагов. Правила вкладываются друг в друга. Плеер по умолчанию задаётся алиасами `videodefaultplayer`/`audiodefaultplayer`. В контекстном меню «Play using…» показываются все подходящие плееры.
- **Fallback внутри VideoPlayer:** при открытии декодера Kodi перебирает HW-кодеки (на Android — MediaCodec (Surface) и MediaCodec) и затем ffmpeg SW. Это делается на уровне декодера, а не движка (по знанию архитектуры, в этом раунде код не открывался).
### 1.13 Сводная таблица
| Клиент | Движки | Абстракция | Выбор | Fallback при ошибке | DeviceProfile |
|---|---|---|---|---|---|
| Findroid | Exo, mpv | media3 `Player` (`MPVPlayer : BasePlayer`) | глобальная настройка | нет | один «Direct play all» |
| Wholphin | Exo, mpv (+ext) | media3 `Player` + стаб `SimpleBasePlayer` | настройка + `PREFER_MPV` (HDR→Exo) + per-item | транскод тем же движком | на каждый движок |
| JellyPlay | Exo, mpv, VLC | свой `MediaEngine` + `EngineCapabilities` | вручную, на устройство | нет, сознательно | на каждый движок |
| AFinity | Exo, mpv | media3 `Player` | булева настройка | транскод | (не проверено) |
| Void | Exo, mpv | Compose-view, без общего интерфейса | `AUTO`: HDR/DV→Exo, иначе mpv | нет | (не проверено) |
| Streamyfin | mpv, Exo (TV) | Expo-модули | настройка, mpv по умолчанию | нет | (не проверено) |
| Swiftfin | AVPlayer, VLC, mpv | `MediaPlayerProxy` + capability-протоколы | глобальная настройка | нет | на каждый движок |
| Plezy | Exo → mpv | плагин с двумя core | Exo по умолчанию, mpv по настройке | **да, автоматически** | n/a (Plex) |
| jellyfin-androidtv | Exo | `PlayerBackend` + `supportsStream` | — | — | — |
| Nova | AVOS (ffmpeg), Android MP | `IMediaPlayer` + `MediaFactory` | настройка декодера | при отсутствии lib | n/a |
| Kodi | VideoPlayer, PAPlayer, внешние | `IPlayer` + rules | правила (codec/res/hdrtype) + «Play using» | HW→SW внутри VideoPlayer | n/a |
---
## 2. Дизайн абстракции
### 2.1 Вариант A: каждый движок реализует media3 `Player`
**Плюсы**
- MediaSession, MediaController, уведомления и Google Assistant/«Now playing» на TV работают даром.
- Состояния из media3-ui-compose (`PlayPauseButtonState`, `ProgressStateWithTickInterval` и т. д.) и `PlayerSurface` работают с любым движком.
- Единая модель слушателей.
**Минусы, подтверждённые кодом**
- `Player` огромен: Timeline, плейлисты, `Tracks`/`Format`, `TrackSelectionParameters`, громкость, устройство. Реализации для mpv в Findroid, Wholphin и AFinity неполные (`TODO()`/`UnsupportedOperationException`) и падают, если сторонний код вызовет «не тот» метод. Пример — `setVideoSurface(Surface)` вместо `setVideoSurfaceView`.
- В `Player` нет audio delay, subtitle delay, aspect/zoom, deinterlace, passthrough и стилей ASS. Всё это всё равно приходится выносить в свой интерфейс и проверять `as?`, как в AFinity: `is MPVPlayer -> …` встречается десятки раз в `PlayerViewModel.kt`.
- Треки mpv и VLC приходится переупаковывать в `TrackGroup(Format)`. Связь с индексами Jellyfin `MediaStream.Index` теряется или требует хаков.
- `BasePlayer` вручную реализовывать сложно: потоки и согласованность событий. Если идти этим путём, брать только **`SimpleBasePlayer`**: он сам сравнивает состояния и рассылает события, а реализация отдаёт `State` и переопределяет `handle*`.
### 2.2 Вариант B: собственный `PlayerBackend`/`MediaEngine`
**Плюсы**
- Контракт описан в терминах домена. Треки адресуются индексами Jellyfin `MediaStream`, как в Swiftfin, где `setAudioStream(MediaStream)` удобно связывает с PlaybackInfo, отчётами о прогрессе и сменой потока на сервере.
- Возможности объявлены явно, UI показывает только поддерживаемое.
- Движок легко заменить: интерфейс маленький.
**Минусы:** MediaSession и Compose-состояния нужно соединить самостоятельно.
### 2.3 Рекомендация: вариант B и один `SimpleBasePlayer`-адаптер в ядре
Так устроен jellyfin-androidtv (`PlayerBackend` + `MediaSessionPlayer : SimpleBasePlayer`). Набор capability-интерфейсов взят у Swiftfin.
```kotlin
// :playback:api
interface PlaybackEngine {
val id: EngineId // EXO, VLC, MPV…
val state: StateFlow<EngineState> // idle/buffering/ready/ended + playWhenReady
val position: PositionSource // currentPositionMs(), bufferedMs, durationMs
val events: Flow<EngineEvent> // FirstFrame, VideoSize, Error(EngineError), TracksChanged
val tracks: StateFlow<EngineTracks> // в терминах Jellyfin MediaStream.Index + engine id
fun attach(output: VideoOutput) // SurfaceView-based, см. 2.4
fun detach()
fun load(request: EngineLoadRequest) // url, headers, startMs, внешние сабы, выбранные индексы
fun play(); fun pause(); fun seekTo(ms: Long); fun setSpeed(x: Float)
fun selectAudio(streamIndex: Int?); fun selectSubtitle(streamIndex: Int?)
fun release()
}
// Опциональные возможности: проверка через `engine as? X`, UI скрывает ненужное
interface AudioDelayCapable { fun setAudioDelay(ms: Long) }
interface SubtitleDelayCapable { fun setSubtitleDelay(ms: Long) }
interface AspectCapable { fun setAspect(mode: AspectMode /* FIT, FILL, ZOOM, 16:9, 4:3, CUSTOM */) }
interface PassthroughCapable { fun setPassthrough(codecs: Set<AudioCodec>) }
interface SubtitleStyleCapable { fun applyStyle(style: SubtitleStyle) }
interface DeinterlaceCapable { fun setDeinterlace(mode: DeinterlaceMode) }
interface StatsCapable { val stats: StateFlow<PlaybackStats> } // decoder name, dropped frames, hw/sw
// Плагин движка: SPI, по одному на Gradle-модуль
interface EngineProvider {
val id: EngineId
val priority: Int
fun capabilities(device: DeviceCaps): EngineCapabilities // для профиля и выбора
fun evaluate(source: MediaSourceInfo, sel: StreamSelection, device: DeviceCaps): Verdict
fun deviceProfile(device: DeviceCaps, prefs: PlaybackPrefs): DeviceProfile
fun create(context: Context): PlaybackEngine
}
```
Ядро (`:playback:core`):
- `PlaybackSessionController` хранит item, mediaSourceId, PlaySessionId, выбранные индексы, задержки, позицию и `generation`. Через него идут вся отчётность в Jellyfin (`/Sessions/Playing*`), выбор движка и переключение.
- `EngineSessionPlayer : SimpleBasePlayer` — **единственная** реализация `Player`. Она транслирует состояние контроллера в `State` (один `MediaItem`, позиция, длительность, playWhenReady, команды) и передаёт `handleSetPlayWhenReady`/`handleSeek`/`handleSetVideoOutput` текущему движку. На этом адаптере строятся `MediaSession`/`MediaSessionService` и Compose-состояния.
- ExoPlayer-движок держит `ExoPlayer` внутри. Ему MediaSession «из коробки» не нужна: сессия всегда смотрит на адаптер, поэтому при смене движка её не надо пересоздавать. Wholphin и AFinity, напротив, пересоздают сессию при новом `Player`.
### 2.4 Видеоповерхность
- **Только `SurfaceView`.** HDR-вывод и tunneling работают лишь с SurfaceView. TextureView проходит через композицию GPU: HDR теряется, tunneling не поддерживается, расход энергии выше. Wholphin явно использует `SURFACE_TYPE_SURFACE_VIEW`.
- **Compose:** `PlayerSurface(player = engineSessionPlayer, surfaceType = SURFACE_TYPE_SURFACE_VIEW)` вызывает `Player::setVideoSurfaceView`/`clearVideoSurfaceView` (проверено в исходнике `media3/ui/compose/PlayerSurface.kt`, ветка release; media3 1.11.1 — последняя на 12.10.2026). У `SimpleBasePlayer` это приходит в `handleSetVideoOutput(Object)`, а адаптер передаёт `SurfaceView` текущему движку:
- Exo: `setVideoSurfaceView`;
- libVLC: `IVLCVout.setVideoView` (или `VLCVideoLayout` со своей subtitle-поверхностью);
- mpv: `MPVLib.attachSurface(holder.surface)`.
- **Своя обёртка для VLC:** `VLCVideoLayout` создаёт свои SurfaceView, в том числе отдельную для субтитров при direct rendering. Поэтому лучше иметь собственный `@Composable EngineVideoSurface(engine)` через `AndroidView`, который сам решает, какой View отдать движку, а PlayerSurface оставить только для Exo. Объявить в `VideoOutput` sealed-варианты: `SurfaceViewOutput(SurfaceView)` и `ViewGroupOutput(FrameLayout)`.
- **Aspect/zoom:**
- Exo — через `AspectRatioFrameLayout`/`Modifier.resizeWithContentScale` (media3-ui-compose) или `VIDEO_SCALING_MODE`;
- VLC — `MediaPlayer.setAspectRatio`/`setScale`/`setVideoScale`;
- mpv — `video-aspect-override`/`panscan`.
Всё это закрывается одним `AspectCapable`.
- **Субтитры:**
- Exo рисует `SubtitleView` с CueGroup, ASS — через `io.github.peerless2012:ass-media` (0.5.1 от 11.08.2026, используется в Wholphin и jellyfin-androidtv);
- VLC и mpv рисуют субтитры сами (libass внутри движка).
В контракте нужен флаг «движок рисует субтитры сам», чтобы ядро не накладывало оверлей поверх.
### 2.5 Смена движка посреди сессии (ручная и автоматическая)
Порядок, обобщающий Plezy, Wholphin и jellyfin-androidtv `switchBackend`:
1. `generation++`; события старого движка с прежним `generation` игнорируются. Повторные запросы fallback схлопываются флагом `fallbackInProgress`.
2. Запомнить `positionMs` (у конца файла ограничить: `min(pos, duration - 10 s)`), `playWhenReady`, Jellyfin-индексы аудио и субтитров, задержки, скорость.
3. Отправить `/Sessions/Playing/Stopped` для старой PlaySessionId (или Progress с понятной причиной).
4. Старый движок: `detach()` → `release()`. VLC и mpv отпускать до создания нового, чтобы избежать конфликтов поверхности и MediaCodec. На слабых TV-боксах два одновременных HW-декодера 4K часто недоступны.
5. Вызвать `POST /Items/{id}/PlaybackInfo` с **DeviceProfile нового движка**. Получится новая PlaySessionId; DirectPlay или транскод определится заново.
6. Новый движок: `attach(output)` → `load(startMs=pos, …)`. Воспроизвести заново внешние субтитры и настройки (задержки, стиль, passthrough).
7. Отправить `/Sessions/Playing` с новым PlayMethod. Показать ненавязчивый toast «Переключено на VLC: аппаратный декодер не поддерживает VC-1» и записать причину в статистику.
8. Защита от петель: один fallback на item за сессию; если упал и fallback-движок, перейти на серверный транскод (как Wholphin и AFinity), а не обратно.
---
## 3. Gradle-модуляризация
### 3.1 Предлагаемая структура
```
:playback:api // Android-library без нативных зависимостей: PlaybackEngine, capabilities, EngineProvider, модели
:playback:core // SessionController, EngineSelector, EngineSessionPlayer (SimpleBasePlayer), MediaSessionService, DeviceCaps (MediaCodecList/Display)
:playback:engine-exo // media3-exoplayer, -hls, decoder-ffmpeg (аудио), ass-media; ExoEngineProvider
:playback:engine-vlc // org.videolan.android:libvlc-all:3.7.7; VlcEngineProvider
:playback:engine-mpv // (позже) libmpv AAR; MpvEngineProvider
:app // flavors: full / lite
```
- **Регистрация через Hilt multibinding.** В каждом engine-модуле:
```kotlin
@Module @InstallIn(SingletonComponent::class)
abstract class VlcEngineModule { @Binds @IntoSet abstract fun bind(p: VlcEngineProvider): EngineProvider }
```
Ядро получает `Set<@JvmSuppressWildcards EngineProvider>`. Если модуль не подключён, движка просто нет. **Стаб не нужен**: Wholphin он понадобился только потому, что `:app` ссылается на класс `MpvPlayer` напрямую. Альтернатива без DI — `ServiceLoader` с `META-INF/services` (R8 его поддерживает).
- **Flavors по движкам:**
```kotlin
flavorDimensions += "engines"
productFlavors {
create("full") { dimension = "engines"; isDefault = true } // Exo + VLC
create("lite") { dimension = "engines"; applicationIdSuffix = ".lite" } // только Exo
}
dependencies {
implementation(project(":playback:engine-exo"))
"fullImplementation"(project(":playback:engine-vlc"))
}
```
Можно добавить второе измерение `store` (`github`, `play`, `amazon`), как Wholphin делает `default/appstore/firetv`.
- **Изоляция API движков.** Нативный AAR подключается как `implementation` только в engine-модуле; `api` для него не делать, чтобы классы libVLC не протекали в `:app`. Разработчикам, не трогающим VLC, можно быстро собирать `liteDebug`.
- **Конфликт `libc++_shared.so`:** у libVLC, libmpv и ass-media своя копия, нужен `packaging { jniLibs { pickFirsts += "lib/*/libc++_shared.so" } }` (как в Wholphin). Версии libc++ из разных NDK формально несовместимы. Лучше проверить, что `full` с VLC и ass-media не падает; ass-media в VLC-сборке не обязателен, так что риск можно убрать, оставив ass-media только в пути Exo.
### 3.2 Размеры (измерено по AAR с Maven Central, только arm64-v8a)
| Артефакт | Дата | `.so` без сжатия | `.so` в deflate | 16 КБ (`LOAD` align) |
|---|---|---|---|---|
| `org.videolan.android:libvlc-all:3.7.7` | 01.10.2026 | **55,3 МБ** (libvlc.so 45,9 + libc++ 9,3) | 24,1 МБ | 0x4000 ✔ |
| `dev.jdtech.mpv:libmpv:1.0.0` (mpv 0.41.0, ffmpeg 8.1) | 06.04.2026 | **25,0 МБ** | 11,6 МБ | 0x4000 ✔ |
| `io.github.abdallahmehiz:mpv-android-lib:0.1.12` | 13.02.2026 | 38,3 МБ (с avfilter и libxml2) | 15,8 МБ | 0x4000 ✔ |
- **Влияние `useLegacyPackaging`.** При `minSdk ≥ 23` AGP по умолчанию кладёт `.so` в APK без сжатия (`extractNativeLibs=false`), поэтому APK на GitHub или F-Droid вырастет на «несжатую» цифру: около +55 МБ для VLC. Варианты:
- `packaging.jniLibs.useLegacyPackaging = true`: APK примерно +24 МБ, но после установки библиотеки распаковываются;
- для Play (AAB) — download size примерно равен сжатому размеру.
- **ABI.** Нужны per-ABI APK (`splits.abi`) или AAB. **armeabi-v7a обязателен для Android TV**: многие TV-устройства работают в 32-битном userspace на 64-битном CPU (по известным данным, ряд Fire TV Stick и Chromecast with Google TV; не проверено в этом раунде). x86 и x86_64 нужны только для эмулятора. Findroid собирает 4 ABI, Wholphin 3 (+universal), JellyPlay по умолчанию только arm64.
- **16 КБ:** все три AAR уже выровнены по 16 КБ (`-Wl,-z,max-page-size=16384` есть в `libmpv-android/buildscripts/build.sh:51`). AGP ≥ 8.5.1 сам выравнивает несжатые `.so` в zip.
### 3.3 Dynamic Feature Delivery для нативного движка
- **Технически возможно.** `.so` можно положить в on-demand feature-модуль, загрузка через `SplitCompat`/`SplitInstallHelper.loadLibrary` или ReLinker, зависимые `.so` грузятся вручную по порядку (документация Play on-demand).
- **Практически не годится:**
- работает только при установке из Google Play; при sideload возвращаются `REQUIRES_USER_CONFIRMATION`/`APP_NOT_OWNED`/`PLAY_STORE_NOT_FOUND`;
- для F-Droid, GitHub APK и Amazon Appstore не работает;
- поддержка on-demand на Android TV в документации не описана (не проверено);
- Hilt и feature-модули сочетаются плохо: граф DI идёт `app → feature`, а не наоборот, поэтому нужен reflection или ServiceLoader.
**Вывод:** использовать flavors `full`/`lite`; DFD откладывается до появления Play-дистрибуции, и тогда только как опция.
---
## 4. libVLC или libmpv в роли fallback-движка
### 4.1 Сравнение
| Критерий | libVLC 3.7.x (`libvlc-all`) | libmpv (сторонние AAR) |
|---|---|---|
| Артефакт | **Официальный VideoLAN на Maven Central.** 3.7.6 — 11.09.2026, 3.7.7 — 01.10.2026; 4.0.0-eap29 — 08.07.2026 (libVLC 4 всё ещё EAP). vlc-android активен (push 07.10.2026). | Официального AAR нет. Варианты: `dev.jdtech.mpv:libmpv` (Findroid; релизы 0.5.1 — 07.2025, **1.0.0 — 04.2026**; в репозитории renovate регулярно обновляет зависимости, ffmpeg 9.0.2 от 20.09.2026, но релизов не было с апреля); `io.github.abdallahmehiz:mpv-android-lib` (0.1.12, 02.2026); `wholphin-extensions` (GitHub Packages, нужен токен); Plezy (`edde746/mpv-build`, свои tarball); `mpv-compose` (одна версия 2025). |
| Размер arm64 | 55 МБ без сжатия / 24 МБ сжато | 25 / 11,6 МБ (jdtech) |
| 16 КБ | ✔ | ✔ |
| Android API | Java/Kotlin API: `LibVLC`, `MediaPlayer`, `Media`, `IVLCVout`, `VLCVideoLayout`; треки, `setSpuDelay`/`setAudioDelay`, `setAspectRatio`/`setScale`, события. Стабилен много лет. | `MPVLib`: строковые свойства и команды через JNI плюс observer. Обёртку-плеер пишешь сам (~1600 строк в Findroid/Wholphin). Исторически глобальный синглтон, один экземпляр на процесс. |
| HW-декод | MediaCodec, с direct rendering в Surface | `hwdec=mediacodec` (в GLES-текстуру) или `mediacodec-copy` |
| **HDR на Android TV** | При HW-декоде с direct rendering кадр уходит на Surface с метаданными, телевизор получает HDR10/HLG, как в VLC for Android (по прошлому исследованию; не проверено в этом раунде). DV — только через HEVC base layer или профиль 8.1 на совместимых SoC (не проверено). | В стоковом mpv-android `vo=gpu`/`gpu-next` (GLES), **HDR тонмапится в SDR**: в `MPVView.kt` нет HDR-пути, `mediacodec_embed` по умолчанию отключён. Wholphin (`PREFER_MPV`) и Void (`AUTO`) поэтому отправляют HDR в Exo. Plezy понадобился форк `vo=mediacodec`. |
| Субтитры | libass внутри (подтверждено `strings`), DVB/PGS/VobSub через ffmpeg и spudec, есть отдельная субтитровая поверхность | libass с полным override стилей (`sub-ass-override`), лучший ASS-рендер; PGS/VobSub через libav |
| Exotic SW-декод | ffmpeg внутри (VC-1, WMV, MPEG-2, Hi10P, DivX/Xvid, RV) | то же: ffmpeg (VC-1, PGS, dvdsub подтверждены `strings`) |
| Passthrough | есть (SPDIF/HDMI encoded AudioTrack) | `audio-spdif=…` с `ao=audiotrack` (JellyPlay и Plezy пользуются) |
| Стабильность жизненного цикла | Известные подводные камни: строгий порядок `detachViews` → `stop` → `release`, один `LibVLC` на процесс, нативные падения при быстром release/re-create (из опыта сообщества; не проверено) | Глобальное состояние MPVLib, `vo`/surface attach/detach, баги GLES на конкретных SoC (Tegra + gpu-next → синий экран, Plezy #2010) |
| Перспектива | libVLC 4 сменит API (EAP, сроки неизвестны). При изолированном модуле переход займёт один модуль. | Зависимость от одного стороннего сопровождающего AAR. Сборка своими силами (buildscripts mpv-android) — заметная нагрузка на CI. |
### 4.2 Рекомендация
**Fallback-движок сейчас — libVLC 3.7.7.** Решающие факторы для клиента, у которого главная цель — **корректный HDR без транскода**:
1. При HW-декоде HDR не деградирует в SDR, и это стоковое поведение. У mpv без форка HDR теряется.
2. Официальный, регулярно обновляемый артефакт и стабильный Java API.
3. Полный набор субтитров, включая ASS, PGS и VobSub, плюс passthrough.
Цена — около +24–55 МБ на ABI; её компенсирует flavor `lite`.
**mpv** заложить как будущий `:playback:engine-mpv` для ценителей ASS и аниме и для тонких настроек. Выбирать его автоматически только для SDR-контента с «тяжёлым» ASS. Брать `dev.jdtech.mpv:libmpv` (Maven Central, 16 КБ, используется в Findroid, AFinity и Streamyfin) или собирать самим по `libmpv-android/buildscripts`.
**libVLC 4:** когда выйдет стабильная версия, добавить `:playback:engine-vlc4` рядом с текущим модулем. Провайдер с большим `priority` вытеснит старый без изменений в ядре.
---
## 5. Алгоритм автоматического выбора движка
### 5.1 Возможности устройства (`DeviceCaps`, кэшировать на запуск и смену дисплея)
- **`MediaCodecList(REGULAR_CODECS)`:** для каждого mime (`video/avc`, `video/hevc`, `video/x-vnd.on2.vp9`, `video/av01`, `video/dolby-vision`, `video/mpeg2`, `video/wvc1`/`video/vc1`, `video/mp4v-es`) собрать:
- `isHardwareAccelerated` (API 29+) или эвристику по имени (`c2.android.*`/`OMX.google.*` — software);
- `CodecCapabilities.profileLevels` (AVC High10 = `AVCProfileHigh10`, HEVC Main10 / Main10HDR10 / HDR10Plus, VP9 Profile2HDR, AV1 Main10HDR10, DV-профили `DolbyVisionProfileDvheSt` (P8) / `DvheStn` (P5) / `DvavSe` и т. д.);
- `VideoCapabilities.areSizeAndRateSupported(w,h,fps)`.
- **Дисплей:** `Display.getHdrCapabilities().supportedHdrTypes` (до API 34) или `Display.Mode.getSupportedHdrTypes()` (API 34+), а также `isHdrSdrRatioAvailable`.
- **Аудио:** `AudioCapabilities` media3 — `isPassthroughSupported` для AC3, E-AC3, DTS, DTS-HD, TrueHD.
- **Exo-движок:** надёжнее всего спрашивать сам Exo, как jellyfin-androidtv: собрать `Format` из `MediaStream` и проверить `RendererCapabilities.supportsFormat` у видео- и аудиорендереров (`getPlaySupportReport`). Тогда учитываются и extension-декодеры (ffmpeg-аудио, dav1d).
### 5.2 Входные данные Jellyfin
`MediaSourceInfo.Container`, `MediaStreams[]`:
- **Видео:** `Codec`, `Profile`, `Level`, `BitDepth`, `Width/Height`, `RealFrameRate`, `IsInterlaced`, `VideoRange`, **`VideoRangeType`** (`SDR, HDR10, HDR10Plus, HLG, DOVI, DOVIWithHDR10, DOVIWithHLG, DOVIWithSDR, …`; у новых серверов есть варианты с EL и `DOVIInvalid` — проверить по версии SDK), `DvProfile`, `DvLevel`, `DvBlSignalCompatibilityId`, `RpuPresentFlag`, `ElPresentFlag`, `BlPresentFlag`, `CodecTag` (xvid/divx).
- **Аудио:** `Codec`, `Profile` (DTS-HD MA, TrueHD/Atmos), `Channels`.
- **Субтитры:** `Codec` (`subrip, ass, ssa, webvtt, pgssub, dvdsub, dvbsub, mov_text`), `IsExternal`, `DeliveryMethod`.
### 5.3 Вердикт по каждому движку
```kotlin
sealed interface Verdict {
data class Direct(val quality: Int, val notes: Set<Note>) : Verdict // quality: 100 = идеально
data class Degraded(val quality: Int, val losses: Set<Loss>) : Verdict // HDR→SDR, ASS упрощён, нет passthrough, SW 4K
data class No(val reasons: Set<Reason>) : Verdict
}
enum class Loss { HDR_TONEMAPPED, DV_TO_BASE_LAYER, ASS_SIMPLIFIED, NO_PASSTHROUGH, SOFTWARE_DECODE_HEAVY, NO_DEINTERLACE }
```
**ExoProvider.evaluate**
1. **Контейнер:** `mkv, webm, mp4, m4v, mov, ts, m2ts, mpegts, avi (AviExtractor), flv, ogg, mp3, flac, wav` → ок. `asf/wmv, rm/rmvb, vob/iso/BDMV, mpeg-ps (частично)` → `No(CONTAINER)`.
2. **Видео:**
- если `supportsFormat` == `FORMAT_HANDLED` по HW (или dav1d для AV1) → ок;
- `h264` с `BitDepth=10` (Hi10P) почти нигде нет в HW → `No(VIDEO_CODEC)`;
- `vc1/wmv3`, `msmpeg4*`, `rv*`, `mpeg4` с packed bitstream (DivX) → обычно `No`;
- `mpeg2video` → по MediaCodecList, на многих TV-SoC есть;
- HEVC выше Level HW-декодера (например, 4K120) → `No`.
3. **HDR:** у Exo потерь нет, если HW-декодер поддерживает профиль, а дисплей — тип HDR (или OS тонмапит сама).
- `DOVI*`: есть DV-декодер нужного профиля → Direct. Нет, но BL совместим (`DvBlSignalCompatibilityId` 1/2/4 → HDR10/SDR/HLG) → `Degraded(DV_TO_BASE_LAYER)` через подмену mime (так делает Plezy `DvBitstreamSanitizer`). P5 без DV-декодера → `No` (цвета будут неправильные).
- `DOVIWithEL` (P7 FEL): Direct только на редких SoC, иначе BL HDR10 → Degraded.
4. **Субтитры** (выбранные по умолчанию или пользователем):
- `subrip/webvtt/mov_text/ttml` → ок;
- `pgssub`, `dvdsub` (VobSub в MKV), `dvbsub` → у media3 есть парсеры, ок (проверить на своих файлах);
- `ass/ssa` → с ass-media ок, без него `Degraded(ASS_SIMPLIFIED)`.
5. **Аудио:** при passthrough-возможностях sink ок. TrueHD/DTS без passthrough → ffmpeg-extension (если в сборке) → ок, иначе `No(AUDIO)`.
6. **Interlaced** (`IsInterlaced`, типично MPEG-2/H.264 из ТВ-записей): Exo деинтерлейс не делает (на части SoC его делает сам декодер) → `Degraded(NO_DEINTERLACE)`.
**VlcProvider.evaluate**
- Контейнер и кодек → почти всегда ок.
- HDR через HW → Direct, если кодек есть в HW; при SW-декоде HDR → `Degraded(HDR_TONEMAPPED)` (не проверено).
- 4K HEVC 10-bit или Hi10P 1080p на слабом SoC → `Degraded(SOFTWARE_DECODE_HEAVY)` по эвристике «пиксели × fps × битность против класса CPU» (`Build.SUPPORTED_ABIS`, `ActivityManager.isLowRamDevice`, число ядер).
- ASS ок, PGS/VobSub ок, passthrough ок.
### 5.4 Правило выбора
```kotlin
fun choose(src: MediaSourceInfo, sel: StreamSelection, caps: DeviceCaps,
providers: List<EngineProvider>, prefs: EnginePrefs): EngineChoice {
prefs.forcedEngineFor(itemId = src.itemId, libraryId = src.libraryId)?.let { return it } // per-item и per-library override
if (prefs.mode is Manual) return EngineChoice(prefs.mode.engine)
val verdicts = providers.associateWith { it.evaluate(src, sel, caps) }
val direct = verdicts.filterValues { it is Verdict.Direct }
val degraded = verdicts.filterValues { it is Verdict.Degraded }
// 1. Основной движок (Exo), если он даёт Direct
direct.keys.minByOrNull { it.priority }?.let { return EngineChoice(it.id, reason = "direct") }
// 2. Конфликт «HDR против совместимости»: HDR важнее мелких потерь
// Exo Degraded(ASS_SIMPLIFIED | NO_DEINTERLACE) лучше, чем VLC с HDR_TONEMAPPED
val ranked = degraded.entries.sortedWith(
compareBy<Map.Entry<EngineProvider, Verdict>> { lossWeight((it.value as Verdict.Degraded).losses) }
.thenBy { it.key.priority })
ranked.firstOrNull()?.let { (p, v) ->
// Если лучший вариант теряет HDR, а сервер может транскодировать с HDR (или в DV→HDR10) — спросить политику
if (Loss.HDR_TONEMAPPED in (v as Verdict.Degraded).losses && prefs.preferServerOverHdrLoss)
return EngineChoice(EXO, forceTranscode = true, reason = "keep HDR via server")
return EngineChoice(p.id, reason = v.losses.toString())
}
// 3. Никто не может → транскод основным движком (профиль Exo)
return EngineChoice(EXO, forceTranscode = true, reason = "no local engine")
}
// Пример весов: HDR_TONEMAPPED=100, DV_TO_BASE_LAYER=30, SOFTWARE_DECODE_HEAVY=40, ASS_SIMPLIFIED=20, NO_PASSTHROUGH=15, NO_DEINTERLACE=10
```
**Порядок вызовов с сервером:**
1. Решение принимается **до** `PlaybackInfo` по `MediaSources`/`MediaStreams` элемента (`/Items/{id}` с `Fields=MediaSources,MediaStreams` или результат предварительного PlaybackInfo).
2. Затем `PlaybackInfo` вызывается с **DeviceProfile выбранного движка**. Так делают Wholphin, Swiftfin и JellyPlay; объединять профили нельзя, иначе сервер разрешит DirectPlay того, что Exo не умеет.
3. Если сервер вернул транскод для профиля Exo, а VLC мог бы играть напрямую, сменить выбор и повторить PlaybackInfo с профилем VLC: прямое воспроизведение лучше транскода.
4. **Смена аудио- или субтитр-трека пользователем** запускает выбор заново. Пример: включили PGS в HDR-фильме, Exo справляется, остаёмся на нём; включили сложный ASS при SDR — можно предложить VLC или mpv с переключением как в 2.5.
### 5.5 Runtime-fallback (по образцу Plezy)
- **Триггеры:**
- `PlaybackException` с кодами `ERROR_CODE_DECODER_INIT_FAILED`, `ERROR_CODE_DECODING_FAILED`, `ERROR_CODE_DECODING_FORMAT_UNSUPPORTED`/`EXCEEDS_CAPABILITIES`, `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`;
- `onTracksChanged`: есть видеогруппа, но ни одна не выбрана;
- watchdog: `DecoderCounters` показывают вход без выхода дольше N с;
- 0 кадров (`onRenderedFirstFrame` не пришёл) дольше M с при `isPlaying`;
- `AudioSink` init failure после восстановления.
- **Порядок:**
1. если кадр уже был отрисован, один повтор в Exo (пересоздать декодер, `seekTo(pos)`);
2. для DV — попытка DV → HDR10;
3. переход на следующий движок по ранжированию из 5.4;
4. серверный транскод.
Не больше одного перехода на движок за item.
- **Наблюдаемость:** записывать в локальный журнал `(item, codec/profile/rangeType, device model/SoC, decoder name, exception code, выбранный fallback)`. По журналу потом уточнять правила 5.3, например заносить в denylist конкретные `codecName` на конкретных SoC.
- **Настройки UI:**
- «Движок: Авто / ExoPlayer / VLC»;
- «Автоматически переключаться при ошибке: вкл»;
- «Предпочитать сохранение HDR (транскод) вместо SW-декода»;
- per-item «Воспроизвести с помощью…» (как `Destination.Playback.backend` в Wholphin и «Play using…» в Kodi);
- per-library override (например, «Аниме → mpv»).
---
## 6. Ссылки (проверены, если не указано иное)
- Findroid: https://github.com/jarnedemeulemeester/findroid — `player/local/.../mpv/MPVPlayer.kt`, `player/local/.../presentation/PlayerViewModel.kt`, `data/.../JellyfinRepositoryImpl.kt`
- Wholphin: https://github.com/damontecres/Wholphin — `app/.../services/PlayerFactory.kt`, `app/.../ui/playback/PlaybackViewModel.kt`, `app/.../util/mpv/MpvDeviceProfile.kt`, `wholphin-mpv-stub/`, `app/build.gradle.kts`
- wholphin-extensions: https://github.com/damontecres/wholphin-extensions (v0.2.4, 11.09.2026)
- JellyPlay: https://github.com/raulshma/jellyplay — `docs/player-engines.md`, `shared/core/player-contract/.../engine/MediaEngine.kt`, `shared/core/network/.../DeviceProfileProvider.kt`, `.../engine/Media3PlayerHost.kt`
- AFinity: https://github.com/MakD/AFinity — `app/.../ui/player/PlayerViewModel.kt`
- Void: https://github.com/hritwikjohri/Void-for-jellyfin — `domain/model/playback/PlayerType.kt`, `presentation/ui/screen/player/VideoPlayerScreen.kt`
- Streamyfin: https://github.com/streamyfin/streamyfin — `utils/atoms/settings.ts`, `modules/mpv-player`, `modules/exoplayer-player`
- Swiftfin: https://github.com/jellyfin/Swiftfin — `Shared/Objects/VideoPlayerType/VideoPlayerType.swift`, `Shared/Objects/MediaPlayerManager/MediaPlayerProxy/*.swift`
- Plezy: https://github.com/edde746/plezy — `android/app/.../exoplayer/ExoPlayerPlugin.kt`, `ExoPlayerCore.kt`, `VideoDecoderRecoveryPolicy.kt`, `.../mpv/MpvPlayerCore.kt`, `mpv-build.lock.json`
- jellyfin-androidtv: https://github.com/jellyfin/jellyfin-androidtv — `playback/core/src/main/kotlin/backend/PlayerBackend.kt`, `BackendService.kt`, `playback/media3/session/.../MediaSessionPlayer.kt`
- Just Player: https://github.com/moneytoo/Player — `Prefs.java` (`decoderPriority`)
- Nova: https://github.com/nova-video-player/aos-MediaLib — `IMediaPlayer.java`, `MediaFactory.java`; https://github.com/nova-video-player/aos-AVP (`exoplayer.xml`)
- Kodi: https://github.com/xbmc/xbmc/blob/master/system/playercorefactory.xml, `xbmc/cores/playercorefactory/PlayerSelectionRule.cpp`; https://kodi.wiki/view/External_players (403 при загрузке, не проверено)
- media3 PlayerSurface: https://github.com/androidx/media/blob/release/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/PlayerSurface.kt
- libmpv-android: https://github.com/jarnedemeulemeester/libmpv-android (`buildscripts/include/depinfo.sh`, `build.sh`)
- mpv-android: https://github.com/mpv-android/mpv-android (`app/src/main/java/is/xyz/mpv/MPVView.kt`)
- Maven Central: https://repo1.maven.org/maven2/org/videolan/android/libvlc-all/ , https://repo1.maven.org/maven2/dev/jdtech/mpv/libmpv/ , https://repo1.maven.org/maven2/io/github/abdallahmehiz/mpv-android-lib/
- ass-media: https://repo1.maven.org/maven2/io/github/peerless2012/ass-media/ (0.5.1)
- Play Feature Delivery on-demand: https://developer.android.com/guide/playcore/feature-delivery/on-demand

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
# Профиль медиатеки тестового сервера (снято через API 2026-10-12)
Сервер: Jellyfin **10.11.8** (нужно обновить до 12.x под SDK 1.9). Публичный адрес сервера резолвится дома в LAN-адрес (split DNS). У тестового пользователя `RemoteClientBitrateLimit = 0`.
12 671 элемент (фильмы + эпизоды). Счётчик = число медиаисточников, в которых встречается признак.
| Категория | Кол-во | Значение для клиента |
|---|---|---|
| Контейнер MKV / AVI / MP4 | 6924 / 4207 / 1524 | AVI — треть библиотеки |
| MPEG-4 ASP (DivX/Xvid) + Simple | 3807 + 375 | HW-декодер ТВ есть (до 1080p); packed bitstream — проверить media3 AVI vs VLC |
| H.264 8-bit | ~5740 | без проблем |
| **H.264 Hi10P (10-bit)** | 151 | HW нет → только программно (libVLC), CPU 4×A73 |
| HEVC SDR 8/10-bit | 739 / 1751 | HW |
| HEVC HDR10 / HDR10+ / HLG | 30 / 9 / 3 | media3 |
| **DV P8.1 (DOVIWithHDR10)** | 25 | media3, DV-декодер |
| **DV + HDR10+** | 4 | известный баг media3 на MediaTek (#1895) — вероятная причина зависаний |
| DV P8.4 (DOVIWithHLG) | 17 | media3 |
| AV1 (SDR/HDR10) | 4 | HW |
| MPEG-2 | 14 | HW до 1080p; часть **чересстрочные** (15 файлов) → нужен деинтерлейс |
| Аудио AAC / AC3 / EAC3 / MP3 | 4500 / 5673 / 1046 / 2057 | — |
| Аудио DTS / TrueHD / FLAC / Opus / Vorbis / MP2 | 77 / 3 / 61 / 471 / 27 / 3 | TrueHD и DTS на встроенных динамиках → декод в PCM (MS12 или ffmpeg) |
| **Субтитры ASS** | 2475 | главный повод транскода у других клиентов (burn-in) → рендерить на клиенте |
| SRT / mov_text / PGS / DVDSUB | 1389 / 14 / 6 / 1 | — |
| Встроенная обложка (mjpeg/png как видеопоток) | 499 | не путать с видеодорожкой при выборе/профиле |
## Примеры проблемных файлов
- **Фильм 4K HEVC HDR10 + TrueHD Atmos** — MKV 73,8 GiB, HEVC Main10 HDR10 4K 23.976, видео ~55 Мбит/с; аудио по умолчанию **TrueHD Atmos 7.1**; субтитры по умолчанию **ASS (forced)**. Вероятные причины проблем: ASS по умолчанию → burn-in → полный транскод 4K HDR с тонмаппингом (тусклая картинка); TrueHD на динамиках; ~60 Мбит/с суммарно по Wi-Fi.
- **Фильм DV P8.1 + HDR10+ и AC3** — HEVC **DV P8.1 + HDR10+** (`DOVIWithHDR10Plus`), 26,6 Мбит/с, AC3. Совпадает с багом media3 на MediaTek (зависание/чёрный/пурпурный экран).
- **Фильм DV P8.1 + E-AC3** — HEVC DV P8.1 (`DOVIWithHDR10`), 20 Мбит/с, EAC3, SRT, 8 аудио и 12 субтитров, встроенная MJPEG-обложка.

327
docs/research/media3.md Normal file
View File

@@ -0,0 +1,327 @@
# media3/ExoPlayer как основной движок Jellyfin-клиента для Android TV: что умеет, что нет
Дата: 2026-10-12. Версии: media3 **1.11.1** (2026-09-10), jellyfin-androidtv master `0032f4a` (2026-10-12), Jellyfin server 12.2, jellyfin-sdk-kotlin 1.9.2.
Метод: исходники читались напрямую (клоны `androidx/media` main `8c6678b` = 1.11.1, `jellyfin/jellyfin-androidtv` master, `jellyfin/jellyfin-androidx-media`, `peerless2012/libass-android`, `anilbeesetti/nextlib`, `moneytoo/Player` (Just Player), `xbmc/xbmc` raw). Issues/PR проверялись через GitHub API или загрузкой страниц. Всё, что не подтверждено кодом или первоисточником, помечено **(не проверено)**.
---
## 0. TL;DR
| Тема | Вывод |
|---|---|
| DV P5 / P8.x / P10 | Работает, если есть DV-декодер (`video/dolby-vision`) **и** дисплей сообщает `HDR_TYPE_DOLBY_VISION`. Без DV-дисплея media3 сам переключается на HEVC/AVC/AV1 для P4/P8/P9/P10 (кроме 10.0). Для P5 фолбэка нет. |
| DV P7 (FEL/MEL, BD-remux) | В media3 **нет фолбэка на HEVC** (`DvheDtb` исключён в `getAlternativeCodecMimeType`). FEL никогда не обрабатывается (только BL+RPU, если декодер SoC умеет). Фикс фолбэка: PR [#3160](https://github.com/androidx/media/pull/3160), ещё открыт. |
| DV в MKV | `MatroskaExtractor` парсит `BlockAdditionMapping` с `BlockAddIDType` = `dvcC`/`dvvC` → MIME `video/dolby-vision`. В MP4 парсятся `dvcC`/`dvvC`/`dvwC`. |
| HDR10 / HLG / HDR10+ | Статические метаданные идут через `ColorInfo.hdrStaticInfo` → `KEY_HDR_STATIC_INFO`. HDR10+ для HEVC/AV1 передаётся внутри потока (SEI/OBU), для VP9 в MKV через BlockAdditional T.35 → `PARAMETER_KEY_HDR10_PLUS_INFO`. Гибриды DV+HDR10+ ломаются на устройствах MediaTek (Fire TV 4K/Max, Google TV Streamer). |
| Tunneling | Включается через `DefaultTrackSelector.Parameters.setTunnelingEnabled(true)`. Полезен для 4K/HDR на слабых SoC, но ломает часть сценариев (точный seek в конец, видео без аудио, эффекты). jellyfin-androidtv его **не включает**. |
| Частота обновления | media3 сам умеет только `C.VIDEO_CHANGE_FRAME_RATE_STRATEGY_ONLY_IF_SEAMLESS` (через `Surface.setFrameRate`). На ТВ переключение почти всегда «не бесшовное», поэтому Kodi, Just Player и jellyfin-androidtv ставят `WindowManager.LayoutParams.preferredDisplayModeId`. |
| Режим HDR | Приложение не может явно включить HDR-режим HDMI. Он переключается автоматически, если HDR-кадры идут в `SurfaceView` (зависит от прошивки и настройки «Match dynamic range»). Обязательно `SurfaceView`, не `TextureView`. Обратного переключения в SDR после остановки может не быть: [#3406](https://github.com/androidx/media/issues/3406). |
| Passthrough | AC3, E-AC3, E-AC3-JOC (Atmos), DTS, DTS-HD, DTS:X P2 (`ENCODING_DTS_UHD_P2`, API 34) и TrueHD (`ENCODING_DOLBY_TRUEHD`) через `DefaultAudioSink` + `AudioCapabilities`. DTS-HD MA пока идёт как `ENCODING_DTS_HD`; PR [#3234](https://github.com/androidx/media/pull/3234) под `ENCODING_DTS_HD_MA` открыт. |
| FFmpeg-расширение | Официальное `media3-decoder-ffmpeg` декодирует **только аудио**: `ExperimentalFfmpegVideoRenderer` в 1.11.1 пустая заглушка. Готовый AAR: `org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1` (только аудио, FFmpeg 6.0, LGPL-сборка, POM помечен GPL-3.0). |
| Программное видео | Официально VP9 (libvpx) и AV1 (**dav1d** с 1.9.0, раньше libgav1); ни то, ни другое не публикуется в Maven. Сторонний `io.github.anilbeesetti:nextlib-media3ext:1.11.1-0.16.0` добавляет FFmpeg-видео (h264, hevc, mpeg1/2, vp8, vp9, av1-dav1d), но вывод **8-bit RGBA/YUV420P**, то есть без HDR. VC-1, MPEG-4 ASP и WMV ни в одной готовой сборке нет. |
| ASS/SSA | `io.github.peerless2012:ass-media:0.5.1` (libass, MIT) поддерживает вложенные шрифты MKV. В jellyfin-androidtv подключён за опцией `libass_enabled` (по умолчанию false), режим `AssRenderType.OVERLAY_OPEN_GL`. |
| PGS / VobSub / DVB | Внутри MKV (`S_HDMV/PGS`, `S_VOBSUB`, `S_DVBSUB`) и DVB в TS работают. Внешние `.sup` и `.idx/.sub` через `SubtitleConfiguration` фактически не годятся **(не проверено)**. |
| Контейнеры | MKV: header stripping поддерживается, zlib нет ([#2361](https://github.com/androidx/media/issues/2361)), ordered chapters и linked segments нет. M2TS (192-байтные пакеты) не поддерживается ([#608](https://github.com/androidx/media/issues/608)). ASF/WMV, ISO, VIDEO_TS и BDMV не поддерживаются. AVI есть (`AviExtractor`). |
---
## 1. Dolby Vision в media3
### 1.1 Профили и сопоставление
Источник: `libraries/common/.../CodecSpecificDataUtil.java` и `libraries/exoplayer/.../mediacodec/MediaCodecUtil.java`.
| Профиль DV | Константа `CodecProfileLevel` | Codec string | Базовый слой | Фолбэк media3 без DV-дисплея |
|---|---|---|---|---|
| 4 | `DolbyVisionProfileDvheDtr` | dvhe.04 | HEVC | → `video/hevc` |
| 5 | `DolbyVisionProfileDvheStn` | dvhe.05 | IPTPQc2, без обратной совместимости | **нет** |
| 7 | `DolbyVisionProfileDvheDtb` | dvhe.07 | HEVC Main10 HDR10 + EL | **нет**, явно исключён: «deprecated and is not always backward compatible» |
| 8.1 / 8.2 / 8.4 | `DolbyVisionProfileDvheSt` | dvhe.08 (dvh1.08 в MP4) | HEVC HDR10 / SDR / HLG | → `video/hevc` |
| 9 | `DolbyVisionProfileDvavSe` | dvav.09 | AVC | → `video/avc` |
| 10.x | `DolbyVisionProfileDvav110` | dav1.10 | AV1 | → `video/av01`, **кроме 10.0** (PQ + full range означает отсутствие совместимости) |
- Поддержку P10 добавили в 1.10.0 ([PR #2830](https://github.com/androidx/media/pull/2830)). В 1.10.1 исправили артефакты из-за initialization data для DV-AV1 ([#3153](https://github.com/androidx/media/pull/3153)).
- MIME: `MimeTypes.VIDEO_DOLBY_VISION = "video/dolby-vision"`. `MediaCodecUtil.getCodecMimeType` учитывает вендорские MIME: `OMX.MS.HEVCDV.Decoder` → `video/hevcdv`, а Realtek (`OMX.RTK.video.decoder`, `OMX.realtek.video.decoder.tunneled`) → `video/dv_hevc`.
- `MediaCodecVideoRenderer.onConfigure`: для DV в `MediaFormat` явно пишется `KEY_PROFILE`, потому что некоторые устройства этого требуют.
### 1.2 Как выбирается декодер (`MediaCodecVideoRenderer.getDecoderInfos`)
```
if (SDK_INT >= 26 && format is DV && !Api26.doesDisplaySupportDolbyVision(context)) {
alt = MediaCodecUtil.getAlternativeDecoderInfos(...) // HEVC/AVC/AV1 по таблице выше
if (!alt.isEmpty()) return alt;
}
return MediaCodecUtil.getDecoderInfosSoftMatch(...) // DV-декодеры + альтернативные
```
- `doesDisplaySupportDolbyVision` проверяет `display.isHdr()` и наличие `HDR_TYPE_DOLBY_VISION` в `Display.getHdrCapabilities().getSupportedHdrTypes()` для `DEFAULT_DISPLAY`.
- Если DV-дисплея нет, `supportsFormat` возвращает `DECODER_SUPPORT_FALLBACK_MIMETYPE`.
- `getDecoderInfosSoftMatch` даёт полный список: декодеры для основного MIME плюс для альтернативного (см. javadoc). Отсюда следствие: если на устройстве **есть** DV-декодер, а дисплей DV не поддерживает, для P5 и P7 будет использован DV-декодер. Что он выведет на не-DV экран (тон-маппинг или фиолетовая картинка), зависит от SoC **(не проверено)**.
- Если ни DV-декодера, ни фолбэка нет (например, P7 на Chromecast/Google TV Streamer без DV), трек получает статус unsupported и видео не играет. Решения: (а) DeviceProfile, чтобы сервер срезал DV или транскодировал; (б) на клиенте подменить `sampleMimeType` на `video/hevc` через собственный `MediaCodecSelector` или обёртку экстрактора (так делают сторонние плееры **(не проверено)**).
- Из release notes:
- 1.9.x: «Fix issue where some playbacks of Dolby Vision files fail when attempting to use a fallback AVC or HEVC codec».
- Ранее: «Disable codec reuse for Dolby-Vision content with different profiles» и «Support CEA-608 in DV content».
- 1.11.0: трек с распознанным, но неподдерживаемым профилем/уровнем теперь `NO_EXCEEDS_CAPABILITIES`, а не `YES`.
- 1.10.1: [#3135](https://github.com/androidx/media/issues/3135) перенёс HDR-предпочтения в quality preferences.
- Открыт PR [#3461](https://github.com/androidx/media/pull/3461) «Select HDR video tracks by display HDR capabilities».
### 1.3 Профиль 7 (FEL/MEL)
- В MKV (MakeMKV) BL+EL+RPU объединены в **один трек** с NAL type 62/63, codec `dvhe.07.06`.
- media3 не делает ни композиции EL, ни конвертации P7 в P8. Feature request [#1865](https://github.com/androidx/media/issues/1865) («DV Profile 7 + FEL on Android TV») открыт с 2024-11, без ответа мейнтейнеров.
- Фолбэк P7 → HEVC (BL HDR10/HDR10+) появится только после PR [#3160](https://github.com/androidx/media/pull/3160) (open, approve от контрибьютора 2026-08-14).
- Устройства: Nvidia Shield 2019 фактически играет P7 (MEL и FEL как BL+RPU), но **не сообщает** об этом в `MediaCodecList`. jellyfin-androidtv держит это в `KnownDefects.unreportedDoviProfile7Support` (`Build.MODEL == "SHIELD Android TV"`).
- Ugoos/CoreELEC с `dovi.ko` и полноценным FEL недоступны обычному Android-приложению.
- Свежий баг jellyfin-androidtv: [#5794](https://github.com/jellyfin/jellyfin-androidtv/issues/5794), «TrueHD passthrough and DV P7 fallback broken on Shield 2019». В 0.20 beta частично исправлен; на не-DV Samsung P7 играет в SDR вместо HDR10.
### 1.4 DV в MKV и MP4
- **MKV**: `MatroskaExtractor` понимает `ID_BLOCK_ADDITION_MAPPING (0x41E4)`, `ID_BLOCK_ADD_ID_TYPE (0x41E7)`, `ID_BLOCK_ADD_ID_EXTRA_DATA (0x41ED)`. При `BlockAddIDType == 0x64766343 ('dvcC')` или `0x64767643 ('dvvC')` байты конфигурации парсятся через `DolbyVisionConfig.parse()`, после чего `mimeType = video/dolby-vision`, `codecs = dvhe.PP.LL`. `dvwC` для MKV не обрабатывается, для MP4 обрабатывается. `BlockAdditional` с ID 4 (ITU-T T.35) берётся **только для VP9**, остальные block additions пропускаются.
- **MP4/fMP4**: боксы `dvcC`, `dvvC`, `dvwC`, sample entries `dvhe`, `dvh1`, `dav1`, `dvav`/`dva1` (`Mp4Box.java`).
- **TS/HLS**: DV определяется через `SUPPLEMENTAL-CODECS` в HLS. В «сыром» TS с DV-дескриптором (Blu-ray m2ts) нет **(не проверено)**; M2TS всё равно не открывается, см. §7.
### 1.5 Известные открытые проблемы DV
- [#1895](https://github.com/androidx/media/issues/1895): DV P8.1 + HDR10+ (гибридные релизы) на Fire TV 4K Max и других MediaTek. Симптомы: зависание, чёрный или пурпурный экран. Фикс PR [#3085](https://github.com/androidx/media/pull/3085) («Skip HDR10+ metadata processing when DV Profile 8 is detected»), open, результаты смешанные. jellyfin-androidtv обходит это через `KnownDefects.hevcDoviHdr10PlusBug` для моделей `AFTKRT`, `AFTKA`, `AFTKM`, `AFTMM`.
- [#5184 (jellyfin-androidtv)](https://github.com/jellyfin/jellyfin-androidtv/issues/5184): чёрное мигание на DoVi при смене сцен.
- [#1635](https://github.com/androidx/media/issues/1635): DASH, нельзя выбрать DV AdaptationSet. Для нас неактуально.
---
## 2. HDR10 / HDR10+ / HLG и tunneling
### 2.1 Метаданные
- **Статические** (MaxCLL/MaxFALL/mastering display): из MKV `Colour/MasteringMetadata`, MP4 `mdcv`/`clli` или VUI/SEI попадают в `ColorInfo.hdrStaticInfo`, затем `MediaFormatUtil.maybeSetColorInfo` записывает `MediaFormat.KEY_HDR_STATIC_INFO`, `KEY_COLOR_TRANSFER`, `KEY_COLOR_STANDARD`, `KEY_COLOR_RANGE`. В 1.11.0 добавлено «Matroska: Use codec bitstream metadata to populate accurate ColorInfo», так что неверные или пустые Colour-элементы MKV больше не ломают HDR.
- **HDR10+ (динамические)**:
- HEVC и AV1: метаданные лежат в SEI/OBU потока и уходят в декодер вместе с кадром. Для MKV ничего специального не нужно, декодер должен поддерживать `HEVCProfileMain10HDR10Plus`.
- VP9 в MKV/WebM: BlockAdditional ID 4 → `supplementalData` → `MediaCodec.setParameters(PARAMETER_KEY_HDR10_PLUS_INFO)` (API 29+, `setHdr10PlusInfoV29`).
- **HLG**: определяется по `COLOR_TRANSFER_HLG`, отдельной обработки нет.
- **Новое**: в 1.11.0 добавлена экспериментальная поддержка HAGC (SMPTE ST 2094-50) для MP4/MKV, работает на API 37+. С 1.11.1 отключается через `ExtractorsFactory.setParseHagcMetadata(false)`.
- Открыт PR [#3463](https://github.com/androidx/media/pull/3463): «Map 10-bit AV1 to AV1ProfileMain10HDR10 only for PQ».
### 2.2 Tunneled playback
- API: `DefaultTrackSelector.Parameters.Builder.setTunnelingEnabled(true)`. Проверка: `ExoPlayer.isTunnelingEnabled()`. Работает, только если **и видео-, и аудиорендерер** поддерживают tunneling. Для видео нужен декодер с `FEATURE_TunneledPlayback`, поддерживающий формат и seamless adaptation (`MediaCodecVideoRenderer.supportsFormat`). Нужен audio session id: AV-sync идёт по аудио (HW sync).
- **Плюсы**: кадры идут от декодера сразу в HW-композитор, минуя app/SurfaceFlinger. Меньше дропов для 4K60/HDR на слабых SoC (Amlogic/MediaTek-стики), точнее A/V sync. В Just Player опция «Tunneled playback» описана как «can improve playback of 4K/HDR content on Android TV». Подробнее: [AOSP multimedia tunneling](https://source.android.com/devices/tv/multimedia-tunneling).
- **Минусы и что ломается**:
- Без аудиотрека не работает ([#2541](https://github.com/androidx/media/issues/2541)). Пауза делается через AudioTrack.
- Seek в конец может зависнуть, не доходя до `STATE_ENDED` ([#1574](https://github.com/androidx/media/issues/1574)).
- Гонка с audio session id: crash `IllegalStateException` (исправлен в 1.10.1, [#3099](https://github.com/androidx/media/issues/3099)).
- DTS:X P2 в HLS-fMP4 теряет первые 2 секунды в tunnel ([#1245](https://github.com/androidx/media/issues/1245)).
- Нельзя применять video effects или GL-обработку к кадрам. Не работают `VideoFrameMetadataListener` и покадровый контроль, render-first-frame на паузе после seek ненадёжен **(частично не проверено)**.
- Нет гарантии нескольких одновременных tunneled-декодеров.
- Часть HDR-метаданных на некоторых SoC в tunnel теряется **(не проверено)**.
- **Субтитры**: text-рендерер не зависит от tunneling, cues приходят как обычно, `SubtitleView` или ASS-overlay (`AssRenderType.OVERLAY_*`) рисуются поверх SurfaceView. Не будут работать только режимы, встраивающие субтитры в видеокадр (`AssRenderType.EFFECTS_*`, deprecated) **(логический вывод, не проверено на устройстве)**.
- **Рекомендация**: опция «Tunneling: Авто/Вкл/Выкл», по умолчанию выкл. Если для конкретной модели есть подтверждённые проблемы с плавностью, включать через device-list. jellyfin-androidtv tunneling не использует, а только репортит поддержку (`ExoPlayerPlaySupportReport.tunnelingFromFlags`).
---
## 3. Режим дисплея: частота и HDR
### 3.1 Что умеет media3
- `ExoPlayer.setVideoChangeFrameRateStrategy(int)` принимает только `C.VIDEO_CHANGE_FRAME_RATE_STRATEGY_ONLY_IF_SEAMLESS` (по умолчанию) или `..._OFF`. На API 30+ `VideoFrameReleaseHelper.Api30.setSurfaceFrameRate` вызывает `Surface.setFrameRate(rate, FRAME_RATE_COMPATIBILITY_FIXED_SOURCE)` (или 0/DEFAULT на паузе). Вариант `CHANGE_FRAME_RATE_ALWAYS` в media3 **не реализован**.
- По [гайду Android](https://developer.android.com/media/optimize/performance/frame-rate):
- для фильмов использовать `Surface.setFrameRate(fps, FIXED_SOURCE, CHANGE_FRAME_RATE_ALWAYS)` (API 31), но только если пользователь выбрал `DisplayManager.getMatchContentFrameRateUserPreference() == MATCH_CONTENT_FRAMERATE_ALWAYS`;
- дождаться `DisplayListener.onDisplayChanged`, затем выдержать паузу около 2 с и запускать воспроизведение;
- на Android TV «тяжёлую» смену режима (HDMI re-negotiation) `setFrameRate` может не вызвать, и гайд прямо советует для этого `preferredDisplayModeId`.
- Связанные баги:
- [#2258](https://github.com/androidx/media/issues/2258): после смены частоты Shield падал на PCM вместо passthrough. Исправлено в 2025-05, рекомендуется `setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)`.
- [#3120](https://github.com/androidx/media/issues/3120) и [#2941](https://github.com/androidx/media/issues/2941): чёрные кадры и сброс кодека при смене fps на API < 30 (исправлено в 1.10.x).
### 3.2 Как делают другие
- **jellyfin-androidtv** (`ui/playback/PlaybackController.java`, `findBestDisplayMode`/`setRefreshRate`): перебирает `Display.getSupportedModes()` и пропускает режимы ниже 1280×720 и ниже разрешения видео. Подходят частоты `== fps`, `== 2×fps`, `== 2.5×fps` (в сотых герца). Вес: `100000 − rateDiff + 100000 − resolutionDiff`. Режимы `SCALE_ON_DEVICE` / `SCALE_ON_TV` (`RefreshRateSwitchingBehavior`). Применяет `window.attributes.preferredDisplayModeId`, по выходу сбрасывает в 0 (`CustomPlaybackOverlayFragment`).
- **Just Player** (`Utils.handleFrameRate`): fps берётся из `MediaExtractor`. Ищется режим **того же разрешения** с частотой, кратной fps; если такого нет, берётся максимальная. Ставится `preferredDisplayModeId`, воспроизведение стартует в `DisplayManager.DisplayListener.onDisplayChanged`. Комментарий в коде: «ExoPlayer already uses Surface.setFrameRate() on Android 11+».
- **Kodi** (`XBMCApp.cpp::SetDisplayMode` / `SetDisplayModeCallback`): тоже `preferredDisplayModeId`. HDR-возможности берёт из `Display.getHdrCapabilities().getSupportedHdrTypes()` (`AndroidUtils.cpp`).
- **Nova Video Player**: `preferredDisplayModeId` с похожей логикой **(не проверено по коду)**.
- **Рекомендация**: свой `DisplayModeManager`:
1. До `prepare()` взять fps из Jellyfin `MediaStream.RealFrameRate`/`AverageFrameRate`.
2. Если режим «Всегда», выбрать `Display.Mode` и выставить `preferredDisplayModeId` (на API 31+ можно ещё `Surface.setFrameRate(..., CHANGE_FRAME_RATE_ALWAYS)`).
3. Дождаться `onDisplayChanged` с таймаутом.
4. Включить `setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)`, потому что после переподключения HDMI меняются AudioCapabilities.
5. Учесть 23.976 против 24.000 (сравнивать с точностью около 0.01 Гц) и фильтровать режимы с меньшим разрешением.
### 3.3 Переключение HDR
- У обычного приложения нет публичного API «включить HDR на HDMI». Платформа или прошивка переключают выход, когда в `SurfaceView` поступают кадры с HDR-dataspace. Это зависит от настроек «Match content dynamic range» (Shield, Google TV, Fire TV) **(не проверено для каждой прошивки)**. На API 34+ есть `DisplayManager.setHdrConversionMode` / `HdrConversionMode`, но это system API **(не проверено)**.
- Проверка возможностей: `Display.isHdr()` и `Display.getHdrCapabilities().supportedHdrTypes` (`HDR_TYPE_DOLBY_VISION`, `HDR_TYPE_HDR10`, `HDR_TYPE_HLG` с API 24, `HDR_TYPE_HDR10_PLUS` с API 29). На API 34+ также `Display.Mode.getSupportedHdrTypes()` **(не проверено)**.
- **Обязателен `SurfaceView`**: `TextureView` HDR не передаёт. В Compose это `PlayerSurface(surfaceType = SURFACE_TYPE_SURFACE_VIEW)` из `media3-ui-compose` или `AndroidExternalSurface`.
- Открытый баг [#3406](https://github.com/androidx/media/issues/3406): после остановки HDR-видео дисплей остаётся в HDR (Mi Box S, Android TV 11/14). Kodi этим не страдает. Гипотеза: нужно освобождать или отсоединять Surface, сбрасывать `preferredDisplayModeId` и закрывать активити плеера **(не проверено)**.
---
## 4. Аудио: passthrough и FFmpeg
### 4.1 Passthrough (`DefaultAudioSink`, `AudioCapabilities`)
- Таблица `AudioCapabilities.ALL_SURROUND_ENCODINGS_AND_MAX_CHANNELS`: `AC3` 6, `AC4` 6, `DTS` 6, `DTS_UHD_P2` 10, `E_AC3_JOC` 6, `E_AC3` 8, `DTS_HD` 8, `DOLBY_TRUEHD` 8.
- Встроенные фолбэки: E-AC3-JOC → E-AC3 (Atmos-метаданные при этом остаются внутри потока); DTS-HD и DTS-UHD-P2 → DTS core, если HD не поддерживается.
- На API 33+ для TV используется `AudioManager.getDirectProfilesForAttributes`. На API 29+ для TV учитываются `external_surround_sound_enabled` (Fire TV) и `force_external_surround_sound`. Для Amazon и Xiaomi есть отдельная ветка.
- **Atmos**: E-AC3-JOC (DD+ Atmos) битстримится как `ENCODING_E_AC3_JOC` (API 28+). TrueHD Atmos битстримится как `ENCODING_DOLBY_TRUEHD` (MAT); Atmos в TrueHD передаётся прозрачно.
- **DTS-HD MA / DTS:X (Blu-ray)**: идут как `ENCODING_DTS_HD`, DTS:X прозрачно внутри. Корректный `ENCODING_DTS_HD_MA` (API 34) появится с PR [#3234](https://github.com/androidx/media/pull/3234) (open), связанный issue [#2487](https://github.com/androidx/media/issues/2487). Детект DTS-HD в MKV и MP4 доработан в 1.11 (PR #3226, [#3229](https://github.com/androidx/media/pull/3229)), в TS склеиваются core и ext ([#3147](https://github.com/androidx/media/pull/3147)).
- **TrueHD**:
- MKV: `TrueHdSampleRechunker` группирует sync-фреймы для passthrough.
- fMP4: TrueHD не поддерживается ([#1519](https://github.com/androidx/media/issues/1519)), PR [#3411](https://github.com/androidx/media/pull/3411) открыт. Для нас это значит: не просить сервер ремуксить TrueHD в HLS-fMP4.
- Программный декод 2.0 TrueHD сломан в FFmpeg-расширении с media3 1.2.1 (FFmpeg 6.0, [#1843](https://github.com/androidx/media/issues/1843), open). 5.1/7.1 работают.
- [#3329](https://github.com/androidx/media/issues/3329): сложности учёта данных в `DefaultAudioSink` при TrueHD → MAT.
- Реальность устройств: TrueHD/DTS-HD битстримит Shield. Chromecast/Google TV Streamer и большинство стиков — только DD/DD+ (часто «Dolby Atmos via DD+»). Сводки нет **(не проверено)**.
- Наблюдение jellyfin-androidtv [PR #5822](https://github.com/jellyfin/jellyfin-androidtv/pull/5822): если объявить TrueHD как поддерживаемый, а устройство не может его битстримить, сервер отдаст оригинал, клиент декодирует его в PCM 7.1, а TV в цепочке может свести это в стерео. Лучше просить сервер перекодировать в AC3 5.1. Контрпример: Chromecast напрямую в ресивер. Нужна пользовательская настройка.
### 4.2 Расширение FFmpeg
- Официальный модуль `libraries/decoder_ffmpeg`. Внутри `FfmpegAudioRenderer` и `ExperimentalFfmpegVideoRenderer`. Последний в 1.11.1 явно **нерабочий**: `supportsFormat` возвращает `FORMAT_UNSUPPORTED_TYPE`, `// TODO: Merge actual implementation in PR #1591`, `b/470357054`. `DefaultRenderersFactory` подхватывает его рефлексией, но толку нет.
- Маппинг MIME → FFmpeg (`FfmpegLibrary.getCodecName`): aac, mp3, ac3, eac3 (+JOC), truehd, dca (DTS / Express / HD), vorbis, opus, amrnb/wb, flac, alac, pcm_mulaw/alaw, h264, hevc.
- В Maven Google модуль **не публикуется** ([ExoPlayer #2781](https://github.com/google/ExoPlayer/issues/2781)). Сборка: NDK r26b, FFmpeg `release/6.0`, `build_ffmpeg.sh` с `--disable-everything --enable-decoder=...`, без `--enable-gpl`, то есть получается **LGPL-2.1+**.
- **Готовый AAR от Jellyfin**: `org.jellyfin.media3:media3-ffmpeg-decoder` (Maven Central, последняя `1.9.0+1`, 2025-12-29). Репозиторий [jellyfin/jellyfin-androidx-media](https://github.com/jellyfin/jellyfin-androidx-media): submodules `androidx/media` (ветка release) и FFmpeg `release/6.0`. `ENABLED_DECODERS=(flac alac pcm_mulaw pcm_alaw mp3 aac ac3 eac3 dca mlp truehd)`, ABI 23, **только аудио**. Лицензия репозитория и POM: GPL-3.0; сам libffmpeg собран без `--enable-gpl` (LGPL). jellyfin-androidtv использует `1.9.0+1` вместе с media3 `1.11.1`: бинарно совместимо на практике, но это риск при обновлениях. Режим `EXTENSION_RENDERER_MODE_ON` или `_PREFER` (опция «prefer ffmpeg»).
- **Лицензия для нас**: при GPL-клиенте (клиенты Jellyfin под GPL-2.0/3.0) конфликта нет. LGPL требует возможности перелинковки, у открытого проекта это выполнено. Патентные вопросы по AC3/E-AC3/DTS/TrueHD декодерам в FFmpeg лежат на дистрибьюторе **(юридически не проверено)**. Для Google Play многие плееры (Just Player, Next Player, jellyfin-androidtv) так и поставляются.
---
## 5. Программное декодирование видео
| Источник | Кодеки | Статус / артефакт | HDR / 10-bit |
|---|---|---|---|
| `decoder_vp9` (`LibvpxVideoRenderer`) | VP9 (+VP8?) | собирать самим, в Maven нет | 10-bit выводится через GL YUV-рендер **(не проверено)** |
| `decoder_av1` (`Libdav1dVideoRenderer`) | AV1 | с **1.9.0** на dav1d вместо libgav1; собирать самим (NDK r27, meson) | **(не проверено)** |
| `ExperimentalFfmpegVideoRenderer` (официальный) | — | заглушка | — |
| PR [#1591](https://github.com/androidx/media/pull/1591) «Implement an FfmpegVideoRenderer» (rabbitknight) | VP8/9, MPEG-1/2, VC-1, ProRes, AV1, H.264 Hi10 | open с 2024-08, без ревью мейнтейнеров; адаптирован к 1.11.1 (2026-09); есть баги (green screen при zero stride, `pixelWidthHeightRatio=1`) | 10-bit приводится к 8-bit |
| **NextLib** [anilbeesetti/nextlib](https://github.com/anilbeesetti/nextlib), `io.github.anilbeesetti:nextlib-media3ext:1.11.1-0.16.0` (2026-09-13) | видео: h264, hevc, mpeg2video, mpegvideo, vp8, vp9, libdav1d; аудио: как у Jellyfin + opus, vorbis, amr | Maven Central, синхронизирован с media3 1.11.1, используется Next Player; FFmpeg 9.0.1, `--enable-version3` + mbedTLS (LGPLv3), репозиторий GPL-3.0 | вывод `WINDOW_FORMAT_RGBA_8888` / YUV420P через `sws_scale`, то есть **HDR и 10-bit теряются** |
Что остаётся **невозможным без libVLC/libmpv** (или без серверной транскодировки):
- **VC-1 / WMV3 / WMV9**: нет ASF-экстрактора. VC-1 в MKV распознаётся (FourCC `WVC1` → `video/wvc1`) и играет только при HW-декодере. Он есть на Shield и части Amlogic, на Chromecast/Google TV, по-видимому, нет **(не проверено)**. Программного VC-1 нет ни в одной готовой сборке (можно пересобрать NextLib с `vc1`/`wmv3`).
- **MPEG-2** без HW: NextLib (программный mpeg2video). HW MPEG-2 есть почти везде, но на свежих SoC иногда выкинут **(не проверено)**.
- **H.264 Hi10P (High 10)**: HW-поддержки практически нет. jellyfin-androidtv проверяет `AVCProfileHigh10`. Программно через NextLib, но с 8-bit выводом и большой нагрузкой на CPU (1080p ещё реально, 4K нет).
- **MPEG-4 ASP / DivX / Xvid**: `AviExtractor` и MKV (`V_MPEG4/ISO/ASP`, FourCC DIVX/XVID/DX50/FMP4) отдают `video/mp4v-es`. Работает только при HW-декодере; программный `c2.android.mpeg4.decoder` надёжен лишь для Simple Profile (B-frames, QPEL, GMC ASP не тянет) **(не проверено)**. MS MPEG-4 v2/v3 (`MP42`/`MP43`) распознаются, но декодеров нет.
- **HEVC 4:2:2 / 4:4:4, 12-bit, ProRes, RealVideo, Theora, WMV, H.263+ экзотика, MPEG-1 (без HW)**: только FFmpeg-движок.
- **Любой HDR при программном декоде через media3-экстеншены**: только SDR-вывод.
---
## 6. Субтитры
### 6.1 Встроенная поддержка
- **Парсеры** (`DefaultSubtitleParserFactory`): SSA/ASS (упрощённо: стили, позиционирование, без анимаций и шрифтов), WebVTT, MP4VTT, SubRip, TX3G, **PGS**, **VobSub**, **DVB**, TTML.
- **MKV codec IDs**: `S_TEXT/UTF8`, `S_TEXT/ASS`, `S_TEXT/SSA`, `S_TEXT/WEBVTT`, `S_VOBSUB`, `S_HDMV/PGS`, `S_DVBSUB`.
- **TS**: DVB-subs (`0x59`); PGS в TS (`0x90`) не поддерживается.
- **MP4**: VobSub (1.9), tx3g, wvtt.
- Последние исправления: VobSub SPU START/STOP (1.10), PGS RLE color index 0, пропуски субтитров VobSub.
- Проблема [#2667](https://github.com/androidx/media/issues/2667): **все** image-based дорожки (PGS/VobSub) парсятся в Bitmap, даже невыбранные. При десятках PGS-дорожек это бьёт по памяти и CPU. Обход: пересоздавать или отключать треки, или просить сервер отдавать без лишних дорожек **(не проверено)**.
- **Внешние (side-loaded)**: `MediaItem.SubtitleConfiguration(uri).setMimeType(...).setLanguage(...).setSelectionFlags(...)` → `SubtitleExtractor` (один файл = один парсер). Надёжно: SRT, ASS/SSA, VTT, TTML. Внешние `.sup` (PGS) и `.idx/.sub` (VobSub) так подключить нельзя или ненадёжно: `.sup` содержит PTS в заголовках сегментов, которые `PgsParser` не читает, а VobSub требует idx как initialization data и MPEG-PS для .sub **(не проверено)**. jellyfin-androidtv для DVBSUB/DVDSUB/IDX/PGS объявляет только `EMBED` + `ENCODE` (burn-in), внешних нет.
### 6.2 ASS/SSA через libass: `io.github.peerless2012:ass-media`
- Репозиторий [peerless2012/libass-android](https://github.com/peerless2012/libass-android), MIT, последний коммит 2026-10-09. Версии: `0.5.1` (2026-08), до этого 0.5.0 и 0.4.0. Это 0.x, API менялся (`EFFECTS_*` → deprecated).
- Модули: `lib_ass` (prefab, libass + freetype/harfbuzz/fribidi/fontconfig), `lib_ass_kt` (JNI-обёртка), `lib_ass_media` (интеграция с media3). Внутренне собран против media3 1.8.0.
- Классы: `AssHandler(AssRenderType)`, `AssHandlerConfig(glyphSize, cacheSize, maxRenderPixels)` (ограничение разрешения рендера, полезно для 4K), `AssRenderersFactory`, `AssSubtitleParserFactory`, `AssMatroskaExtractor` (наследник `MatroskaExtractor`, **достаёт вложенные шрифты из MKV Attachments** → `AssHandler.addFont`), `AssSubtitleView`.
- `AssRenderType`: `CUES` (через `SubtitleView`, bitmap-cues), `OVERLAY_CANVAS`, `OVERLAY_OPEN_GL`, deprecated `EFFECTS_CANVAS` / `EFFECTS_OPEN_GL`.
- Пользователи: Jellyfin Android TV, Fladder, NuvioTV, AfuseKt.
- Интеграция в jellyfin-androidtv (`playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt`):
```kotlin
val assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
val parserFactory = AssSubtitleParserFactory(assHandler)
val extractors = DefaultExtractorsFactory().withAssMkvSupport(parserFactory, assHandler)
DefaultMediaSourceFactory(dataSourceFactory, extractors).setSubtitleParserFactory(parserFactory)
val renderers = AssRenderersFactory(assHandler, DefaultRenderersFactory(context).apply {
setEnableDecoderFallback(true); setExtensionRendererMode(ON / PREFER) })
// после build(): assHandler.init(player)
// в SubtitleView: addView(AssSubtitleView(context, assHandler))
```
Включается пользовательской настройкой `assDirectPlay` (ключ `libass_enabled`, по умолчанию `false`). Без неё ASS/SSA в DeviceProfile получает `ENCODE` (burn-in на сервере).
- **Риски**: `AssMatroskaExtractor` наследует внутренности `MatroskaExtractor` (protected-методы), поэтому может ломаться при обновлениях media3. Производительность тяжёлых typesetting-субтитров на слабых ТВ-SoC не измерена.
---
## 7. Контейнеры и экстракторы
| Формат | Статус в media3 1.11.1 |
|---|---|
| **MKV/WebM** | Основной. Поддерживаются lacing (Xiph/EBML/fixed), **header stripping** (`ContentCompAlgo = 3`). **zlib/bzlib/lzo** (`ContentCompAlgo` 0/1/2) приводят к `ParserException "ContentCompAlgo N not supported"` ([#2361](https://github.com/androidx/media/issues/2361), open). Шифрование только AES (WebM). Главы парсятся в `Chapter` metadata (1.11.0), но **ordered chapters, linked segments (SegmentUID) и editions не реализованы** (воспроизводится сегмент как есть). Треки после кластеров исправлены в 1.11.1 ([#3377](https://github.com/androidx/media/issues/3377)), треки в последнем кластере в [#3250](https://github.com/androidx/media/issues/3250). ALAC, float64 PCM. `V_MS/VFW/FOURCC` распознаёт только DIVX, H263 и WVC1. Theora и RealVideo не поддерживаются. |
| **MP4/MOV/fMP4** | Полный (DV, HDR10+, главы с 1.11, `mfra`-seek). TrueHD в fMP4 нет (#1519). |
| **AVI** | `AviExtractor`: H.264, MPEG-4 ASP (DivX/Xvid), MJPEG, MP42/MP43; аудио PCM, MP3, AAC, AC3, DTS. Исправлена потеря аудио без keyframe-флагов (1.11.0). Индексы и OpenDML у больших AVI могут работать с ограничениями **(не проверено)**. |
| **MPEG-TS** (188 байт) | H.262/H.264/H.265, AAC/AC3/E-AC3/AC4/DTS/DTS-HD/DTS-UHD, DVB-subs. **Нет TrueHD (0x83), LPCM BD (0x80), PGS (0x90), VC-1 (0xEA)**. |
| **M2TS/MTS** (192 байта, BDAV) | **Не поддерживается**: sniff требует sync-байт через каждые 188 байт. [#608](https://github.com/androidx/media/issues/608) закрыт как дубль [ExoPlayer #1488](https://github.com/google/ExoPlayer/issues/1488): «isn't supported», PR приветствуется. |
| **MPEG-PS/VOB** | `PsExtractor` (MPEG-2 + AC3/MPA/LPCM?) работает для простых случаев; навигации DVD нет. |
| **ISO, VIDEO_TS, BDMV** | Не поддерживаются: нет UDF/ISO, IFO/MPLS, меню. Только через сервер: Jellyfin сам разбирает BDMV/VIDEO_TS и отдаёт поток (remux/transcode) **(поведение 12.2 не проверено)**. |
| **ASF/WMV** | Экстрактора **нет**. Мейнтейнер в [#3249](https://github.com/androidx/media/issues/3249) (2026-06): «WMV ... isn't supported as per CDD». Сторонние ASF-экстракторы есть в форках (mytv-android, FongMi), качество не оценено. |
| FLV, OGG, WAV, FLAC, MP3, AMR, AC3/AC4/ADTS, HEIF/AVIF/JPEG/PNG | есть. Ogg с несколькими треками не поддерживается ([#2876](https://github.com/androidx/media/issues/2876)). |
**Вывод**: в DeviceProfile для media3 **не** объявлять как direct play: `asf`, `wmv`, `m2ts`, `iso`, `vob` (кроме простого MPEG-PS), MKV с zlib-сжатием (сервер это не видит, нужен фолбэк по ошибке), а также кодеки без HW-декодера. При этом jellyfin-androidtv объявляет `ASF`, `WMV`, `VOB`, `XVID`, `OGM` в `directPlayProfile`, что выглядит как источник ошибок. Для нашего гибрида такие контейнеры правильнее маршрутизировать в libVLC/libmpv.
---
## 8. DeviceProfile в jellyfin-androidtv: что переиспользовать
Файлы: `app/src/main/java/org/jellyfin/androidtv/util/profile/`: `deviceProfile.kt`, `MediaCodecCapabilitiesTest.kt`, `KnownDefects.kt`, `deviceProfileReport.kt`, `codec/{MediaCodecQuery, AvcCodecCapabilities, HevcCodecCapabilities, Av1CodecCapabilities, audioPassthrough}.kt`. Сборка через `org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile { }` из SDK (DSL: `transcodingProfile`, `directPlayProfile`, `codecProfile { conditions; applyConditions }`, `subtitleProfile`).
### 8.1 Опрос MediaCodec (`MediaCodecQuery`)
- `MediaCodecList(REGULAR_CODECS)`, только декодеры. Опция `softwareCodecsEnabled`: на API 29+ отбрасываются `isSoftwareOnly`.
- `hasCodecForMime`, `hasDecoder(mime, profile, level)` (по `capabilities.profileLevels`, с особым случаем H.263 Level45), `getDecoderLevel` (максимальный level для профиля), `getMaxResolution` (`videoCapabilities.supportedWidths/Heights.upper`), `supportsMultiInstance` (`maxSupportedInstances > 1`).
- **AVC**: `AVCProfileHigh10 @ Level4` даёт поддержку «high 10». Уровни переводятся в ffprobe-шкалу (×10, 1b = 9) через `LEVEL_MAP`.
- **HEVC**:
- `Main10 @ MainTierLevel4`;
- HDR10 = `HEVCProfileMain10HDR10`, HDR10+ = `HEVCProfileMain10HDR10Plus` (API 29+);
- DV = есть хоть какой-то декодер `video/dolby-vision` (API 24+);
- **DV с EL** = `DolbyVisionProfileDvheDtb @ LevelHd24` **и** multi-instance HEVC (для двухдекодерной схемы).
- Уровни ×30 (4.1 → 123).
- **AV1**: Main10 @ Level5, `AV1ProfileMain10HDR10` / `HDR10Plus`, DV P10 = `DolbyVisionProfileDvav110`. Для Fire OS, где AV1 есть ниже API Q, значения констант захардкожены.
- **VC-1**: `hasCodecForMime(video/wvc1)`.
- **Аудио passthrough** (`audioPassthrough.kt`): `AudioCapabilities.getCapabilities(context, AudioAttributes(USAGE_MEDIA, MOVIE), null, [STEREO, 5.1])` → `isPassthroughPlaybackSupported(Format(mime, 2ch))`. Режимы `BitstreamAudioMode.AUTO`, `ENABLE`, `DISABLE` для AC3, EAC3, DTS и TrueHD по отдельности.
### 8.2 Логика профиля
- **Direct play**:
- контейнеры asf, hls, m4v, mkv, mov, mp4, ogm, ogv, ts, vob, webm, wmv, xvid;
- видео av1, h264, hevc, mpeg, mpeg2video, vc1, vp8, vp9;
- аудио: список с фильтром по bitstream-настройкам; при «downmix to stereo» только aac, mp2, mp3.
- **Transcoding**:
- HLS-TS (hevc если есть, иначе h264; аудио aac/ac3/eac3/mp3);
- HLS-fMP4 (+alac/flac/opus/dts);
- `copyTimestamps=false`, `enableSubtitlesInManifest=true`.
- **CodecProfiles**:
- H.264: допустимые профили; level ≤ max (отдельно для High10); ref frames ≤ 12 при ширине ≥ 1200 и ≤ 4 при ширине ≥ 1900. Это эвристика для слабых декодеров.
- HEVC: main и, если есть, main 10, плюс level.
- AV1: `notEquals "main 10"`, если нет Main10.
- VC-1: `profile == "none"`, если нет декодера.
- Max width/height для каждого кодека.
- `AUDIO_CHANNELS ≤ 8` (или 2).
- **HDR через `VideoRangeType`**: трюк с `conditions { VIDEO_RANGE_TYPE notEquals "A|B|C" }` плюс `applyConditions { VIDEO_RANGE_TYPE inCollection [...] }`. Профиль применяется только к медиа с неподдерживаемым range type, а сервер берёт список из notEquals для StreamBuilder: может срезать DV и оставить HDR10 BL вместо полной транскодировки **(механизм на сервере не проверен)**. Множества:
- всегда `DOVI_INVALID`;
- без DV-EL (и не Shield-исключение) → `DOVI_WITH_EL` (+`DOVI_WITH_ELHDR10_PLUS`);
- без DV → `DOVI`; `DOVI_WITH_HDR10` только если и HDR10 нет;
- без HDR10+ → `HDR10_PLUS` (+`HDR10`, если нет и его);
- на Fire TV 4K (`AFTKRT`, `AFTKA`, `AFTKM`, `AFTMM`) всегда исключать `DOVI_WITH_HDR10_PLUS` / `DOVI_WITH_ELHDR10_PLUS`.
- Пользовательские оверрайды по форматам (`HdrFormat`: HDR10, HDR10+, DV P5, P7, P8) с режимами ENABLE/DISABLE/AUTO.
- **Субтитры**:
- VTT/WebVTT: embed, external, hls;
- SRT/SubRip/TTML: embed, external;
- DVBSUB/DVDSUB/IDX: embed + encode;
- PGS: embed (если `pgsDirectPlay`) + encode;
- ASS/SSA: embed/external при libass, иначе encode.
- **KnownDefects**: всего 2 списка (Fire TV DV+HDR10+, Shield P7). Отдельной базы «плохих» устройств нет.
- **Чего там нет, но нам нужно**:
1. Проверки, что **дисплей** поддерживает DV/HDR10/HDR10+ (`HdrCapabilities`). Сейчас смотрят только декодер. media3 при этом сам уйдёт в фолбэк, а профиль может ошибочно разрешить P5 на не-DV ТВ.
2. Учёта P5 (без фолбэка).
3. Маршрутизации на второй движок (VLC/mpv) для VC-1/WMV/M2TS/Hi10P и т.п.
4. HDR10+ на VP9.
5. `HLG` и `DOVIWithHLG` не рассматриваются явно.
### 8.3 Прочее из ExoPlayerBackend jellyfin-androidtv
- `DefaultRenderersFactory.setEnableDecoderFallback(true)`.
- `DefaultExtractorsFactory.setConstantBitrateSeekingEnabled(true)` + `AlwaysEnabled`. `TsExtractor` timestamp search bytes `TS_PACKET_SIZE * 1800` на low-RAM устройствах.
- `AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED`, `setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)`.
- `PlaySupportReport` из `RendererCapabilities` (формат, adaptive, tunneling, HW-accel, decoder support) для `supportsStream()`. Это готовый паттерн для выбора движка: если media3 не может, идём во второй движок.
---
## 9. Риски и рекомендации для проекта
1. **DV P7 (самые популярные remux)**: на всём, кроме Shield, P7 не даст DV. Без DV-декодера он не даст и картинки, пока не смержен PR #3160. Варианты: (а) свой `MediaCodecSelector`, отдающий HEVC-декодеры для `video/dolby-vision` с профилем DvheDtb, когда нет DV-дисплея или декодера; (б) DeviceProfile с `DOVI_WITH_EL` в unsupported, чтобы сервер срезал DV.
2. **Гибриды DV + HDR10+ на MediaTek** (Fire TV, Google TV Streamer): держать KnownDefects-список и опцию «предпочитать DV / HDR10+» (так сделано в Wholphin, судя по ссылке в #1895).
3. **FFmpeg-видео в media3** официально не готово. NextLib жизнеспособен, но даёт SDR 8-bit и CPU-нагрузку. Для HDR или экзотики при программном декоде нужен второй движок; HDR-вывод libmpv/libVLC на Android отдельно не исследован здесь **(не проверено)**.
4. **Совместимость бинарных расширений**: `org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1` и `ass-media` (собран под 1.8.0) работают с 1.11.1, но это не гарантировано. Лучше собирать FFmpeg-аудио самим в CI под точную версию media3 или взять NextLib, у которого версия привязана к media3 (`1.11.1-0.16.0`).
5. **Смена частоты и HDR**: делать самим через `preferredDisplayModeId` + `DisplayListener`, с паузой. Явно сбрасывать режим и освобождать Surface при выходе (см. #3406).
6. **TrueHD**: не просить HLS-fMP4 с TrueHD. Для 2.0 TrueHD программный декод FFmpeg сломан (#1843). Без passthrough лучше транскод в AC3/E-AC3 на сервере.
7. **Tunneling**: опционально и по умолчанию выкл. Тестировать seek, паузу, субтитры и HDR на целевых устройствах.
8. **Контейнеры**: M2TS, ASF/WMV, ISO, VIDEO_TS и MKV-zlib сразу отправлять во второй движок или на серверный remux.
---
## Источники (проверено)
- androidx/media main @ `8c6678b` (1.11.1): `MatroskaExtractor.java`, `MediaCodecUtil.java`, `MediaCodecVideoRenderer.java`, `VideoFrameReleaseHelper.java`, `AudioCapabilities.java`, `DefaultRenderersFactory.java`, `decoder_ffmpeg/*`, `decoder_av1/*`, `TsExtractor.java`, `avi/StreamFormatChunk.java`, `DefaultSubtitleParserFactory.java`, `RELEASENOTES.md`. https://github.com/androidx/media
- jellyfin-androidtv master @ `0032f4a`: https://github.com/jellyfin/jellyfin-androidtv/tree/master/app/src/main/java/org/jellyfin/androidtv/util/profile, `playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt`, `ui/playback/PlaybackController.java`
- https://github.com/jellyfin/jellyfin-androidx-media (`build.sh`, `.gitmodules`); Maven: https://repo1.maven.org/maven2/org/jellyfin/media3/media3-ffmpeg-decoder/
- https://github.com/peerless2012/libass-android; Maven: https://repo1.maven.org/maven2/io/github/peerless2012/ass-media/
- https://github.com/anilbeesetti/nextlib; Maven: https://repo1.maven.org/maven2/io/github/anilbeesetti/nextlib-media3ext/
- https://github.com/moneytoo/Player (Just Player, `Utils.java`, `PlayerActivity.java`)
- Kodi: https://github.com/xbmc/xbmc/blob/master/xbmc/platform/android/activity/XBMCApp.cpp, `xbmc/windowing/android/AndroidUtils.cpp`
- https://developer.android.com/media/optimize/performance/frame-rate
- https://source.android.com/devices/tv/multimedia-tunneling
- Issues/PR: androidx/media #1865, #3160, #1895, #3085, #3406, #3461, #3463, #2541, #1574, #3099, #1245, #2258, #3120, #2941, #3234, #2487, #3229, #3147, #1519, #3411, #1843, #3329, #1591, #3249, #608, #2361, #2667, #2876, #2830, #3153, #3135; jellyfin-androidtv #5794, #5822, #5184.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,373 @@
DISPLAY MANAGER (dumpsys display)
mSafeMode=false
mPendingTraversal=false
mViewports=[DisplayViewport{type=INTERNAL, valid=true, isActive=true, displayId=0, uniqueId='local:4627014146675823360', physicalPort=0, orientation=0, logicalFrame=Rect(0, 0 - 1920, 1080), physicalFrame=Rect(0, 0 - 3840, 2160), deviceWidth=3840, deviceHeight=2160}]
mDefaultDisplayDefaultColorMode=0
mWifiDisplayScanRequestCount=0
mStableDisplaySize=Point(3840, 2160)
mMinimumBrightnessCurve=[(0.0, 0.0), (2000.0, 50.0), (4000.0, 90.0)]
mHdrConversionMode=HdrConversionMode{ConversionMode=HDR_CONVERSION_SYSTEM, PreferredHdrOutputType=HDR_TYPE_INVALID}
Display States: size=1
Display Id=0
Display State=ON
Display Brightness=1.0
Display SdrBrightness=1.0
Display Adapters: size=3
LocalDisplayAdapter
VirtualDisplayAdapter
OverlayDisplayAdapter
mCurrentOverlaySetting=
mOverlays: size=0
Display Devices: size=1
DisplayDeviceInfo{"Встроенный экран": uniqueId="local:4627014146675823360", 3840 x 2160, modeId 1, renderFrameRate 60.000004, defaultModeId 1, supportedModes [{id=1, width=3840, height=2160, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3, 4]}], colorMode 0, supportedColorModes [0], hdrCapabilities HdrCapabilities{mSupportedHdrTypes=[1, 2, 3, 4], mMaxLuminance=100.0, mMaxAverageLuminance=50.0, mMinLuminance=0.05}, allmSupported false, gameContentTypeSupported false, density 640, 59.0 x 59.0 dpi, appVsyncOff 1000000, presDeadline 16666666, touch INTERNAL, rotation 0, type INTERNAL, address {port=0, model=0x403674d9f751d3}, deviceProductInfo DeviceProductInfo{name=MStar Demo, manufacturerPnpId=MST, productId=48, modelYear=null, manufactureDate=ManufactureDate{week=10, year=2014}, connectionToSinkType=1}, state ON, committedState ON, frameRateOverride , brightnessMinimum 0.0, brightnessMaximum 1.0, brightnessDefault 1.0, hdrSdrRatio NaN, FLAG_ALLOWED_TO_BE_DEFAULT_DISPLAY, FLAG_ROTATES_WITH_CONTENT, FLAG_SECURE, FLAG_SUPPORTS_PROTECTED_BUFFERS, FLAG_TRUSTED, installOrientation 0, displayShape DisplayShape{ spec=-545313861 displayWidth=3840 displayHeight=2160 physicalPixelDisplaySizeRatio=1.0 rotation=0 offsetX=0 offsetY=0 scale=1.0}}
mAdapter=LocalDisplayAdapter
mUniqueId=local:4627014146675823360
mDisplayToken=android.os.BinderProxy@b7048f
mCurrentLayerStack=0
mCurrentFlags=1
mCurrentOrientation=0
mCurrentLayerStackRect=Rect(0, 0 - 1920, 1080)
mCurrentDisplayRect=Rect(0, 0 - 3840, 2160)
mCurrentSurface=null
mPhysicalDisplayId=4627014146675823360
mDisplayModeSpecs={baseModeId=1 allowGroupSwitching=false primary=physical: (0.0 Infinity) render: (0.0 60.0) appRequest=physical: (0.0 Infinity) render: (0.0 Infinity)}
mDisplayModeSpecsInvalid=false
mActiveModeId=1
mActiveColorMode=0
mDefaultModeId=1
mUserPreferredModeId=-1
mState=ON
mCommittedState=ON
mBrightnessState=1.0
mBacklightAdapter=BacklightAdapter [useSurfaceControl=true (force_anyway? true), backlight=null]
mAllmSupported=false
mAllmRequested=false
mGameContentTypeSupported=false
mGameContentTypeRequested=false
mStaticDisplayInfo=StaticDisplayInfo{isInternal=true, density=2.0, secure=true, deviceProductInfo=DeviceProductInfo{name=MStar Demo, manufacturerPnpId=MST, productId=48, modelYear=null, manufactureDate=ManufactureDate{week=10, year=2014}, connectionToSinkType=1}, installOrientation=0}
mSfDisplayModes=
DisplayMode{id=0, width=3840, height=2160, xDpi=59.0, yDpi=59.0, refreshRate=60.000004, appVsyncOffsetNanos=1000000, presentationDeadlineNanos=16666666, supportedHdrTypes=[1, 2, 3, 4], group=0}
mActiveSfDisplayMode=DisplayMode{id=0, width=3840, height=2160, xDpi=59.0, yDpi=59.0, refreshRate=60.000004, appVsyncOffsetNanos=1000000, presentationDeadlineNanos=16666666, supportedHdrTypes=[1, 2, 3, 4], group=0}
mActiveRenderFrameRate=60.000004
mSupportedModes=
DisplayModeRecord{mMode={id=1, width=3840, height=2160, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3, 4]}}
mSupportedColorModes=[0]
mDisplayDeviceConfig=DisplayDeviceConfig{mLoadedFrom=/vendor/etc/displayconfig/display_id_4627014146675823360.xml, mBacklight=[0.035433073, 0.5, 1.0], mNits=[7.0866146, 100.0, 200.0], mRawBacklight=[0.0, 0.5, 1.0], mRawNits=[0.0, 100.0, 200.0], mInterpolationType=0, mBrightness=[0.0, 0.48163262, 1.0], mBrightnessToBacklightSpline=MonotoneCubicSpline{[(0.0, 0.035433073: 0.96456695), (0.48163262, 0.5: 0.9645669), (1.0, 1.0: 0.9645668)]}, mBacklightToBrightnessSpline=MonotoneCubicSpline{[(0.035433073, 0.0: 1.0367347), (0.5, 0.48163262: 1.0367348), (1.0, 1.0: 1.0367348)]}, mNitsToBacklightSpline=MonotoneCubicSpline{[(7.0866146, 0.035433073: 0.005), (100.0, 0.5: 0.005), (200.0, 1.0: 0.005)]}, mBacklightMinimum=0.035433073, mBacklightMaximum=1.0, mBrightnessDefault=1.0, mQuirks=[canSetBrightnessViaHwc], isHbmEnabled=false, mHbmData=null, mSdrToHdrRatioSpline=null, mThermalBrightnessThrottlingDataMapByThrottlingId={}
, mBrightnessRampFastDecrease=0.70472443, mBrightnessRampFastIncrease=0.70472443, mBrightnessRampSlowDecrease=0.23228346, mBrightnessRampSlowIncrease=0.23228346, mBrightnessRampDecreaseMaxMillis=0, mBrightnessRampIncreaseMaxMillis=0
, mAmbientHorizonLong=10000, mAmbientHorizonShort=2000
, mScreenDarkeningMinThreshold=0.0, mScreenDarkeningMinThresholdIdle=0.0, mScreenBrighteningMinThreshold=0.0, mScreenBrighteningMinThresholdIdle=0.0, mAmbientLuxDarkeningMinThreshold=0.0, mAmbientLuxDarkeningMinThresholdIdle=0.0, mAmbientLuxBrighteningMinThreshold=0.0, mAmbientLuxBrighteningMinThresholdIdle=0.0
, mScreenBrighteningLevels=[0.0], mScreenBrighteningPercentages=[10.0], mScreenDarkeningLevels=[0.0], mScreenDarkeningPercentages=[20.0], mAmbientBrighteningLevels=[0.0], mAmbientBrighteningPercentages=[10.0], mAmbientDarkeningLevels=[0.0], mAmbientDarkeningPercentages=[20.0]
, mAmbientBrighteningLevelsIdle=[0.0], mAmbientBrighteningPercentagesIdle=[10.0], mAmbientDarkeningLevelsIdle=[0.0], mAmbientDarkeningPercentagesIdle=[20.0], mScreenBrighteningLevelsIdle=[0.0], mScreenBrighteningPercentagesIdle=[10.0], mScreenDarkeningLevelsIdle=[0.0], mScreenDarkeningPercentagesIdle=[20.0]
, mAmbientLightSensor=Sensor{type: , name: , refreshRateRange: [0.0, Infinity]} , mScreenOffBrightnessSensor=Sensor{type: null, name: null, refreshRateRange: [0.0, Infinity]} , mProximitySensor=Sensor{type: null, name: null, refreshRateRange: [0.0, Infinity]} , mRefreshRateLimitations= [], mDensityMapping= DensityMapping{mDensityMappingEntries=[DensityMappingEntry{squaredDiagonal=19411200, density=640}]}, mAutoBrightnessBrighteningLightDebounce= 4000, mAutoBrightnessDarkeningLightDebounce= 8000, mBrightnessLevelsLux= [0.0], mBrightnessLevelsNits= [], mDdcAutoBrightnessAvailable= true, mAutoBrightnessAvailable= false
, mDefaultLowBlockingZoneRefreshRate= 0, mDefaultHighBlockingZoneRefreshRate= 0, mDefaultPeakRefreshRate= 0, mDefaultRefreshRate= 60, mRefreshRateZoneProfiles= {}, mDefaultRefreshRateInHbmHdr= 0, mDefaultRefreshRateInHbmSunlight= 0, mRefreshRateThrottlingMap= {}
, mLowDisplayBrightnessThresholds= [], mLowAmbientBrightnessThresholds= [], mHighDisplayBrightnessThresholds= [], mHighAmbientBrightnessThresholds= []
, mScreenOffBrightnessSensorValueToLux=null
, mUsiVersion= null}
LogicalDisplayMapper:
mSingleDisplayDemoMode=false
mCurrentLayout=[{dispId: 0(ON), displayGroupName: , addr: {port=0, model=0x403674d9f751d3}, mThermalBrightnessThrottlingMapId: null, mRefreshRateZoneId: null, mLeadDisplayId: -1, mThermalRefreshRateThrottlingMapId: null}]
mDeviceStatesOnWhichToWakeUp={}
mDeviceStatesOnWhichToSleep={}
mInteractive=true
mBootCompleted=true
mDeviceState=0
mPendingDeviceState=-1
mDeviceStateToBeAppliedAfterBoot=-1
Logical Displays: size=1
Display 0:
mDisplayId=0
mIsEnabled=true
mIsInTransition=false
mLayerStack=0
mPosition=-1
mHasContent=true
mDesiredDisplayModeSpecs={baseModeId=1 allowGroupSwitching=false primary=physical: (0.0 Infinity) render: (0.0 60.0) appRequest=physical: (0.0 Infinity) render: (0.0 Infinity)}
mRequestedColorMode=0
mDisplayOffset=(0, 0)
mDisplayScalingDisabled=false
mPrimaryDisplayDevice=Встроенный экран
mBaseDisplayInfo=DisplayInfo{"Встроенный экран", displayId 0, displayGroupId 0, FLAG_SECURE, FLAG_SUPPORTS_PROTECTED_BUFFERS, FLAG_TRUSTED, real 3840 x 2160, largest app 3840 x 2160, smallest app 3840 x 2160, appVsyncOff 1000000, presDeadline 16666666, mode 160.000004, defaultMode 1, modes [{id=1, width=3840, height=2160, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3, 4]}], hdrCapabilities HdrCapabilities{mSupportedHdrTypes=[1, 2, 3, 4], mMaxLuminance=100.0, mMaxAverageLuminance=50.0, mMinLuminance=0.05}, userDisabledHdrTypes [], minimalPostProcessingSupported false, rotation 0, state ON, committedState ON, type INTERNAL, uniqueId "local:4627014146675823360", app 3840 x 2160, density 640 (59.0 x 59.0) dpi, layerStack 0, colorMode 0, supportedColorModes [0], address {port=0, model=0x403674d9f751d3}, deviceProductInfo DeviceProductInfo{name=MStar Demo, manufacturerPnpId=MST, productId=48, modelYear=null, manufactureDate=ManufactureDate{week=10, year=2014}, connectionToSinkType=1}, removeMode 0, refreshRateOverride 0.0, brightnessMinimum 0.0, brightnessMaximum 1.0, brightnessDefault 1.0, installOrientation ROTATION_0, layoutLimitedRefreshRate null, hdrSdrRatio not_available, thermalRefreshRateThrottling {}, thermalBrightnessThrottlingDataId default}
mOverrideDisplayInfo=DisplayInfo{"Встроенный экран", displayId 0, displayGroupId 0, FLAG_SECURE, FLAG_SUPPORTS_PROTECTED_BUFFERS, FLAG_TRUSTED, real 1920 x 1080, largest app 1920 x 1920, smallest app 1080 x 1080, appVsyncOff 1000000, presDeadline 16666666, mode 160.000004, defaultMode 1, modes [{id=1, width=3840, height=2160, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3, 4]}], hdrCapabilities HdrCapabilities{mSupportedHdrTypes=[1, 2, 3, 4], mMaxLuminance=100.0, mMaxAverageLuminance=50.0, mMinLuminance=0.05}, userDisabledHdrTypes [], minimalPostProcessingSupported false, rotation 0, state ON, committedState ON, type INTERNAL, uniqueId "local:4627014146675823360", app 1920 x 1080, density 320 (29.5 x 29.5) dpi, layerStack 0, colorMode 0, supportedColorModes [0], address {port=0, model=0x403674d9f751d3}, deviceProductInfo DeviceProductInfo{name=MStar Demo, manufacturerPnpId=MST, productId=48, modelYear=null, manufactureDate=ManufactureDate{week=10, year=2014}, connectionToSinkType=1}, removeMode 0, refreshRateOverride 60.000004, brightnessMinimum 0.0, brightnessMaximum 1.0, brightnessDefault 1.0, installOrientation ROTATION_0, layoutLimitedRefreshRate null, hdrSdrRatio not_available, thermalRefreshRateThrottling {}, thermalBrightnessThrottlingDataId default}
mRequestedMinimalPostProcessing=false
mFrameRateOverrides=[]
mPendingFrameRateOverrideUids={}
mDisplayGroupName=
mThermalBrightnessThrottlingDataId=default
mLeadDisplayId=-1
mLayoutLimitedRefreshRate=null
mThermalRefreshRateThrottling={}
DeviceStateToLayoutMap:
Registered Layouts:
state(-1): [{dispId: 0(ON), displayGroupName: , addr: {port=0, model=0x403674d9f751d3}, mThermalBrightnessThrottlingMapId: null, mRefreshRateZoneId: null, mLeadDisplayId: -1, mThermalRefreshRateThrottlingMapId: null}]
Callbacks: size=12
0: mPid=750, mWifiDisplayScanRequested=false
1: mPid=1115, mWifiDisplayScanRequested=false
2: mPid=1365, mWifiDisplayScanRequested=false
3: mPid=1500, mWifiDisplayScanRequested=false
4: mPid=1961, mWifiDisplayScanRequested=false
5: mPid=2223, mWifiDisplayScanRequested=false
6: mPid=3734, mWifiDisplayScanRequested=false
7: mPid=9064, mWifiDisplayScanRequested=false
8: mPid=9914, mWifiDisplayScanRequested=false
9: mPid=17555, mWifiDisplayScanRequested=false
10: mPid=18239, mWifiDisplayScanRequested=false
11: mPid=28050, mWifiDisplayScanRequested=false
Display Power Controllers: size=1
Display Power Controller:
mDisplayId=0
mLeadDisplayId=-1
mLightSensor=null
mDisplayBrightnessFollowers={}
Display Power Controller Locked State:
mDisplayReadyLocked=true
mPendingRequestLocked=policy=BRIGHT, useProximitySensor=false, screenBrightnessOverride=NaN, screenAutoBrightnessAdjustmentOverride=NaN, screenLowPowerBrightnessFactor=1.0, blockScreenOn=false, lowPowerMode=false, boostScreenBrightness=false, dozeScreenBrightness=NaN, dozeScreenState=UNKNOWN
mPendingRequestChangedLocked=false
mPendingUpdatePowerStateLocked=false
Display Power Controller Configuration:
mScreenBrightnessDozeConfig=0.0
mScreenBrightnessDimConfig=0.05
mUseSoftwareAutoBrightnessConfig=false
mSkipScreenOnBrightnessRamp=false
mColorFadeFadesConfig=false
mColorFadeEnabled=false
mIsDisplayInternal=true
mCachedBrightnessInfo.brightness=1.0
mCachedBrightnessInfo.adjustedBrightness=1.0
mCachedBrightnessInfo.brightnessMin=0.0
mCachedBrightnessInfo.brightnessMax=1.0
mCachedBrightnessInfo.hbmMode=0
mCachedBrightnessInfo.hbmTransitionPoint=Infinity
mCachedBrightnessInfo.brightnessMaxReason =0
mDisplayBlanksAfterDozeConfig=false
mBrightnessBucketsInDozeConfig=false
Display Power Controller Thread State:
mPowerRequest=policy=BRIGHT, useProximitySensor=false, screenBrightnessOverride=NaN, screenAutoBrightnessAdjustmentOverride=NaN, screenLowPowerBrightnessFactor=1.0, blockScreenOn=false, lowPowerMode=false, boostScreenBrightness=false, dozeScreenBrightness=NaN, dozeScreenState=UNKNOWN
mBrightnessReason=manual
mAppliedDimming=false
mAppliedLowPower=false
mAppliedThrottling=false
mDozing=false
mSkipRampState=RAMP_STATE_SKIP_NONE
mScreenOnBlockStartRealTime=580658077
mScreenOffBlockStartRealTime=521250842
mPendingScreenOnUnblocker=null
mPendingScreenOffUnblocker=null
mPendingScreenOff=false
mReportedToPolicy=REPORTED_TO_POLICY_SCREEN_ON
mIsRbcActive=false
AutomaticBrightnessStrategy:
mDisplayId=0
mAutoBrightnessAdjustment=0.0
mPendingAutoBrightnessAdjustment=NaN
mTemporaryAutoBrightnessAdjustment=NaN
mShouldResetShortTermModel=false
mAppliedAutoBrightness=false
mAutoBrightnessAdjustmentChanged=false
mAppliedTemporaryAutoBrightnessAdjustment=false
mUseAutoBrightness=false
mWasShortTermModelActive=false
mAutoBrightnessAdjustmentReasonsFlags=0
mScreenBrightnessRampAnimator.isAnimating()=false
Display Power State:
mStopped=false
mScreenState=ON
mScreenBrightness=1.0
mSdrScreenBrightness=1.0
mScreenReady=true
mScreenUpdatePending=false
mColorFadePrepared=false
mColorFadeLevel=1.0
mColorFadeReady=true
mColorFadeDrawPending=false
Photonic Modulator State:
mPendingState=ON
mPendingBacklight=1.0
mPendingSdrBacklight=1.0
mActualState=ON
mActualBacklight=1.0
mActualSdrBacklight=1.0
mStateChangeInProgress=false
mBacklightChangeInProgress=false
No Reduce Bright Colors Adjustments
HighBrightnessModeController:
mBrightness=0.0
mUnthrottledBrightness=0.0
mThrottlingReason=none
mCurrentMin=0.0
mCurrentMax=1.0
mHbmMode=off
mHbmStatsState=HBM_OFF
mHbmData=null
mAmbientLux=0.0 (old/invalid)
mIsInAllowedAmbientRange=false
mIsAutoBrightnessEnabled=false
mIsAutoBrightnessOffByState=false
mIsHdrLayerPresent=false
mBrightnessMin=0.0
mBrightnessMax=1.0
remainingTime=0
mIsTimeAvailable= false
mRunningStartTimeMillis=-1 (51650707 ms ago)
mIsBlockedByLowPowerMode=false
width*height=3840*2160
mEvents=
BrightnessThrottler:
mThermalBrightnessThrottlingDataId=default
mThermalThrottlingData=null
mUniqueDisplayId=local:4627014146675823360
mThrottlingStatus=-1
mBrightnessCap=1.0
mBrightnessMaxReason=none
mDdcThermalThrottlingDataMap={}
mThermalBrightnessThrottlingDataOverride={}
mThermalBrightnessThrottlingDataString=null
SkinThermalStatusObserver:
mStarted: false
ThermalService not available
WakelockController State:
mDisplayId=0
mUnfinishedBusiness=false
mOnStateChangePending=false
mOnProximityPositiveMessages=false
mOnProximityNegativeMessages=false
DisplayBrightnessController:
mDisplayId=: 0
mScreenBrightnessDefault=1.0
mPersistBrightnessNitsForDefaultDisplay=false
mPendingScreenBrightness=NaN
mCurrentScreenBrightness=1.0
mLastUserSetScreenBrightness=NaN
Last selected DisplayBrightnessStrategy= InvalidBrightnessStrategy
DisplayBrightnessStrategySelector:
mDisplayId= 0
mOldBrightnessStrategyName= InvalidBrightnessStrategy
mAllowAutoBrightnessWhileDozingConfig= false
TemporaryBrightnessStrategy:
mTemporaryScreenBrightness:NaN
DisplayStateController:
mPerformScreenOffTransition:false
DisplayPowerProximityStateController:
mPendingWaitForNegativeProximityLocked=false
mDisplayId=0
mWaitingForNegativeProximity=false
mIgnoreProximityUntilChanged=false
mProximitySensor=null
mProximitySensorEnabled=false
mProximityThreshold=0.0
mProximity=Unknown
mPendingProximity=Unknown
mPendingProximityDebounceTime=-1 (51650707 ms ago)
mScreenOffBecauseOfProximity=false
mSkipRampBecauseOfProximityChangeToNegative=false
PersistentDataStore
mLoaded=true
mDirty=false
RememberedWifiDisplays:
DisplayStates:
0: local:4627014146675823360
ColorMode=0
BrightnessValue=1.0
DisplayBrightnessConfigurations:
Resolution=0 0
RefreshRate=0.0
StableDeviceValues:
StableDisplayWidth=3840
StableDisplayHeight=2160
GlobalBrightnessConfigurations:
mBrightnessNitsForDefaultDisplay=-1.0
Display Window Policy Controllers: size=0
BrightnessTracker state:
mStarted=false
mLightSensor=null
mLastBatteryLevel=NaN
mLastBrightness=-1.0
mEventsDirty=false
mEvents.size=0
mWriteBrightnessTrackerStateScheduled=false
mSensorRegistered=false
mColorSamplingEnabled=false
mNoFramesToSample=0
mFrameRate=0.0
DisplayModeDirector
mSupportedModesByDisplay:
0 -> [{id=1, width=3840, height=2160, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3, 4]}]
mDefaultModeByDisplay:
0 -> {id=1, width=3840, height=2160, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3, 4]}
mModeSwitchingType: SWITCHING_TYPE_WITHIN_GROUPS
mAlwaysRespectAppRequest: false
SettingsObserver
mDefaultRefreshRate: 60.0
mDefaultPeakRefreshRate: 0.0
AppRequestObserver
mAppRequestedModeByDisplay:
mAppPreferredRefreshRateRangeByDisplay:
BrightnessObserver
mAmbientLux: -1.0
mBrightness: 255
mDefaultDisplayState: 2
mLowPowerModeEnabled: false
mRefreshRateChangeable: false
mShouldObserveDisplayLowChange: false
mShouldObserveAmbientLowChange: false
mRefreshRateInLowZone: 0
mShouldObserveDisplayHighChange: false
mShouldObserveAmbientHighChange: false
mRefreshRateInHighZone: 0
mRegisteredLightSensor: null
mLightSensor: null
mLightSensorName:
mLightSensorType:
mLastSensorData: 0.0
mTimestamp: 1970-01-01 03:00:00.000
UdfpsObserver
mUdfpsRefreshRateEnabled:
mAuthenticationPossible:
HbmObserver
mHbmMode: {}
mHbmActive: {}
mRefreshRateInHbmSunlight: 0
mRefreshRateInHbmHdr: 0
SkinThermalStatusObserver:
mStatus: 0
mThermalThrottlingByDisplay: {0={}}
mVotesByDisplay:
-1:
PRIORITY_USER_SETTING_MIN_RENDER_FRAME_RATE -> Vote: {width: -1, height: -1, refreshRateRanges: physical: (0.0 Infinity) render: (0.0 Infinity), disableRefreshRateSwitching: false, appRequestBaseModeRefreshRate: 0.0}
PRIORITY_DEFAULT_REFRESH_RATE -> Vote: {width: -1, height: -1, refreshRateRanges: physical: (0.0 Infinity) render: (0.0 60.0), disableRefreshRateSwitching: false, appRequestBaseModeRefreshRate: 0.0}
SensorObserver
mIsProxActive=false
mDozeStateByDisplay:
0 -> false
BrightnessSynchronizer
mLatestIntBrightness=255
mLatestFloatBrightness=1.0
mCurrentUpdate=null
mPendingUpdate=null

View File

@@ -0,0 +1,755 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright (C) 2012 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
<!DOCTYPE MediaCodecs [
<!ELEMENT MediaCodecs (Decoders,Encoders)>
<!ELEMENT Decoders (MediaCodec*)>
<!ELEMENT Encoders (MediaCodec*)>
<!ELEMENT MediaCodec (Type*,Quirk*)>
<!ATTLIST MediaCodec name CDATA #REQUIRED>
<!ATTLIST MediaCodec type CDATA>
<!ELEMENT Type EMPTY>
<!ATTLIST Type name CDATA #REQUIRED>
<!ELEMENT Quirk EMPTY>
<!ATTLIST Quirk name CDATA #REQUIRED>
]>
There's a simple and a complex syntax to declare the availability of a
media codec:
A codec that properly follows the OpenMax spec and therefore doesn't have any
quirks and that only supports a single content type can be declared like so:
<MediaCodec name="OMX.foo.bar" type="something/interesting" />
If a codec has quirks OR supports multiple content types, the following syntax
can be used:
<MediaCodec name="OMX.foo.bar" >
<Type name="something/interesting" />
<Type name="something/else" />
...
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
<Quirk name="output-buffers-are-unreadable" />
</MediaCodec>
Only the three quirks included above are recognized at this point:
"requires-allocate-on-input-ports"
must be advertised if the component does not properly support specification
of input buffers using the OMX_UseBuffer(...) API but instead requires
OMX_AllocateBuffer to be used.
"requires-allocate-on-output-ports"
must be advertised if the component does not properly support specification
of output buffers using the OMX_UseBuffer(...) API but instead requires
OMX_AllocateBuffer to be used.
"output-buffers-are-unreadable"
must be advertised if the emitted output buffers of a decoder component
are not readable, i.e. use a custom format even though abusing one of
the official OMX colorspace constants.
Clients of such decoders will not be able to access the decoded data,
naturally making the component much less useful. The only use for
a component with this quirk is to render the output to the screen.
Audio decoders MUST NOT advertise this quirk.
Video decoders that advertise this quirk must be accompanied by a
corresponding color space converter for thumbnail extraction,
matching surfaceflinger support that can render the custom format to
a texture and possibly other code, so just DON'T USE THIS QUIRK.
-->
<MediaCodecs>
<Settings>
<Setting name="max-video-encoder-input-buffers" value="1" />
<Setting name="supports-multiple-secure-codecs" value="true" />
<Setting name="supports-secure-with-non-secure-codec" value="true" />
</Settings>
<Decoders>
<MediaCodec name="c2.mtk.atv.decoder" type="tv/atv" >
<Limit name="concurrent-instances" max="2" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.hdmi.decoder" type="tv/hdmi" >
<Limit name="concurrent-instances" max="2" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.component.decoder" type="tv/component" >
<Limit name="concurrent-instances" max="2" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.composite.decoder" type="tv/composite" >
<Limit name="concurrent-instances" max="2" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.avc.decoder" type="video/avc" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="30" />
<Limit name="performance-point-7680x4320" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.avc.decoder.secure" type="video/avc" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="30" />
<Limit name="performance-point-7680x4320" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.hevc.decoder" type="video/hevc" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-7680x4320" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.hevc.decoder.secure" type="video/hevc" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-7680x4320" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.shvc.decoder" type="video/vnd.shvc" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="4096x2176" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.shvc.decoder.secure" type="video/vnd.shvc" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="4096x2176" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.mpeg4.decoder" type="video/mp4v-es" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="16x16" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.h263.decoder" type="video/3gpp" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="16x16" max="1408x1152" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1280x720" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.mpeg2.decoder" type="video/mpeg2" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="16x16" max="1920x1088" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.mpeg2.decoder.secure" type="video/mpeg2" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="16x16" max="1920x1088" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.vp8.decoder" type="video/x-vnd.on2.vp8" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="128x128" max="1920x1088" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.vp9.decoder" type="video/x-vnd.on2.vp9" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="30" />
<Limit name="performance-point-7680x4320" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.vp9.decoder.secure" type="video/x-vnd.on2.vp9" >
<Limit name="concurrent-instances" max="16" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="30" />
<Limit name="performance-point-7680x4320" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.av1.decoder" type="video/av01" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="tunneled-playback" />
<Feature name="adaptive-playback" />
<Feature name="low-latency" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-7680x4320" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.av1.decoder.secure" type="video/av01" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="64x64" max="7680x4320" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="tunneled-playback" />
<Feature name="adaptive-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-7680x4320" value="60" />
<Feature name="secure-playback" required="true" />
<Feature name="low-latency" />
</MediaCodec>
<MediaCodec name="c2.mtk.mjpeg.decoder" type="video/x-motion-jpeg" >
<Limit name="concurrent-instances" max="16" />
<Limit name="size" min="8x8" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.vc1.decoder" type="video/VC1" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="32x32" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.flv.decoder" type="video/flv" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="16x16" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.avs.decoder" type="video/avs" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.avs2.decoder" type="video/avs2" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2160" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.wmv3.decoder" type="video/x-ms-wmv" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="32x32" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.rv.decoder" type="video/vnd.rn-realvideo" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="32x32" max="1920x1088" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-1920x1088" value="60" />
</MediaCodec>
<!--
<MediaCodec name="c2.mtk.dvav.per.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav.per.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav.pen.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav.pen.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.der.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.der.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.den.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.den.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.dtr.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.dtr.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.stn.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.stn.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.dth.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.dth.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.dtb.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.dtb.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.st.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvhe.st.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav.se.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav.se.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav1.10.decoder" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
<MediaCodec name="c2.mtk.dvav1.10.decoder.secure" type="video/dolby-vision" >
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="64x64" max="4096x2176" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Feature name="adaptive-playback" />
<Feature name="tunneled-playback" />
<Feature name="secure-playback" required="true" />
<Limit name="performance-point-3840x2160" value="60" />
<Limit name="performance-point-4096x2176" value="60" />
</MediaCodec>
-->
<MediaCodec name="c2.mtk.hdmi.capture" type="audio/vnd.hdmicap">
<Limit name="channel-count" max="8" />
<Limit name="sample-rate" ranges="8000-48000" />
<Limit name="bitrate" range="8000-960000" />
</MediaCodec>
<MediaCodec name="c2.mtk.atv.capture" type="audio/vnd.atvcap">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</MediaCodec>
<MediaCodec name="c2.mtk.analog.capture" type="audio/vnd.analogcap">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</MediaCodec>
<MediaCodec name="c2.mtk.ac3.decoder">
<Type name="audio/ac3">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/eac3">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/eac3-joc">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
</MediaCodec>
<MediaCodec name="c2.mtk.dts.decoder">
<Type name="audio/vnd.dts">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/vnd.dts.hd">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/vnd.dts.hd;profile=lbr">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/vnd.dts.uhd;profile=p2">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
<Limit name="concurrent-instances" max="2" />
</Type>
</MediaCodec>
<MediaCodec name="c2.mtk.adts.decoder">
<Type name="audio/aac-adts">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/mp4a-latm-hw">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
</MediaCodec>
<MediaCodec name="c2.mtk.mpeg.decoder">
<Type name="audio/mpeg-L1">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/mpeg-L2">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
</MediaCodec>
<MediaCodec name="c2.mtk.wma.decoder">
<Type name="audio/x-ms-wmapro">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
<Type name="audio/x-ms-wma">
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</Type>
</MediaCodec>
<MediaCodec name="c2.mtk.adpcmima.decoder" type="audio/x-adpcm-dvi-ima" >
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</MediaCodec>
<MediaCodec name="c2.mtk.adpcmms.decoder" type="audio/x-adpcm-ms" >
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</MediaCodec>
<MediaCodec name="c2.mtk.cook.decoder" type="audio/vnd.rn-realaudio" >
<Limit name="channel-count" max="2" />
<Limit name="sample-rate" ranges="8000-48000" />
</MediaCodec>
<MediaCodec name="c2.mtk.heif.decoder" type="image/vnd.android.heic" >
<Limit name="concurrent-instances" max="2" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="16383x16383" />
<Limit name="blocks-per-second" min="1" max="2088960" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.jpeg.decoder" type="image/jpeg" >
<Limit name="concurrent-instances" max="1" />
<Limit name="alignment" value="32x16" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="8x8" max="16383x16383" />
<Limit name="blocks-per-second" min="1" max="2088960" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="c2.mtk.frame.filter" type="image/vnd.android.filter" >
<Limit name="concurrent-instances" max="2" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="size" min="64x64" max="16383x16383" />
<Limit name="blocks-per-second" min="1" max="2088960" />
<Limit name="performance-point-1920x1088" value="30" />
</MediaCodec>
<MediaCodec name="OMX.MS.MP1.Decoder" >
<Type name="audio/mpeg-L1">
<Limit name="concurrent-instances" max="2" />
</Type>
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.MP2.Decoder" >
<Type name="audio/mpeg-L2">
<Limit name="concurrent-instances" max="2" />
</Type>
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.MP3.Decoder" >
<Type name="audio/mpeg">
<Limit name="concurrent-instances" max="2" />
</Type>
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.AAC.Decoder" type="audio/mp4a-latm-hw" >
<Limit name="concurrent-instances" max="2" />
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.VORBIS.Decoder" type="audio/vorbis" >
<Limit name="concurrent-instances" max="2" />
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.ADPCM.Decoder">
<Type name="audio/adpcm" />
<Type name="audio/x-adpcm-ms" />
<Type name="audio/x-adpcm-ima" />
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.LPCM.Decoder" type="audio/lpcm" >
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.RA.Decoder" type="audio/vnd.rn-realaudio" >
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.WMA.Decoder" >
<Type name="audio/x-ms-wma" />
<Type name="audio/x-ms-wmapro" />
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.AC3.Decoder" >
<Type name="audio/ac3">
<Limit name="channel-count" max="6" />
<Limit name="sample-rate" ranges="8000-48000" />
<Limit name="concurrent-instances" max="2" />
</Type>
<Type name="audio/eac3">
<Limit name="channel-count" max="6" />
<Limit name="sample-rate" ranges="8000-48000" />
<Limit name="concurrent-instances" max="2" />
</Type>
<Type name="audio/ac3p">
<Limit name="concurrent-instances" max="2" />
</Type>
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.DTS.Decoder" >
<Type name="audio/dts" />
<Type name="audio/dts-hd" />
<Type name="audio/dts-lbr" />
<Type name="audio/vnd.dts" />
<Type name="audio/vnd.dts.hd" />
<Type name="audio/vnd.dts.hd;profile=lbr" />
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.DRA.Decoder" type="audio/dra" >
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
<MediaCodec name="OMX.MS.FLAC.Decoder" type="audio/flac" >
<Limit name="concurrent-instances" max="2" />
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
</MediaCodec>
</Decoders>
<Encoders>
<MediaCodec name="c2.mtk.avc.encoder" type="video/avc">
<Limit name="concurrent-instances" max="2" />
<Limit name="size" min="176x144" max="1920x1088" />
<!--<Limit name="aspect-ratio" in="blocks" range="11:9-16:9" />-->
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="bitrate" range="102400-28000000" />
<!--<Quirk name="kAvoidMemcopyInputRecordingFrames" />-->
<Quirk name="requires-allocate-on-input-ports" />
<Quirk name="requires-allocate-on-output-ports" />
<Limit name="performance-point-1920x1088" value="60" />
<Limit name="blocks-per-second" range="1-489600" />
<Limit name="frame-rate" range="1-60" />
</MediaCodec>
</Encoders>
</MediaCodecs>

402
docs/tv-app.md Normal file
View File

@@ -0,0 +1,402 @@
# SleepyOwl для Android TV: устройство приложения, решения и найденные ловушки
SleepyOwl — open-source клиент Jellyfin на Kotlin (GPL-3.0-or-later). Сначала Android TV, рядом — версия для Linux на тех же общих модулях. Цель — воспроизводить всё возможное без серверного транскода, с корректным HDR и Dolby Vision, и при этом быстро работать на слабых ТВ и приставках.
Этот документ — об интерфейсе и прикладном слое ТВ-версии: как устроено, почему так, какие факты о ТВ, Compose и сервере Jellyfin выяснились при работе, сколько что стоит в миллисекундах. Воспроизведение (движки, выбор движка, исправленные экстракторы media3, прокси libVLC, HDR/DV, особенности MediaTek) описано в [`playback.md`](playback.md), сводка приёмов оптимизации интерфейса — в [`ui-performance.md`](ui-performance.md). Контракт ряда «Продолжить просмотр» для своей оболочки ТВ — [`launcher-feed.md`](launcher-feed.md).
**Эталонное устройство** (на нём сделаны все замеры): Xiaomi TV S Pro Mini LED 75 (`MiTV-MOEU1`), MediaTek MT9689, Android 14, **только 32-bit**, 2,4 ГБ RAM, GPU Mali, интерфейс рисуется в 1920×1080 (960×540 dp, плотность 2.0). **Сервер:** Jellyfin 12.x (проверено на 12.2), `jellyfin-sdk-kotlin` 1.9.2; снаружи — за reverse proxy по HTTPS, в LAN — напрямую по HTTP:8096.
---
## 1. Архитектура
### 1.1 Модули
| Модуль | Что внутри |
|---|---|
| `:app` | ТВ-экраны под пульт (`ui/home`, `details`, `player`, `series`, `library`, `search`, `favorites`, `person`, `settings`, `login`, `shell`, `menu`), `MainActivity` (навигация), `SleepyOwlApp`, `LauncherFeed`, Koin-модуль Android, `TextInput` с подъёмом над клавиатурой, `AnchoredScrolling`, панели плеера, `TileArt`, `BackdropTransformation`, список лицензий |
| `:ui:shared` | KMP (android + desktop), Compose Multiplatform 1.12 (на Android это те же androidx Compose 1.12). Все ViewModel'и, навигационные ключи `Screen`, вкладки `Tab`, `ScreenFocus`, `LibraryMemory`, `CardMapper`, подписи (`Words`, `MediaLabels`, `PlayerLabels`, `TrackChoices`), модели панелей плеера (`PanelChoices`), логика пропуска заставок (`SkipPlan`), цель меню «просмотрено» (`WatchTarget`, `clickableWithMenu`), тема (`Tokens`), шрифты, иконки, логотип, кнопки, чипы, карточки, свечение фокуса, `RemoteImage`, `uiModule` (Koin) |
| `:core:common` | `Log` (на Android через Timber), `monotonicMs()`, каталоги и фабрика DataStore |
| `:core:jellyfin` | `JellyfinSession` (аккаунты, токен, `call {}` на IO, `authorizedUrl()`), `ServerLogin` (поиск сервера, проверка адреса, публичные пользователи, пароль, Quick Connect), `LocalServerDiscovery`, `HttpModule` + `ServerRouting` (маршрут на LAN-адрес) |
| `:core:data` | модели без DTO SDK (`ItemSummary`, `ItemDetails`, `LibraryView`, `Person`, `Page`, `ItemQuery`, `MediaSegment` …), `Mapping.kt`, репозитории `LibraryRepository`, `WatchRepository`, `HomeRepository`, `UserSettingsRepository`, `SessionCache`, `ImageUrls`, `SearchHistory` |
| `:playback:api`, `:playback:core` | интерфейсы движков, `PlaybackSession`, `EngineSelector`, `PlaybackPreferences` (вид субтитров, запомненные дорожки, настройки по умолчанию) |
| `:playback:engine-exo`, `:playback:engine-vlc` | движки (только Android) |
| `:baselineprofile` | генерация baseline profile на ТВ (macrobenchmark + UiAutomator) |
Общие модули (`core:*`, `playback:api/core`, `ui:shared`) — Kotlin Multiplatform; `commonMain` компилируется как JVM-код, поэтому `java.time`, `Locale`, ZXing работают в общем коде. DI — Koin 4.2 (движки подключаются через `bind EngineProvider::class` в модуле движка). Платформа отдаёт общему слою `UiPlatform`: плотность экрана (для размера картинок с сервера), строки версии и устройства, доступен ли отладочный обход.
### 1.2 Слой интерфейса
- **Свои компоненты на Compose**, без Leanback и `tv-material` (он убран полностью: APK на 30 КБ меньше, ушла зависимость от `material-icons-core`).
- **Навигация — Navigation 3:** `NavDisplay` + свой стек `Screen` в `MainActivity`; у каждого экрана своё сохранённое состояние и ViewModel. Анимаций переходов нет. Ключи: `Login`, `Root(tab, account, library?)`, `Detail(itemId)`, `Series(seriesId, episodeId?)`, `Person(personId)`, `Player(…)`, `Items(parentId, title)` (сетка коллекций и папок), `License`, `Tour` (debug).
- **`Root` различается по аккаунту.** Иначе после смены профиля оставались главная и ViewModel прежнего аккаунта. Строить через `MainActivity.root(tab)`.
- **ViewModel'и с параметром** — `koinViewModel { parametersOf(id) }` (образец — `DetailsViewModel`).
- **Модели для UI** — неизменяемые (`@Immutable`), без DTO SDK в composable-функциях. Модели карточек строит `CardMapper` вне главного потока.
- **Перезагрузка экрана после изменения данных просмотра** — подписка на `SessionCache.invalidations` (как в `DetailsViewModel`): «ещё N мин» обновляется, выбор дорожек и фокус сохраняются.
- **Запечённые картинки — один интерфейс, две реализации.** На Android код вылизан под Mali (маски `BlurMaskFilter`, углы и затенение запекаются при декодировании); на ПК — простая реализация (Skia-размытие, `clip`, градиент поверх). Разбор BlurHash, QR-код (`argbImageBitmap`) и пунктирная рамка (Canvas Compose) — общие.
- **Ресурсы:** `androidResources { enable = true }` в `android {}` KMP-библиотеки (AGP 9.4) включает обычные Android-ресурсы (класс `ru.stenfredd.sleepyowl.ui.shared.R`); `src/androidMain/res` подключён и к ресурсам ПК, файлы не дублируются.
- **`internal` между модулями не работает:** раскладки (`DetailsLayout`, `PlayerLayout`), `plural`, `PlayerLabels`, запись `ScreenFocus.lastKey` — публичные. Умный каст на свойство из другого модуля не срабатывает — заменять локальной переменной.
- **Koin `verify()`** проверяет и `getAll()` по типу элемента: где движков нет (ПК-тест), `EngineProvider` указывается в `extraTypes`. Граф ТВ проверяет `KoinGraphTest`, граф и отрисовку компонентов ПК — `:ui:shared:desktopTest`.
---
## 2. Ключевые решения и почему
- **Дизайн** — макет «Jellyfin TV» (`design/Дизайн Jellyfin для Android TV/Jellyfin TV.dc.html`, демо-данные в `tv-core.js`; варианты A и B не используются). Открывается в браузере, сверху переключатель экранов. Макет 1920×1080 px, **px из макета ÷ 2 = dp**. Скриншоты макета удобно снимать headless-Chromium (playwright): переключение экранов — клик по кнопке с названием экрана вверху, дальше — Enter и стрелки. Шрифты — Onest (текст) и JetBrains Mono (служебные подписи и цифры), OFL, подмножество латиница + кириллица, в `res/font`.
- **Навигация — боковое меню («рейл»):** узкая полоса иконок, при фокусе раскрывается с подписями. Режим «скрытое меню» не делался. Пункты: Поиск, Главная, Медиатека, Избранное, затем библиотеки сервера, внизу «Настройки» и аватар профиля («сменить профиль»). Часы справа сверху.
- **PIN профилей нет.** Вход — пароль или Quick Connect, дальше хранится токен.
- **Движок, вывод видео и Dolby Vision не выносятся в обычный интерфейс.** Пользователь видит только «Качество» (Оригинал / потолки битрейта — всё ниже оригинала означает транскод) и чип «Direct/Транскод». Движок, вывод видео и DV — блок «Дополнительно» в панели «Информация о потоке» и раздел настроек «Воспроизведение». Отладочный значок, варианты 0–9 и режим обхода — только в debug-сборке.
- **Хранение на ТВ — DataStore** (аккаунты, выбор дорожек, недавние запросы, настройки). Room не используется. Снимок главной — JSON-файл.
- **«Новинка»** — добавлено за последние 14 дней.
- **Языки звука и субтитров — настройки пользователя на сервере** (общие для всех его клиентов Jellyfin); качество по умолчанию, вывод видео, DV, вид субтитров и пропуск заставок — на ТВ, для всех профилей.
- **Выход и «Забыть» отзывают токен на сервере** и забывают профиль на ТВ.
- **Release без обфускации:** проект открытый, теги логов и стеки остаются читаемыми.
- **Весь трафик дома идёт на LAN-адрес сервера** по HTTP (API, картинки, видео), а не через HTTPS-hairpin публичного адреса — см. §5.2. Минус: токен внутри домашней сети идёт открытым текстом.
- **Ряд для своей оболочки ТВ приложение присылает само**, оболочка его не опрашивает — см. [`launcher-feed.md`](launcher-feed.md).
- **Слово «Jellyfin» в интерфейсе — только там, где речь о сервере** («Ищем Jellyfin в локальной сети»). Название и логотип свои.
- **Производительность — главное требование** (§3): если элемент макета заметно тормозит на эталонном ТВ, он упрощается. Все такие отличия от макета перечислены по областям в §5.
---
## 3. Производительность
### 3.1 Что выяснилось о GPU эталонного ТВ
- **Частичная перерисовка выключена производителем** (`debug.hwui.render_dirty_regions=false`): каждый кадр GPU рисует весь экран 1920×1080, каждый видимый элемент платит на каждом кадре, даже неподвижный. Узкое место — GPU, а не CPU и не Compose.
- **Слабое смешивание:** полупрозрачный слой на весь экран — 5–13 мс; сплошная заливка почти бесплатна.
- **Любая текстура на весь экран ~12 мс GPU на кадр** — даже непрозрачная, запечённая в один bitmap и в полном разрешении (растяжение ни при чём).
- **Градиенты на больших площадях дороги** (HWUI их дизерит): затемнения 130 + 230 dp градиентом — ~8 мс; тот же градиент, запечённый и растянутый bitmap'ом, — ~6 мс. Дешевле всего полосы сплошного цвета.
- **Прямоугольник с дробными координатами** рисуется сглаженным, более тяжёлым шейдером — границы ставить по целым пикселям.
- **Тонкие примитивы по всей ширине дороги:** сотни рамок из прямоугольников по 1 px — ~9 мс; скругления, штрихи и число вызовов вторичны.
- **Перепекать bitmap на каждое нажатие тоже плохо** — дорога загрузка текстур (фаза `sync`).
- **Любая непрерывная анимация = перерисовка всего экрана 60 раз в секунду:** плавно пульсирующая точка стоила ~17 мс GPU на кадр.
- **Смена содержимого большой области во время анимации фокуса** делает дорогими все её кадры (~15 мс GPU против 4).
- Ориентир: лаунчер Google TV на том же ТВ — ~5 мс GPU на кадр.
### 3.2 Правила для всего интерфейса
- Фон — только сплошной цвет окна (фон окна = фон приложения). Сетка точек из макета убрана (~5 мс каждого кадра), радиальные свечения фона — тоже.
- **Никаких `Modifier.clip` по скруглённой форме у картинок и затемнений поверх картинок:** углы и затемнение под текстом запекаются в bitmap при декодировании (`CardImageTransformation`). Картинка сначала обрезается под размер карточки, потом скругляется — иначе у постеров не 2:3 запечённые углы срезались и поверх рамки фокуса торчали прямые углы.
- **Никаких `drawPath`** в часто перерисовываемом: иконки растеризуются в bitmap один раз (`Icon`), формы — скруглённые прямоугольники; пунктир запечён (`Modifier.dashedBorder`).
- **Свечение фокуса — готовая картинка** (`GlowCache`: размытый прямоугольник считается один раз на размер), стоит ~0. Только у элемента в фокусе. Никаких `blur`/`RenderEffect` при прокрутке.
- **Составные картинки** (мозаики, плитки) — одним bitmap на `Dispatchers.Default` с `LruCache` (`TileArt`: `context.imageLoader.execute(... allowHardware(false))`).
- **Большие фоны — только на части экрана** с краями, плавно уходящими в цвет фона. Плеера это не касается: видео на своей плоскости.
- **Затемнения — полосами сплошного цвета** с целыми границами (`Modifier.verticalShade`), а не `Brush.verticalGradient`.
- **Анимации — только в фазе отрисовки/слоя:** масштаб и прозрачность, 150–180 мс; значение анимации не читать при композиции; раскладку не анимировать (ширина рейла переключается разом, анимируется только отрисовка).
- **Бесконечные анимации — ступенчатые** (~8 кадров/с) или никакие. Каретка поля ввода мигает шагами (2 перерисовки в секунду).
- **Где за интерфейсом видео — фокус без анимации** (`FocusStyle(animate = false)`).
- **Содержимое большой области менять с паузой после фокуса** (0,3 с: разделы настроек, библиотеки в медиатеке); «→» или OK — сразу.
- **Списки — только ленивые** с `key` и `contentType`; **короткие экраны — обычная колонка**, а не `LazyColumn` (сборка ряда при прокрутке давала кадры до 50 мс).
- **Картинки:** у сервера просить ровно размер на экране (`ImageUrls`, постер 220×330 dp → ~440×660 px), кэш в памяти ≤ ~1/6 кучи, кэш на диске, RGB_565 там, где нет прозрачности, BlurHash-заглушка.
- **Сеть:** данные экрана — параллельно, первым видимое; кэш ответов на сессию; постраничная загрузка больших библиотек.
### 3.3 Как мерить
- **Только release-сборка** (R8 + baseline profile). Compose в debug в разы медленнее, по нему судить нельзя.
- `adb shell dumpsys gfxinfo <пакет> framestats` — пропущенные кадры, перцентили и фазы (скрипт `tools/tv/framestats.py`); `dumpsys gfxinfo <пакет> reset` перед замером. Главная цифра на этом ТВ — **GPU p50/p90**: медиана кадра включает задержку конвейера (~19 мс при GPU 10 мс).
- `am start -W` — холодный запуск. Цели: прокрутка ряда без пропусков на 60 Гц, холодный запуск до первого экрана ≤ 1,5 с.
- `source tools/tv/env.sh; install_release; ui_bench right` (или `ui_bench down 4`): N нажатий вперёд и назад с паузой 0,35 с → перцентили и фазы.
- Время в логах: `Home shown in`, `Home fetched in`, `Home snapshot read in`, `Series loaded in`.
- **Состояние ТВ «плавает»:** тот же экран между прогонами даёт то ~8, то ~17 мс GPU, а иногда весь ТВ рисует втрое медленнее (главная ~32 мс вместо ~7–10) до перезагрузки. Контроль — главная ~7 мс; если хуже, `adb reboot`, а не оптимизация. Варианты сравнивать только вперемешку, по 2–3 прогона каждого.
- **Флаги-эксперименты в release без пересборки:** временно читать системное свойство (например, `debug.<app>.x`) через `SystemProperties` (reflection) и менять `adb shell setprop`. Флаги — заглавные однобуквенные без пересечений («none» содержит «e» и «o»).
- **Baseline profile:** модуль `:baselineprofile`, профиль в `app/src/release/generated/baselineProfiles/`, снять заново — `gen_profile` (ТВ с выполненным входом; от ~8 мин для главной до ~27–30 мин для полного сценария: главная → «Продолжить» → ряды → страница фильма → медиатека → поиск → избранное → настройки → страница сериала). `install_release` сразу компилирует приложение по профилю (`INSTALL_PROFILE` broadcast + `cmd package compile -f -m speed-profile`), иначе ТВ сделает это только при фоновом обслуживании.
- Gradle завершает генерацию с «There were failing tests» / «BUILD FAILED», хотя тест прошёл: ошибка на шаге сбора покрытия (причину Gradle не показывает и с `--info`). `gen_profile` копирует профиль сам.
- Генератор жмёт OK «вслепую» и может запустить воспроизведение — запускать только под тестовым аккаунтом. В первом, нескомпилированном проходе приложение отстаёт от нажатий, проверять фокус после каждого нажатия нельзя — очередь клавиш проскакивает цель. Число шагов рейла до «Настроек» генератор считает по подписям меню между «Избранным» и «Настройками» (библиотек на сервере может быть сколько угодно).
- Запуск фильма в сценарии меняет «Продолжить просмотр» аккаунта — поэтому плеер в профиль не входит.
### 3.4 Итоговые цифры (release, эталонный ТВ)
| Что | Значение |
|---|---|
| Главная, листание ряда: GPU на кадр | 45 → ~10 мс (после правил §3.2), «рывков» 8 % → 0–0,5 % (с baseline profile, первый проход тоже без рывков) |
| Главная, `ui_bench right`: кадр p50/p90, GPU p50/p90 | 15–19 / 31–32 мс, 6 / 14 мс |
| Холодный запуск (`am start -W`) | 0,35–0,6 с → 0,28–0,42 с → 243–283 мс (среднее 263) |
| Главная на экране из снимка / свежие данные с сервера | 0,09–0,16 с / 0,4–1,0 с (было 1,3–2,5 с с «Загрузка…») |
| Чтение снимка главной (130 КБ JSON) | 150 мс без профиля, 60–80 мс с профилем |
| Страница фильма: первый экран / ряд «Похожие» | GPU ~8 мс (p90 ~9) / ~7 мс |
| Плеер с OSD | GPU ~7–8 мс (было 15–20); вывод через GPU: 6 мс с OSD, 4–5 без |
| Страница сериала, лента серий | GPU ~6 мс (16 серий), ~7 (365 серий в одном сезоне), ~8,5 (28 сезонов, ~5 % рывков — столько же и без карты сезонов) |
| Загрузка страницы сериала (холодно) | 0,23 с (16 серий), 0,65–0,7 с (333–365 серий) |
| Медиатека: загрузка библиотеки / листание сетки | 0,15–0,23 с (было 0,4–0,65) / GPU ~6–7 мс, рывков ~1 % |
| Смена библиотеки | один кадр 55–70 мс (собираются ряды и первые ряды сетки) |
| Поиск: клавиатура / результаты | GPU ~8 мс / ~8 мс |
| Настройки: разделы и чипы | GPU ~4 мс |
| Экран входа с пульсирующей точкой | 47 кадров за 5 с, GPU 6 мс (при плавном пульсе — 60 к/с по ~17 мс) |
| APK release / debug | ~52,8 МБ / 106 МБ (armeabi-v7a + arm64-v8a) |
---
## 4. Фокус и пульт: общие приёмы и ловушки Compose
- **Запоминание фокуса:** `ScreenFocus` + `Modifier.remembered(focus, key)` у каждого фокусируемого элемента. После «Назад» фокус встаёт на ту же карточку, прокрутка сохраняется. `Restore` ждёт 2 кадра. `ScreenFocus.ForgetMissing(keys)` — если запомненный элемент пропал из списка (например, снято избранное), фокус встаёт на элемент по умолчанию.
- **`lastKey = x; requestFocus()` сразу не работает:** requester ещё висит на старом элементе — дождаться перекомпозиции (2 кадра). Ручная навигация между зонами экрана (`moveFocus(key, list, index)` на странице сериала): прокрутить, сменить `lastKey`, два кадра, `requestFocus`.
- **Когда пропадает слой с фокусом (панель), система ставит фокус на первую кнопку экрана** и тем перезаписывает `lastKey`. Возвращать фокус на карточку **до** исчезновения панели, через отдельный `FocusRequester` у каждой карточки.
- **Ряд чипов «фокус при входе — на выбранный»:** `Modifier.focusRestorer(requester)` + `focusRequester` на выбранном. У обычного `Row` без `focusGroup()` восстановление не срабатывает (`focusRestorer(chosen).focusGroup()`).
- **«Назад», которое никто не перехватил, сначала только снимает фокус**, и `BackHandler` срабатывает со второго нажатия. В плеере и панелях «Назад» ловится в `onPreviewKeyEvent` и выполняется **по отпусканию** — иначе отпускание прилетит следующему экрану.
- **Удержанная OK шлёт повторы** (`KeyDown` с `repeatCount > 0`), а `clickable` в Compose считает нажатием любой `KeyDown`: открывшаяся по удержанию панель нажала бы свою первую кнопку сама. Панель принимает OK только после «свежего» нажатия (`repeatCount == 0`).
- **`Key.Menu`** Compose на Android — тот же `KEYCODE_MENU`, отдельного `nativeKeyCode` не нужно.
- **Ввод с физической клавиатуры** на экране без поля — `onPreviewKeyEvent` с `nativeKeyEvent.unicodeChar`.
- **Вертикальная прокрутка экранов с рядами — своя** (`AnchoredScrolling` + `anchorViewport` на список, `anchoredRow` на элемент): 450 мс с замедлением к концу. Встроенную анимацию bring-into-view в текущем Compose настроить нельзя, и она «выстреливала». `AnchoredScrolling` generic по `ScrollableState` (`animateScrollBy`) — годится и для колонки, и для `LazyColumn`/`LazyVerticalGrid`.
- **Встроенная TV-прокрутка** держит фокус около 30 % от края: горизонтальные ряды так и оставлены; панели, которые не должны прокручиваться при первом же фокусе, используют `MinimalScrolling(margin)` (прокрутка ровно на сколько нужно).
- **Экраны-сетки** (медиатека, избранное, поиск, человек): весь экран — одна `LazyVerticalGrid`; ряды и заголовки — элементы `span = maxLineSpan`, растянутые на всю ширину экрана модификатором `bleed(fullWidth)` поверх боковых полей сетки; ячейки `GridCells.FixedSize(POSTER_W)`; подгрузка страниц по `snapshotFlow` от `layoutInfo` (`ui/components/Grid.kt`: `fullItem`, `bleed`).
- **Нажатие в первом кадре после появления OSD уходит в никуда.** С пульта так не бывает, но в скриптах между нажатиями нужны паузы ≥ 0,25 с.
---
## 5. По областям
### 5.1 Основа: дизайн-система, компоненты, оболочка, сборка
- `ui/theme` (`Tokens`): цвета, типографика, отступы, радиусы, свечение фокуса из макета. `ui/components`: `FocusCard`/`focusCard` (фокус-узел со свечением), `GlowCache`, `Cards` (постер, широкая, большая), `Buttons` (основная с градиентом, круглые, чип; `Chip(swatch = …)` — цветная точка), `Icons`, `RemoteImage` + `CardImageTransformation`, `BlurHash`, `Avatar`, `StatusDot`, `Brand`, `Grid`, `Section`, `Scrolling`, `TextInput`.
- **Оболочка** (`ui/shell/Shell.kt`): «Назад» на вкладке → фокус в рейл, «Назад» в рейле → выход из приложения.
- **Упрощения макета ради скорости:** нет сетки точек на фоне; у постеров нет затемнения снизу — значок «4K» на маленькой тёмной плашке; полоска прогресса на постере — со скруглёнными концами и отступами, а не во всю ширину по краю; тень раскрытого рейла — узкая полоса 40 dp вместо размытия 80 px. Длинное название в большой карточке обрезается многоточием.
- **Release-сборка:** R8 с keep-правилами (ass-media лезет рефлексией в поля `MatroskaExtractor` — `extractorOutput`, `subtitleSample`; libVLC; ffmpeg; SDK), `profileinstaller`, подпись пока debug-ключом. Воспроизведение Exo и VLC в release проверено.
- **Открытие из adb** (`am start -n ru.stenfredd.sleepyowl/.MainActivity …`), работает и в release: `--es item <id> [--ez play true] [--el start <мс>]`; `--es series <id> [--es episode <id>]` — страница сериала. Только в debug: `--es engine exo|vlc`, `--ez tour true [--ei tour_file N]` (режим обхода). Без `--el start` фильм с `--ez play true` начинается с нуля и выпадает из «Продолжить просмотр».
### 5.2 Данные
- **Репозитории `:core:data`:**
- `LibraryRepository` — библиотеки, новинки (`/Items/Latest`, в т. ч. по библиотеке), страницы элементов с сортировкой и фильтрами (`items(query, start, limit)`), количество, подробности, сезоны, серии, похожие, поиск (`search(term, view)`, `searchPeople`), человек и фильмография, сегменты;
- `WatchRepository` — Resume, NextUp, «Продолжить просмотр», история, избранное, «просмотрено»/«в избранное» — всё и по библиотеке (`parentId`);
- `HomeRepository` — содержимое главной; `UserSettingsRepository` — языки на сервере, имя и версия сервера.
- **`SessionCache`** — кэш ответов в памяти: одинаковые запросы в полёте объединяются; всё, что зависит от просмотра (`Kind.UserData`), сбрасывается по событию; ключи по аккаунту; `dropUserData()` — сбросить зависящее от пользователя и сказать экранам перезагрузиться; `invalidations` — поток событий для экранов.
- **Обновление после просмотра:** `PlaybackSession` после `reportPlaybackStopped` вызывает `JellyfinSession.notifyUserDataChanged` → кэш сбрасывает данные просмотра → экраны перезапрашивают в фоне. То же делают `setPlayed`/`setFavorite` и меню «просмотрено».
- **Главная показывается сразу из снимка** (последний показ в этом запуске или JSON-файл с прошлого запуска, `files/home/`), затем обновляется с сервера одним параллельным заходом.
- **Ряды библиотек на главной — через `/Items/Latest?parentId`** (20–80 мс на сервере; «недавно пополненное»: сериал с новой серией поднимается вверх) вместо `/Items` по дате с подсчётом (≈0,2 с каждый, а 5 параллельных сервер выполнял ≈1 с). Число элементов библиотеки догружается отдельно (появляется чуть позже ряда) и помнится 30 мин.
- **«Продолжить просмотр»** = начатое (сначала самое недавнее), потом следующие серии (NextUp). **Один тайтл — одна карточка:** от сериала остаётся последняя по времени просмотра начатая серия (resume у сервера отсортирован по дате просмотра, `distinctBy { seriesId ?: id }`); «следующая серия» — только у сериалов без начатых. Начатых запрашивается втрое больше лимита, чтобы после схлопывания ряд оставался полным. Тот же вызов используют главная, медиатека и ряд оболочки ТВ.
- **Локальный адрес для всего трафика:** один `OkHttpClient` (`core/jellyfin/HttpModule.kt`) для SDK и Coil с перехватчиком `ServerRouting`: запросы на публичный адрес уходят на LAN-адрес сервера, если он известен. Адреса картинок строятся на публичном адресе — это ключ кэша, он одинаков дома и вне дома. Если LAN-адрес не отвечает (вне дома), запрос за 1,5 с уходит на публичный адрес и запускается новый поиск сервера. Найденный LAN-адрес запоминается в аккаунте и используется сразу при следующем запуске. Видео и субтитры тоже идут на LAN-адрес (`authorizedUrl`). Причина: hairpin через роутер и TLS на процессоре ТВ давали Exo 40–115 Мбит/с против ~240 Мбит/с напрямую.
- **Несколько аккаунтов** (`core/jellyfin/Accounts.kt`, `JellyfinSession`): сервер + пользователь + токен + LAN-адрес, список в DataStore (JSON), текущий аккаунт; `signedIn`, `switchTo(id)`, `logout()` (отзывает токен), `forget(accountId)`, `accounts`, `restored` (до него `session == null` не значит «вышли»).
- **Картинки:** Coil 3 + Image API Jellyfin ровно под размер на экране (`fillWidth`/`fillHeight`/`quality`). **BlurHash** приходит с каждым элементом: декодируется 16 px, увеличивается до ¼ карточки с запечёнными углами и затемнением под текст, рисуется одним bitmap; декодирование вне главного потока. Картинки и API ходят через тот же LAN-маршрут.
- **Значок качества («4K», «1080p») на постерах требует `fields=Width`** — без него сервер не отдаёт ширину видео.
- Карточка без картинки на сервере — пустая плашка.
### 5.3 Вход
- **Экран входа** (`ui/login`: `LoginScreen`, `LoginViewModel`, `QrCode`): шаги «Сервер → Профиль → Вход» с цепочкой шагов вверху; «Назад» — на предыдущий шаг, с первого — выход из экрана.
- **Сервер:** карточки серверов из LAN (UDP 7359, все ответившие; имя, LAN-адрес, версия, пинг по уже открытому соединению; не больше трёх карточек) + серверы сохранённых аккаунтов, которых в сети нет (пометка «ИНТЕРНЕТ», пинг через сохранённый адрес) + «Ввести адрес». Поиск повторяется каждые 8 с, пока открыт шаг. Введённый адрес проверяется через SDK `getRecommendedServers` — он сам подставляет схему и порт (`jellyfin.example.com`, голый IP и т. п.). Для найденного в сети сервера аккаунт сохраняется с его публичным адресом (если сервер его объявляет и отвечает по нему тем же id) и LAN-адресом.
- **Поиск сервера в LAN** (`LocalServerDiscovery`): UDP-запрос «Who is JellyfinServer?» на порт 7359, берётся IP отправителя + порт 8096, проверка `/System/Info/Public` → тот же `Id`. Поле `LocalAddress` в ответе сервера за reverse proxy содержит публичное имя, поэтому на него полагаться нельзя.
- **Профиль («Кто смотрит?»):** сохранённые на ТВ аккаунты этого сервера («сейчас», «сегодня», «вчера» …) + публичные пользователи (`/Users/Public`; пуст, если пользователи на сервере скрыты) + «Другой». Если выбирать не из чего, шаг пропускается (в цепочке он всё равно отмечается пройденным). Пользователь без пароля входит сразу. Аватар — градиентный круг с буквой, цвет по id пользователя (формула из макета); он же в рейле.
- **Вход:** Quick Connect (код крупно + QR со ссылкой `<сервер>/web/#/quickconnect?code=…` — веб-клиент Jellyfin 12 подставляет код из ссылки; опрос раз в 3 с; код обновляется, когда сервер его забывает; если Quick Connect выключен на сервере — так и написано) или имя и пароль. Ошибки по-русски («Неверное имя пользователя или пароль», «Нет связи с сервером» …). QR — ZXing core 3.5.3.
- **Слой входа** (`ServerLogin`) работает через отдельный клиент без токена с тем же device id; `JellyfinSession.signedIn` сохраняет аккаунт. Ловушка прежней реализации: вход через основной клиент сбрасывал LAN-маршрут текущего аккаунта ещё до успешного входа.
- **Смена профиля:** пункт аккаунта в рейле открывает «Кто смотрит?» текущего сервера; «Назад» — к серверам (как в макете), ещё раз — обратно в приложение. При запуске открывается последний аккаунт без выбора.
- **Поле ввода для пульта** (`TextInput` + `rememberTextInputState()`): фокус встаёт на рамку, OK — курсор и клавиатура; кнопка действия клавиатуры, «Назад», ↑/↓ — выход из поля (`BasicTextField` иначе захватывает D-pad).
- **Gboard для ТВ плавает поверх нижней половины экрана (~45 %) и не сообщает свою высоту** (IME insets нулевые), поэтому `adjustPan`/`adjustResize` не работают. Экран сам приподнимается, пока поле редактируется (`KeyboardLift` + `Modifier.liftForKeyboard`, сдвиг слоя; содержимое обёрнуто в `CompositionLocalProvider(LocalKeyboardLift provides lift)`), у активности `adjustNothing`. Для клавиатур, которые сообщают высоту, берётся она.
- **Индикатор «Ищем…» / «Ждём подтверждения»** — `StatusDot` со ступенчатым пульсом (~8 кадров/с), см. §3.1.
- Упрощение: фон без радиального свечения.
### 5.4 Главная и страница фильма
**Главная** (`ui/home`): «Продолжить просмотр» (большая карточка первого + 4 широких), «Недавно добавлено», ряд на каждую библиотеку. Данные — §5.2.
**Страница фильма** (`ui/details`: `DetailsScreen`, `DetailsLayout`, `MediaLabels`, `BackdropTransformation`; `Screen.Detail`). Во весь экран, без рейла.
- Состав: постер, значки (4K/1080p, HDR10/HDR10+/DV/HLG, Atmos/7.1/5.1), строка файла («HEVC 2160p DV P8 · 39.4 ГБ»), метка «ФИЛЬМ», название (до 2 строк), ★ / возраст / год / длительность / жанры, описание (до 4 строк), «Продолжить · ещё N мин» / «Смотреть», «С начала» (если начат), ♥, ✓, «Параметры воспроизведения» (аудио и субтитры), «В ролях · режиссёр …» (фото с сервера, иначе инициалы; открывают страницу человека), «Похожие». Для серии (открывается только из intent) — метка «СЕРИЯ · S1 · E3», название сериала и серии, постер сериала, без «Похожих».
- **Подписи дорожек:** язык + собственное название дорожки из файла («Русский · Дубляж», «Русский · <студия>») — у локализованных релизов именно оно отличает дорожки одного языка; справа кодек и каналы («AC3 5.1», «E-AC3 Atmos 5.1»). Субтитры: «Выкл» + дорожки («SRT», «PGS», «ASS»; forced без названия → «надписи»). `defaultSubtitleIndex` = null, если сервер дал -1.
- **Ряды чипов дорожек прокручиваются в строку** (бывает десяток субтитров), ряд открывается на выбранной дорожке, вход в ряд сверху/снизу — на выбранную (`OptionRow`). Выбор передаётся в плеер (`Screen.Player.streams`) и запоминается для фильма (§5.5).
- **Ловушка навигации:** «вверх» из ряда субтитров ведёт на *выбранный* чип аудио, а оттуда «вверх» — на ближайшую по горизонтали кнопку (часто ♥), а не на «Продолжить». В скриптах к «Продолжить» идти явно (`DPAD_LEFT` до упора).
- **Избранное и «просмотрено»** меняются на экране сразу, запрос на сервер, при ошибке откатываются. Если кнопка исчезла («С начала» после досмотра), `ScreenFocus.lastKey` переводится на «Продолжить».
- **Возраст:** префикс системы рейтинга убирается («RU-16+», «age6+» → «16+», «6+»), «PG-13» остаётся.
- Кнопки 42 dp, чип 29 dp со светлой заливкой в фокусе.
- **Фон:** картинка на весь экран + затемнение даже запечённая в один непрозрачный bitmap стоила ~12 мс GPU (кадр 19 мс вместо 7). Поэтому фон — **только справа сверху, 70 % ширины и высоты** (672×378 dp), края плавно уходят в фон, затемнение макета запечено (`BackdropTransformation`). При прокрутке к актёрам фон уходит вверх вместе с первым экраном. Свечение вокруг постера убрано. Без фона на сервере — тёмный фон.
### 5.5 Плеер
Код: `ui/player` — `PlayerScreen` (состояние OSD `PlayerUiState`, клавиши `onKey`/`onNavigationKey`), `PlayerViewModel` (в `ui:shared`: сессия, `PlayerInfo`, `SeasonEpisodes`, `playEpisode(id)`, качество, вид субтитров, сегменты), `PlayerOsd`, `PlayerPanels` (`SidePanel`, `PanelList`, `PanelChips`), `PlayerDrawer`, `PlayerNext`, `PlayerSkip`, `PlayerLabels`, `PlayerDebug` (только debug).
- **OSD:** метка «ФИЛЬМ»/«СЕРИАЛ», название, «год · жанры» или «Сезон 1 · Серия 4 · название», часы, «закончится в …»; время, таймлайн (буфер — только у Exo, libVLC его не сообщает; отметки глав; части заставок/титров), ⏮ ⟲ ⏯ ⟳ ⏭ (⏮/⏭ — у серий), чипы «Аудио», «Субтитры», «Качество», «Direct/Транскод», подсказка «⌄ О фильме».
- **Пульт:** при скрытом OSD ←/→ — перемотка по 10 с (при удержании 30, потом 60 с) с показом таймлайна; нажатия копятся и уходят в движок одним seek через 0,6 с; OK — пауза; ↓ — шторка; ↑ — OSD. Медиаклавиши (⏯, ⏩/⏪, ⏭/⏮ серии) работают всегда. OSD прячется через 6 с без нажатий (только во время игры; не на паузе и не при открытой панели/шторке; в начале воспроизведения он виден, пока идёт загрузка, + 6 с). «Назад»: панель → шторка → OSD → выход.
- **Уход с экрана (Home):** `PlaybackSession.pauseInBackground()` на `ON_STOP` — пауза, отчёт о позиции на сервер и `notifyUserDataChanged`. Раньше была только пауза, и списки (в том числе ряд оболочки ТВ) не узнавали о новой позиции.
- **Панели справа** (непрозрачные; фокус из панели не уходит на кнопки за ней):
- Аудио (выбор закрывает панель);
- Субтитры: дорожка + размер S/M/L/XL, цвет белый/жёлтый/голубой, фон нет/тень/плашка, превью; пояснение для ASS и графических;
- Качество: «Оригинал» + потолки 20/10/4/1,5 Мбит/с ниже битрейта файла (`QualityCaps.ALL`); сервер сам решает — прямое воспроизведение, если файл влезает, иначе транскод. «Авто» нет (нужен замер скорости сети);
- Информация о потоке: режим и причина транскода по-русски, контейнер, видео, частота, битрейт, аудио, субтитры, движок, декодер, звук, буфер, пропуски + **«Дополнительно»**: движок (Авто/ExoPlayer/VLC), вывод видео (Авто/видеослой/GPU), Dolby Vision (базовый слой/DV-декодер — только для DV-файлов). «Авто» возвращает к значению из настроек.
- **Вид текстовых субтитров** (`SubtitleStyle` в `playback/api`, интерфейс `SubtitleStyleable`): Exo — `SubtitleView` на лету; пока виден OSD, субтитры Exo приподнимаются над ним (`setSubtitleLift`). VLC — параметрами freetype при создании libVLC (libVLC создаётся лениво), поэтому смена вида при включённых субтитрах перезапускает VLC с той же позиции. ASS и PGS рисуются как есть. Вид общий для ТВ (DataStore `playback`).
- **Запоминание дорожек** (`PlaybackPreferences`): на аккаунт и фильм/сериал. Дорожка ищется в следующем файле по языку, собственному названию и кодеку (`TrackPick.bestOf`): номера потоков у серий разные. Запоминается выбор в плеере, на странице фильма и в панели сериала; плеер без явного выбора (следующая серия, запуск извне) берёт запомненное.
- **Качество:** выбранное в плеере переходит на следующую серию; иначе берётся качество по умолчанию из настроек (`PlaybackPreferences.defaults`, применяет `PlaybackSession` при старте).
- **Шторка (↓):** «О фильме» (постер, ★, возраст, год, длительность, жанры, описание, режиссёр, 4 актёра с фото) с кнопкой «Страница фильма»; у серий — «Серии · сезон N» (лента сезона, текущая «СЕЙЧАС», ✓, «НОВАЯ»; выбор серии играет её с места остановки без выхода из плеера, шторка при этом сворачивается) и кнопка «К сериалу» (возврат на страницу сериала с фокусом на играющей серии; ↑ из ленты ведёт на неё).
- **Конец:** у серии — «Следующая» (§5.6, §5.12), конец фильма — выход из плеера.
- **Скорость:** видео на своей плоскости, окно интерфейса рисуется только при изменениях: OSD скрыт — 0 кадров; виден — кадр раз в секунду (часы) и по одному на нажатие. Затемнения сверху и снизу — полосы сплошного цвета по 4 dp с целыми границами, высота 105 + 170 dp (в макете 130 + 230 dp градиентом, без радиальной виньетки). Фокус без анимации: анимированная смена фокуса — ~8 дорогих кадров на нажатие, мгновенная — один (на нажатие ~4 кадра вместо ~15). У полосы прогресса, ползунка и значка паузы нет свечения. Под панелями нет затемнения экрана.
- **Debug-сборка:** значок варианта, цифры 0–9 (варианты движка/вывода), MENU — старое отладочное меню, INFO — диагностика, режим обхода.
- **Скрипты:** снимок экрана на ТВ занимает ~5 с (OSD за это время может спрятаться); HDR/DV на видеослое на `screencap` не виден, SDR — виден.
### 5.6 Сериалы
Код: `ui/series` — `SeriesScreen` (раскладка без прокрутки, ручная навигация ↑/↓ между кнопками, чипами и лентой), `SeriesViewModel` (модель серий/сезонов, «следующая», дорожки), `SeriesTray` (нижняя панель). `Screen.Series(seriesId, episodeId?)`; время загрузки в логе — `Series loaded in`.
- **Экран** во весь экран без рейла и без вертикальной прокрутки: постер, «СЕРИАЛ · 2 СЕЗОНА · 16 СЕРИЙ», название, ★ / возраст / год / жанры / значки файла следующей серии; «Продолжить S1 E4 · ещё 38 мин» или «Смотреть S1 E5 · 46 мин»; кнопка «● AC3 5.1 ● Русский» (нижняя панель дорожек); ♥; ✓ (меню «просмотрено» для всего сериала, §5.11); карта сезонов; чипы сезонов («Сезон 1 3/10», «✓», «новый»); все серии всех сезонов одной лентой с разделителем «СЕЗОН N»; внизу — описание выбранной серии (2 строки), длительность и дата выхода.
- **Карточки сериала, сезона и серии ведут сюда** (на серию — фокус и прокрутка на ней).
- **Какая серия «следующая»:** последняя включённая, если не досмотрена (даже если её включили на секунды и сервер не сохранил позицию), иначе первая непросмотренная после неё; ничего не смотрели — первая непросмотренная. Спецвыпуски (сезон 0) — в конце, в «следующую» не попадают.
- **Состояния серий:** ✓ просмотрена, «42%» начата (+ полоска), «далее», «новая» (14 дней). На карте: зелёная / фиолетовая / розовая рамка / серая рамка, выбранная — высокая белая. Полоски сужаются для длинных сезонов; если всё равно не влезает (28 сезонов), показывается окно сезонов вокруг выбранного с «…».
- **Карта сезонов запекается в bitmap один раз** на сезон; выбранная серия — отдельный белый маркер, который двигается слоем (`graphicsLayer.translationX`) без перерисовки. Сотни рамок по 1 px на кадре стоили ~9 мс GPU (17 мс вместо 8 на сериале с 28 сезонами).
- **Пульт:** ↓ с кнопок → чип текущего сезона → его следующая серия; ↑ из ленты → чип сезона этой серии; ↑ с чипов → «Продолжить». Движение по чипам прокручивает ленту к сезону, движение по ленте переключает чип и карту. OK на чипе — к серии сезона; OK на серии — плеер (с места остановки; просмотренная — с начала).
- **Нижняя панель «Аудио и субтитры»:** три колонки — аудио, субтитры, вид субтитров (общий `SubtitleChoices` с плеером). Дорожки — из файла следующей серии, выбор сразу запоминается для всего сериала, плеер ищет похожую дорожку в каждой серии. Фокус не уходит из панели; «Назад» (по отпусканию) или ↓ ниже края — закрыть, фокус возвращается на кнопку дорожек. Панель непрозрачная, без затемнения экрана.
- **Плеер:** карточка **«Следующая через N с»** за 30 с до конца серии (если следующая есть; при известных титрах — с их началом, §5.12): кадр, «S1 · E6 · название», «Смотреть сейчас» / «Досмотреть титры», полоска отсчёта. Над голым видео карточка берёт фокус (←/→/OK — её кнопки, ↑ — OSD, ↓ — шторка); при показанном OSD стоит над ним. «Назад» при скрытом OSD = «Досмотреть титры». В конце серии следующая включается сама. `SeasonEpisodes.nextEpisode`, `PlayerInfo.seriesId`.
- **Данные сервера:** все серии сериала (365 серий) сервер отдаёт за ~0,1 с (~1 МБ JSON), остальное время — разбор. У некоторых сезонов сервер не знает номеров серий — номер берётся по порядку в сезоне; у серий без своего кадра — картинка сериала.
- Упрощения: фон как у фильма (справа сверху), без свечения у выбранного чипа, полосок карты и прогресса; кнопки шапки 42 dp.
### 5.7 Медиатека
Код: `ui/library` — `LibraryScreen`, `LibraryViewModel`, `TileArt`, `LibraryMemory`.
- **Плитки библиотек:** «● БИБЛИОТЕКА», название, «158 фильмов · 3 новинки» (новинки — `/Items/Latest` библиотеки за 14 дней; нет новинок — только число; у смешанной библиотеки — «N тайтлов» и заголовок «Всё»), мозаика из трёх свежих постеров. Фон плитки (заливка, наклонённые постеры, затемнение слева, скругления) запекается в один bitmap в двух видах (выбранная ярче). Плитки фиксированной ширины 260 dp (видно три), ряд прокручивается. Фокус на плитке выбирает библиотеку через 0,3 с (быстрое листание не грузит промежуточные); OK — вниз, в её содержимое. Библиотеку можно открыть и из рейла.
- **Под плитками:** «Продолжить просмотр» (по библиотеке), «Недавно добавлено», «История просмотра» (серии сведены к сериалу, подпись «вчера», «3 дня назад» — `Words.daysAgo`), «Все фильмы/сериалы N» с чипами А–Я / Добавлено / Год / Рейтинг и «Непросмотренное», сетка постеров по 6 в ряд (у сериала «3 сезона · ★ 8,2»).
- **Весь экран — одна ленивая сетка** (§4). Сетка грузится страницами по 60 (следующая — когда до конца остаётся 2 ряда), без `TotalRecordCount`; число — отдельным запросом. Смена сортировки оставляет старые карточки до прихода новых (иначе экран «прыгает»). Сетка прокручивается, когда фокус уходит на не полностью видимый ряд.
- **Память:** выбранная библиотека и сортировка каждой библиотеки помнятся до перезапуска (`LibraryMemory`, singleton); содержимое уже открытых библиотек показывается сразу и обновляется в фоне; после просмотра перезагружается.
- **Сервер: запрос «фильмы и сериалы» в библиотеке в ~10 раз медленнее одного типа** (список 60 шт.: 0,35–0,44 с против 0,05; подсчёт: 0,2 с против 0,02). Списки и подсчёты просят только тип библиотеки (`LibraryView.itemKinds`) — это ускорило и числа в заголовках рядов главной.
- **Смена библиотеки** даёт один кадр 55–70 мс; baseline profile почти не помогает. Он приходится на момент, когда ничего не движется, и на глаз выглядит как появление содержимого. Если станет заметно — собирать ряды по одному в соседних кадрах.
- Коллекции и папки (BoxSet/Folder) открывают простую сетку `ItemsScreen`.
### 5.8 Поиск, избранное, страница человека
- **Поиск** (`ui/search`): слева поле запроса и экранная клавиатура 6×7 — русские буквы (+ «-», «.», «,»), «ABC·123» переключает на латиницу и цифры, «⌫» (долгое нажатие — стереть всё), «Пробел». Ввод с физической клавиатуры и цифр с пульта работает на всём экране. Справа без запроса — «Недавние запросы» и «Недавно добавлено», с запросом — «Найдено: N», группы по библиотекам (в порядке библиотек) + «Люди» последними (круглое фото или инициалы). Поиск идёт через 0,35 с после последнего нажатия. Постеры в результатах — обычные (110 dp, название под картинкой, 4 в ряд).
- **Недавние запросы** (`core/data/SearchHistory`, DataStore, по аккаунту, до 8): запрос запоминается, когда открыт один из его результатов.
- **Сервер: `/Persons?searchTerm=` не находит людей по-кириллице** на Jellyfin 12 (русский запрос → 0 результатов); `/Items?includeItemTypes=Person&searchTerm=` находит. Поиск людей — через `/Items`.
- Скорость сервера: поиск по библиотеке своим типом — 0,03–0,07 с; избранное по библиотекам — ~0,05 с; фильмография — 3 запроса (фильмы, сериалы, серии) ~0,1 с.
- **Избранное** (`ui/favorites`): «Избранное N тайтлов», чипы «Всё» + библиотеки, где есть избранное (с числом), сетка постеров с ♥ и подписью «Фильм · 2021» (`CardMapper.kindPoster`, `PosterModel.favorite`). Если отмеченная карточка пропала — фокус на «Всё».
- **Страница человека** (`ui/person`, `Screen.Person`; в макете нет): фото, имя, «5 фильмов · 1 сериал», сетка фильмов и сериалов (новые сначала); гостевая роль в серии показывается сериалом. Открывается из «Людей» в поиске и с актёров на странице фильма.
- `adb shell input text "…"` печатает только латиницу; кириллицу — экранными клавишами; стирать `KEYCODE_DEL`.
### 5.9 Настройки
Код: `ui/settings` — `SettingsScreen`, `SettingsViewModel`, `Licenses`, `LicenseScreen`; вкладка `Tab.Settings` внизу рейла над профилем. Раскладка «раздел слева — настройки справа» (`Setting`, `Choices` — ряд чипов с фокусом при входе на выбранном). Выбор применяется сразу. Раздел показывается через 0,3 с после фокуса (§3.1).
- **Воспроизведение** (на ТВ, для всех профилей; `PlaybackPreferences.defaults` / `PlaybackDefaults`): качество по умолчанию (Оригинал / 1080p 20 / 1080p 10 / 720p 4 / 480p 1,5 Мбит/с), вывод видео (Авто / Видеослой ТВ / Через GPU — для Exo), Dolby Vision (базовый слой / DV-декодер — пункт есть, только если у ТВ есть DV-декодер и экран с DV), «Заставки» и «Титры» (§5.12).
- **Языки** — настройки пользователя на сервере (`AudioLanguagePreference` + `PlayDefaultAudioTrack`, `SubtitleMode`, `SubtitleLanguagePreference`): язык звука (Как в файле / Русский / Английский / Японский + текущий, если другой), субтитры (Как в файле / Выкл / Только надписи / Если звук не на моём языке / Всегда), язык субтитров (Любой / Русские / Английские). Сервер сам применяет их при выборе дорожек по умолчанию; после смены кэш сбрасывается. Пишутся только изменённые поля.
- **Сервер помнит дорожки, выбранные при прошлом просмотре** (`UserData.AudioStreamIndex/SubtitleStreamIndex`, «Запоминать выбор» в профиле Jellyfin) — для таких элементов языки из настроек не действуют. На экране сказано, что выбранное вручную важнее.
- **Вид субтитров** — как в панели плеера.
- **Аккаунт и сервер:** аватар, имя, «Сервер «<имя>» · Jellyfin 12.x», адрес, «Дома: напрямую, <LAN-адрес>»; «Сменить профиль», «Выйти» (со второго нажатия «Точно выйти?»); другие сохранённые профили — «Переключиться» и «Забыть» (тоже со второго нажатия). «Выйти» и «Забыть» отзывают токен (`POST /Sessions/Logout`, `sessionApi.reportSessionEnded`, не дольше 3 с; без сети — просто забывают).
- **О приложении:** версия и сборка, модель ТВ; лицензии — приложение (GPL-3.0) и компоненты (libVLC LGPL-2.1, FFmpeg для Media3 GPL-3.0, Jellyfin SDK LGPL-3.0, Media3 и форк `PsExtractor` Apache 2.0, ass-media MIT, libass ISC, AndroidX/Compose/Kotlin/Koin/Coil/OkHttp/ZXing/Timber Apache 2.0, шрифты OFL 1.1). OK — полный текст из `assets/licenses` (↑/↓ листают, ←/→ — страницами).
- **Отладка** (только debug): «Режим обхода».
### 5.10 «Продолжить просмотр» на главном экране ТВ (своя оболочка)
Контракт — [`launcher-feed.md`](launcher-feed.md). Сторона приложения:
- **`LauncherFeed`** лежит в `app` (не в `core/data`: интент ссылается на `MainActivity`, телефону это не нужно). Запускается из `SleepyOwlApp.onCreate` одной корутиной на IO; ждёт `JellyfinSession.restored` и ещё 3 с, чтобы не мешать главной (её списки к тому времени уже в `SessionCache`, лишних запросов нет). Пересчёт — при старте, при смене аккаунта и по `SessionCache.invalidations`, пачки событий схлопываются за 1 с. В логе: `Launcher feed: sent N cards`.
- **`MainActivity` — `singleTask` + `onNewIntent`.** Раньше запуск с `NEW_TASK` при открытом приложении просто поднимал старую задачу: `intentTarget()` читался один раз в `LaunchedEffect(Unit)`, и открывался прежний экран. Теперь цель из нового интента → стек «главная приложения + цель», как при холодном старте; открытый плеер закрывается и сохраняет позицию. Запуск без цели (иконка) и экран входа — экран не меняется.
- Под плеером, запущенным с карточки, — страница фильма, у серии — страница сериала на этой серии (экстра `series` + `item`). Запомненные дорожки подхватываются.
- «Home shown in» после добавления ленты не изменился (140–153 мс против 137–151).
### 5.11 Отметка «просмотрено / не просмотрено»
Код: `ui/menu/WatchMenu.kt` (`WatchMenuHost` над всей навигацией в `MainActivity`, `LocalWatchMenu`, панель и тексты); в `ui:shared` — `WatchTarget`, `WatchMenuViewModel`, `Modifier.clickableWithMenu(target, onClick)` (`combinedClickable` + Menu) вместо `clickable` у карточек. Цели строят `CardMapper.watchTarget(item)` и `SeriesViewModel` (с точными числами серий).
- **Меню по удержанию OK** (системный порог долгого нажатия, ~0,4 с) или по кнопке Menu пульта. Работает на всех карточках (постеры, «Продолжить» большая и малые, «Похожие»), на сериях и чипах сезонов. Короткое нажатие — как раньше.
- **На странице сериала** — круглая ✓ рядом с ♥ (зелёная, если просмотрено всё): открывает меню для всего сериала, а не переключает — отметка целого сериала стирает места остановки и даты, поэтому сначала видно, сколько серий затронет. У фильма ✓ на странице — мгновенный переключатель.
- **Меню** — панель справа во всю высоту (`SidePanel`, непрозрачная): «ФИЛЬМ / СЕРИЯ / СЕЗОН / СЕРИАЛ», название, состояние («Просмотрен», «Просмотрено 42%», «Просмотрено 5 из 6 серий», «Не просмотрено: 24 серии») и только осмысленные действия: «Отметить просмотренным» (у сезона/сериала — «весь сезон/сериал», с числом серий), «Отметить непросмотренным» (если что-то просмотрено или начато; «Место остановки сбросится» / «Отметки и места остановки всех серий сбросятся»). Окончания по роду. После выбора — «Сохраняем…», ответ сервера → панель закрывается, фокус на карточку; ошибка — текст в панели.
- **Сервер:** сезон и сериал отмечаются одним запросом (`markPlayedItem`/`markUnplayedItem` на сезон/сериал — сервер сам проходит по сериям). Снятие отметки сбрасывает и место остановки, и `LastPlayedDate`. При любой отметке у записи пользовательских данных меняется внутренний `Key`. У карточки сериала сервер сообщает только число непросмотренных, не просмотренных, поэтому меню с карточки всегда предлагает и «непросмотренным» (на странице сериала — только если есть что снимать).
- **Ловушки:** повторы удержанной OK (§4); возврат фокуса до исчезновения панели (§4); «Назад» — по отпусканию.
- **adb:** `input keyevent --longpress KEYCODE_DPAD_CENTER` — долгое нажатие; `--duration` этот ТВ не знает и шлёт **обычное** нажатие (запустит воспроизведение).
- Меню — отдельный слой, только когда открыто; рисование карточек не изменилось.
### 5.12 Пропуск заставок и титров
Данные — стандартный Media Segments API Jellyfin (`GET /MediaSegments/{itemId}`, в SDK `mediaSegmentApi.getItemSegments`), подойдёт любой источник сегментов; проверено с плагином Intro Skipper 12.0.6. Типичные данные: `Intro` 0–88 с (бывает после пролога, например 94,5–178,9 с), `Outro` до конца файла; бывают титры в середине (за ними эпилог ~2 мин).
Код: `core/data` — `MediaSegment`/`SegmentKind`, `MediaSegmentDto.toSegment()`, `LibraryRepository.segments(id)` (кэш `Stable`); `PlayerViewModel.segments` грузится отдельно от информации о серии (ошибка сегментов не ломает шторку); `SeasonEpisodes.currentId` (пока сезон новой серии не загружен, «следующая» из старого не используется); `SkipPlan.of(...)` — что предлагать, что пропускать и где начинаются «хвостовые» титры (`Outro`/`Preview`, доходящие до конца файла с допуском 10 с); `SegmentSet` — что уже пропущено / куда перемотал пользователь / у чего кнопка спряталась (по id серии, смена серии забывает); `SkipButton`, `SkipNotice`; логика в `PlayerScreen.kt` (`skip`, `autoSkip`, `creditsGo`, `nextLeftMs`).
- **Кнопка «Пропустить заставку 1:12»** (время до конца части) — справа внизу, на месте карточки «Следующая» (пилюля 40 dp, светлая в фокусе, непрозрачная). Над голым видео сразу получает фокус: OK пропускает, ←/→ перематывают, ↑ — органы управления, ↓ — шторка, «Назад» убирает кнопку (второй «Назад» — выход). Через **10 с над голым видео** кнопка прячется (считается только время без органов управления; иначе 1,5 мин опенинга OK не ставит паузу) и возвращается вместе с ними — над таймлайном (отступ 150 dp, туда же поднята карточка «Следующая», чтобы не задевать оставшееся время), достаётся ↑ с таймлайна. После пропуска фокус — на ⏯ или на плеер. Пропущенная часть больше не предлагается в этом просмотре.
- Подписи по типу: «Пропустить заставку / пересказ / анонс / титры / рекламу» (`Intro/Recap/Preview/Outro/Commercial`); `Unknown` и куски короче 2 с не учитываются.
- **Титры до конца файла у серии со следующей:** карточка «Следующая» появляется с их началом и отсчитывает **15 с**, потом включает следующую серию. Без данных о титрах — за 30 с до конца. Титры **не до конца** — кнопка «Пропустить титры». Титры до конца **без** следующей серии (фильм, последняя серия) ничего не показывают.
- **Настройки «Заставки» и «Титры»:** «Кнопка» (по умолчанию) / «Сразу» / «Не пропускать» (`PlaybackDefaults.skipIntro/skipCredits`, `SkipMode`; на ТВ для всех профилей). «Сразу»: заставка перескакивается сама (плашка «Заставка пропущена» на 3 с), следующая серия включается с началом титров без карточки. Если пользователь сам перемотал внутрь части, она не пропускается — показывается кнопка. «Не пропускать» у титров — карточка за 30 с до конца.
- **Таймлайн:** части, которые плеер пропускает или предлагает пропустить, подсвечены фиолетовым на непройденной части.
- **Ловушки:** перемотка в часть помечается при фиксации (`SCRUB_COMMIT_MS`), до того как движок сообщит новую позицию, — иначе «Сразу» перескакивал бы туда, куда перемотали нарочно. В adb-скриптах кнопка живёт 10 с: пока разглядываешь снимок, она прячется, и следующий OK ставит паузу — нажатия и снимки слать одной командой. R8 в release ничего не вырезал (проверено).
### 5.13 Название и логотип
- `app_name` = «SleepyOwl»; так же — имя клиента для сервера (`JellyfinSession.CLIENT_NAME`, видно в «Устройствах» Jellyfin), имена профилей устройства у движков (`SleepyOwl / ExoPlayer`, `SleepyOwl / VLC`). Пакет — `ru.stenfredd.sleepyowl`.
- Исходник логотипа — `design/logo/sleepyowl.png` (сова над надписью на тёмно-синем фоне). **Картинки делает `tools/logo/make_assets.py`** (перезапустить при смене логотипа; пишет в ресурсы `ui:shared`): тёмно-синий фон вырезается («цвет в прозрачность»), свечение остаётся полупрозрачным янтарём, тело совы непрозрачное.
- `drawable-xhdpi/banner.png` — баннер Android TV 320×180 dp: сова слева, надпись справа;
- `mipmap-anydpi/ic_launcher.xml` — адаптивная иконка: сова (`drawable-xxxhdpi/ic_launcher_owl.png`, тело в круге 62 dp из 108) на `@color/ic_launcher_background` (#050927);
- `drawable-xhdpi/logo_owl.png` (36 dp) и `logo_wordmark.png` (высота 18 dp) — для интерфейса, ровно в размер на xhdpi, без масштабирования на ТВ.
- В интерфейсе — `Brand.kt` (`OwlMark`, `Wordmark`): рейл (сова над иконками, надпись появляется с открытием рейла), шапка входа, «О приложении». Проверка ресурсов — `aapt2 dump badging` (label, icon, banner).
- **Смена пакета:** applicationId, пакеты Kotlin, `namespace` модулей, имена классов приложения и темы, `rootProject.name`, baseline profile (имена классов заменяются по тексту), `tools/tv/env.sh`; после — `./gradlew clean` (сгенерированный DI-код держал старое имя класса приложения). Для системы это новое приложение; данные переносятся через debug-сборки обоих пакетов: `run-as <старый> tar -cf - files/datastore files/home` → `run-as <новый> tar -xf -` (+ `chmod 771 files`), потом release поверх. Device id переезжает вместе с данными, поэтому на сервере устройство то же.
### 5.14 Свечение фокуса без «лесенки»
- Свечение шло ступеньками с переливом цвета: `GlowCache` хранил тусклые цвета прямо в 8 битах (у R слоя `0x384678FF` всего ~15 уровней на весь хвост) — полосы по 5–10 px, R/G/B переключаются в разных местах.
- Теперь (`Glow.kt`, Android): покрытие каждого слоя рисуется в ALPHA_8 в полную силу, слои складываются во float (premultiplied), в ARGB_8888 переводятся с белым шумом ±1,5 уровня. Белый, а не IGN: маска считается в половинном разрешении и растягивается вдвое, сглаживание гасит мелкий узор. На кадре ничего не меняется (та же картинка того же размера); считать маску стало чуть дольше.
---
## 6. Особенности сервера Jellyfin 12 (сводка)
- **Авторизация:** `X-Emby-Token` сервер 12.x не принимает, работает `Authorization: MediaBrowser Token="…"`. Картинки (`/Items/{id}/Images/...`) и `/Videos/{id}/stream?static=true` отдаются без токена.
- `/Persons?searchTerm=` не ищет по кириллице — искать через `/Items?includeItemTypes=Person&searchTerm=` (§5.8).
- `/Items` со смешанными типами (`includeItemTypes=Movie,Series`) в ~10 раз медленнее одного типа (§5.7).
- `/Items/Latest?parentId` — 20–80 мс; `/Items` по дате с подсчётом — ≈0,2 с, пять параллельных — ≈1 с (§5.2).
- Ширина видео (значок «4K») — только с `fields=Width`.
- `fillWidth`/`fillHeight` сохраняют пропорции: 326×184 приходит как 327–328×184.
- `/Users/Public` пуст, если пользователи скрыты; `LocalAddress` за reverse proxy содержит публичное имя (§5.3).
- Resume отсортирован по дате просмотра; у карточки сериала есть только число непросмотренных серий.
- Снятие отметки «просмотрено» сбрасывает место остановки и `LastPlayedDate`; отметка сезона/сериала — одним запросом; при любой отметке меняется внутренний `Key` записи.
- Сервер помнит выбранные дорожки (`UserData.AudioStreamIndex/SubtitleStreamIndex`), и они важнее языковых настроек.
- Короткий просмотр (секунды) может не сохранить позицию, но выставить `LastPlayedDate` — элемент в «Историю» не попадает (там только просмотренное).
- Quick Connect: ссылка `<сервер>/web/#/quickconnect?code=…` подставляет код в веб-клиенте; код со временем забывается сервером.
- Отчёт Stopped, не дошедший до сервера (процесс убит), оставляет «висящую» сессию на сервере — некритично.
- После очистки данных приложения новый device id даёт на сервере лишнее устройство — его можно удалить в панели Jellyfin.
---
## 7. Отладка, инструменты и гигиена тестов
### 7.1 Помощники adb (`source tools/tv/env.sh`)
Адрес ТВ, сервер, `JAVA_HOME` и id сериала для профиля берутся из `local/dev.env` (шаблон `tools/dev.env.example`). Беспроводной adb на эталонном ТВ: порт сопряжения меняется после перезагрузки, но классический `<ip-тв>:5555` тоже открыт.
| Функция | Назначение |
|---|---|
| `install_debug` / `install_release` | собрать и поставить (release — сразу с компиляцией по baseline profile); debug и release ставятся друг поверх друга с сохранением входа |
| `play <id> [exo\|vlc]` | открыть карточку / запустить воспроизведение |
| `k KEY…` | нажатия клавиш |
| `shot name` | скриншот |
| `plog` | журнал решений `PlaybackSession` |
| `sess` | что играет по данным сервера: позиция, PlayMethod |
| `aout` | реально ли звук уходит на выход (AudioFlinger) |
| `vproxy` | журнал прокси VLC |
| `tv_token` | токен текущего аккаунта с ТВ (`run-as`, только debug-сборка) |
| `ui_bench [right\|down] [N]` | замер плавности (§3.3) |
| `gen_profile` | снять baseline profile заново |
Плюс `tools/tv/run-case.sh <name> <id> [engine]`, тестовый набор файлов `tools/tv/testset.json`, список для режима обхода `app/src/debug/assets/tour.json` (генератор `tools/tour/build_tour.py --server <url> --token <токен>`: жадное покрытие признаков медиатеки).
### 7.2 Приёмы
- **Код Quick Connect с экрана — текстом:** `adb exec-out uiautomator dump /dev/tty | grep -oE '[0-9]{3} [0-9]{3}'`.
- **Подтвердить Quick Connect без пароля** — уже имеющимся токеном: `curl -X POST -H 'Authorization: MediaBrowser Token="<токен>"' '<сервер>/QuickConnect/Authorize?code=<код>'`.
- **Проверить выход, не потеряв вход:** заранее получить запасной токен через Quick Connect API — `POST /QuickConnect/Initiate` с заголовком `Authorization: MediaBrowser Client=…, Device=…, DeviceId=…, Version=…` → `POST /QuickConnect/Authorize?code=` текущим токеном → `POST /Users/AuthenticateWithQuickConnect {"Secret"}`; после выхода подтвердить им код с экрана ТВ, потом отозвать (`POST /Sessions/Logout`). Токен с ТВ брать **до** `pm clear`.
- **Навигация пультом в скриптах:** «Назад» с контента — фокус в рейл, дальше ↑/↓ по пунктам; лишний «Назад» в рейле закрывает приложение. В debug главная грузится 2–4 с — сначала дождаться её, иначе фокус уйдёт обратно в контент, а OK откроет карточку. Число шагов до «Настроек» зависит от числа библиотек в рейле.
- Между нажатиями ≥ 0,25 с (§4); снимок экрана ~5 с (§5.5); долгое нажатие — только `--longpress` (§5.11).
- Ряд оболочки ТВ: `adb shell content call --uri content://ru.stenfredd.tvhome.feed --method dump` ([`launcher-feed.md`](launcher-feed.md)).
### 7.3 Гигиена тестов
- **Тесты меняют данные просмотра аккаунта** (запуск плеера, отметки, генератор baseline profile): проверять под отдельным тестовым пользователем и откатывать через API — `DELETE /UserPlayedItems/{id}` (снять просмотр), `POST /UserItems/{id}/UserData` (`Played`, позиция, `PlayCount`, `LastPlayedDate` — порядок «Продолжить просмотр» задаётся датой). Языковые настройки сервера и настройки ТВ возвращать к исходным.
- **Закрывать плеер кнопкой «Назад», а не `am force-stop`.** Если процесс убит во время воспроизведения: (1) аудио-HAL Xiaomi может продолжать играть AC3 passthrough после закрытия приложения; (2) поток (замечено на 1080i) остаётся висеть в видеоконвейере MTK, и после этого любой 4K HEVC получает `NO_MEMORY` (0xfffffff4) до перезагрузки ТВ. **Если 4K вдруг перестал декодироваться — сначала `adb reboot`.**
- Состояние ТВ влияет и на скорость интерфейса (§3.3): долгий аптайм и занятая подкачка — повод перезагрузить перед замерами.
---
## 8. Воспроизведение (что нужно знать интерфейсу)
Подробности — в [`playback.md`](playback.md). Для экранов важно:
- `PlaybackSession` (в `playback:core`) — единственная точка для UI: `setMaxBitrate`, `setSubtitleStyle`, `setSubtitleLift`, `pause`, `pauseInBackground`, ручная смена движка; в состоянии — `bufferedMs`, `itemId`, `seriesId`, `maxBitrate`, `manualEngine`, `activeEngine`. Выбор движка, fallback при сбое и транскод — внутри сессии.
- Движки подключаемые (`EngineProvider` в Koin-модуле движка): ExoPlayer — основной, libVLC — запасной.
- `android:largeHeap="true"`: без него буфер Exo доводил Java-кучу (192 МБ) до OOM; буфер ограничен ¼ кучи, не больше 64 МБ.
- Вывод видео «Авто»: SDR ≤1080p 30/60 к/с — через GPU (TextureView, обходит постобработку ТВ), HDR, >1080p, чересстрочное и 23,976/24/25/50 к/с — видеослой (SurfaceView).
---
## 9. Не сделано / отложено
- Превью кадров на таймлайне (trickplay) — нужны trickplay-картинки на сервере.
- Этап «Полировка и релиз»: состояния загрузки, пусто и ошибки в стиле макета (сейчас простые подписи «Загрузка…», «Здесь пока пусто», ошибки страницы сетки — только в логе), экран потери связи, анимации переходов, подпись release-ключом (оболочка ТВ при этом перестанет принимать ряд — см. [`launcher-feed.md`](launcher-feed.md)), APK по ABI, проверка на слабом устройстве. Пока грузятся данные (0,1–0,3 с), страница фильма пустая.
- Watch Next (androidx.tvprovider) для главного экрана Google TV.
- Голосовой поиск (нужен системный распознаватель).
- Транскод Dolby Vision-файла в Exo падает (`FragmentedMp4Extractor: Top bit not zero` на HLS fMP4 от сервера, повтор тоже падает, VLC не пробуется); обычный HEVC транскодом играет.
- Выбор вывода видео/DV в плеере не запоминается между запусками (по умолчанию — из настроек).
- В шторке плеера актёры не открывают страницу человека; шторка серий не получила меню «просмотрено»; страницы отдельной серии нет (код `DetailsScreen` для серии открывается только из intent).
- Картинка профиля с сервера не показывается (только буква).
- Монохромного слоя иконки (темы Android 13) нет; картинки интерфейса только в xhdpi (для телефона нужны xxhdpi/xxxhdpi).
- Телефон на тех же модулях данных и воспроизведения.

193
docs/ui-performance.md Normal file
View File

@@ -0,0 +1,193 @@
# Оптимизация интерфейса на слабом Android TV: что тормозило, как мерили, какие приёмы работают
Интерфейс — Jetpack Compose (без Leanback, без `tv-foundation` lazy-списков) на Xiaomi TV S Pro Mini LED 75: MediaTek MT9689, слабый Mali GPU, 32-bit, 2,4 ГБ RAM. Требование: плавно на 60 Гц на этом ТВ и на более слабых приставках. Работа шла 2026-10-14 по 9 этапам интерфейса. План интерфейса и правила производительности — [`tv-app.md`](tv-app.md).
---
## 0. Главное
1. **На этом ТВ узкое место — GPU, а не CPU и не Compose.** Производитель выключил частичную перерисовку (`debug.hwui.render_dirty_regions=false`): **каждый кадр GPU рисует весь экран 1920×1080 целиком**. Каждый видимый элемент платит на каждом кадре, даже неподвижный.
2. **GPU очень слаб по заполнению и смешиванию.**
- Сплошная заливка почти бесплатна.
- Полупрозрачный слой на весь экран — 5–13 мс.
- Любая текстура на весь экран (даже непрозрачная и запечённая) — ~12 мс.
- Большой градиент — ~8 мс.
- Сотни тонких линий по 1 px — ~9 мс.
3. **Значит, всё сложное нужно рисовать один раз в bitmap** (углы, затемнения, свечения, иконки, составные картинки), а на кадре — только простые непрозрачные bitmap и сплошные прямоугольники.
4. **Любая непрерывная анимация = перерисовка всего экрана 60 раз в секунду.** Бесконечные анимации делать ступенчатыми (~8 кадров/с) или не делать. Фокус — только масштаб и прозрачность слоя.
5. **Судить о скорости только по release-сборке** (R8 + baseline profile). Debug в разы медленнее и вводит в заблуждение.
6. **Сам ТВ «плавает»**: тот же экран даёт то 8, то 17 мс GPU между прогонами, а иногда весь ТВ втрое медленнее до перезагрузки. Варианты сравнивать только вперемешку, по 2–3 прогона.
Ориентир: лаунчер Google TV на том же ТВ тратит ~5 мс GPU на кадр. Наш интерфейс после оптимизации — 4–8 мс (было до 45).
---
## 1. Как измеряли
### 1.1 Инструменты
- **`dumpsys gfxinfo <пакет>`** — перцентили кадра и GPU, доля «рывков» (janky frames). Перед замером `dumpsys gfxinfo <пакет> reset`.
- **`dumpsys gfxinfo <пакет> framestats`** + свой скрипт `tools/tv/framestats.py`, который разбивает кадр по фазам (p50/p90/max):
- `input+anim` — обработка ввода и анимаций;
- `traversal` — measure/layout;
- `record draw` — запись команд рисования (композиция Compose тоже здесь);
- `sync` — загрузка текстур (bitmap → GPU);
- `issue cmds` — отправка команд;
- `swap`;
- `gpu` — сколько GPU рисовал кадр. **Главная цифра на этом ТВ.**
- **`ui_bench [right|down] [N]`** (`tools/tv/env.sh`): сбросить статистику → N нажатий «вперёд» и N «назад» с паузой 0,35 с → перцентили и разбивка по фазам. Один и тот же сценарий для всех сравнений.
- **`am start -W`** — время холодного запуска.
- **Время в логах приложения**: `Home shown in`, `Home fetched in`, `Home snapshot read in`, `Series loaded in` — время до показа экрана из снимка и до свежих данных.
- **Флаг-эксперимент в release без пересборки:** приложение временно читает системное свойство `debug.movies.x` через `SystemProperties` (reflection), меняем его через `adb shell setprop debug.movies.x ABC`, и код включает или выключает варианты (фон, слой, градиент…). Флаги — заглавные однобуквенные, без пересечений: например, «none» содержит «e» и «o», и такие флаги сработают по ошибке. Так можно сравнивать варианты вперемешку на одной установке.
- **Layout Inspector и счётчики перекомпозиций** — чтобы убедиться, что при прокрутке перекомпонуется только нужное.
### 1.2 Правила замеров
- **Только release**: R8 (минификация и оптимизация, без обфускации — проект open source, а стеки и теги логов остаются читаемыми), подпись debug-ключом, `profileinstaller`.
- После установки — сразу компиляция по baseline profile:
```
adb shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE <pkg>/androidx.profileinstaller.ProfileInstallReceiver
adb shell cmd package compile -f -m speed-profile <pkg>
```
Иначе ТВ скомпилирует профиль только при фоновом обслуживании, и первые замеры будут на JIT.
- **Контрольная точка**: главная должна давать ~7 мс GPU. Если она даёт 20–30, ТВ в «плохом» состоянии — перезагрузить (`adb reboot`), а не оптимизировать.
- **Сравнивать вперемешку** A, B, A, B, по 2–3 прогона каждого. Разница между прогонами одного варианта бывает 2×.
- Смотреть **GPU p50/p90**, а не только «время кадра»: в медиану кадра входит задержка конвейера (у нас ~19 мс при GPU 10 мс).
---
## 2. Что тормозило больше всего (и сколько стоило)
Все цифры — GPU на кадр, release, тестовый ТВ.
| Что | Стоимость | Что сделали | После |
|---|---|---|---|
| Главная до оптимизации (листание ряда) | 45 мс | всё ниже | 10 мс → затем 7 мс |
| Фоновая сетка точек из макета (на весь экран) | ~5 мс каждый кадр | убрали, фон окна — сплошной цвет | 0 |
| Полупрозрачный слой на весь экран (затемнение, радиальное свечение фона, затемнение под панелью) | 5–13 мс | не делаем; панели непрозрачные | 0 |
| Фон страницы фильма: картинка на весь экран + затемнение + свечение постера, **даже запечённые в один непрозрачный bitmap** | ~12 мс (кадр 19 мс вместо 7) | фон только справа сверху, 70 % ширины и высоты, края запечённым градиентом уходят в цвет фона | ~8 мс |
| Затемнения сверху и снизу в плеере (`Brush.verticalGradient`, 130 + 230 dp) | ~8 мс (HWUI дизерит градиенты) | полосы сплошного цвета по 4 dp с границами по целым пикселям, высота 105 + 170 dp | 7–8 мс с OSD (было 15–20) |
| Тот же градиент, запечённый в bitmap и растянутый | ~6 мс | — (полосы дешевле) | |
| Карта сезонов сериала: сотни рамок из тонких прямоугольников 1 px | ~9 мс (17 мс вместо 8 на 28 сезонах) | полоски сезона запекаются в bitmap **один раз**, выбранная серия — отдельный маркер, двигается `graphicsLayer.translationX` | ~8,5 мс, как без карты вообще |
| Пульсирующая точка «Ищем…» (плавная бесконечная анимация) | ~17 мс, 60 кадров/с всего экрана | пульс ступенчатый, ~8 кадров/с | 6 мс, 47 кадров за 5 с |
| Анимированный фокус в плеере (поверх видео) | ~8 дорогих кадров на нажатие | фокус без анимации (`FocusStyle(animate = false)`) | 1 кадр на смену фокуса, ~4 кадра на нажатие вместо ~15 |
| Смена содержимого большой области прямо во время анимации фокуса (раздел настроек, библиотека) | ~15 мс каждый кадр анимации | менять содержимое через 0,3 с после фокуса (быстрое листание не грузит промежуточное) | ~4 мс |
| `LazyColumn` на коротком экране (сборка ряда во время прокрутки) | кадры до 50 мс | короткие экраны — обычная `Column` | |
| Встроенная анимация bring-into-view при вертикальной прокрутке | «выстреливала», настроить нельзя | своя прокрутка `AnchoredScrolling`: 450 мс с замедлением к концу | рывков ~4 % → 0–0,5 % с baseline profile |
| `Modifier.clip` по скруглённой форме у картинок + затемнение поверх | дорогие слои и шейдеры | углы и затемнение запекаются в bitmap при декодировании (`CardImageTransformation`) | |
| `drawPath` (векторные иконки) в часто перерисовываемом | дорого | иконки растеризуются в bitmap один раз (`Icon` через `drawWithCache`), формы — скруглённые прямоугольники | |
| Свечение фокуса из макета (размытые тени 30–120 px) | `blur`/`RenderEffect` недопустимы | готовая картинка: размытый прямоугольник считается один раз на размер и кэшируется (`GlowCache`), только у элемента в фокусе | ~0 |
| Прямоугольник с дробными координатами | рисуется сглаженным, более тяжёлым шейдером | границы округлять до целых пикселей | |
| Перепекание bitmap на каждое нажатие | загрузка текстуры в GPU каждый раз (фаза `sync`) | печь один раз, менять только слой поверх | |
| Crossfade картинок | перерисовывает каждую видимую карточку всё время перехода | `crossfade(false)` | |
| Смена библиотеки (собираются ряды и первые ряды сетки) | один кадр 55–70 мс | оставлено: приходится на момент, когда ничего не движется. Если станет заметно — собирать ряды по одному в соседних кадрах | |
---
## 3. Приёмы, которые стоит использовать
### 3.1 Рисование
- **Фон окна — сплошной цвет, совпадающий с фоном приложения** (`windowBackground`). Никаких полноэкранных текстур, сеток, виньеток, полупрозрачных слоёв.
- **Всё составное — в bitmap один раз**, на `Dispatchers.Default`, с `LruCache`:
- скругления и затемнение под текстом у картинок (Coil `Transformation`). Сначала обрезать под размер карточки, потом скруглять — иначе при постере не 2:3 запечённые углы обрежутся при рисовании, и поверх рамки фокуса будут торчать прямые углы;
- плитки из нескольких картинок (мозаика постеров + заливка + затемнение + углы, `TileArt`). Картинки грузить через `imageLoader.execute(... allowHardware(false))`, чтобы с ними можно было рисовать на Canvas;
- иконки (растеризация вектора в `drawWithCache`);
- свечение фокуса (`GlowCache`, на размер + радиус);
- пунктирные рамки, карта сезонов — всё, что состоит из множества мелких примитивов.
- **Движущееся поверх запечённого — отдельным слоем** (`graphicsLayer { translationX }`), без перерисовки содержимого.
- **Большие фоновые картинки — только на части экрана** (например, правый верхний угол 70 %), края запечённым градиентом уходят в цвет фона.
- **Затемнения — полосами сплошного цвета** (4 dp) с целыми границами вместо `Brush.verticalGradient`. Пример: `Modifier.verticalShade` в `ui/player/PlayerOsd.kt`.
- Без `Modifier.clip` по форме у картинок, без `blur`/`RenderEffect`, без `drawPath` в часто перерисовываемом.
- Панели и шторки — непрозрачные, без затемнения экрана под ними.
### 3.2 Анимации и фокус
- Анимировать только свойства слоя (масштаб, прозрачность, сдвиг) и **читать значение анимации только в фазе отрисовки/слоя** (`graphicsLayer { }`, `drawBehind { }`), а не при композиции. Раскладку не анимировать. Боковое меню, например, анимируется только в отрисовке, а ширина раскладки переключается разом.
- Анимация фокуса: 150–180 мс. Поверх видео (плеер) — без анимации.
- Бесконечные анимации (пульс, курсор) — ступенчато: курсор мигает 2 раза в секунду, пульс ~8 кадров/с.
- Тяжёлую смену содержимого откладывать до конца анимации фокуса (пауза 0,3 с; «→» или OK — сразу).
- Переходы между экранами пока не анимируются (Navigation 3 без анимаций, переходы отложены на этап полировки). По правилам выше анимированный переход — это несколько кадров с двумя полноэкранными слоями, его нужно мерить отдельно.
### 3.3 Списки и прокрутка
- Только ленивые списки с `key` и `contentType` для длинного; короткие экраны — обычная `Column`.
- Сложный экран (медиатека: плитки, ряды, заголовки, сетка) — **одна `LazyVerticalGrid`**: ряды и заголовки — элементы `span = maxLineSpan`, выходящие за поля сетки (`bleed`), постеры — её ячейки. Составляется только видимое.
- Постраничная загрузка: страницы по 60, следующая — когда до конца остаётся 2 ряда (`snapshotFlow` от `layoutInfo`). Без подсчёта `TotalRecordCount` в том же запросе — число отдельным запросом.
- Своя вертикальная прокрутка к ряду (`AnchoredScrolling`, `animateScrollBy`, 450 мс, замедление к концу) вместо встроенного bring-into-view. Для панелей — `MinimalScrolling(margin)`: прокрутка только насколько нужно (по умолчанию на ТВ фокус держится на 30 % высоты, и панель прокручивается при первом же фокусе).
- При смене сортировки не очищать сетку до прихода новых данных — иначе экран «прыгает».
- UI-модели `@Immutable`, без DTO SDK в composable; модели карточек строятся вне главного потока.
### 3.4 Картинки (Coil 3)
- Запрашивать у сервера **ровно размер на экране** (постер 220×330 dp → ~440×660 px): `fillWidth`/`fillHeight`/`quality` в Image API Jellyfin.
- `allowRgb565(true)` — постеры и кадры непрозрачные, память вдвое меньше.
- Кэш в памяти — 15 % кучи, на диске — 200 МБ.
- `crossfade(false)`.
- BlurHash как заглушка: декодировать 16 px, увеличить до ¼ карточки с запечёнными углами и затемнением, рисовать одним bitmap; декодирование вне главного потока.
- Ключ кэша — на публичном адресе сервера, а сами запросы перехватчик OkHttp переписывает на LAN-адрес.
### 3.5 Данные и запуск
- **Снимок главной**: показ сразу из последнего показа в этом запуске или из JSON-файла с прошлого запуска, потом обновление с сервера одним параллельным заходом.
- Кэш ответов на время сессии; одинаковые запросы в полёте объединяются; данные просмотра сбрасываются по событию.
- Запросы к серверу — параллельно, первым то, что видно.
- **Ловушки сервера Jellyfin:**
- `/Items` с несколькими типами (`includeItemTypes=Movie,Series`) в ~10 раз медленнее одного типа (0,35–0,44 с против 0,05). Запрашивать библиотеку её собственным типом;
- ряды «Новинки» через `/Items/Latest?parentId` (20–80 мс), а не `/Items` с сортировкой по дате и подсчётом (≈0,2 с каждый, 5 параллельных ≈1 с);
- без `fields=Width` сервер не отдаёт ширину видео (значок «4K» не построить).
- **Baseline profile** (модуль `:baselineprofile`, macrobenchmark + UiAutomator): сценарий «запуск → главная → вдоль рядов → страница фильма → медиатека → поиск → избранное → настройки → сериал». Переснимать после крупных изменений экранов (`gen_profile`, ~8–27 мин на ТВ). Gradle может написать «failing tests» / «BUILD FAILED», хотя профиль снят — ошибка на шаге покрытия, профиль копируется скриптом.
### 3.6 Плеер
- Видео — на своей плоскости (SurfaceView), окно интерфейса рисуется только при изменениях. OSD скрыт — 0 кадров; виден — кадр раз в секунду (часы/время) и по одному на нажатие.
- Нажатия перемотки копятся и уходят в движок одним seek через 0,6 с.
- Если видео идёт через TextureView (каждый кадр видео перерисовывает окно), бюджет на OSD особенно мал: 6 мс GPU с OSD, 4–5 мс без.
---
## 4. Итоговые цифры (release, тестовый ТВ)
| Экран / действие | GPU на кадр | Рывки |
|---|---|---|
| Главная, листание ряда | 45 → ~7–10 мс | ~8 % → 0–0,5 % |
| Страница фильма | ~8 мс (p90 ~9) | — |
| Ряд «Похожие» | ~7 мс | 0 |
| Плеер с OSD | 15–20 → 7–8 мс | — |
| Сериал, лента серий | 6–8,5 мс | 0–5 % |
| Медиатека, сетка | 6–7 мс | ~1 % |
| Поиск (клавиатура, результаты) | ~8 мс | 0 |
| Настройки | ~4 мс | 0 |
| Время | До | После |
|---|---|---|
| Главная на экране (от создания экрана) | 1,3–2,5 с | 0,09–0,14 с (из снимка) |
| Свежие данные главной | 1,3–2,5 с | 0,4–1,0 с |
| Чтение снимка главной (130 КБ JSON) | — | 150 мс без профиля → 60–80 мс с профилем |
| Холодный запуск (`am start -W`) | 0,35–0,6 с | 0,28–0,42 с |
| Страница сериала (16 / 365 серий) | — | 0,23 / 0,65–0,7 с (сервер отдаёт ~1 МБ JSON за 0,1 с, остальное — разбор) |
| Содержимое библиотеки | 0,4–0,65 с | 0,15–0,23 с |
---
## 5. Ловушки Compose на ТВ (не скорость, но всплыли при работе)
- **«Назад», которое никто не перехватил, сначала только снимает фокус**, и `BackHandler` срабатывает со второго нажатия. В плеере «Назад» ловится в `onPreviewKeyEvent` и выполняется **по отпусканию** клавиши, иначе отпускание прилетит следующему экрану.
- `lastKey = x; requestFocus()` сразу не работает: requester ещё висит на старом элементе. Нужно дождаться перекомпозиции (2 кадра).
- Когда пропадает слой с фокусом (панель), система ставит фокус на первую кнопку экрана — для возврата фокуса держать отдельный `FocusRequester`.
- `focusRestorer` у обычного `Row` без `focusGroup()` не восстанавливает фокус.
- `BasicTextField` на ТВ «захватывает» D-pad — нужен ручной перевод фокуса. Gboard для ТВ плавает поверх нижних ~45 % экрана и **сообщает нулевые IME insets**: `adjustPan`/`adjustResize` не работают, экран нужно приподнимать самому (сдвигом слоя), активность — `adjustNothing`.
- В тестовых скриптах между нажатиями нужны паузы ≥0,25 с: нажатие в первом кадре после появления OSD уходит в никуда. Снимок экрана на ТВ занимает ~5 с.
---
## 6. Порядок действий, если экран тормозит
1. Собрать release, установить с компиляцией по профилю, проверить контрольную точку (главная ~7 мс). Если хуже — перезагрузить ТВ.
2. `ui_bench` на проблемном действии, посмотреть фазы:
- большой `gpu` → слишком много заливки (полноэкранные слои, текстуры, градиенты, тонкие примитивы);
- большой `record draw` / `traversal` → лишняя композиция или раскладка (ленивый список, смена содержимого во время анимации);
- большой `sync` → загрузка текстур (перепекание bitmap, новые картинки).
3. Выключать элементы по одному (флаг через `setprop`), сравнивать вперемешку, найти дорогой.
4. Заменить на запечённый bitmap, сплошную заливку, слой или ступенчатую анимацию.
5. Если упрощение заметно меняет дизайн — согласовать с автором дизайна, показав варианты.

View File

@@ -1,4 +1,4 @@
// The desktop's playback engine (LINUX_PLAN.md §2.3): libmpv through JNA, video drawn offscreen and shown in Compose.
// The desktop's playback engine (docs/desktop.md): libmpv through JNA, video drawn offscreen and shown in Compose.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.compose)

View File

@@ -28,7 +28,7 @@ import kotlin.concurrent.thread
import kotlin.math.roundToInt
/**
* libmpv engine of the desktop (LINUX_PLAN.md §2.3). One mpv core per playback, without a window of its own
* libmpv engine of the desktop (docs/desktop.md). One mpv core per playback, without a window of its own
* (`vo=libmpv`): the picture comes out of [video] and is shown by [MpvVideoSurface].
*
* Commands go to mpv asynchronously, so the UI thread never waits on the core; mpv's events and observed properties are

View File

@@ -26,7 +26,7 @@ val mpvEngineModule = module {
/**
* mpv plays everything ffmpeg demuxes and decodes, in hardware where the GPU can (NVDEC, VAAPI), otherwise on the CPU,
* which a PC can afford. HDR and Dolby Vision are tone-mapped to SDR (the desktop has no HDR output, LINUX_PLAN.md §0).
* which a PC can afford. HDR and Dolby Vision are tone-mapped to SDR (the desktop has no HDR output, see docs/desktop.md).
*/
class MpvEngineProvider : EngineProvider {
override val id = ID

View File

@@ -21,7 +21,7 @@ import kotlin.math.min
import kotlin.math.roundToInt
/**
* mpv's picture, variant B of LINUX_PLAN.md §2.3: mpv draws in its own invisible OpenGL context (EGL pbuffer) on its
* mpv's picture, variant B of docs/desktop.md: mpv draws in its own invisible OpenGL context (EGL pbuffer) on its
* own thread, into an FBO; the frame is read back through a persistently mapped PBO into memory that Compose shows
* ([MpvVideoSurface]). mpv knows nothing about the window, so the window can be rebuilt freely.
*

View File

@@ -28,5 +28,5 @@ include(":playback:engine-vlc")
include(":playback:engine-mpv")
include(":ui:shared")
// SleepyOwl for the Linux desktop (LINUX_PLAN.md).
// SleepyOwl for the Linux desktop (docs/desktop.md).
include(":desktop")