Files
SleppyOwl/docs/playback.md
StenFredd b077388b74 Add documentation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:23:58 +03:00

59 KiB
Raw Permalink Blame History

Воспроизведение видео на 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. Коротко: главные выводы

  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.
  • Сервер доступен по публичному адресу (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 §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 его не видит.