# Воспроизведение видео на 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://<путь сервера>?`. - Прокси повторяет запрос через 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 :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 [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 /.MainActivity --es item [--es engine exo|vlc] [--ez play true] [--el start ]`. ### 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` его не видит.