59 KiB
Воспроизведение видео на Android TV: итоговые выводы
Итог работы над воспроизведением в клиенте Jellyfin для Android TV (2026-10-12…14). Документ написан так, чтобы по нему можно было повторить то же качество воспроизведения в другом приложении. В нём: как устроен движок, какие правила выбора, что сломано в media3, libVLC и на телевизорах MediaTek и как это обходится, как тестировать.
Исходное исследование, сделанное до кода, лежит в research/. Здесь только то, что подтвердилось или выяснилось на реальном устройстве.
Код, на который ссылается документ, находится в модулях playback/* этого репозитория:
playback/api— интерфейсы;playback/core— сессия, выбор движка, опрос устройства;playback/engine-exo— ExoPlayer;playback/engine-vlc— libVLC.
0. Коротко: главные выводы
- Гибрид из двух движков работает. media3/ExoPlayer — основной (аппаратный MediaCodec, HDR10/HDR10+/HLG/Dolby Vision, passthrough). libVLC — запасной (программный ffmpeg). На тестовой медиатеке (12 671 файл, треть — AVI) ни один файл не потребовал серверного транскода.
- Выбор движка — автоматический, по «весу потерь», до запроса к серверу. Плюс два сторожа во время просмотра: «нет первого кадра за 60 с» и «застой». По их сигналу сессия переходит на следующий движок с той же позиции.
- Большинство проблем оказались не в выборе движка, а в мелочах, которые не видны в документации:
- экстракторы media3 ломаются на реальных файлах: MKV с цепочкой SeekHead, AC3 в AVI, MPEG-2 в VOB без PTS, DVD private stream;
- libVLC не дружит с HTTPS через reverse proxy и перекачивает данные при каждом шаге назад;
- на ТВ многоканальный PCM беззвучен, а видеослой «улучшает» SDR-картинку;
- убитый посреди видео процесс оставляет мусор в видеоконвейере MTK, и тот лечится только перезагрузкой ТВ.
- Путь вывода видео — отдельное решение, не равное выбору движка:
- SurfaceView (аппаратный видеослой) нужен для HDR, 4K, чересстрочного видео и частот 24/25/50 (ТВ сам переключает частоту панели);
- TextureView (через GPU) даёт более естественную SDR-картинку ≤1080p 30/60 к/с на Xiaomi: обходит постобработку ТВ.
- Dolby Vision: DV-декодер с базовым слоем HDR10 работает, ТВ включает режим DV. Если DV-декодера нет или дисплей не DV — играть базовый слой (HEVC/AV1/AVC) через свой
MediaCodecSelector. - Звук: passthrough всего, что ТВ принимает (AC3, E-AC3, JOC, TrueHD, DTS, DTS-HD, AC4, MAT — декодирует Dolby MS12 телевизора). Остальное — стерео PCM. Для устройств без passthrough — ffmpeg-аудиодекодер в media3.
- Сеть: дома видео нужно брать с LAN-адреса сервера по HTTP, а не через HTTPS-hairpin на reverse proxy. Иначе 4K-ремукс на 60+ Мбит/с уходит в подзагрузку каждые 5–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-kotlin1.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. - Сервер доступен по публичному адресу (
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)
- Выбор движка:
EngineSelector.rank(...)— список движков по возрастанию суммарного веса потерь. POST /Items/{id}/PlaybackInfoс DeviceProfile выбранного движка. Флаги:enableDirectPlay/DirectStream/Transcoding = true,allowVideoStreamCopy/AudioStreamCopy = true,alwaysBurnInSubtitleWhenTranscoding = false, выбранные индексы аудио и субтитров,mediaSourceId,startTimeTicks.- Если
supportsDirectPlay— URL/Videos/{id}/stream?static=true&mediaSourceId&playSessionId&deviceId&tag. Иначе —transcodingUrl. Причины транскода (TranscodeReasonsв URL) пишутся в лог и показываются в UI. - Внешние субтитры (
deliveryMethod = External) передаются движку отдельными URL. - Запуск движка, отчёт
Playing,Progressкаждые 10 с, в концеStopped. - Сторожа (§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.
- нет MIME для кодека →
- 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.
- DV-декодер «есть», только если
- Чересстрочное:
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), — queryApiKey=. /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без ограничений, транскод — HLSts, субтитры — всё, включая sup/idx/sub. maxStreamingBitrate=maxStaticBitrate= 400 Мбит/с, чтобы сервер не транскодировал по битрейту.
- Exo: перечислены контейнеры и кодеки, которые реально есть на устройстве;
- 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 с — запрос уходит на публичный адрес, и запускается новый поиск; - ключи кэша картинок строятся на публичном адресе, чтобы не меняться дома и вне дома.
- поиск сервера в LAN: UDP broadcast на порт 7359, сообщение
- Итог: 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 всегда на видеослое.
screencapHDR/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-media0.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§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)
- Фильтр
prefetchVLC 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 Аппаратное декодирование
- Модуль
mediacodecVLC при старте опрашивает все декодеры. На 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 с; ложных срабатываний при перемотках нет.
- движок в READY и
- Неперематываемый файл:
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. Чек-лист для своего приложения
- Два движка за одним узким интерфейсом. Дорожки адресовать индексами сервера. Свой DeviceProfile на движок.
- Опрашивать устройство в рантайме:
MediaCodecList,HdrCapabilities,isDirectPlaybackSupported. Не доверятьmedia_codecs.xmlи заявленным пределам (allowExceedsCapabilities). - Выбор движка по весам потерь; HDR дороже всего. Пересчитывать при смене дорожки.
- Сторожа первого кадра и застоя; fallback с позиции запуска, если кадра ещё не было; транскод последним.
- ExoPlayer: ограничить буфер (¼ кучи, ≤64 МБ),
largeHeap, decoder fallback, ffmpeg-аудио после MediaCodec, tunneling выключен. - Свой
MediaCodecSelectorдля DV (пустой список → базовый слой). DV-декодер — только при DV-дисплее. - HDR, 4K, чересстрочное и 24/25/50 к/с — всегда SurfaceView. SDR ≤1080p 30/60 к/с — по устройству (на Xiaomi через TextureView).
- Исправленные экстракторы: SeekHead-цепочка в MKV, AC3 в AVI, MPEG-2 PTS в PS, DVD private stream.
- ASS через
ass-mediaвAspectRatioFrameLayout, без серверного прожига. - libVLC только через локальный HTTP-прокси с кэшем недавних байт; токен — заголовком из прокси; сокет на
127.0.0.1. - libVLC: аппаратное декодирование только >1080p на MediaTek; звук — passthrough или
stereo, никогда многоканальныйpcmна ТВ; release в фоне;VLCVideoLayoutс Activity-контекстом; повтор выбора внешних субтитров на каждомESAdded; поправка перемотки в MPEG-PS. - Видео и картинки дома — с LAN-адреса сервера (UDP 7359), а не через HTTPS reverse proxy.
- Авторизация Jellyfin 12 — только
Authorization: MediaBrowser …илиApiKey; вызовы SDK — на IO. - В диагностике обязательно «кадров/с на экране» и «пропущено декодером», а не только dropped.
- Тестировать на реальном ТВ, по обходу, покрывающему признаки медиатеки, со штатным выходом из плеера. HDR оценивает только человек:
screencapего не видит.