Add documentation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
34
docs/README.md
Normal 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
609
docs/desktop.md
Normal 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
84
docs/development.md
Normal 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
162
docs/launcher-feed.md
Normal 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
507
docs/playback.md
Normal 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
61
docs/research/README.md
Normal 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 |
|
||||
55
docs/research/decisions.md
Normal file
55
docs/research/decisions.md
Normal 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.
|
||||
134
docs/research/device-xiaomi.md
Normal file
134
docs/research/device-xiaomi.md
Normal 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
511
docs/research/engines.md
Normal 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
|
||||
1693
docs/research/jellyfin-client-research.md
Normal file
1693
docs/research/jellyfin-client-research.md
Normal file
File diff suppressed because it is too large
Load Diff
30
docs/research/library-profile.md
Normal file
30
docs/research/library-profile.md
Normal 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
327
docs/research/media3.md
Normal 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.
|
||||
1219
docs/research/tv-dump/audio_policy.txt
Normal file
1219
docs/research/tv-dump/audio_policy.txt
Normal file
File diff suppressed because it is too large
Load Diff
373
docs/research/tv-dump/display.txt
Normal file
373
docs/research/tv-dump/display.txt
Normal 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
|
||||
755
docs/research/tv-dump/media_codecs-mt5896.xml
Normal file
755
docs/research/tv-dump/media_codecs-mt5896.xml
Normal 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
402
docs/tv-app.md
Normal 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
193
docs/ui-performance.md
Normal 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. Если упрощение заметно меняет дизайн — согласовать с автором дизайна, показав варианты.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user